The next object runtime moves from the private repository into pi: src/next (contracts, hosts and objects, documents, derives, links and relays, routines and jobs, the store) with its tests and examples in test/next, src/next-node (Node compression), and its design in docs/next (design.md, the decision log decisions.md, the slice plan slices.md, slice specs, books and benchmarks). AGENTS.md gets its code style. Not exported: the API changes with every slice until it has an entry point.
14 KiB
Slice 4b: authority and housekeeping (spec)
Status: revised (2026-10-24) for flow control per origin; the owner sent it to building directly, with reviews of
the build by fresh reviewers. Guiding rule (owner): simple, fast, correct. The single source for building 4b, together
with the feature map in slices.md (entries assigned to 4b) and its "Carry-over" section for slice 4b, design.md read
in full (most relevant: §3 links, relays and keep-alive, §4 principals and revocation, §6 cycles, §19 presence, §22
size limits and compression, §26 open points), the decision log (R2, R10, R11, Q2, Q5, R26, "Compression at the
origin", "Relay flow control per origin", "Slice 4b: host.activity is a watchable", "Simplification pass after 4a")
and the root AGENTS.md section "Chord v2 Code Style". Builds on slice 4a as simplified (79a0206dc).
Points marked proposal are the builder's default; the owner decides them after the build (they are collected in the last section).
What an app can do after this slice
type Revocation = { grant: string } | { device: string } | { session: string };
const server = createHost<P, Revocation>({
address: Server(), principal: { kind: "server" }, onError,
revokedBy: (p) => p.kind === "guest" ? [{ grant: p.grant }, { session: p.session }]
: p.kind === "owner" && p.access === "device" ? [{ device: p.device }] : [],
compression: nodeCompression, // from `@earendil-works/chord/next/node`
});
server.revoke({ grant: "g1" }, ctx); // ends g1's objects here and on every worker, closes g1's links
host.serve(Notes, ({ session }, principal, self) => {
const viewers = self.derive([host.references(Notes)], ([refs]) =>
refs.filter((r) => r.params.session === session).length);
…
});
const activity = await worker.activity.watch(ctx); // the app's supervisor decides
server.accept(webSocketLink(ws), { principal, limits: { part: 16 * MiB, decoded: 64 * MiB } }, ctx);
1. Revocation (§4, R10, R11)
createHost({ revokedBy }):(principal: P) => R[],RJSON; the same app code on every host. A principal's revocation keys are the canonical JSON of each valuerevokedByreturns, computed once per principal object and cached weakly (aWeakMapkeyed by the principal object, with a GC test). L1's unusedrevocationKeyswas cut (owner, 2026-10-24): write the helper here, next to its user.host.revoke(r, context)on a host, synchronously:- ends at once every object whose identity principal
phasrin its keys (remote objects; local objects have no principal), whatever references remain (R11): each holder getsgonewithforbidden("revoked"), running calls' contexts are aborted (their callers are rejected), an activation in progress is abandoned. Teardown then runs as for any ending (drain, closes with reasonend). - closes every child link (made by
accept) whose principal hasr; never the parent link, never the host's own principal. Closing a tab's link at a relay sendsclosed { origin }upstream (4a), so that tab's holds on the workers are released without a revocation message. - sends
revoke { revocation: r }on every route link (its children), which apply it with their ownrevokedBy, and so on down. Nothing is replayed: a child connected later gets no past revocation (its objects are new, and the revoked links are closed already).
- ends at once every object whose identity principal
- A
revokemessage is honoured only on the parent link (like the principal fields on holds); arriving on any other link it is ignored. - What
gonemeans for consumers is 3a's rule (calls on the hold reject; a relay forwards it). Holds acting for a revoked principal arrive at a worker as their own objects (identity principal = the hold's principal, 4a), so the worker's ownrevokedByends them. - Reconnecting never restores revoked authority: authentication is the app's; a changed grant is a new principal (its key includes the grant revision, design §4).
- Jobs and revocation (R11) arrive with slice 5.
2. Cycles across hosts (§6, Q5)
- Every object instance gets an instance id (uuidv7) at its first attempt; a restarted object (a new instance) gets a new one, so an old chain never matches it (owner, 2026-10-24, D4).
- A hold made during an activation (a
self.getof a type that is not served here, routed over a link) carrieschain: string[]: the chain of the activating object (the chain of the hold that created it, if any) plus its own instance id; at most 32 entries (a longer chain keeps its last 32: a longer cycle is not detected; documented). - A provider refuses a hold whose chain contains the instance id of the object it would bind while that object is
activating: the hold fails broken
invalid/cyclenaming the cycle by labels where known. - Proposal: a dependency failing with
invalid/cyclefails the dependent broken with the same error (an exception to R18's "dependency failures are transient"). Otherwise, A (server) → B (worker) → A would fail B transient, B would retry with backoff, and every retry would hit the same cycle. chainis honoured on trusted links (any role: a cycle runs server → worker → server over route and parent links) and dropped from holds arriving on untrusted links. Relays copy it upstream unchanged (they activate nothing).- One-host cycles stay as built (slice 1: following waiting objects).
3. Presence: host.references(Type) (§19)
host.references(Type)(on the host, and on the facet's host): a JSONWatchableof the references to objects of that type on this host:{ params, principal, origin }[](principalabsent for a local type).originis the incarnation of the host where the reference was made: for a hold over a link, the hold's caller incarnation (the tab's own, carried by a relay on route links, 4a); for a local reference, this host's incarnation. Two tabs are two origins.- It changes in the batch where a reference binds or ends; derives over it recompute there (W1). A link drop is a departure (its holds end); smoothing is the UI's. Lingering objects have no references.
- Cost: one entry added or removed per reference, kept per type (a type nobody asks about costs a counter at most: proposal build the list per type only while it is watched or read).
4. Keep-alive counts: host.activity (§3, R26)
host.activity: a JSONWatchableof{ references, calls, jobs, lingering }(owner, 2026-10-24): holds arriving over links and served here, method calls running on this host (local and remote), running jobs (0 until slice 5), lingering objects.- Counters are kept where the work happens; a change marks the watchable dirty, and one publication per microtask turn carries every change of that turn (not one per call start and end); its value is built from the counters when published or read. A slow watcher gets the newest counts (pending queue).
5. Size limits per part (R2, Q2)
accept(link, { …, limits }),connect(link, { …, limits })andresolve's result takelimits: { part?: number; decoded?: number }(bytes):partbounds a part's size on the wire,decodedits size after inflating (section 6). Defaults (Q2): none on trusted links (IPC); on untrusted links 16 MiB and 64 MiB.- Checked from the part's length before it is parsed or inflated (parts are opaque); the decoded bound is enforced while inflating (section 6).
- An oversized part fails only its hold or call (
too_large/part); the other parts of the message apply: an argument part fails its call (errorto the caller), a result fails its call, a snapshot fails its hold (no automatic re-hold), a state part fails its hold. The link stays up. - The admitting link's limits travel with relayed work (Q2): a relay checks
parton the admitting link itself (the bytes are there) and stampslimit(the admitting link'sdecoded) on the upstreamhold; honoured only on the parent link (like the principal fields); calls on the hold inherit it. The host that inflates enforces the smaller of its own link's and the stamped one, so a worker never inflates a tab's argument beyond the tab link's limit. - The WebSocket's
maxPayloadstays the transport's hard cap (4a).
6. Compression at the origin (#27: owner leaning (b), built as (b))
Problem: encoding happens in the synchronous flush (one message per commit, W1), so compression must be synchronous;
browsers have only the asynchronous CompressionStream/DecompressionStream.
- Who compresses: a host with a synchronous compressor (
createHost({ compression })) compresses each JSON part above 16 KiB (deflate raw, level 1) when it encodes it; the part's envelope says so (a codec flag per part in both codecs). Byte segments stay raw. Hosts without one (browsers) send every part uncompressed; their parts are arguments and params, which are small. - Who inflates: the end that owns the part, when its message is handled. Relays never inflate (parts are opaque).
- The adapter, portable:
compression?: { deflate(bytes): Uint8Array; inflate(bytes, limit): Uint8Array }(synchronous). Chord shipsnodeCompression(Node'szlib.deflateRawSync/inflateRawSyncwithmaxOutputLength) in a Node-only subpath;src/nextitself imports no Node module. Without the adapter, a host inflates asynchronously with the webDecompressionStream("deflate-raw"), counting output bytes and aborting past the limit. - Order with asynchronous inflate: a received message whose parts need an asynchronous inflate is held, and every
later message of that link waits behind it, in order, until it is inflated and handled. The queue is bounded: a
provider has at most two
bound/statemessages in flight per origin (2W + 2 for a tab behind a relay with W workers), at most N = 256 call results per origin, and theackgoes out only after the message was applied, so a slow inflate is backpressure like a slow consumer (its commits fold at the provider). Control messages (ping,pong) are answered when they are reached in order; the 30 s heartbeat leaves ample room. - Threshold (16 KiB) and level (1) are protocol constants, not per link.
PROTOCOL_VERSIONstays 1 (nothing deployed).
Messages
holdgainschain?: string[](trusted links only) andlimit?: number(parent link only).revoke { revocation }(parent to child; honoured on the parent link only; no origin).- A part's codec flag: in the binary codec a flag in the part's length prefix (builder's choice, documented); in the text codec, which is for debugging but may sit on a relayed link, a compressed part travels as a tagged element holding its base64 (proposal; the text codec itself never compresses).
Races
revokewhile an object of the principal activates: the attempt is abandoned, holders getgone.revokewhile a call of the principal runs: its context is aborted; the caller is rejected (gonereaches it).- A hold stamped with a revoked principal is already on its way to a worker: it binds, then the worker's
revoke(sent right after on the same link, per-direction order) ends it: R10's window, accepted. - Two tabs of one grant, one revoked: both principals have the key, so the shared objects end for both and both links close.
revokecrossing aresolve: the new worker link gets no pastrevoke.- A
revokefrom a child link: ignored. - A cycle through a relay: the chain is copied upstream; detection at the provider.
- A cycle through a restart: new instance ids, no false cycle.
host.referencesand a link drop in one batch with a commit: the derive sees the departure in that batch.- An oversized part inside a
statemessage with other holds' entries: only its hold fails. - A compressed part whose inflated size exceeds the limit: its hold or call fails
too_large/part; the link stays. - A stamped
limitorchainfrom a child or untrusted link: ignored. - An asynchronous inflate in flight when the link closes: its message and everything queued behind it are dropped.
- A slow asynchronous inflate at a tab: the worker's window for the tab's origin fills; its commits fold.
Cost per hop
| Hop | Work |
|---|---|
| revoke | keys cached per principal; one lookup per object and link; O(objects + links) once |
| hold during activation | one chain array (at most 32) |
| presence | one entry added or removed per reference, in the batch |
| activity | counters; at most one publication per microtask turn with changes |
| size limits | one comparison per part before parsing |
| compression | one deflate per part above 16 KiB at its origin, one inflate at its owner; relays nothing |
Carry-over taken (from slices.md, section "Slice 4b")
All of it: revocation, cross-host cycles with instance ids, host.references and host.activity, size limits and
compression, the Durable conversation view's size on re-hold (known cost until pi #11327; nothing to build), route
links' context (give route a context option, defaulting to the host's background context, used for the link's
work and telemetry), one N on every host (unchanged; note stays), gone becomes reachable (test it through
revocation; drop the hand-ended test only if the new one covers the same paths).
Tests
Every race; revocation on one host, across a relay to a worker, two tabs of one grant; a child's revoke ignored;
cycles server → worker → server and through a relay, no false cycle after a restart, the broken dependent (proposal);
presence per origin with derives and link drops; activity counts per kind and one publication per turn; limits per
part in every message kind, the stamped decoded limit, a compressed bomb (both inflate paths); compression round
trips in both codecs, relays not inflating (a counter), the asynchronous inflate's ordering and its close race; cost
guards; GC (revoked objects, presence entries, revocation key cache, chains, the inflate queue). Examples:
15-revocation.ts, 16-presence-and-activity.ts, 17-limits-and-compression.ts. Benchmarks in benchmarks.md: a
500 KB snapshot and a 1 MiB result compressed and not (bytes, encode and inflate time, synchronous and asynchronous).
Size
About 800-1,000 source lines.
Decisions for the owner after the build
Collected here by the builder and reviewers; known now:
- A cycle error makes the dependent broken (section 2), an exception to R18.
- Compression (b) as built: adapter shape, the asynchronous fallback, the text codec's form of a compressed part.
host.referencesbuilt lazily per type, or always.