Files
dev/packages/agent/docs/mobile-handoff/02-plugins/01-facets/facets.md
alexion 67b361f0e9 Squashed 'third_party/pi/' content from commit d981de122
git-subtree-dir: third_party/pi
git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
2026-09-14 10:40:08 -04:00

1856 lines
79 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<T, M extends ServiceMode = ServiceMode> {
readonly id: string;
readonly mode: M;
readonly rpc: boolean;
readonly __type?: T; // phantom
}
function defineService<T>(
id: string,
options?: { rpc?: boolean },
): Service<T, "singleton">;
function defineKeyedService<T>(
id: string,
options?: { rpc?: boolean },
): Service<T, "keyed">;
/** One instance per connected peer; each peer sees exactly its own. */
function definePeerService<T>(
id: string,
options?: { rpc?: boolean },
): Service<T, "peer">;
```
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<U, P> {
/** Resolved singleton dependency. Only accepts tokens declared in `uses`. */
use<S extends U[number]>(token: S): ServiceType<S>;
/** Owner handle for a keyed token declared in `provides`. */
owner<S extends KeyedOf<P>>(token: S): ServiceInstances<ServiceType<S>>;
/** Host-built replicated state; see §9.2. */
state<T>(definition: StateDefinition<T>, initial: T): MutableState<T>;
/** Runs after the whole graph has constructed, in dependency order. */
onActivate(fn: (context: Context) => void | Promise<void>): void;
/** Runs before disposal, in reverse dependency order. */
onDeactivate(fn: (context: Context) => void | Promise<void>): void;
}
interface ServiceInstances<T> {
/** 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<T>(token: Service<T, "singleton">, impl: T): ProvideEntry<typeof token>;
function provide<T>(token: Service<T, "keyed">, factory: (key: string, scope: InstanceScope) => T): ProvideEntry<typeof token>;
function provide<T>(token: Service<T, "peer">, factory: (principal: Principal, scope: InstanceScope) => T): ProvideEntry<typeof token>;
/** Instance-lifetime equivalent of the construct context's state(). */
interface InstanceScope {
state<T>(definition: StateDefinition<T>, initial: T): MutableState<T>;
}
function watch<T>(
token: Service<T, "keyed">,
handler: (instance: Instance<T>, context: Context) => void | Promise<void>,
): WatchEntry<typeof token>;
interface Instance<T> {
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 extends readonly Entry[]> = R[number]["token"];
type CheckComplete<R extends readonly Entry[], Declared> =
[TokensIn<R>] extends [Declared]
? [Declared] extends [TokensIn<R>]
? 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<U, P>) => R & CheckComplete<R, P[number] | O[number]>;
}): 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<string, PendingQuestion>();
tools.add((draft) => {
draft.set("question", {
/* ... */
async execute(_id, params, _u, _tc, invocation, context) {
const completion = Promise.withResolvers<QuestionResponse>();
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<void>;
}
export const SessionStatusReporting =
defineService<SessionStatusReporting>("pi.session-status-reporting");
export interface SessionStatusView {
readonly state: State<Record<string, SessionStatus>>;
}
export const SessionStatusView =
defineService<SessionStatusView>("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 <id>` | 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<TranscriptTail>("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<T> {
readonly value: T | undefined; // undefined until base batch zero
subscribe(listener: (value: T) => void): void; // returns nothing (§13)
}
// on ConstructContext and InstanceScope
state<T>(definition: StateDefinition<T>, initial: T): MutableState<T>;
```
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<AgentLane>;
lanes(context: Context): Promise<LaneInfo[]>;
}
interface AgentLane {
watch(context: Context): Promise<WatchHandle<LaneSnapshot>>;
}
interface WatchHandle<T> {
snapshot: T;
start(listener: EventListener): void;
resnapshot(context: Context): Promise<T>;
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<LaneView>("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<BrowseView>("pi.browse.view");
export interface FileBrowsing {
readonly view: State<BrowseView>;
list(handle: string, path: string, context: Context): Promise<DirEntry[]>;
}
export const FileBrowsing = definePeerService<FileBrowsing>("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<string, string>();
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<Record<PeerId, Principal>> }>("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<void>;
acquireModal(signal: AbortSignal): Promise<TuiModal>;
select<T>(title: string, items: SelectItem<T>[], options: { signal: AbortSignal }): Promise<T | undefined>;
}
const Tui = defineService<TuiHost>("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<P>(slot: string, factory: (props: P) => Component): void;
/** Additive. Ordered by contribution order. */
add<P>(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<typeof TranscriptEntry>;
export const TranscriptState = defineState<TranscriptTail>("pi.transcript.tail", {
schema: object({ entries: array(TranscriptEntry) }),
});
export interface Transcript {
readonly tail: State<TranscriptTail>;
page(params: { before: string; limit: number }, context: Context): Promise<TranscriptEntry[]>;
subscribeRaw(sink: (e: TranscriptEntry) => void): Unsubscribe;
}
export const Transcript = defineService<Transcript>("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<P>, context: Context) => Promise<Static<R>>` must be
assignable to the interface member and vice versa;
- every key in `protocol.state` must be a `State<T>` 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.