# Plugin and Facet Architecture > **Status:** Design specification. Supersedes the facet/service model in `plugins.md` > where the two disagree. Read `rpc.md` for transport framing. ## 1. Shape of the system A **plugin** is a package with up to three entry points, one per host kind: ```text my-plugin/ contract.ts service tokens + JSON DTOs (shared, no host imports) server.ts server facet (optional) worker.ts session-worker facet (optional) tui.ts presentation facet (optional) ``` Nothing links the entry points at runtime. They share only the tokens in `contract.ts`. Each is built into a single JavaScript file by esbuild, pointed at that entry. A **facet** is the in-process unit: one object with a static manifest and one construct function. A **host** is a process that assembles a facet graph — server, session worker, or presentation. The topology is a tree of connections: ```text server ├─ TUI A ├─ web B ├─ session worker S0 └─ session worker S1 ``` ## 2. Tokens A token is the identity of a service contract. It carries the service type as a phantom, its stable ID, whether it is RPC-capable, and — critically — its **mode**. ```ts type ServiceMode = "singleton" | "keyed" | "peer"; interface Service { readonly id: string; readonly mode: M; readonly rpc: boolean; readonly __type?: T; // phantom } function defineService( id: string, options?: { rpc?: boolean }, ): Service; function defineKeyedService( id: string, options?: { rpc?: boolean }, ): Service; /** One instance per connected peer; each peer sees exactly its own. */ function definePeerService( id: string, options?: { rpc?: boolean }, ): Service; ``` The three modes differ only in how many instances exist and who sees them: | mode | instances | consumer declares | consumer gets | | --- | --- | --- | --- | | `singleton` | one, shared | `uses` | the service | | `keyed` | many, all visible to everyone | `observes` | one task per instance | | `peer` | one per connected peer, visible only to that peer | `uses` | the service | `peer` is the answer to per-client state (§10.2). It is not a fourth mechanism — the host instantiates lazily per peer and announces to that peer alone — but the consumer ergonomics matter: from a client's side there is exactly one, so it says `uses` and calls it, exactly like a singleton. Watching a *set* of instances is what `observes` is for, and per-client state is not that. Mode lives on the token because it is a property of the contract, not a choice the provider makes. This deletes an entire class of assembly error: a token cannot be provided as a singleton and consumed as keyed, because there is nothing to declare. Tokens are RPC-capable by default. `{ rpc: false }` confines a token to its providing process; such tokens are never announced and never resolvable remotely. ## 3. Facets ```ts const modelSelectionTui = defineFacet({ id: "@pi/model-selection:tui", uses: [Models, Tui], provides: [], observes: [], construct(ctx) { const models = ctx.use(Models); const tui = ctx.use(Tui); tui.commands.register("models.select", async (context) => { /* ... */ }); return []; }, }); ``` Three declaration fields, all pure token data, all readable without executing anything: - **`uses`** — singleton tokens this facet requires. - **`provides`** — tokens this facet implements, singleton or keyed. - **`observes`** — keyed tokens this facet watches instances of. `construct` runs only after the kernel has validated the whole graph and constructed every dependency. Everything `ctx.use()` returns is a **real object**, never a proxy that becomes valid later. There is no phase during which a facet holds something unusable. **`construct` is synchronous.** It wires objects and returns provisions; it does no I/O. Anything asynchronous registers through `ctx.onActivate`, which runs after the entire graph is constructed, in dependency order. `ctx.onDeactivate` runs before disposal in reverse order. Three phases, each with a single job: | phase | sync? | may call dependencies? | purpose | | --- | --- | --- | --- | | `construct` | yes | no | wire objects, return provisions | | activate | no | yes | I/O, subscriptions, initial fetches | | deactivate | no | yes | orderly shutdown before disposal | Keeping `construct` synchronous is what makes the ordering guarantee simple: a facet cannot observe a half-built graph, because nothing runs until all of it exists. ### 3.1 Why not declaration-by-side-effect `plugins.md` derives the dependency ledger from `env.use()`/`env.provide()` calls made during a synchronous `setup()`. That avoids writing the manifest twice, but it forces `use()` to return a disconnected lazy proxy — an object whose type is a lie until assembly finishes. Every facet author then has to remember a rule the language cannot enforce. Separating declaration from construction costs one extra mention of each token and buys: - graph validation with **zero facet code executed** — decisive for third-party plugins, where a bad set should be rejected before it runs; - honest types throughout construct; - a facet whose entire surface is readable in ten seconds. The duplication is eliminated by type checking (§4), so it cannot drift. ### 3.2 Construct context ```ts interface ConstructContext { /** Resolved singleton dependency. Only accepts tokens declared in `uses`. */ use(token: S): ServiceType; /** Owner handle for a keyed token declared in `provides`. */ owner>(token: S): ServiceInstances>; /** Host-built replicated state; see §9.2. */ state(definition: StateDefinition, initial: T): MutableState; /** Runs after the whole graph has constructed, in dependency order. */ onActivate(fn: (context: Context) => void | Promise): void; /** Runs before disposal, in reverse dependency order. */ onDeactivate(fn: (context: Context) => void | Promise): void; } interface ServiceInstances { /** Invokes this facet's factory for `key`, registers it, announces it. */ add(key: string): () => void; } ``` `ctx.use()` is a typed lookup rather than a positional tuple or a named bag: no invented labels, no positional matching, and passing an undeclared token is a compile error. Owner handles arrive through `ctx` rather than being returned, because they are kernel-owned machinery that exists before the factory does. Construct receives dependencies and its own handles; it returns implementations. ## 4. Return type and completeness `construct` returns an array of entries built by two helpers: ```ts function provide(token: Service, impl: T): ProvideEntry; function provide(token: Service, factory: (key: string, scope: InstanceScope) => T): ProvideEntry; function provide(token: Service, factory: (principal: Principal, scope: InstanceScope) => T): ProvideEntry; /** Instance-lifetime equivalent of the construct context's state(). */ interface InstanceScope { state(definition: StateDefinition, initial: T): MutableState; } function watch( token: Service, handler: (instance: Instance, context: Context) => void | Promise, ): WatchEntry; interface Instance { readonly key: string; readonly service: T; } ``` The union of tokens in the returned array must equal the union of `provides` plus `observes` — mutual assignability in both directions, which gives both **completeness** (nothing forgotten) and **no extras** (nothing undeclared): ```ts type TokensIn = R[number]["token"]; type CheckComplete = [TokensIn] extends [Declared] ? [Declared] extends [TokensIn] ? unknown : { __error: "missing implementation for declared token" } : { __error: "returned an undeclared token" }; function defineFacet< const U extends readonly AnySingleton[], const P extends readonly AnyService[], const O extends readonly AnyKeyed[], const R extends readonly Entry[], >(facet: { id: string; uses: U; provides: P; observes: O; construct: (ctx: ConstructContext) => R & CheckComplete; }): Facet; ``` Note this is an array of token/value pairs, not an object map. Tokens are objects, and objects cannot be keys. String-ID keys reintroduce a seam between the token in the manifest and a string literal in construct; unique symbols do not survive `defineService`'s return type, collapsing to plain `symbol` and merging every key. Pairing tokens with values in an array keeps each entry individually typed against its own token and keeps completeness checkable. > **Open:** error message ergonomics for `CheckComplete` need experimentation. > The failure points at construct's return type, which is correct but not pretty. ### 4.1 Worked example ```ts export const questionSession = defineFacet({ id: "@pi/question:session", uses: [Tools], provides: [QuestionDialogs], // keyed token observes: [], construct(ctx) { const tools = ctx.use(Tools); const dialogs = ctx.owner(QuestionDialogs); const pending = new Map(); tools.add((draft) => { draft.set("question", { /* ... */ async execute(_id, params, _u, _tc, invocation, context) { const completion = Promise.withResolvers(); pending.set(invocation.invocationId, { params, completion }); const close = dialogs.add(invocation.invocationId); try { return toResult(await awaitAbortable(completion.promise, context.abortSignal)); } finally { close(); pending.delete(invocation.invocationId); } }, }); }); return [ provide(QuestionDialogs, (key) => { const entry = pending.get(key)!; return { request: entry.state, async submitAnswer(candidate, _context) { /* memoOnce, resolve */ }, }; }), ]; }, }); ``` The factory receives the key and closes over facet-private state. `dialogs.add()` triggers it. Construct still runs exactly once. ## 5. Ordering and cycles `uses` and `provides` are static, so the kernel computes a topological order over facets **before running any of them**. Every facet is constructed after all of its dependencies. Failures — missing provider, duplicate singleton owner, cycle — are reported against the manifest with no facet code executed. **Cycles between facets are rejected.** No ordering satisfies them, and the survey of coding-agent features found no genuine construction-time cycle: tools and providers are fan-in through contribution registries; hooks are the host calling you; telemetry is a leaf; wrappers are ordered composition. The cases that look circular are late *call-time* references, not construction dependencies. Those get an explicit, visible escape hatch: ```ts uses: [Tools, deferred(QuestionDialogs)] // ctx.use(deferred(X)) returns () => X, resolved on first call, after assembly ``` Deferral is opt-in and rare, so the cost of a knot is local and legible rather than being paid by every facet in the form of universally unusable proxies. ### 5.1 Same-facet composition A facet's services share module and closure scope. Two services that need each other are ordinary JavaScript: build both inside construct, wire the references by hand, return both. Shared private state is a variable both close over. The kernel is not involved and there is no intra-facet graph. If two services in one facet need each other symmetrically, that is usually one service with two faces, or one private object behind two facades. ## 6. Hosts, connections, and direction of authority **A host may depend only on what it connects to, and connections form a tree.** That single rule replaces any global sort and keeps the cross-process graph acyclic without special-casing the server as "built-ins that are just there." Each host sorts locally. Remote provisions are **leaves** — the upstream process constructed them before announcing, so they are already satisfied by definition. Concretely: - A **session worker** connects to the server, so worker facets may `use` server tokens. This is required: a `spawn_subagent` tool needs the server's session management. - A **presentation** connects to the server, so TUI facets may `use` server tokens. - A presentation also consumes **session** services — but the provider from its point of view is the server, which routes. The server's catalogue simply grows when a session is attached. There is no direct presentation↔worker connection. ### 6.1 The server never depends on a worker The server must construct before any worker exists, so it cannot `use` a worker service. Inverted flows use a **reporting registry**: the server provides a token that workers push into. ```ts // contract.ts export interface SessionStatusReporting { report(status: SessionStatus, context: Context): Promise; } export const SessionStatusReporting = defineService("pi.session-status-reporting"); export interface SessionStatusView { readonly state: State>; } export const SessionStatusView = defineService("pi.session-status"); ``` The worker `uses` the reporting token and pushes; the server aggregates into replicated state; presentations read the aggregate. Dependencies still point upward, data flows down. This gets the lifecycle right for free: the aggregate exists immediately and is empty; workers appear, register, and vanish; a crashed worker is an entry removal, which the server already knows about because it owns the connection. ## 7. Facet delivery and generations **A presentation ships with no plugin facets.** It has host services (`Tui`) and nothing else. All plugin facets arrive over the wire as built bundles. This dissolves the problem of a detached presentation holding unresolved requirements: while detached there are no requirements, because there are no facets. Two generations, with different lifetimes: | generation | source | lifetime | example | | --- | --- | --- | --- | | **connection** | server | the server connection | session picker | | **attachment** | session worker | one attachment | question dialog, chat | Switching sessions tears down and rebuilds only the attachment generation. The picker keeps running throughout — which it must, since it is what triggers the switch. The attachment generation resolves against the connection generation (upstream first, per §6). The reverse is forbidden: a connection-generation facet cannot depend on an attachment-generation token, because the attachment can go away. ### 7.1 Why the worker chooses the presentation facets Plugins may be **global** or live in a session's **working directory**. So the set of TUI facets depends on where the TUI was started — same binary, different capabilities per project. The worker therefore knows which TUI bundles belong with it, and ships them through the server on attach. The working directory is not merely a spawn parameter; it is an input to the graph. > **Security:** attachment-generation bundles are third-party code arriving from a > project directory and executing in the user's presentation process. Trust policy > for directory-local plugins is an open decision and must be settled before this > ships. ## 8. Startup and handshakes ### 8.1 Presentation connect ```text TUI → server: connect server → TUI: catalogue of server RPC provisions + connection-generation bundles TUI: assemble, validate, construct connection generation ``` ### 8.2 Reaching a session Three routes, converging on one attach: | invocation | route | | --- | --- | | `pi` (bare, in a directory) | ask server to create a session for cwd | | `pi --resume` | invoke the picker command at startup | | `pi --session ` | attach directly | `--resume` needs no special machinery: the picker is an ordinary command registered by the server-sourced picker facet, and resume invokes it at startup instead of waiting for a keystroke. Same code path either way. Create carries the working directory, since the server needs it to spawn and the worker needs it to resolve local plugins. ### 8.3 Attach ```text TUI → server: attach(sessionId) server: authorize; tear down previous attachment generation; bind routing to worker server → worker: client attached (identity) worker → server: catalogue of session RPC provisions + TUI bundles server → TUI: catalogue + bundles TUI: assemble, validate, construct attachment generation worker/server: hydrate state state values ``` The TUI's kernel validates once, with the worker's catalogue already in hand. There is no provisional or degraded resolution state. ### 8.4 Worker startup ```text worker → server: connect server → worker: catalogue of server RPC provisions worker: load global + cwd-local plugins; assemble; construct ``` The worker's own dependencies on server tokens resolve here, before any attachment. ## 9. Replication primitives Exactly three things cross a connection. ### 9.1 Service calls Ordinary request/response. For actions (`select`, `submitAnswer`) and one-shot reads of things that do not change (scrolling back into chat history). Nothing is replicated. Arguments and results are strict JSON; `Context` is stripped by the proxy and reconstructed at the endpoint. ### 9.2 Replicated state One authoritative writer, many readers. The stream carries one base op batch followed by delta batches; the vocabulary is in [delta.md](../../01-harness/01-delta/delta.md). Full-value replication is the degenerate configuration where the producer explicitly replaces or rebases on every update. It is not a separate primitive: Chord emits the root replacement op `r`. #### State is plain TypeScript Facets never write ops. A provider mutates its state object normally; the framework's tracker (`delta.md`) records intent and emits ops. ```ts // contract.ts export const TranscriptState = defineState("pi.transcript.tail"); ``` ```ts const tail = scope.state(TranscriptState, initial); tail.mutate((s) => { s.entries.push(entry); // -> splice s.entries[0].text += chunk; // -> append delete s.pending; // -> delete }); ``` No mutation map, no recipes, no declared operations, no Immer. There is no `defineValueState` / `defineReducedState` split either — an earlier draft needed one because reduced states carried typed deltas a differ could not recover, and the tracker recovers them. `mutate` is synchronous and takes no `Context`. A mutation is a pure state transition: it calls nothing, cannot be cancelled, and has no caller identity to consult. Authority is checked in the method that decides to mutate. #### Ops never carry provider code Consumers apply ops. They never see mutation names and never run provider code. This matters for a specific reason: a consumer running provider code would have to resolve a definition from a registry, making the fold depend on ambient registration — a reload changes the answer for the same input. Ops remove that, and make non-JS consumers trivial, since the applier is six verbs. Consequences: - mutation names are not part of the wire contract and appear in no member table, so there is no mutation-name version skew; - a consumer needs no schema for arguments, only for the value in the root `r` op; - `x = undefined` normalises to `delete`, since JSON has no `undefined`. #### Wire protocol The producer and applier use `Op[]`; a subscription carries encoded `WireOp[]`. There is no frame type and no `seq` in the payload: the SSE binding stamps `id:`, which is where transport metadata belongs (`delta.md` §6). `Op` and its encoding — six verbs, array paths, tuple form, second-use path interning — are specified in [delta.md](../../01-harness/01-delta/delta.md) §2 and §4. They are not restated here. **A replacement is an op**, `["r", value]`. There is no frame discriminator. A batch whose first op is `r` is a **base batch**, and batch zero is always one. This collapses several things `plugins.md` treats separately: - there is no distinct wire hydration shape — the snapshot is simply the first batch; - snapshot buffering remains a local adapter detail rather than a second wire protocol; - cold start, reconnect, provider reload, session switch, a sequence gap, and a fold that could not apply are **one code path**: send a fresh base batch. That last item is why there is no `Rebase` type anywhere in this design. The SSE `id:` is stamped by the host binding, starts at zero on every subscription, and is contiguous. A consumer seeing a gap discards its replica and resubscribes. #### Resubscription is a base batch plus buffered batches There is no cross-subscription resume and no `Last-Event-ID`. Resubscribing works the way the harness already hands out lane state: snapshot the current value as base batch zero, buffer whatever arrives while the client is catching up, then deliver the buffer as ordinary batches once it is ready. That is the same two-phase shape as `AgentHarness.watch` — `snapshot` then `start` — and it is a purely local detail of the provider binding, invisible on the wire. A consumer sees a base batch followed by contiguous batches, exactly as on cold start. Real resume would need a retained op log so a client could ask for "everything after seq N". We deliberately do not keep one: the durable form is a list of batches per *value* ([delta.md §9](../../01-harness/01-delta/delta.md#9-durable-form)), retired with its scope, not a per-subscription history. Buffering costs a bounded amount of memory for the duration of a handshake; an op log would cost unbounded disk forever. The landed tracker emits structural ops without a serialized-size heuristic. Provider replacement, reconnect, and policy-driven recovery bounds call `replace()`/`rebase()` explicitly; those are the only sources of a root `r` batch. #### The harness does not emit ops The harness emits typed `HarnessEvent`s and always will. Ops appear one layer up. A lane facet calls the harness in process, subscribes to typed events, and folds them into **its own** state shape by plain mutation. The tracker turns that into ops. The facet never writes an op and never thinks about paths. This fold has to exist regardless, because **the replicated shape is a facet's choice, not the harness's**. Emitting ops from the harness would not remove the fold; it would only replace "events into state" with "ops into state, with worse types", and it would make `LaneSnapshot`'s field layout the wire contract. ```ts // packages/agent/src/harness/runtime/reducer.ts export function reduceLaneSnapshot(view: LaneView, event: HarnessEvent): void; ``` Plain mutation, no Immer, no return value. See `harness-tools.md` §6. #### Shaping state to avoid write amplification Two rules, both consequences of the tracker recording intent: - **Keep growing strings at stable paths** — one per content block, one per tool operation's output. A delta then touches exactly one path, which is also what makes path interning collapse it to a single integer. - **No derived fields in replicated state.** A parsed value recomputed from a raw one is a fresh reference on every parse, so it ships as a whole-value `set` and duplicates information already present. Derive on demand instead (`message-update.md` §5.2). An array of content blocks is fine: it takes one `splice` when a block appears and then nothing. What changes afterwards is a string *inside* a block. #### Requesting a state value A provider asks the host to build the state value during `construct`. Facets never construct one themselves, because the state value is a binding: the host owns its subscriber table, revision stamping, and disposal. ```ts interface State { readonly value: T | undefined; // undefined until base batch zero subscribe(listener: (value: T) => void): void; // returns nothing (§13) } // on ConstructContext and InstanceScope state(definition: StateDefinition, initial: T): MutableState; ``` Provider side: ```ts export const transcriptSession = defineFacet({ id: "@pi/transcript:session", uses: [Agent], provides: [Transcript], observes: [], construct(ctx) { const agent = ctx.use(Agent); const tail = ctx.state(TranscriptState, { entries: [] }); agent.onEntry((entry, context) => { tail.append([entry]); if (tail.value.entries.length > TAIL_LIMIT) { tail.evict(tail.value.entries.at(-TAIL_LIMIT)!.id); } }); return [ provide(Transcript, { tail, page: (params, context) => archive.read(params.before, params.limit, context), }), ]; }, }); ``` A `mutate()` call advances the authoritative value **and** publishes the resulting ops — one statement, so the value and the wire cannot disagree. `replace()` publishes an explicit root `r` batch and is what reconnect, provider reload, resnapshot, and policy-driven recovery bounds use. Consumer side: ```ts export const transcriptTui = defineFacet({ id: "@pi/transcript:tui", uses: [Transcript, Tui], provides: [], observes: [], construct(ctx) { const transcript = ctx.use(Transcript); const tui = ctx.use(Tui); transcript.tail.subscribe((tail) => render(tail.entries)); // base batch zero arrives here too tui.commands.register("transcript.older", async (context) => render(await transcript.page({ before: oldestId(), limit: 100 }, context)), ); return []; }, }); ``` The consumer never sees opcodes. It receives values, and base batch zero is delivered as an ordinary update — so there is no separate "ready" callback and no hydration branch in facet code. #### Worked: adapting `AgentHarness.watch` The real API is asynchronous throughout: ```ts interface AgentHarness { lane(name: string, context: Context): Promise; lanes(context: Context): Promise; } interface AgentLane { watch(context: Context): Promise>; } interface WatchHandle { snapshot: T; start(listener: EventListener): void; resnapshot(context: Context): Promise; unsubscribe(): void; } ``` `watch()` installs the subscription **and** captures the snapshot inside a single `readLane` critical section, so both are taken under the same lock. Events arriving before `start()` are buffered, then flushed. `resnapshot()` does the same with a `markBoundary()` callback re-establishing where the stream resumes. That is exactly the guarantee base batch zero needs, so the adapter is thin. `LaneSnapshot` is per lane, so `Lane` is keyed — one instance per lane, each taking its own `watch()`. The harness already filters by lane (`event.type === "usage" || !("lane" in event) || event.lane === this.name`), so the adapter does no routing. Because `watch()` is async, **instance factories may be async**. The instance is announced once the factory resolves; `add(key)` returns its closer immediately. ```ts export const Lane = defineKeyedService("pi.lane"); // key = lane name export const laneSession = defineFacet({ id: "@pi/lane:session", uses: [Harness], provides: [Lane], observes: [], construct(ctx) { const harness = ctx.use(Harness); const lanes = ctx.owner(Lane); ctx.onActivate(async (context) => { for (const info of await harness.lanes(context)) lanes.add(info.name); }); return [ provide(Lane, async (laneName, scope, context) => { const lane = await harness.lane(laneName, context); const handle = await lane.watch(context); // phase 1: snapshot + subscribe // The facet's own shape, not LaneSnapshot. reduceLaneView is its code. const state = scope.state(LaneState, toLaneView(handle.snapshot)); handle.start((event) => { // phase 2: buffered, then live if (event.type === "navigation_end") { void handle.resnapshot(context).then((fresh) => state.replace(toLaneView(fresh))); return; } state.mutate((v) => reduceLaneView(v, event)); // plain mutation; ops fall out }); return { snapshot: state, setModel: (ref, ctx2) => lane.setModel(ref, ctx2) }; }), ]; }, }); ``` Consumers declare `observes: [Lane]` and get one task per lane, each with its own replica. Four things to notice. - **The `watch`/`start` handshake does not cross the wire.** It is a local detail of one adapter. Remote consumers see base batch zero, then ops. Mini's buffering dance disappears, because the harness already buffers between the two phases and the flush lands as ordinary mutations after the initial value. - **The facet owns the replicated shape.** `LaneView` is the facet's, not the harness's — hence `toLaneView`. This is the point made above: the fold exists regardless, so the harness gains nothing by emitting ops itself. - **Rebase never reaches consumers, and is not a type.** `navigation_end` is an ordinary event the adapter answers with `resnapshot()` plus `replace()` — the same base batch a reconnecting client receives. `markBoundary()` supplies the ordering. No reducer signals anything by returning a value. - **`HarnessEvent` is not a wire format.** `message_update` today carries the full message *and* an `AssistantMessageEvent` holding a second copy *and* the delta, so shipping it would be worse than shipping a snapshot. See `message-update.md`. It never ships here, because ops travel. - **Most of the union is already lane state.** `usage` folds into `stats.usage`; config `value_update` into `configuration.*`; `message_update` into `operation.streamingMessage`; `tool_start`/`tool_update` into `operation.runningTools`; retries into `operation.retry`. The state value is very nearly everything a presentation needs. The three that do **not** belong in it are a useful test of §9.4: | event | why not a mutation | where it goes | | --- | --- | --- | | `lane_created` | creates a lane, does not change one | `lanes.add(name)` — a new instance | | `handler_error` | diagnostic; no late joiner needs it | events primitive | | global `value_update` | not lane-scoped | state on whichever service owns that value | Note `lane_created` is delivered on the lane event stream, so discovering new lanes after startup requires a session-level watch rather than the per-lane ones above — an open question in §16. #### Host responsibilities Everything below is host machinery, not facet API: - stamping `seq` per batch within the provider binding, so business snapshots and ops carry no transport metadata; - delivering a root `r` base batch before deltas on a fresh subscription; - resetting each subscriber's encoder dictionary on that base batch; - applying only consecutive batches, and requesting a fresh base batch after a gap, reconnect, or provider replacement; - hardening values as they cross the boundary (§14.3); - announcing the state value definition ID in the member table. Mutation names are **not** announced, because they never travel — ops are structural, so a consumer needs no knowledge of how a value was produced. Two rules follow from the provider and every replica running the same fold: > **A reducer's state must be part of the replicated value.** Anything accumulated > beside it — the classic case being a raw JSON string being parsed incrementally — > diverges on any consumer that did not run the producer's fold. > **The durable path uses only host-owned reducers.** Plugin-interpreted data must be > skippable, so an unreadable stream degrades that one value instead of failing the > record that contains it. Mutation-name version skew does not exist: mutations never cross a boundary, so adding, renaming or removing one is not a breaking change. The version-skew surface is the *value shape* alone, which the root `r` batch's schema describes. Prefer several coarse state values to one large value. A cold replica has `value === undefined`; that is local readiness and never crosses the wire. ### 9.3 Events Fire-and-forget broadcast. No history, no durability, dropped on disconnect. For presence, cursors, in-progress drags, transient notifications. ### 9.4 Which to use > **Does a late joiner need it?** > Yes → replicated state. No → event. State: transcript, model catalogue, session status, chat tail, canvas document. Events: who is typing, where someone's cursor is, an uncommitted stroke. ### 9.5 Worked: chat, and the tail/archive split A chat room is not one state. It splits along the same line: - **live tail** — last N messages as a state value. The first batch replaces with the window; appends and evictions are ops. - **archive** — a plain service call returning a page of older messages. Immutable history, no liveness requirement, not replicated onto anything. There is no `subscribe` method. Attaching to the state value *is* the subscription, and it produces the snapshot, so the server registers the reader before it reads. That closes the window between a query and a later subscribe, and means the server never has to answer "what happened after an arbitrary client-supplied cursor" — the class of race that client-chosen timestamps create. The lane transcript is the same pattern: recent entries in the state value, older ones fetched by query when the user scrolls up. ### 9.6 Worked: shared canvas Document state is a state value; strokes are ops. Multi-writer is handled by **optimistic apply with acknowledgement**, not CRDT: 1. client generates an op ID, applies locally as unacknowledged, sends it; 2. server accepts first-come-first-served and publishes the resulting ops; 3. client sees its own op ID return and promotes it to acknowledged. Note this is the one place the single-writer assumption behind `delta.md` is relaxed, and it is relaxed by serialising at the server rather than by merging. The op vocabulary has no `test` verb precisely because there is no conflict model to support. In-progress drags never touch the document. They are events, keyed by peer, latest-wins, dropped on disconnect — which is exactly why the events primitive exists. ## 10. Peers, principals, and authority ### 10.1 Principals on the context Every service method already takes a `Context`. The proxy strips it on the way out and **reconstructs it at the endpoint**, so the kernel fills in the caller's principal from the authenticated connection. It is control-plane data: never an argument, never forgeable. ```ts type Principal = | { kind: "local" } // same process, full authority | { kind: "user"; peer: PeerId; userId: string; role: Role } | { kind: "process"; peer: PeerId; host: "worker" | "server"; sessionId?: string }; interface Context { readonly abortSignal: AbortSignal; readonly principal: Principal; // ... } ``` There is exactly one `Context` type, and `principal` is always populated — the same interface locally and across a connection, so a method body reads `context.principal` without knowing which it was. That is the point: a service must not have an unchecked path that only appears in-process. Peers are not only humans — a worker calling the server through a reporting registry is a peer too, and server facets often want to distinguish *my own worker* from *some TUI*. In-process calls carry an explicit `local` principal rather than an absent one, so a missing check cannot masquerade as a missing peer. ### 10.2 Per-peer services **Authority-bearing state belongs on a `peer` service, not a singleton.** A singleton has one value by definition, so it cannot show an owner and a guest different roots — there is nowhere for the difference to live. The provider declares the token `peer` and returns a factory. The host calls it once per connected peer, passing that peer's principal, and announces the result to that peer only. Each instance gets its own state values from its own `scope`. The consumer declares it in `uses` and calls it. No `observes`, no reconciliation, no set to watch — from a client's side exactly one instance exists, because exactly one was ever announced to it. ### 10.3 Worked example: file browsing ```ts // contract.ts export interface BrowseRoot { readonly handle: string; // opaque, per-principal, revocable readonly label: string; } export interface BrowseView { readonly roots: BrowseRoot[]; readonly note?: string; // e.g. "Ask the owner for wider access" } export const BrowseState = defineState("pi.browse.view"); export interface FileBrowsing { readonly view: State; list(handle: string, path: string, context: Context): Promise; } export const FileBrowsing = definePeerService("pi.file-browsing"); ``` Server facet: ```ts export const fileBrowsingServer = defineFacet({ id: "@pi/file-browsing:server", uses: [Fleet], provides: [FileBrowsing], observes: [], construct(ctx) { const fleet = ctx.use(Fleet); return [ provide(FileBrowsing, (principal, scope) => { // One table per instance. Handles are minted here, so a handle a peer // was never given is not merely rejected — it does not exist for them. const table = new Map(); function publish() { const roots = rootsForPrincipal(principal, fleet).map((path) => { const handle = newHandle(); table.set(handle, path); return { handle, label: labelFor(path) }; }); return roots.length > 0 ? { roots } : { roots: [], note: "No browsable locations for this account" }; } const view = scope.state(BrowseState, publish()); return { view, async list(handle, path, context) { const root = table.get(handle); if (root === undefined) throw new ServiceError("forbidden", "unknown root"); return readDirectory(root, path, context); }, }; }), ]; }, }); function rootsForPrincipal(principal: Principal, fleet: Fleet): string[] { if (principal.kind === "local") return [ROOT]; if (principal.kind === "user" && principal.role === "owner") return [ROOT, ...fleet.workspaces()]; if (principal.kind === "user") return fleet.workspacesFor(principal.userId); return []; } ``` TUI facet — note that it contains no permission logic at all: ```ts export const fileBrowsingTui = defineFacet({ id: "@pi/file-browsing:tui", uses: [FileBrowsing, Tui], provides: [], observes: [], construct(ctx) { const browsing = ctx.use(FileBrowsing); const tui = ctx.use(Tui); tui.commands.register("files.browse", async (context) => { const view = browsing.view.value; if (view === undefined) return; if (view.roots.length === 0) { await tui.notify(view.note ?? "Browsing unavailable"); return; } const root = await tui.select( "Browse", view.roots.map((r) => ({ label: r.label, value: r })), { signal: context.abortSignal }, ); if (root === undefined) return; let path = ""; for (;;) { const entries = await browsing.list(root.handle, path, context); const chosen = await tui.select( root.label, entries.map((e) => ({ label: e.name, value: e })), { signal: context.abortSignal }, ); if (chosen === undefined) return; if (!chosen.isDirectory) return void open(root.handle, join(path, chosen.name)); path = join(path, chosen.name); } }); return []; }, }); ``` An owner's `view` state carries both a machine root and workspace roots; a guest's carries one or none. Same bundle, same code path, different data. The TUI never branches on a role, never greys anything out, and cannot construct a handle it was not given. ### 10.4 Server-side validation is still mandatory The state governs rendering. It does not constrain the wire: a hostile client can call any method with any argument, including one it never received. `list()` therefore checks its handle against the instance's table. Prefer **unforgeable handles over parsed paths** — a table lookup fails by construction, where path validation invites `..`, symlink, normalisation and case-folding bugs. Revocation is free: drop the entry and outstanding handles stop working. Where an argument cannot be a handle, the method reads `context.principal` and decides. The rule is that authority is checked at the provider, every time, no matter what the client was previously shown. ### 10.5 The peers cell Presence is replicated state the host provides: ```ts export const ConnectedPeers = defineService<{ readonly state: State> }>("pi.peers"); ``` Server and worker facets read it to enumerate who is attached. A facet needs it only for genuinely multi-peer features — a presence roster, a "3 viewers" badge. Per-peer state does **not** need it, because `peer` mode handles instantiation and teardown for you. ### 10.6 Keyed instances `keyed` remains what it always was: many instances, all visible to every consumer, each producing one abortable task under `observes`. Question dialogs are the canonical case — three concurrent invocations produce three instances, and every attached presentation sees all three. An instance is addressed by `(service, key, generation, member)`, so its state values are naturally per-instance. Reusing a closed key creates a new generation, so stale proxies cannot address the replacement. The distinction to keep straight: - **Do I want all of them?** → `keyed` + `observes`. - **Do I want mine?** → `peer` + `uses`. ## 11. The presentation surface `Tui` is an ordinary singleton token, declared in `uses` and resolved through `ctx.use` like any other. It is provided by the presentation kernel rather than a plugin, so it sits at the root of the local sort, but facets cannot tell the difference. ```ts interface TuiHost { readonly slots: SlotContributions; readonly commands: CommandContributions; // handlers take an AbortSignal; see §11.3 readonly keybindings: KeybindingContributions; readonly toolRenderers: ToolRendererContributions; notify(message: string): Promise; acquireModal(signal: AbortSignal): Promise; select(title: string, items: SelectItem[], options: { signal: AbortSignal }): Promise; } const Tui = defineService("pi.local.tui", { rpc: false }); ``` ### 11.1 Slots A slot is a named region of host chrome a facet may fill. ```ts interface SlotContributions { /** Exclusive. A second claim on the same slot is an assembly error. */ claim

(slot: string, factory: (props: P) => Component): void; /** Additive. Ordered by contribution order. */ add

(slot: string, factory: (props: P) => Component): void; } ``` ```ts ctx.use(Tui).slots.claim("footer", (props) => new FooterComponent(props, models)); ``` `claim` is exclusive and a duplicate is an **assembly error, not last-write-wins** — two plugins silently fighting over the footer is a bug that must surface at validation, alongside missing providers and duplicate singletons. Props are DTOs. A factory never receives the `TUI` or `Theme` instance, so a component cannot reach back into host internals through its own arguments. ### 11.2 Mounting is host-owned Facets do not mount or unmount. They contribute a factory; the host instantiates it, tracks the mount against the contributing facet, and **unmounts it on disposal** — restoring the built-in chrome for a claimed slot, or dropping the entry for an additive one. There is therefore no `dispose?()` on a contribution, and no teardown for a facet author to forget. This is the same rule as §13: registration is ownership. Two consequences fall out of the generation model in §7: - **Attachment drop** — presentation facets consuming session services are disposed, so their mounts vanish and host chrome renders in their slots. Only connection-generation chrome reads attachment state. - **Session switch** — attachment-generation facets are disposed and their mounts go with them; connection-generation mounts survive, so there is no flicker and no lost scroll state. ### 11.3 Contributions with in-flight work §11.2 covers *removal*: the host tracks each mount against its contributing facet and unmounts on disposal, so there is no teardown to forget. That is complete for anything whose disposal is synchronous. It is not complete for contributions that can be **executing** when their contributor is disposed — the presentation analogue of an in-flight tool call (§13.2). Three cases, and only one of them is hard. **Synchronous callbacks are free.** A keypress handler, a render call, a click listener. JavaScript is single-threaded, so a handler runs to completion before disposal can be scheduled — disposal cannot interleave with a synchronous frame. Deregistration is a list removal. Nothing further is needed, and this is most of the surface: slots, keybindings, tool renderers, theme tokens. **Modals and pickers already carry a signal.** `acquireModal(signal)` and `select(title, items, { signal })` take an `AbortSignal` because the host may need to dismiss them. Disposal aborts it, the promise rejects, and the facet's `await` unwinds. The mechanism exists; disposal just has to use it. **Async command handlers are the hard case**, and they are exactly a tool call in different clothing: ```ts ctx.use(Tui).commands.add("deploy", async (args, signal) => { await longRunningThing(signal); }); ``` The user runs `/deploy`, the facet is disposed ten seconds later, and the handler is still awaiting. So `CommandContributions` is a registry with in-flight work and takes the same four phases as the tool registry: deregister, signal, race the kernel deadline, and settle by outcome — here dismissing any progress UI and restoring host chrome rather than producing a `ToolResultMessage`. Two consequences for the API: - **A command handler receives an `AbortSignal`.** Not optional. Without it the registry can deregister but cannot signal, and phase 2 is unavailable. - **The host renders command progress, not the facet.** If a facet paints its own progress and is disposed mid-command, the paint outlives the painter. Host-owned progress is removed with the mount, which is the §11.2 rule applied to a case §11.2 did not name. The rule this generalises to: > A contribution registry needs settlement machinery **iff** a contribution can be > mid-execution across disposal. Synchronous contributions never can; anything > handed an `AbortSignal` always can. Which makes the signal the marker. A registry whose contributions take a signal owns invocation tracking and a deadline; one whose contributions do not is a plain list and disposal is a removal. ### 11.4 Reload Reload is unload plus load, so mounts are torn down and rebuilt by the same path. Cross-host reload is two-phase, so no facet is ever alive with a dead provider: ```text 1. server → presentations with dependent facets: dispose, ack (the provider is still ALIVE, so in-flight calls abort against a live peer) 2. server → session worker: dispose + reload 3. server → presentations: load race(acks, 2s); a presentation that misses the window goes degraded, which disposes those facets anyway. ``` Calls carry a caller generation and the provider drops retired ones. Because a reload is a round trip to every connected presentation, it stays user-initiated — it must not be wired to a file watcher. ## 12. Contribution registries Services are one owner, many consumers. Providers and tools invert that: many contributors, one host-owned result. A registry replays ordered contributions over a fresh working copy, so removal is a rebuild rather than an inverse mutation. (Unrelated to the tracker in `delta.md`; nothing here is replicated.) ```text fresh working copy → built-in providers (@pi/providers-builtin) → remote catalogue (@pi/providers-catalog) → models.json transform (@pi/providers-models-json) → auth/availability mark (@pi/auth) → validated state ``` Tools additionally support ordered wrapping — `telemetry(permission(sandbox(bash)))` — which recomposes deterministically when a contributor disappears. Only the host finalizes a draft; facets never call `setTools()`. Slots, commands, keybindings, and tool renderers (§11) are all instances of this same shape on the presentation host. **Registries split by whether a contribution can be in flight.** A registry whose contributions are values — slots, keybindings, theme tokens, tool renderers — is a list, and disposal is a removal (§11.2). A registry whose contributions are *invoked and take an `AbortSignal`* — tools, commands — owns invocation tracking, a signal chained from the caller's, and settlement against the kernel's disposal deadline (§13.2). The signal in the contribution's signature is the marker for which kind it is. Contributions configure rebuilt behaviour; hooks intercept live operations. They remain separate mechanisms. ## 13. Lifecycle, failure, disposal - Construct in dependency order; dispose in reverse. - A facet owns its provisions, keyed instances, and observations. Ownership is **implicit**: every handle a facet receives is a host-built binding that registers its own disposer, so `subscribe`, `on`, timers, and instance handles are released with the facet. There is no `own()` and nothing to remember. This covers handles the host hands over; it does not cover ambient authority a facet can reach without being handed anything (§14.3). Consequently `subscribe()` returns nothing — there is no unsubscribe handle to hold or leak. - Admitted inbound calls may finish while their provider deactivates; withdrawal rejects new calls. - Disconnect aborts in-flight requests and closes that peer's subscriptions and instance tasks. It must **not** perform service-owned or durable cancellation. - Three cancellation domains stay distinct: per-invocation, service-owned (`job.cancel()`), and durable Harness (`requestAbort()`). - Never blindly replay a mutation after an uncertain disconnect. Reconnect, hydrate, reconcile — or design around a stable operation ID. - Errors cross as `{ code, message }` with stable codes; unexpected exceptions become `internal_error` with no stack. Reload: shape-preserving replacement by `Facet.id` is permitted when the replacement declares an identical manifest. Because the manifest is static, this check happens **before** constructing the candidate — a real improvement over validating shape by running setup. Structural changes require graph reassembly or a process restart. ### 13.1 Teardown, not swapping The alternative is a proxy per dependent, swapping the implementation behind consumers so they are never torn down. It is rejected, and the prior art is unusually clear. **OSGi ships both.** Bundle refresh computes the transitive closure of everything wired to the old exports and restarts it; Declarative Services also offers `ReferencePolicy.DYNAMIC`, where a consumer keeps running across a swap. Their own guidance makes `STATIC` the default, because dynamic requires every consumer to be defensive about the service vanishing mid-call. **Cordis is the proxy model, and the ergonomics show.** `ctx.get(name)` returns `undefined` when absent and the guidance is to *"handle their absence"* — the defensive check is the recommended path. `inject` opts *into* teardown: the plugin enters waiting and is reactivated when the service returns. **JS HMR is this design with one addition.** Updates propagate up the import graph until a module calls `import.meta.hot.accept()`; if nothing accepts, full reload. React Fast Refresh bails to full reload when a hook signature changes — the same conclusion, reached from a different direction: implementation swaps survive, shape changes do not. **Erlang is the counterexample and it does not transfer.** Two module versions coexist and `code_change/3` migrates state at the transition. That works because contracts are messages rather than types, so a process can handle both shapes during changeover, and because isolation means there is no shared state to reconcile. Neither holds here. Three specific objections, in increasing order of severity: **Contract changes.** JVM HotSwap permits method bodies only; DCEVM and JRebel go further and still break on shape change. We are better placed than any of them, because a token's `protocol` block carries a TypeBox schema and `CheckComplete` already does mutual assignability — so a swap *could* be gated on the new provider's schema being assignable both ways. This is the one objection we can actually answer. **In-flight registrations.** A tool contributed by the old provider may be mid-execution. It cannot be cancelled (side effects have happened), handed over (different closure), or allowed to settle into a registry that no longer contains it. A proxy does not help: the invocation is bound to the implementation that started it. §13.2 is required either way, which is most of why the proxy earns nothing. **State.** This is the one with no answer. A facet holds a replica derived from the old provider's stream. After a swap it is either stale — silently wrong — or the new provider sends a base batch, which *is* a resubscribe. The state was torn down; only the facet was not. Erlang solves this with an explicit migration hook, which is Erlang's design without Erlang's isolation. **Cached references make the proxy worse, not better.** Cordis's guard lives on the access path, not on the value: `ctx.foo` throws if the service is gone, but a reference you stored keeps working and calls into the dead plugin's closure. Our `ctx.use(Token)` returns a value at construct time by design, so we have the same hazard — and teardown-of-dependents is what makes it safe, because the holder dies with the provider. A proxy does not fix cached references; it makes them silently wrong instead of impossible. > **Deferred, not adopted.** If teardown ever proves too coarse, the addition is > HMR's `accept()` rather than OSGi's dynamic policy: a per-dependency opt-in, > `uses: [Harness, accepts(Models)]`, meaning *my acquisition can be re-pointed, I > derive no state from this provider, and I hold no in-flight registrations against > it*. The kernel would permit the swap only if the schema check passes and > silently fall back to teardown otherwise. **Teardown must remain the path that > always works**; the moment `accepts` is load-bearing for correctness, every > consumer is writing defensive code again. > > Worth measuring the cost of teardown before building it. If disposing a > presentation facet is a repaint, the mechanism buys nothing. ### 13.2 Disposal with in-flight work Disposal removes a registration. That is sufficient for a service, and **not** for a registry whose contributions can be executing when their contributor goes away. The forcing constraint is settlement, not cleanup: the harness owes the model a `ToolResultMessage` for every call it started. "The facet went away" is not a result, and an operation waiting on one hangs. Neither reference system solves this. Cordis's `_unload` is `await Promise.all(disposers)` with a try/catch and no deadline — a disposer that hangs hangs the reload. DSH goes further and places the obligation on the tool author: async work must *"observe or forward `exec.signal` and settle only after"* reaching *"quiescence"*, with the registry rechecking cancellation afterwards. That is the settlement concept Cordis lacks, but it is stated in prose and enforced by nothing, so a tool that ignores its signal still wedges the unload. **Four phases, and the last two are what neither system has:** 1. **Deregister.** The contribution leaves the registry immediately. Synchronous, cheap, and it stops the problem growing. 2. **Signal.** Abort each in-flight invocation. 3. **Race a deadline.** Kernel-imposed, on every disposer. 4. **Settle by outcome.** At expiry the registry resolves *its own* promise with an aborted outcome and abandons the contributor's, attaching a catch and dropping the reference. The harness sees an ordinary aborted invocation and its existing `abortedMessage` path produces the result. No new settlement machinery. Phase 4 is what makes the deadline safe rather than a leak with extra steps: **the invocation settles even when the contribution does not.** **Layering.** The kernel knows facets and contributions, not invocations, so it bounds disposal generically: every disposer gets a deadline, and one that overruns is logged and dropped. A registry with in-flight work is what makes that deadline meaningful, because only it knows a call is outstanding. The signal is **registry-owned and chained** from the harness signal, never the harness signal itself — so deregistering one tool aborts that tool's invocations without cancelling the operation. DSH's warning that *"replacement cannot detach caller cancellation"* is about exactly this; the chain must be one-way. **Invocation tracking belongs to the registry, not the contributor.** A facet could track its own outstanding calls and signal them, but then a buggy or hostile facet has no bound at all. Every invocation already routes through the registry, so it can hold the signal and the count without trusting anyone. **What the deadline actually guarantees, stated honestly:** disposal completes in bounded time and the kernel holds no reference afterwards. **Not** that the bundle is collected. If the abandoned contribution has a live interval, an open socket, or a pending child process, its own async work still references the closure and the compartment stays alive. We can bound our retention; we cannot bound the runtime's. The only real fix is process isolation, where teardown is a kill — which is why Erlang supervisors terminate rather than negotiate, and why OSGi `refresh` can hang on a bundle that will not stop. ## 14. Isolation and trust Condensed; a full treatment lives in the isolation spec. Recorded here because it constrains the API surface above. ### 14.1 Threat model **A malicious server ships a facet to an unsuspecting client.** §7 states that a presentation ships with no plugin facets and that all of them arrive over the wire as built bundles. So third-party code executes in the user's process by design, and connecting to a server is not consent to run its code. The victim is the *user*, with their filesystem, their credentials, their SSH keys — not the operator. An earlier draft scoped this to "a careless plugin author, not a hostile one", with a determined attacker explicitly out of scope. **That was wrong** and it selected the wrong mechanism. The attacker is hostile, the delivery is remote, and the target is a workstation. **Availability is not in scope.** A malicious server can already refuse to serve, hang, or send garbage; crashing the client is a subset of that. What must be prevented is **disk access and exfiltration**. That asymmetry decides several things below, and it is the reason this section is shorter than a general sandboxing treatment would be. ### 14.2 Mechanism: a V8 isolate behind a string-only membrane Facet code runs in an `isolated-vm` isolate. Measured behaviour, not inference — see the sandbox PoC: ``` require / process / fetch undefined globals visible 64 Function("return process")() undefined spinning guest interrupted at its timeout, isolate reusable after ``` **What was rejected, and why.** *SES / `lockdown()`* — an earlier draft chose this. It hardens intrinsics but leaves guest and host **in the same VM**, which is precisely the class Figma's Realms shim failed on: "confusing an object from outside the sandbox with an object from inside... possible because the shim uses the same JavaScript VM for all code both inside and outside". Figma shipped Realms, was breached within two months, and moved to a different VM. Choosing SES repeats their first attempt. *Node `worker_threads`* — `terminate()` is a genuine kill (3 ms on a tight loop), but a worker has full `fs`, `env` and `child_process`. Node's `--permission` model does apply inside workers, but Node documents it as a **"seat belt"** that "malicious code can bypass", and it does not inherit per-worker. Availability without authority is the wrong half for this threat model. *Deno workers* — `permissions: "none"` gives real per-worker authority reduction, verified. But `terminate()` does **not** stop a spinning worker: 2990 ms of CPU over 3 s wall, measured after terminate. And it is a runtime switch. *QuickJS-WASM* — a genuinely different VM, so object confusion is impossible, and it is what Figma shipped. Rejected on two measurements: **8–17× slower** (300 markdown components repaint in 1118 ms versus 108 ms), and **no `Intl`**, which `packages/tui` needs for grapheme segmentation in every width calculation. It remains the fallback if a native addon is unacceptable. *ShadowRealm* — same thread, same VM, and its own explainer disclaims being "a full spectrum mechanism against security issues". Not a successor for this purpose. **The membrane is what makes `isolated-vm` safe, and it is structural.** `isolated-vm` can be used unsafely, and an earlier draft rejected it for exactly that: handing the guest a live handle via `derefInto()`, or returning a `Reference` from a host call, lets the guest walk that object's prototype chain into the host realm. (The draft also called the project "maintenance mode" — that is out of date; releases run 6.0.1 in Jul 2025 through 7.0.1 in Aug 2026.) The membrane forecloses it by construction rather than by care: 1. `encode()` is the only path from host to guest, and it is `JSON.stringify` with a replacer. **Its output is a string.** A string cannot carry a reference. 2. Host callables never cross. They become an integer id into a host-side table. The guest receives a **number**. 3. Exactly **one** `Reference` reaches the guest — the single call-in point — and its `deref()` throws cross-isolate. 4. `derefInto()` is used once, on the guest's **own** global. No host object is ever its argument. The guest's entire view of the host is `{ number, string }`. There is no object graph, so there is nothing to walk. Audited: `deref`, `copySync` and `getSync` all blocked; `derefInto()` yields an inert marker that is not callable and exposes no host globals. **One isolate, N contexts** — not one isolate per facet. Contexts give separate globals, and a timeout on one context leaves the others running. They share a `memoryLimit`, so one facet's allocation bomb disposes the isolate and every context in it — which does not matter here, because availability is out of scope (§14.1). The cost difference is real: **164 KB per context versus 1124 KB per isolate**. A facet that must survive its neighbours — host chrome running as a facet — takes its own isolate. That is a per-facet decision, not a global one. **Limits worth knowing.** - **Budgets do not nest.** A timeout bounds one evaluation. A guest function re-entered *by the host* — a contributed callback, a component method — gets its own budget, not the outer one. Bounding total facet time needs separate accounting. - **References release, but lazily.** The membrane interns by id with `WeakRef` plus a `FinalizationRegistry`, which both gives identity across crossings and releases on collection. Finalization is not prompt, so **do not create references in a hot path**: a factory registered at construct is one reference forever; a component method returning a fresh closure each frame is one per frame. - **Native addon.** Prebuilds cover linux-x64/arm64, darwin-arm64 and win32-x64 for Node 22 and 24 only; anything else falls back to a source build needing Python and a toolchain. Shipping SEA builds moves that from every user's install to CI. - **Still one engine.** A separate isolate is a far stronger boundary than SES, but it is not Figma's "different VM" claim. The guarantee here is V8's isolate boundary plus the membrane's discipline. ### 14.3 Ambient authority defeats registration-is-ownership §13 states that every handle a facet receives is a host-built binding that registers its own disposer, so there is nothing to remember. That holds for handles the host *hands over*. It says nothing about authority a facet can reach **without** being handed anything: `setInterval`, `process.on`, `document.addEventListener`, a WebSocket. Registration-is-ownership is a property of the API surface, and it is only as complete as the surface is exclusive. Where a facet has another way to reach the outside world, disposal is back to author discipline — exactly the position §13.1 criticises Cordis for (*"do not assume unload automatically removes arbitrary third-party callbacks"*). How complete we can make it is **not uniform**, and pretending otherwise would be the wrong kind of tidy. **Session and server facets: closable, and this argues for compartments.** They run in Node with full ambient authority today — a facet can `import` timers, `fs`, `net`. Inside a compartment with no module access, the only globals are the ones we endow, so an endowed `setTimeout` that registers its own disposer is the *only* `setTimeout`. That is a real argument for resolving the open question above toward compartmenting session facets, on ergonomics rather than on trust. **TUI: closable for the same reason.** A presentation facet already gets a compartment (§14.2) and reaches the terminal only through the `TuiHost` facade. There is no ambient terminal to grab. Endow timers the same way and the surface is exclusive. **Web: not closable, and it is worth being explicit about why.** The DOM is an ambient mutable graph reachable from *any node in it*. Hand a component one element and it has `ownerDocument`, `parentNode`, `window` — and from there `addEventListener`, `MutationObserver`, a detached subtree that outlives its mount. `lockdown()` does not help: the escape is not a prototype, it is a live object graph we deliberately gave away. Two mechanisms would actually close it, and both cost more than the problem is currently worth: - **An iframe per facet.** Real isolation, `postMessage` boundary, teardown is removing the frame. VS Code does this for webviews. The cost is no shared DOM: styling, layout, and focus all become protocol. Figma goes further and is worth studying, because it is the strongest form of "make the surface exclusive". They split by *capability*: plugin logic runs in QuickJS-on-WASM with the scene graph and **no browser APIs at all**, while UI runs in an iframe with browser APIs and **no scene access**, connected by `postMessage`. Plugin code never touches the DOM — not "sandboxed DOM access", no `document` in the environment. That is what lets them guarantee cleanup. Two things to take from their history. They shipped **Realms** first and it was found insecure within two months, which is the same escape class §14.2 cites for rejecting `isolated-vm` — production evidence rather than inference. And the QuickJS cost is real: practitioners report *"truly impenetrable errors"* and a badly degraded debugging story, which §14.2 should weigh when it names QuickJS as the upgrade path. As one put it, sandboxing is a thing *"everyone wants, but there are so few examples of it actually ever working."* - **Declarative-only components.** The facet never receives a node — it returns a description and the host reconciles. §11.1 already points this way (*"props are DTOs; a factory never receives the `TUI` or `Theme` instance"*), and extending it to *never receives a DOM node* would hold. But it rules out refs, and therefore most component frameworks worth using. **So the rule is: make the safe path the easy path, and do not pretend the escape is closed.** Provide `ctx.dom.on(el, event, fn)` and `ctx.timer.every(ms, fn)` that register their own disposers, and make them the obvious way to do the thing. §14.1's threat model is a *careless* author, not a hostile one, and a careless author uses the ergonomic path. The escape remains reachable; it just is not the one you fall into. Where the surface can be made exclusive — session, server, TUI — enforce it with endowments rather than relying on the ergonomics. Where it cannot — web — document that facet authors own their own DOM cleanup, and treat the iframe as the upgrade path if the threat model ever moves from careless to hostile. This is the same shape as §14.2's isolate decision: take the mechanism that fits the current threat model, name the stronger one, and record what would trigger moving to it. ### 14.4 Consequences for the API Isolation does not change the shapes in §3–§4, but it fixes three things that would otherwise be conventions: - **Ownership is implicit.** Every handle a facet receives is a host-built binding that registers its own disposer. Hence no `own()`, and `subscribe()`/`on()` return nothing — there is no unsubscribe handle to hold, and therefore none to leak. Local `{ rpc: false }` services are the exception: they hand back the raw implementation with no interception and no auto-disposal. - **Every object a binding returns is itself a binding** — host-built, plain prototype, hardened, registering its own disposer. This is the invariant that makes the previous point hold transitively. - **Borrowed immutable JSON is enforced, not advised.** Values are hardened as they cross the boundary and rejected if unfrozen, so §9's no-clone rule is safe rather than a documented hope. This does **not** extend to a replica's internal buffer. `apply` from `delta.md` mutates a plain object in place and would throw against a frozen one, so the host keeps the replica mutable and hardens only the value it hands to a facet through `State.value`. Freezing happens at the binding, not at the buffer. The same rule governs the producer side: the tracker proxies a mutable object and hardens what it exposes. Facets are built with `await` compiled to generators, so the host owns resumption. Disposal then stops three distinct things, none of which subsumes the others: cancel the scope's root context (stops the **operation**), unwind the driver (stops the **continuation**, running every `finally` and no `catch`), then release registrations in reverse (stops the **effects**). Endowments are minimal: `Date` and `Math` are replaced with clock-backed versions, timers return opaque integers and are scope-owned, `process` and `console` are shape-only stubs, and `fetch`, `Buffer`, `require`, and `process.exit` are never endowed. **Network access is a binding, never an endowment.** ## 15. Protocol: exposing services to foreign clients Everything above assumes both ends are TypeScript compiling against the same tokens. A browser, a script, or another language cannot do that — it needs a description it can fetch. **Schemas are opt-in and per service.** Most plugins never expose anything, and pay nothing. A service with no `protocol` block is in-process and native-RPC only: it is absent from the catalogue and the HTTP router returns `no_such_member`. ### 15.1 Declaring a protocol ```ts import { object, array, string, int, oneOf, literal } from "@pi/schema"; export const TranscriptEntry = object({ id: string(), role: oneOf([literal("user"), literal("assistant")]), text: string(), }); export type TranscriptEntry = Static; export const TranscriptState = defineState("pi.transcript.tail", { schema: object({ entries: array(TranscriptEntry) }), }); export interface Transcript { readonly tail: State; page(params: { before: string; limit: number }, context: Context): Promise; subscribeRaw(sink: (e: TranscriptEntry) => void): Unsubscribe; } export const Transcript = defineService("pi.transcript", { protocol: { methods: { page: { params: object({ before: string(), limit: int() }), result: array(TranscriptEntry) } }, state: { tail: TranscriptState }, }, }); ``` `page` and `tail` are published. `subscribeRaw` is absent from `protocol`, so it is local — nothing marks it, and publishing it later means adding one entry. Only the state **value** needs a schema, for the root `r` batch. Mutations do not, because recipes never leave the provider (§9.2) and ops are structural. Method parameters are a single object, not positional. Names then survive into the TypeScript signature, into the JSON Schema as `properties`, and into the request body, and adding an optional field does not change arity. `@pi/schema` re-exports TypeBox constructors as free functions so declarations stay readable. They are functions rather than constants because TypeBox schemas are mutable objects and a shared constant would alias into every schema referencing it. ### 15.2 The protocol must match the type A schema that drifts from the interface is the failure this design exists to prevent, so it is checked, with the same mutual-assignability technique as `CheckComplete` (§4): - every key in `protocol.methods` must exist on the service interface — a typo is a compile error, not a silently unpublished member; - for each, `(params: Static

, context: Context) => Promise>` must be assignable to the interface member and vice versa; - every key in `protocol.state` must be a `State` member whose `T` matches the definition's value schema. Omission is always legal. Omission is the feature. ### 15.3 Routes ``` GET /v1/catalogue POST /v1/call/{service}/{member} POST /v1/call/{service}/{key}/{member} keyed instance GET /v1/state/{service}/{member} SSE GET /v1/state/{service}/{key}/{member} SSE ``` `peer` services take no key: the server resolves the instance from the authenticated session, so a client cannot address another peer's (§10.2). **Catalogue** ```json { "version": "1", "services": { "pi.transcript": { "mode": "singleton", "methods": { "page": { "params": { "$ref": "#/definitions/PageParams" }, "result": { "type": "array", "items": { "$ref": "#/definitions/TranscriptEntry" } } } }, "state": { "tail": { "value": { "$ref": "#/definitions/TranscriptTail" } } } } }, "definitions": { "TranscriptEntry": { "type": "object", "properties": { "...": {} } } } } ``` **Call** ``` POST /v1/call/pi.transcript/page { "before": "entry_88", "limit": 50 } 200 { "result": [ { "id": "entry_87", "role": "assistant", "text": "..." } ] } 403 { "code": "forbidden", "message": "unknown root" } 404 { "code": "no_such_member" } ← unpublished members land here 422 { "code": "invalid_params", "message": "limit: expected integer" } ``` **State over SSE** ``` GET /v1/state/pi.lane/main/snapshot Accept: text/event-stream id: 0 event: ops data: [["r",{"lane":"main","transcript":[],"operation":null}]] id: 1 event: ops data: [["p",["transcript"],12,0,[{"id":"e12"}]]] id: 2 event: ops data: [["a",["operation","streamingMessage","content",0,"text"],"Sure, I"]] ``` `id` is stamped by the binding, not carried in the payload. The first batch is always a base batch, so hydration is not a separate route. `Last-Event-ID` is **not** honoured: `seq` restarts per subscription, and resubscription is a base batch plus buffered batches (§9.2); a gap, an unknown id, and a provider reload all take that same path. A closed keyed instance ends the stream with `event: closed` and the client does not retry. A foreign client needs only the six ops of [delta.md §2](../../01-harness/01-delta/delta.md#2-ops) — `replace`, `set`, `delete`, `append`, `truncate`, `splice`. No mutation names, no recipes, no provider code. The applier is a page of code in any language. **This is where non-conformance becomes visible**, and it is the one place worth being deliberate about it. The format is JSON-Patch-*shaped*, not RFC 6902: paths are arrays rather than string pointers, and `append`, `truncate` and `splice` have no RFC equivalent. A client reaching for an off-the-shelf `jsonpatch` library will not work. Two mitigations, neither of which is "conform": - The catalogue advertises the op vocabulary explicitly, so a client discovers the six verbs rather than assuming a different vocabulary. - If a client genuinely needs RFC 6902, the server can offer a lossy downgrade behind a content negotiation header — join paths with slashes and `~0`/`~1` escaping, materialise `append` and `truncate` into whole-value `replace`, expand `splice` into `add`/`remove` runs. About fifteen lines. It reintroduces the quadratic text cost, which is the point: conformance is available and it is the client paying for it. Conforming by default would impose that cost on every consumer to serve a hypothetical one, and would not even buy real interoperability, since Immer's output was never conformant either (§9.2). ### 15.4 What publishing commits you to A member in the catalogue is a compatibility promise in a way an internal token is not. Members should carry a stability marker from the start so that publishing does not silently mean freezing, and §16 lists version negotiation as unresolved. ## 16. Deltas against `plugins.md` | topic | `plugins.md` | here | | --- | --- | --- | | dependency declaration | derived from `setup()` side effects | static `uses`/`provides`/`observes` | | handles during setup | disconnected lazy proxies | real objects, after validation | | validation | requires running facet code | pure manifest analysis | | mode | declared per call site, validated | property of the token | | cycles | tolerated via laziness | rejected; explicit `deferred()` escape | | replication | `ReplicatedState` full-value; `DeltaState` deferred | one primitive; explicit root `r` + six-verb ops | | hydration | separate atomic snapshot + buffering | base batch zero of the stream | | presentation facets | loaded locally | delivered by server and worker | | server↔worker | unspecified inversion | reporting registries; deps point upstream | | authority | `requireClientIdentity` in method bodies | principal on context; filtered views + handle checks | | per-client state | not addressed | `peer` mode: one instance per peer, consumed as a singleton | | resource ownership | explicit `own()` | implicit; every handle is a self-disposing binding | | state updates | hand-written patch unions | plain mutation on the provider; six-verb ops on the wire | | UI mounting | facet-owned panels, unspecified host API | slots with `claim`/`add`; host unmounts on disposal | | isolation | trusted code, unspecified | SES compartment per presentation facet | | foreign clients | not addressed | opt-in `protocol` block; JSON Schema catalogue + HTTP/SSE | | lifecycle | `setup` only | sync `construct`, async activate / deactivate | ## 17. Open decisions - Whether cwd-sourced *session* facets need compartments too (§14.2). - `CheckComplete` error message ergonomics. - Where roles come from, and whether they are per-server or per-session. - Whether handle tables are facet-owned or a kernel-provided utility. - How an owner grants and revokes guest access at runtime. - Protocol version negotiation, and how a state value definition's reducer participates in source-generation skew (§9.2). - Flow control for high-frequency state values; gap recovery is settled (§9.2). - Discovering lanes created after startup (§9.2). - Whether `reduceLaneSnapshot` becomes a draft mutator now or later; until it does, lane state is replace-only. - Whether `deferred()` survives contact with real plugins, or should be removed. - Property tests for the tracker (`delta.md` §3.3) before any facet depends on it. - Which durable values need an explicit periodic `rebase()` cadence to bound recovery work.