Skip to content
Logo

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:

MemberRequiredSignatureCalled when
appendyesappend(tables, table, row)any effect that appends a row to a table
initnoinit(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.appendStays local
pushDraft, pushInput, pushInputWithsetConst, setInput, setFromRow, setFromScalar
appendFromScalars, appendFromScalarsIfAbsentremoveWhereField, likeRow
appendFromRow, pushConvMessagesetConvId, 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.html

build-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:

  1. 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.
  2. Route from the declaration, not from a table name. deleteRuleFor(table) beats table === 'message_ops'. The first is data a reviewer can audit and a test can delete; the second is invisible policy.
  3. 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.
  4. 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 __protoUp and 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_APP by hand, or Node runtimes that have a real node: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_ADAPTER is set before the app core runs (inject into <head>).
  • append(tables, table, row) echoes locally, then submits.
  • init(S, render) hydrates declared queries and calls render().
  • The adapter reads window.__ENC_APP.binding instead 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.binding before 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