ENC protocol integration
Encapp emits the app. It does not emit the host. Connecting a proven app to the ENC protocol — or to any backend — means implementing one small contract and satisfying whatever the app declares that it needs.
This page is the contract, the wiring, and the two integration failures that have actually happened.
The adapter contract
The emitted core defines the store-write seam as an injectable global. From appCoreJs:
// The store-write seam (the `Layer` boundary): store effects go through an
// injectable ADAPTER. Default = the in-memory World (the emulator). A live
// deployment sets window.ENC_ADAPTER to an Enc-SDK World that submits to a node
// and folds the returned events back via render().
var ADAPTER = (typeof window !== 'undefined' && window.ENC_ADAPTER) || {
append: function (tables, t, row) {
if (!tables[t]) tables[t] = []
tables[t] = tables[t].concat([row])
}
}That is the whole interface:
| Member | Required | Signature | Called when |
|---|---|---|---|
append | yes | append(tables, table, row) | any effect that appends a row to a table |
init | no | init(S, render) | once, at the end of core boot — the hydration seam |
// hydration seam: a live ADAPTER may seed S from the node, then re-render.
if (ADAPTER.init) ADAPTER.init(S, render)init receives the live Doc and the re-render function, so a real adapter can query the node,
merge rows into S.tables, and call render(). It may be async; the core does not await it,
so a real implementation renders as data arrives rather than blocking boot.
Which effects reach the adapter
Only table appends cross the seam. Scalar writes and local table edits stay in the app.
Reaches ADAPTER.append | Stays local |
|---|---|
pushDraft, pushInput, pushInputWith | setConst, setInput, setFromRow, setFromScalar |
appendFromScalars, appendFromScalarsIfAbsent | removeWhereField, likeRow |
appendFromRow, pushConvMessage | setConvId, routeIfHex64, lookupRoute |
What the app hands the host
Before anything else, the core publishes the app's declared binding:
if (typeof window !== 'undefined' && APP.binding)
window.__ENC_APP = { binding: APP.binding }So a host adapter reads window.__ENC_APP.binding to learn what the app declared — which
tables it submits, which it queries, which rows mean a delete or a membership change. The host
becomes a generic executor of declarations rather than a place where app policy is
re-implemented.
Wiring it up
The emitted dist/index.html is self-contained and runs on the in-memory emulator. A live build
bundles the adapter and injects it into <head>, so window.ENC_ADAPTER exists before the
runtime reads it:
lake exe super # emit dist/index.html (offline emulator)
node build-live.mjs # bundle the adapter → dist/index-live.htmlbuild-live.mjs runs esbuild in IIFE format for the browser and injects the result. Order is
not negotiable: the core reads window.ENC_ADAPTER at definition time, so an adapter that loads
afterwards is simply not used.
What a real ENC adapter does
For the flagship app, adapter/enc-entry.mjs implements the two contract methods and delegates
all protocol work to the generated SDK:
window.ENC_ADAPTER = {
get __protoUp() { … }, // observability for the browser smoke
get __queued() { … }, // pre-protocol submits still queued
async init(S, render) {
// restore session → mint/restore enclaves → hydrate declared queries
// into S.tables → start the receive poll → render()
},
async append(tables, table, row) {
// 1. local echo so the UI is instant
// 2. read the DECLARED rule for this table from window.__ENC_APP.binding
// 3. route to the generated SDK's submit for that enclave + event
// 4. settle-track the receipt so failures surface
}
}The shape worth copying:
- Echo first, submit second. The app is a synchronous state machine; the UI must not wait for a network round trip. Append locally, then submit.
- Route from the declaration, not from a table name.
deleteRuleFor(table)beatstable === 'message_ops'. The first is data a reviewer can audit and a test can delete; the second is invisible policy. - Track every submit. A fire-and-forget submit whose promise rejects is an invisible failure — the row is on screen and nowhere else. Settle-track them and surface errors through the app's error channel.
- Queue before the protocol is up. Connect, then an immediate action, is a real sequence. Queue those submits and flush them when the delegate comes up.
The failure mode that matters
The default adapter is the in-memory emulator. If your live adapter fails to install, the app does not error. It silently runs offline: every screen renders, every click works, every local test passes — and nothing reaches the node.
This has happened for a reason worth knowing. In the live bundle, node:fs was marked
external rather than aliased. The manifest loader imports it at module scope, so the
external shim's require threw at bundle load, which killed the entire adapter IIFE.
window.ENC_ADAPTER never installed, and the live build ran as the offline emulator in every
real browser. Node-side tiers have a real node:fs and could never have seen it.
Two rules follow:
- Assert the adapter is installed, don't assume it. Expose something like
__protoUpand check it in a smoke test against a real browser. - A test tier that supplies the missing piece itself cannot detect its absence. Headless
pools that set
__ENC_APPby hand, or Node runtimes that have a realnode:fs, are blind to exactly the bugs that only bite in a browser.
See Testing an integrated app.
Provisioning, identity and enclaves
None of this is Encapp's concern, and that is deliberate — it is the layer above. For ENC specifically, an adapter typically must:
- derive or restore an identity (bytes: private key, public key, hex);
- mint or restore one enclave per declared purpose, writing an initial RBAC state that authorizes the owner — without it every submit is unauthorized;
- register a plugin for envelope encryption rather than overriding the SDK's encrypt path;
- publish the identity to a registry so peers can resolve it.
Keep all of it in the adapter. The moment a decision about which enclave, which event, or which rail belongs to the app, move it into the declared binding so a deletion test can prove the host obeys it.
Checklist for a new integration
-
window.ENC_ADAPTERis set before the app core runs (inject into<head>). -
append(tables, table, row)echoes locally, then submits. -
init(S, render)hydrates declared queries and callsrender(). - The adapter reads
window.__ENC_APP.bindinginstead of hard-coding table names. - Every submit is settle-tracked and failures reach the app's error channel.
- A real-browser smoke test asserts the adapter installed and that a write reached the node.
- Any policy you found yourself writing in JavaScript is a candidate for a new binding form.
Recap
- The adapter contract is minimal:
append(tables, table, row)is required;init(S, render)is optional for hydration - The default adapter is in-memory; if your live adapter fails to install, the app runs offline silently with no error
- Table appends cross the seam to the adapter; scalars, local table edits, and deletions stay local — use declared rules for wire deletes
- The app publishes
window.__ENC_APP.bindingbefore anything else; the adapter reads this to become a generic executor of declarations rather than reimplementing policy - The four steps that make adapters maintainable: echo first (instant UI), route from declarations (auditable policy), settle-track (surface failures), queue before protocol (real startup sequence)
- A real-browser smoke test asserts both that the adapter installed and that a user's action reached the node — what no headless tier can detect
Next steps
- Host bindings — how apps declare what they expect, and why that prevents hand-written protocol
- Testing an integrated app — tiers of evidence and why in-memory green is not wire evidence
- Testing and coverage gates — the four verification tiers