Chord's telemetry schema; spans for calls, methods, relays, holds, activations, natives, loads, reloads, resolves, revocations, routines, jobs and HTTP requests; trace carriers on hold and call (bounded, printable ASCII) continued by relays and workers; events and host-wide metrics; the app kit passes an adapter to its hosts. renderTrace escapes control characters. Example 62 prints a trace across three hosts.
242 KiB
Chord v2: target design
The design of Chord v2 (packages/chord/src, the package's root export @earendil-works/chord) and of how apps (the playground, p3) use it: the one current statement of every rule (owner,
2026-10-24). Every decision is recorded in the decision log packages/chord/docs/decisions.md (the history and the
reasons, kept complete) and folded into this document in the same commit. Where a rule is built, the slice is named
(slices.md); the code follows this document. It replaces apps/p2/docs/target-design.md.
Marks: everything is decided unless marked Decision needed (problem with an example, then options; nothing in such a block is settled; section 26 lists them all). (carried over) marks behaviour of today's Chord that v2 keeps without a new decision. API names are working names.
Contents: 1 goals · 2 concepts · 3 hosts and links · 4 principals · 5 object types · 6 objects · 7 references · 8 ctx · 9 documents · 10 store and storage · 11 kinds of state · 12 derives · 13 natives · 14 routines · 15 jobs · 16 the facet · 17 registries · 18 start data and platform bindings · 19 presence · 20 input into running work · 21 reload and rollout · 22 wire · 23 optional components · 24 Durable integration · 25 apps: ownership and porting · 26 decisions needed · 27 versions and adapters · 28 building: slices · 29 telemetry · 30 cost model · 31 rejected
1. Goals and principles
- Reload without restarts. A plugin's new code takes over inside the running app: hosts keep running; data, open connections and running work survive where they can. Reloads are explicit (a button or a command), never on save. Base code (Chord, the app runtime) restarts the hosts.
- Ownership. Every thing has exactly one owning object, the smallest it belongs to. The owner holds the data, enforces its rules in its methods and offers the operations others need. Nobody reaches past it.
- Factories declare, Chord activates. A factory is synchronous and only declares; Chord performs activation. Handles a factory gets are valid for the object's whole life.
- Natives only in objects. OS-level values are opened and closed by the object that owns them, and reach other plugins only through that owner's methods, state or registry entries.
- Errors look like disconnects. No request-id framework; consumers handle a failed call as they handle a dropped link. References re-hold by themselves.
- Tagged unions, always. States, results and outcomes are discriminated by
kind; each state carries exactly its own fields. - Portable kernel. A host is a logical identity plus an incarnation; facets may come pre-imported; values are JSON plus bytes independent of the transport. Node first; Cloudflare Durable Objects and similar stay possible.
- Context last. Parameters are the required arguments, then the context (
ctxor a DurableContext), then optional arguments:self.tx(fn, ctx),handle.change(key, fn, ctx),chord(fn, context),job.start(args, ctx, { key })(owner, 2026-10-21). - Few concepts, no ceremony. No special directories, no
defineNative, no lints standing in for design. - Aligned with Durable (owner, 2026-10-27): where Chord and Durable solve the same problem (commits, lines, loads, storage errors, watches), they use the same rules and mechanisms.
2. Concepts
| Concept | What it is |
|---|---|
| host | one participant (a process, a tab, a DO): a logical identity plus an incarnation |
| link | a connection between two hosts |
| host kind / address | typed description of addressable hosts / one host of a kind (Session({ id })) |
| facet | a plugin's declarations for one host kind: the types it serves, its contributions; no state or behaviour of its own |
| object type | a contract: name, params (which instance), host address, members, version |
| object | one instance per (type, params, principal key); local: per (type, params) |
| principal | who calls; stamped by links, typed by the app |
| reference | what get returns; keeps its object alive; live and hydrated when awaited |
| document | a named JSON value an object owns, written in transactions |
| derive | a value computed from watchables |
| live query | a bounded query of an object's own table, kept current by its commits (self.query, section 11) |
| native | an OS-level value opened with self.open; only in objects |
| routine | standing behaviour of an object: self.on(source, fn), self.onLive(fn) |
| job | owned work a call starts that outlives it: intent, run, finish; optional state document per run |
| registry | an owner's extension point: one contribution per contributor, composed by the owner |
| shared store | a local object holding shared data once, behind per-principal remote views |
ctx |
per call: cancellation, principal, call-scoped references |
| revision | (server incarnation, sequence): the complete code set a host runs |
3. Hosts and links
Roles
A Node app has one shape:
client client client browsers, TUIs: not addressable
└──────┼──────┘
server exactly one, addressable
┌──────┼──────┐
worker worker worker addressable by a typed address, started on demand
| Role | Addressable | Principal comes from | Lifecycle |
|---|---|---|---|
| client | no | authentication of its link | connects by itself |
| server | yes, one | its own declaration | runs |
| worker | yes, many | stamped by the parent | started on demand by the app |
Clients serve only local types (they are not addressable): a host without a principal or without an address refuses
to serve a remote type (a declaration error: load leaves that plugin out and reports it, a reload keeps it on its
old code; a remote object is owned by a principal of the host serving it, owner,
2026-10-24; and placed by its host address, owner, 2026-10-29). No client providers for now (owner,
2026-10-28): client-side capabilities (a camera, WebMCP tools) go through an owner's requests that clients answer
(section 20, the Questions service). A client's local
calls have no principal; what a client calls on the server is stamped with the client's authenticated principal.
Clients reach workers only through the server, which relays; workers reach each other through the server too. A
detached process (e.g. a terminal host) would be another child kind of the server. Things that are not hosts: a code
mode sandbox (a worker thread inside a worker), remote environment daemons (their own protocol, owned by an object),
the TUI sandbox behind its launcher (the launcher only passes frames). A Durable Object deployment is a mesh routed by
the edge (section 24 and the research note).
Host kinds and addresses
export const Server = hostKind("server");
export const Session = hostKind("session", { id: str() }); // params like a contract's
export const Notes = remote("notes", {
version: 1,
params: { session: str() }, // flat record of required scalars
hostAddress: ({ session }) => Session({ id: session }), // a typed function of the params
variants: { … },
});
export const Catalog = remote("catalog", { hostAddress: () => Server(), … });
-
hostKind(name, params?)declares a kind of addressable host; its params follow the params policy of contracts (section 5).Session({ id: "S1" })is an address:{ kind, params, key }, the key built like an identity key (session|S1); two addresses are equal when their keys are. No host-name strings appear in app code. -
hostAddress(owner, 2026-10-21 asplacement, renamed 2026-10-29) is a typed function of the params returning the address of the host that serves the object, required on everyremotecontract (owner, 2026-10-29);localcontracts have none. It is not part of the contract fingerprint; a changed rule surfaces as a refusal (below). -
params(owner, 2026-10-21; wasinput) select the instance among objects of its type and are part of its identity (section 5). -
The caller computes the address and sends it in the
hold; a host that relays routes by that address alone and never needs the contract or parses params. The provider computes it again from the params: a hold naming this host whose params the provider's code places elsewhere (code skew) waits unboundversion(failed { waiting },invalid/host_address, dependency model §1; slice 7a); a hold naming another host's address is relayed, never served here (misrouted holds are never captured by a relay that serves the same name). -
Every reference is a request (dependency model, slice 7a): it has one holder (an attempt, a call, outside code, a link's peer) and ends only when its holder ends it (release, a call settling, a getter giving up, the peer's link or origin closing, revocation, the host closing), never because its provider is missing, went away or does not fit.
-
Where a reference made on a host, or a hold arriving on one of its links, goes, by address alone (slice 4a; slice 7a: no same-name check on the consumer's host):
Case Goes a localtypehere a remotetype whose host address (computed here from the params) is this host's address, served here or nothere an arriving hold naming this host's address here the address (computed, or named by the hold) is of a kind this host has a route for over that route's link (the address's params checked against the kind, then authorize)otherwise (a remote type) over the parent link; a hold that arrived on the parent link is never sent back to it: unavailableno parent link: the host never had one unavailableno parent link: it is down (a drop) waits on the parent path, loading, until the next link (design audit, 2026-10-27)A
hostAddressorauthorizethat throws fails the request broken with that error; an address whose params fail the kind's check is brokeninvalid/host_address. -
The publication gate (slice 7a): a hold arriving on its links before the host's first publication (
load's batch of declarations) is not placed yet: it isloading, and the publication places it (a worker connects first, W6). The gate lasts only for the synchronous setups, never for activation, so it cannot deadlock. This host's own requests beforeloadis called (get,hold,registry, a root'sctx.get) would wait for a publication nobody started: they thrownot_readyat once, "callhost.loadfirst"; a client host with nothing to load callsload([])(decision "Mistakes that wait are reported at once" (a), 2026-11-03; footgun 1 of the p2 port). -
Then the selected provider is checked (here: served, the version and fingerprint, the params, the provider's own host-address check; over a path: at the provider host). Version checks never look at a same-name registration elsewhere. What the check finds:
The check finds The request a fitting provider bound (to the object here, or to the hold over a path) not published yet unbound unpublished:loadingnobody serves the name at its address (or serves it as the other kind) unbound not_served:failed { waiting }(unavailable"X is not served here")no fitting version, a fingerprint that differs at the same version, a host address the provider computes elsewhere unbound version:failed { waiting }(contract_mismatch,invalid/host_address)a registry served, but its first compose(or the reader's version mapping) threwunbound compose:failed { waiting }(§17)the path has no link (a drop, a route resolving) on the path: loadingauthorizerefused, invalid params, a cyclefailed { broken }, never indexedThe unbound reasons that wait for a name are indexed under it on the host that decided them: the consumer's host for a request made there, the provider's host for a hold that arrived over a link (kept refused there; a
retryre-runs its start). A publication serving a name (load's, or a reload's commit, which wakes only the requests of the names it serves, slice 7b) wakes its requests, which run their check again (a refused hold sendsloading, thenboundorfailed); no new message kind. Index entries go when their request ends (GC-tested). The consumer never re-holds a request by itself: no backoff, no polling. -
A request waiting unbound is reported (decision "Mistakes that wait are reported at once" (c), 2026-11-03): one indexed with a cause after the publication (
not_served,version: a reference made here, a hold refused here for a name) and a registry value waited for while it has none (a parkedself.registry, ahost.registry:not_served,version,compose; one wait per value, not per waiter) is reported once throughonErrorafter 5 s asunbound_long { waitingFor, error, since }(waitingForthe type's name orregistry <name>,errorthe cause,sinceDate.now()time); it keeps waiting until itsreadyTimeoutas before. One clock per host, one timer armed for the earliest wait not reported yet (no timer per request); a wait that binds, ends or is woken leaves it, and one woken and left unbound again starts anew. Reported on the host that decided it: a consumer whose hold a provider host refused seesfailed { waiting }, the provider host reports. Notparked_long, which reports an attempt (an object) parked past itsreadyTimeoutwhatever its blocker (loading included); aself.getwaiting unbound gets both,unbound_longfor the request after 5 s andparked_longfor its object later.
The low-level host API
// server.ts
const server = createHost<P>({ address: Server(), principal: { kind: "server" }, onError });
server.route(Session, {
authorize: (p, address) => p.kind === "owner" || p.kind === "server" || (p.kind === "guest" && p.session === address.params.id),
resolve: async (address, context) => {
const child = fork("worker.ts", [], { serialization: "advanced" }); // no `signal`: see below
child.send({ id: address.params.id, init: await initFor(address.params.id) }); // the app's own start message
return { link: ipcLink(child), principal: { kind: "worker", session: address.params.id }, trusted: true };
},
});
await server.load([{ name: "@app/core", facet: coreServerFacet(config) }, ...serverPlugins], ctx);
wss.on("connection", (ws, req) => { // an entry point: app or adapter code
const principal = authenticate(req);
if (!principal) return ws.close(4401);
server.accept(webSocketLink(ws), { principal }, ctx);
});
radius.onChannel((ws) => server.accept(webSocketLink(ws), { principal: { kind: "owner", access: "relay" } }, ctx));
// worker.ts: connect first (W6), then load; start data through the core facet (section 18)
const { id, init } = await startMessage(process);
const worker = createHost<P>({ address: Session({ id }), principal: { kind: "worker", session: id }, onError });
worker.connect(ipcLink(process), { principal: { kind: "server" }, trusted: true }, ctx); // stores may reference server types
await worker.load([{ name: "@app/core", facet: coreWorkerFacet(init) }, ...workerPlugins], ctx); // serves SessionInfo
await (await worker.get(Harness, {}, ctx)).resume(ctx); // tasks run only after every plugin loaded
// a browser tab
const tab = createHost<P>({ onError });
await tab.load(webPlugins, ctx);
tab.connect(webSocketLink(new WebSocket("/chord")), { principal: { kind: "server" } }, ctx);
const notes = await tab.get(Notes, { session: "S1" }, ctx); // tab → server → route(Session) → worker
// a test: everything in one process
server.route(Session, {
authorize: () => true,
resolve: async (address) => {
const [up, down] = memoryLinkPair();
const principal = { kind: "worker", session: address.params.id } as const;
const worker = createHost<P>({ address, principal, onError });
worker.connect(up, { principal: { kind: "server" } }, ctx); // W6: connect, load, resume
void worker.load([{ name: "@app/core", facet: coreWorkerFacet({ id: address.params.id }) }, ...workerPlugins], ctx);
return { link: down, principal };
},
});
Do not pass context.abortSignal to fork: host.close aborts it, which kills the worker and makes Node emit an
error event that crashes an app without a handler. Chord closes the link at host.close; the worker exits when its
parent link closes. resolve returns the link before the worker has loaded (the worker connects first, W6): made
later, the link would leave the worker's hello unread and the heartbeat would close it during a long load (slice
10's spec review; the deadlock once feared here does not happen: 7a's barrier ends its first pass on a wait on another
host). The workers component (§23) does this.
| Call | Meaning |
|---|---|
createHost<P>({ address?, principal?, ...options }) |
with an address: addressable, serves only objects placed there, acts as principal on its own host; without: a client. P is the app's principal type |
load([{ name, facet }], context) |
facets with their plugin names (the plugin's package.json name, npm style: an invalid name is a programming error, load throws before it changes anything; a duplicate is refused and listed in failed); runs every facet's setup, publishes all of them in one batch (types and registries served, registries composed once, contributions; requests waiting for the publication are placed after it; a registry with contributions and no provider is reported registry_undeclared), then waits for the start barrier (section 21: the first outcomes of its host-lifetime objects, slice 7a); a plugin whose setup throws is left out and reported (load resolves with { failed: [{ plugin, error }] }, decision "Per plugin at startup and reload"); runs once. Loaders are adapters; base code passes its core facet the same way |
reload([{ name, facet }], context) |
the complete new plugin set after load (a facet passed as the same object is an unchanged plugin): staging, then one commit, then every object of a changed or removed plugin takes its fate; resolves with a report (a plugin whose new code fails keeps its old code, listed in failed); an invalid plugin name throws before anything changes; one at a time (section 21, slice 7b) |
connect(link, { principal, trusted?, codec? }, context) |
the link to the parent, at most one open; once it closed (a drop), connect takes the next, and every remote reference over the parent is held again on it. principal = who the parent is |
accept(link, { principal, trusted?, codec? }, context) |
a link from a child; holds and calls on it act as principal |
route(Kind, { authorize, resolve }, context) |
how to reach hosts of a kind (below); called before load, once per kind; work arriving on its links runs under context; returns a RouteHandle: retry(address) resolves the address again (D10b), suspend(address) stops resolving it until retry (R1) |
get(Type, params, context, { readyTimeout? }) |
a reference once its object is live, waiting through loading, failed { transient } and failed { waiting } up to its readyTimeout (section 7); before load it rejects not_ready at once |
hold(Type, params, context, { readyTimeout? }) |
a reference at once that follows its object until released (section 7); before load it throws not_ready |
close(context) |
closes the links (children, routes, then the parent), then ends every object at once and tears them down, the last to go live first (reason close); rejects with an AggregateError of the native closes that threw |
revoke(r, context) |
revocation (section 4; slice 4b) |
root(fn, context, { principal? }) |
a call scope for code outside objects (a Durable hook, a CLI, an HTTP request): fn gets a handler's ctx, sealed when fn settles, context aborts or the host closes (slice 8 D3, section 8); an omitted principal acts as the host, an explicit undefined runs anonymous (slice 10) |
registry(R, context, { readyTimeout? }) |
registry R's composed value for code outside objects once it is usable; while it has none it waits up to its readyTimeout, then rejects with the last error (slice 8, 7a, section 17); before load it rejects not_ready at once |
Options of createHost: address, principal, principalKey (section 4; the same function on every host),
telemetry (section 29), onError, closeTimeout, readyTimeout, heartbeat: { ping?, close? }; later revokedBy
(section 4, slice 4b) and store (section 10, slice 6a: the store, opened by the app with openStore(backend, ctx)
and closed by the app with store.close(ctx) after host.close; the host never closes it). onError receives everything that fails in the
background, as a tagged HostError: listener_error (a watch listener threw), delivery_failed (a frame delivery
threw), activation_failed { object, failure }, close_failed { object, native, error }, close_timeout { object, native }, drain_timeout { object }, protocol_error { error }, resolve_failed { address, error }; later routine
errors (slice 5), derive_failed { object, member?, error } (a derive's compute threw, once per failure episode, slice 6a), the registries' registry_conflict { registry, message, plugins }, registry_failed { registry, error }, contribution_failed { registry, plugin, type?, error } and contribution_refused { registry, plugin, type?, version } (each once per episode, slice 8), object_failed { object, failure } (a live object failing, slice 6a, broken; transient when a self.follow source failed, slice 6b R4; failure where the value is a FailureCause, error otherwise, 2026-10-27), job_interrupted { object, job, id, restarts } (a persisted run ended at the restart limit, slice 6b D6), parked_long { object, waitingFor, state, since } (an attempt parked longer than its readyTimeout, whatever its blocker, once per parking episode: a diagnostic of a ring across hosts started at the same moment or a long wait, slice 7a, audit A11, §6), the reports of mistakes that would otherwise look like slowness (decision "Mistakes that wait are reported at once", 2026-11-03): registry_undeclared { registry, plugins } (at a publication, or when a host-lifetime object's contributions enter, a registry with contributions and no provider here, naming its contributing plugins; again only once they change), unbound_long { waitingFor, error, since } (a request waiting unbound after the publication, below) and transaction_cancelled { object, error } (§8), and storage_failed { error }
(slice 6a), which every app must handle: Chord never ends the process,
the app exits with its own code for its supervisor or reopens.
Timeouts are host options; no fixed numbers in the code. Two host timeouts (C13, owner, 2026-10-22):
| Timeout | Bounds | Host default | Override |
|---|---|---|---|
closeTimeout |
a native's close (then reported as a possible leak; the rest continues); the drain of an ending object's running handlers (callers are rejected at once) |
30 s | per native: self.open(open, close, { closeTimeout }) |
readyTimeout |
an activation attempt's own steps (its wait for the previous instance's teardown included; paused while the attempt is parked on its dependencies, slice 7a); a getter's whole wait; how long a call made while its reference is loading waits (the park deadline) | 60 s | per get/hold/registry: { readyTimeout } |
The park deadline's per-type override is not built (only if a real case needs it). Timeouts of later slices belong to
their mechanisms: the routine handover uses closeTimeout, a job finish backs off 1 s to 60 s, provider-side
linger is per type; a reload has no install timeout (it takes the app's context, slice 7b); a per-type drain
override only if a case needs it (section 26). A route's resolve is bounded by the app.
- Incarnations are minted by Chord (uuidv7, code copied from pi-ai, no dependency), never passed in, and carried
in the link handshake (
hello): the other side sees a restart as a new incarnation of the same identity; references to it re-hold. - Entry points vary (loopback or tunnel WebSocket servers, Radius dialing out and delivering channels, a DO edge,
memory), so the kernel has no
listen; each entry point authenticates and callsaccept. Radius no longer needs to pose as a loopback request with a secret header. - App code, not Chord: starting processes, init data, authentication, keep-alive decisions. Helpers come only when apps repeat the same code.
- Compared with today's Chord: same shape (
createHost,connect,accept,route,load); gone are string host names and prefix parsing, app-managed generations, the duplicateupstreamoption,opaqueruntime objects and the loader inside the kernel. - Worker keep-alive works as in p2: the worker counts, the app's supervisor decides.
host.activity(slice 4b; owner, 2026-10-24) is a JSONWatchableof{ references, calls, jobs, lingering }: holds arriving over links, method calls running on this host, running jobs, provider-side lingering objects. Counters are kept where the work happens; a change marks it dirty and it is published at most once per microtask turn with changes. A hold placed here counts from its arrival to its end, bound or not (one waiting for a missing provider keeps the worker alive; slice 7a); a hold this host relays counts at its destination;host.referenceslists bound references only. Core code reports it plus Durable tasks (the coreHarness); the server's supervisor stops the worker when all are zero for the idle time (p2: 5 minutes, checked every second). Chord provides the counts, no idle logic.
Routes
host.route(Session, {
authorize(principal: P, address: Address<typeof Session>): boolean;
resolve(address: Address<typeof Session>, context: Context):
Promise<{ link: Link; principal: P; trusted?: boolean; codec?: Codec }>;
});
authorizeruns for every hold routed over the route, with the hold's principal (section 4), before anything starts;falsefails the hold brokenforbidden.resolveruns at most once at a time per address: the first hold starts it, later holds wait for the same link. The returned principal is stamped on that link;trustedandcodecas foraccept. Itscontextis aborted only when the host closes; a link it returns after that is closed at once. Aresolvewhose holds were all released meanwhile finishes and its link is kept (a worker start that produced a link finishes even if its first requester left). Aresolvethat never settles is the app's (it bounds its ownfork); getters still time out.- Failure and re-resolve (slice 10 R2, owner, 2026-10-30; replaces the per-address backoff): a route has no
timer. A rejected
resolve(reportedresolve_failed) or a returned link already closed fails the route's holders with its error (failed, not an endlessloading); the route resolves again only for a new hold, onRouteHandle.retry(address), or once after the address's link dropped while holds exist (a drop of a healthy link recovers at once; whether a crashed worker comes back is theresolve's answer).RouteHandle.suspend(address)(R1) stops resolving an address (its holders stayloading) untilretry. A network route that wants to reconnect keeps its own policy in itsresolve. The workers component answers a failed address'sresolvewith its stored failure at once: workers never restart by themselves (§23). - Chord closes a route link itself only at
host.close, on the heartbeat and on a protocol error; stopping idle workers is the app's (host.activity, the workers component, §23). - Work arriving on a route's links runs under the context given to
host.route(Kind, options, context)(slice 4b).
Links
- A link is delivery only: an ordered, bidirectional channel of
Uint8Arraymessages; adapters only move bytes and close both ends. Session recovery is the adapter's policy. Implementations (slices 3a, 4a):memoryLinkPair(): delivers withqueueMicrotask(no Node APIs); closing one end closes the peer a microtask later.ipcLink(port)over a Node IPC channel (forkwithserialization: "advanced", orprocessin the child), typed structurally (no Node import in Chord). AUint8Arraycrosses as one message; other messages on the channel (the app's start message) are skipped. It listens from the moment it is made and queues until its listener is set.sendreturningfalseis backpressure, not failure; a thrownsend, an error callback ordisconnectcloses the link once; listeners are removed on close.webSocketLink(socket)over a WebSocket (wsin Node, the browser's), typed structurally: one binary frame per message,binaryType = "arraybuffer"; messages sent while the socket connects are buffered untilopen; a socket closing or closed already gives a closed link;close,errorand a text frame close it. The transport'smaxPayloadstays its hard cap (code 1009 closes the connection).
- One link object per link carries both directions (slice 4a): the channel (handshake, codec, checks, heartbeat), a provider side per origin (holds and calls the peer sent, its own or those of a relay's downstream link, by the peer's ids) and a consumer side (holds and calls this host's own references sent, by its own counters). Message kinds are directional, so each goes to its side. A link carries nothing until a hold or call (W6).
- Roles:
parent(made byconnect),child(accept),route(resolve). The role decides whose principal a hold acts as (section 4). Trusted links are an explicit option (trusted: trueonaccept,connectand inresolve's result; default untrusted; never inferred from the link kind; owner, 2026-10-23): they skip only theMESSAGEschema check; framing, the codec's decode,checkParams,checkArgsand Delta decoding still run. Trust never grants the principal fields (they are read only on the parent link). - Remote objects belong to a path: the parent, or a route address. Each new link of a path holds every remote
object of that path again (with new hold ids); a closed link drops its tables and the objects go
loading. - Heartbeat on every link kind, in the protocol layer (owner, 2026-10-23): each link keeps a
lastReceivedtimestamp (every received message; sends do not count); the host runs one interval timer for all its links (none per message). A link silent for 15 s sendsping(after its handshake); the peer answerspongat once, never queued behind the window. A link silent for 30 s, the handshake included, is closed (its references goloading). Both numbers are host options (heartbeat). The heartbeat detects a silent transport (TCP does not notice a sleeping laptop), not a peer that answers pings but withholds acks (bounded by the window and the pending cap instead). A handler that blocks the event loop longer than 30 s gets its links closed (documented). Time this process did not run (a tick at least one interval late: a sleep, a blocked event loop) is no silence: each link's silence stays what it was before, at leastping, so an open link pings at once (decision "A pause of the process is no silence"). - Flow control: an ack-clocked window per origin (owner, 2026-10-23; per origin 2026-10-24). Per (link, origin), at
most K = 2 of the provider's own
boundandstatemessages are unacknowledged; only these two kinds count (everything else is control, never queued behind them). The consumer sendsack(with the message's origin) after applying each, after its batch; anackcrossing nothing in flight is ignored. While the window is full:- each touched hold member keeps its ops in its pending queue (section 11: raw, never mutated, folded through
the leaf index, at most 10,000 ops, then only "replace with the current value"); when an
ackfrees a slot, onestatemessage carries every waiting hold's entry, its members' pending ops encoded once (or[["r", value]]after an overflow), all reflecting the latest commit, still atomic for the consumer. So a slow consumer gets coarser updates, never stale ones (measured: abufferedAmountcap alone left state 9.4 s old behind kernel and proxy buffers; acks kept it 0.0 s old); - a hold that binds waits for a slot; its snapshot and subscription are taken in one synchronous step when its
boundis sent; bindings and waiting state take turns when both wait, so neither starves; - a hold whose object leaves
livewhile itsboundwaits goes back to pending (D1, owner, 2026-10-24; built in slice 7b with the reload, which makes a live object leavelive); so does a bound one, unbound (§7, §22). Not built, by decision: a byte window, credit messages, snapshot admission or debt, an adapterbacklog()API,resetfrom anyone.
- each touched hold member keeps its ops in its pending queue (section 11: raw, never mutated, folded through
the leaf index, at most 10,000 ops, then only "replace with the current value"); when an
- Calls: a call window N (default 256, the same on every host) announced in
hello: how many concurrent calls a side accepts per origin. A side sends no call before the peer'shello. A host sends at most N of its own calls per link in flight; further calls wait in the link's call queue, one FIFO, sent in order as replies free slots; nothing is refused for being "too many" (K16). A slot is freed once per call: by itsresultorerror, itsabort(when sent, at the caller; when received, at the provider), its hold'srelease,gone, its origin'sclosedor the link closing. A call still queued when its hold rebinds (loading) is rejectedunavailable(it may name a method of the old variant). A provider seeing more than N running calls of one origin closes the link (protocol error); a call naming a hold it does not know is ignored before it is counted (Q1). There is no limit on calls in progress per link (owner, 2026-11-03; Chord v1'smaxCallshad no counterpart): the call window N per origin and the part limits (section 22) are the bounds; 200 concurrent calls over one socket all run, N at a time in flight. A per-link N, if ever needed, must bound a relay's forwarding too. - Holds are not limited by count (authorization and params checks protect them); size limits are a separate check (section 22). Byte-heavy traffic (terminals) uses separate channels (section 23), so a large state message never holds up keystrokes.
Relays (slice 4a; decision "Relay flow control per origin", owner, 2026-10-24)
A host that receives a hold it cannot serve (the table above: a route or the parent) relays it: it holds it again on that link (upstream) with the arriving hold's principal and incarnation, and forwards between the two without parsing payloads. The provider keeps the window, the pending queues and the calls per origin; the relay never drops, folds or resets.
- Origins: the relay gives each downstream origin (a downstream link, and the origin its messages carry when that
link is itself a relay's) a number, one counter per host, never reused; 0 is a host's own work. Every message
upstream for that origin carries
origin; the provider keys holds, calls, windows and acks by (link, origin, id), and its answers carry the origin back.originis read only on parent and route links, where a relay's messages arrive (a worker may relay a child's hold to its parent); on a child link it is a protocol error (decision "Origins only from relays", 2026-10-31); a per-origin record goes once it has no holds, calls or messages in flight; principals and incarnations stay parent-only. - Ids are not renumbered: an upstream hold has the downstream hold's id under the origin's number; calls the same. Two tabs (two holds) of one principal are two upstream holds, each with its own encoders (R1).
- Nothing is parsed: the relay routes by the hold's address and authorizes it; params, arguments, results, snapshots and ops travel as opaque parts. Cost per message: decode the envelope, swap the origin, encode the envelope; parts copied once into the outgoing buffer.
- What the relay keeps: per relayed hold, its upstream link and its original
holdmessage (params copied once), which routescall,abort,retryandreleaseupstream at once and holds again on the next link; per downstream origin, the FIFO of the sender of eachbound/statemessage in flight and the set of upstream links it relayed to. - Forwarding up:
hold,call,abort,retry,releasewith the origin's number; a hold waiting for its route is sent when the link exists; areleasebefore that drops it. A call is forwarded at once; with no upstream link it is answeredunavailable(it crossed theloadingnotice). Each downstream consumer is bounded by its own N, so the upstream sees at most N per origin. - Forwarding down: a message from upstream with an origin goes to that origin's provider side, which forwards it
with its own origin (none for a tab), ids and parts unchanged. A link answers only for what was relayed over it
(owner, 2026-10-24): every entry of a
boundorstate, everyfailed,loadingandgoneby its hold, everyresultanderrorby its call is checked against the relay's table; an answer for a hold or call not relayed over the link it came from is dropped (Q1: also one crossing a release). Aboundorstateleft with some entries is forwarded with those; left with none, it is acknowledged to that link at once. A message for an origin from a link the origin relayed nothing to is ignored whole. - Acks: the relay forwards each upstream
bound/stateat once and returns itsackto the upstream link that sent it when the downstream acknowledges it (the senders FIFO). A tab served by W workers may have 2W messages in flight (plus the relay's own 2), each worker's window bounding its own. A slow tab stalls only its origin at each worker; other tabs are other origins. Measured: a slow tab through the relay catches up like a direct one (a 60 s stall: 0.38 s, 23.7 KiB at 64 KiB/s; before the rework, 24 s and three full snapshots). - Upstream drop: the relay sends
loading { hold }downstream for every relayed hold on the dropped link, fails their relayed calls in flightunavailable(never resent), re-resolves and holds again upstream from the kept message (same id, same origin); the newboundis forwarded. Acks for messages of the dropped link still in flight downstream go nowhere. - Downstream drop: the origin's provider side ends: its served holds are released, its relayed holds forgotten,
and every upstream link it relayed to gets one
closed { origin }, which releases the origin's holds and aborts its calls there; the origin's number is never used again; messages crossing it are dropped. gonefrom upstream is forwarded; the relayed hold is forgotten. Relays never make agone.
Recovery
- After a drop the consumer re-holds every hold of the path over the new link and receives snapshots (a
boundon a bound hold replaces its replicas in one batch, another variant allowed); calls in flight fail like on a disconnect; calls made meanwhile park (section 7). - A slow connection never ends an object: nothing releases a hold for falling behind (the reference count is unchanged).
- A link drop is a departure (section 19).
Values
- JSON plus
Uint8Arrayleaves, with a bytes schema type, defined independently of the transport. Every link carries the sameMessagethrough its codec (section 22); parts are encoded at their origin; byte segments travel raw, never base64. Same-host calls (no link) pass values by reference. - Schemas are plain JSON Schema built with
@earendil-works/pi-schema(decision "The schema switch") and must describe data (no callbacks, no native types, no runtime codecs);formatis an annotation, never checked. Method arguments are validated at the provider for every caller against closed schemas (section 5, R7, Q12, Q15); results and state are never validated (section 30). Values are immutable by convention once handed over (Delta's rule): nobody mutates a value it got, and nothing is copied or frozen to enforce it. There is no development mode (R9): one behavior everywhere.
4. Principals
-
The app defines its principal type
P: JSON, every variant with akind, a type alias (not an interface), unversioned (owner, 2026-10-22): Chord never persists principals, base code changes every host at once, links stamp them. Objects persist a projection with its own schema, never the principal (p2: chat'sAuthor { kind, id, name }). p2/p3:owner(access: "local" | "relay" | "device"),guest(session,mode, grant revision),server,worker(session). -
Links stamp principals. A caller's own data never sets its principal.
-
The principal key maps a principal to its object identity (
principalKey, default canonical JSON; cached per principal object; the same function on every host). It should include connection provenance and grant revision, so a revoked grant's objects are distinct from a new grant's (carried over). -
Principals on holds (slice 4a; owner, 2026-10-23): a
holdcarriesprincipal?andincarnation?. The receiver uses them only on its parent link; everywhere else they are ignored and the link's principal and peer incarnation apply (a tab cannot claim to be the owner; a relay overwrites what a client sent). Calls carry neither: a call runs as its hold's principal and with its hold's incarnation (refines R1/Q13, which stamped calls too). What a host sends in those fields when it holds over a route link (towards a child that honours them):The reference was made by principalincarnationhost.get/host.holdthe host's own this host's ctx.getin a handlerthe handler's ctx.principal(the caller's)the handler's ctx.incarnationself.getin a remote objectthat object's principal this host's self.getin a local object or a facetthe host's own this host's a relayed hold the arriving hold's principal the arriving hold's incarnation So a guest's call on the server whose handler
ctx.gets a worker type reaches the worker as the guest and gets the guest's variant; two references with different principals are two remote objects. A server object that acts as itself towards a worker takes the reference withself.getin its own object or in a server root (host.root, acting as the host), never with the method'sctx.get(owner, 2026-11-03; p2's footgun 7): the owner callsSearch.rebuildIndex, whosectx.get(IndexScan, { session })reaches the worker as the owner, and a worker factory checkingprincipal.kind === "server"refuses it. -
Upward is a boundary: a worker's hold of a server type goes over its parent link, and the server ignores principal fields from a child, so it acts as the worker, whoever the worker's handler serves. A worker that acts for a user towards the server passes the user as data and the server object decides.
-
The caller's incarnation (R20, Q13):
ctx.incarnationis the incarnation of the host where the call started (from the handshake, or from the hold's field on a parent link), next toctx.principal, not part of the principal or its key, never sent as data by apps; it is this host's own for local calls (section 25 uses it as a fence). -
Revocation (slice 4b) is a typed app function, not field matching:
type Revocation = { grant: string } | { device: string } | { session: string }; const server = createHost<P, Revocation>({ address: Server(), principal: { kind: "server" }, onError, revokedBy: (p): Revocation[] => p.kind === "guest" ? [{ grant: p.grant }, { session: p.session }] : p.kind === "owner" && p.access === "device" ? [{ device: p.device }] : [], }); server.revoke({ grant: "g1" }, ctx);host.revoke(r)ends every object and closes every child link whose principalphasrinrevokedBy(p)(compared by canonical JSON of each valuerevokedByreturns, computed once per principal; never the host's own principal), and propagatesrto the children as arevokemessage on its route links, honoured only on a child's parent link, where each applies its ownrevokedBy(the same app code; nothing is replayed to a child connected later). Holders of a revoked object getgonewithforbidden/revoked(owner, 2026-10-24: a reason of its own, so a UI can say "access revoked"); holds still waiting forloadare ended too. Order: therevokemessage goes out on the route links before the revoked child links close, so a worker's objects endforbidden/revoked, notreleasedby the tab'sclosed. Revocation ends a principal's objects at once, whatever references or jobs remain (R11); their jobs get the abort signal and the normalcloseTimeout. There is no re-stamping: a changed grant (modewrite→read) is a revocation of the old one; the client reconnects and authenticates anew, and its references re-hold. Reconnecting never restores revoked authority. Work started on behalf of a revoked principal in a shared store stops when its reconciling routine runs (R10's window, accepted; section 25); there is no revocation hook. -
localobjects have no principal in their identity; their handlers and references act as the host (section 5).
5. Object types
export const Notes = remote("notes", { version, hostAddress, params, variants });
export const NoteStore = local("notes.store", { version, params, members });
remote |
local |
|
|---|---|---|
| reachable from | any host, across links | its own host only |
| identity | (type, params, principal key) | (type, params) |
| principals, variants | yes | no |
| factory | (params, principal, self) returning { variant, members } |
(params, self) returning the members |
| arguments | validated once at the provider for every caller (compiled closed schema, R7, Q12); unknown fields rejected; passed by reference on the same host | validated the same way (Q15), never copied |
| results | not validated; passed by reference on the same host | not validated, not copied |
| members | state (JSON) and methods (JSON plus bytes) | plain-data state and methods; no opaque, no callbacks |
ctx.principal in methods |
the caller | the host's principal (none on a client); references a local object makes act as the host |
variants: {
editor: { state: { notes: NotesSchema }, methods: { add: method(AddArgs, NoteId), remove: method(RemoveArgs) } },
reader: { state: { notes: PublicNotesSchema } },
},
// the factory chooses one variant per object (slice 3a, C15):
host.serve(Notes, ({ session }, principal, self) =>
principal.kind === "owner" ? { variant: "editor", members: { notes, add, remove } } : { variant: "reader", members: { notes: publicNotes } });
- Names (owner, 2026-10-27): object type names, host kind names, document (
defineState) and job (defineJob) names use[a-z0-9@/._~-], lowercase, checked at declaration (they become SQL table names in the store, joined with:). - Keyed state members (owner, 2026-10-26;
localcontracts only):members: { notes: keyed(NoteSchema) }; the provider returns a keyed document handle; a consumer on the same host takesref.notes.at(key)in its factory (a counted pin: the key stays loaded while the consumer lives) orref.notes.watch(key, ctx)/get(key, ctx)anywhere; an absent key readsinitial(), a retirement arrives as theretiredflag (a derive over it carries the flag on). Across links, a view per entity (NoteView { session, id }) is held as an ordinary remote object. - Methods:
method()(no arguments, no result),method(args),method(args, result),method(undefined, result). A method without arguments omits them in the API (ref.bump(ctx), implemented asbump: async (ctx) => …);nullis their internal and wire form (owner, 2026-10-22). - Implementations (owner, 2026-10-22): a state member is a JSON
Watchablethe factory returns (a document handle, a derive, a reference member,constant(...)), never a getter; a method is a function(args, ctx)or(ctx). Activation checks what the factory returned (plugin code): exactly the variant's members, methods as functions, state members as JSON watchables (aNativeWatchableor anything else fails the objectbrokennaming the member, Q14). - Reserved member names:
then,release,state,retryandvariantare refused at declaration (a reference has them;thenwould make an awaited reference thenable). - Compatibility (strict, like documents; R6): a hold carries only the consumer's contract
versionand fingerprint, never a description. The provider binds when both equal its current ones, or (slice 7b) when the version is one it accepts and the fingerprint equals the one computed from its own history (section 27). Every contract change, additive ones included, bumpsversion. Anything else leaves the request unboundversion:failed { waiting }withcontract_mismatch(slice 7a: not the consumer's fault; a publication that fits wakes it, §3). The fingerprint covers params, variants and members, not the host address. A consumer whose contract does not fit waits on its own request; the provider object and its other bindings are not affected. - Variants: a member name has the same kind (state or method) in every variant that has it, and a method the same
signature (arguments and result) in every variant; state schemas may differ per variant (redaction). Enforced at
the type level in
remote(...)and at declaration at runtime. A remote factory returns one variant,{ variant, members };ref.variantis the discriminant (section 7). Calls are checked against the binding's variant (invalid/memberoutside it). - Params (owner, 2026-10-21/22): a flat record of required fields, each
str(),int(),bool()oroneOf(...)of string literals, constraints allowed (str({ pattern }),int({ min: 0 })); no defaults, optional, nested or unknown fields. Checked for every caller with one compiled closed-object pi-schema check (a missing or extra field failsinvalid/params). The identity key is the field values in declared order, each escaped, joined with|. Params are never copied or frozen: the object keeps the caller's object, immutable by convention. - Arguments (R7, Q12, Q15): always validated with the provider's schema, whoever the caller is (trusted code is
not type-checked code: plugins are bundled without type checks), never with a schema a peer sent; object schemas are
closed (
obj()setsadditionalProperties: false), so an unknown field failsinvalid/argsinstead of being stripped; nothing is copied or mutated; arguments have no defaults (optional fields areopt(), handlers writeargs.limit ?? 50). Same-host arguments are not checked for plain data (no prototype or getter checks): callers pass plain data by convention. Provider schemas compile lazily at first use, cached inWeakMaps keyed by the schema owner; accepted versions' validators also on first use. Bytes are leaves (instanceof Uint8Array, optional length bound), never walked. Results and state are not validated. - Providers (
bindProvider(Type, accepts), L1): runscheckAcceptsonce per load and reload and keeps the validated mappings; per callcheckArgs(provider, method, raw, boundVersion, context)returns a tagged value,{ kind: "method"; args; result? }(current-shape arguments and, for an old-version caller, the result mapping back) or{ kind: "call"; args; call }(the provider's own replacement of a removed method, invoked by Chord, slice 7b). The binding is held only by the served type's registration and by calls in flight (GC-tested; no global strong cache). - Errors (L1, slice 3a):
ChordErrorwith a code and, for some codes, a reason, a closed union that is part of the link protocol (adding one bumps the protocol version; a receiver rejects unknown reasons; a test pins the list):invalid(args,params,member,host_address,reentrant,read_after_bulk,cycle),contract_mismatch(updating),forbidden(revoked),gone,not_ready,released(ended,closed,settled,version,discarded; none for a holder's own release),too_large(part),unavailable(locked,park_timeout,busy,storage_failed,undeclared: a registry no plugin serves here, slice 8),aborted. A failed check names pi-schema's JSON Pointer path ("unknown field", "missing", else pi-schema's message;~and/in keys escaped as~0,~1). Plugins throwPluginError(code, message, details?)for codes of their own; anything else crosses a link as the plugin codeinternalwith its message, never a stack or a cause; plugin errors never use Chord's codes. An error rebuilt from the wire is not copied and only its envelope is checked; a malformed envelope is a protocol error. - Methods return data, never objects; an object is handed out as its address and the receiver does
ctx.get. - No callbacks as arguments, on either kind: behaviour goes through registries, multi-step work through jobs,
input through flows, runtime services through core objects. Real schemas instead of
Type.Unsafe.
6. Objects
Lifecycle
(get) → activating (parked on its dependencies → own steps) → live → ending → ended
└→ failed (backoff) → activating …
live → failed (a runtime failure of the owner, slice 6a: broken, by reload or retry only)
-
One object per identity. The first
getruns the factory; later gets share the object. -
Lifetime follows from kind and params (Q-A, owner, 2026-10-21): there is no
lifetimeoption and no lazy flag. Alocaltype with empty params lives as long as the host: started eagerly at host start, never ended by reference count (it may hold persisted data, P3, and contribute to registries, R21). Every otherlocaltype and everyremotetype lives while referenced (lingerkeeps it briefly after the last reference). No remote object has host lifetime: its identity includes the principal, and long-lived parts belong in a local shared store. Below, "host-lifetime object" means alocalobject with empty params. Running and finishing job runs keep their object alive as a count, not as references, while an attempt is live or activating ("Slice 6a small items" (12)); when the count reaches zero with no holders, the object ends (§15). -
Activation (one fresh attempt each time, bounded by
readyTimeout, its wait for the previous instance's teardown included): the factory runs; singleton documents takeinitial()and, on the object's first activation, persisted documents load and its tables' definitions are checked and migrated (slice 6a; the deadline paused during eager migration work); its requests are made (self.get,self.registry, keyed pins), and the attempt is parked until every one is live and hydrated (below; the deadline paused); then its own steps run under the deadline: natives open one after another, followed sources attach, the returned members are checked (section 5), derives compute; routines start (slice 5). The object goes live when all of that succeeded. Calls wait behind its gate until then. -
The seal fence (owner, 2026-10-26): every attempt has a seal token (a job run invocation its own, for its state changes and outcome). After the seal, new work through the attempt's handles is refused at use (
self.tx,ctx.get, a job or routine start); commits are checked when they enter the store's commit step (sealed: rejectedreleased, nothing written) and at adopt (sealed during the SQL: durable, adopted into the object's documents, its staged job changes adopted into the object too: decision "Second spec review: the majority" (3)); at the seal a transaction not yet in the step is aborted and the line released, a started step finishes before teardown completes; cleanup (references, presence, activity counts) still runs. A pure swap is not a replacement. -
Ending (slice 1, 2): new calls are rejected; every caller of a running call gets
releasedat once; the handler keeps running in the background under an aborted context and itsself.txthrowsreleased(a running method can change nothing after the end). Each call runs under a context aborted by its caller and by the object's end (one controller per attempt, linked until the call settles; a caller's cancellation aborts only its own call); a call whose caller context is already aborted is rejected before its handler runs. Then the drain waits for the handlers' own promises, bounded bycloseTimeout(drain_timeoutreported), natives close in reverse order, documents are dropped, derives detached, and references released last (closes may use them). Routines stop and jobs follow their rules (slice 5). -
A live object can fail (owner, 2026-10-26): a runtime failure of the owner (a
migratefailing on access, a stored version newer than the code) seals the live attempt and makes the objectfailed(broken): calls reject with the cause, state members keep their last values, natives close, documents stay on the object; recovery by reload orref.retry(); live dependents stay live. Its references keep following every member (its own and re-exposed ones) until the commits admitted before the seal adopted and published once, then take their last values (audit A10, 2026-10-30: one rule, as restarts), and a retry's factory waits for that. -
Parked on its dependencies (dependency model §3, slice 7a; supersedes R18's dependency deadline and backoff): a dependency is what an attempt declares and needs before it goes live:
self.get(local or remote),self.registry, keyed pins (at(key)on another object's keyed member);self.followis the attempt's own step, not a dependency. While any dependency is not live (and hydrated), the attempt is parked and its object shows:Its dependencies The object shows any loading, none failedloadingany failedfailed { waiting },retryAtundefined, errorunavailable"waiting for X: " (X the first failed one in declaration order, the root cause found through its message's "waiting for …: "; always these two, so a ring of waiting attempts settles after one round; within 500 characters, X's label clipped first, fix round 1)one failed with invalid/cycle(a dependent off the ring) orforbidden(anauthorizethat refused it: authority, M§3.6)brokenwith that error: nothing heals it butref.retry()or a reloadone ended(revoked, its host closed)brokenwith that error: the request cannot come backNothing times out while parked: no dependency deadline, no backoff (only
parked_long's diagnostic report, below); the attempt'sreadyTimeoutdeadline is paused (as eager migrations pause it) and resumes with the budget left; the identity backoff stays for the object's owntransientfailures only. Wakes: the parked attempt checks again at every change of a dependency (live, loading, failed, a new cause compared by value, ended, and its first outcome, below), coalesced per object after the batch that caused them (a change while it checks or loads pinned keys is remembered); it never re-runs its factory. A parked attempt keeps its documents, declarations, requests and seal token, and holds no natives. The dependency check is the boundary: once all are live (and the pinned keys loaded), the own steps run; a dependency that leavesliveafterwards changes nothing (R18 after live: the object goes live; its calls through that reference meet its state). Retry passes on:ref.retry()(or aretrymessage) on an object parkedfailed { waiting }passes on to its failed dependencies (audit A5, 2026-10-30): on one host one retry's walk keeps a visited set, so a diamond retries a shared dependency once; across links theretrymessage carries a hop budget (hops, 16 from the first host, one less per host, dropped at 0), so a waiting ring across hosts passes it round until the budget runs out (starting no attempt) and a diamond across hosts may retry a shared dependency twice (harmless). Abrokenortransientone starts an attempt, a parked one passes it on, a request waiting unbound in an index is left as it is (an event wakes it), a refused hold over a link re-runs its start. AretryOnwake while parked does nothing. When a parked object's last reference goes, it ends at once (it is not live: nolinger) and releases its requests.brokenis only for an object's own code, cycles and authority; no failure keeps references (the R18 retry trigger is gone: parking replaces it). -
Dependency cycles are rejected (Q5): every object instance has a uuidv7 instance id, minted at its first attempt; an object that restarts gets a new one, so an old chain never matches (slice 4b, the first user; D4, owner, 2026-10-24). On one host, at
self.get(B)during A's activation Chord follows the waiting edges of activating objects from B; reaching A is a cycle. Across hosts, a hold made during an activation carries the activation chain of instance ids in its header (bounded; ignored from untrusted links): the provider rejects the hold when the instance being activated is in the chain (slice 4b; on one host slice 1; at most 32 entries; locally created objects inherit the activating chain). A cycle fails the activationbroken invalid/cyclewith the cycle as cause ("A → B → A"), and every other activating object on it the same way; no references are kept. A dependency failing withinvalid/cyclemakes its dependent broken with the same error (owner, 2026-10-24: it would wait on a ring that never heals); dependents that are not on the cycle break too, and a reload orref.retry()recovers them. The walk on one host follows the waiting edges of activating and parked attempts (exact). Checked again after a reload. Cost: O(chain) per declared reference, only during activation. The gap: rings across hosts started at the same moment (owner, 2026-10-29: no probe): a chain is fixed when its object is created, so a cycle entering an object already activating is not detected. Example: A on the server and B on a worker start at the same moment, A declares B and B declares A; the holds carry[A]and[B], neither finds the other, and without a dependency deadline both stay parked. It is made visible: an attempt parked longer than itsreadyTimeout, whatever its blocker (audit A11, 2026-10-30), is reported once per parking episode asparked_long { object, waitingFor, state, since }(waitingForthe blocker's label,stateloadingorfailed); nothing fails or retries. A cycle is a plugin bug; the guide's four rules for plugin authors and agents (host README): (1) a factory declares only what the object needs, never a type that needs it back; (2) back-references and occasional lookups go into methods and routines (ctx.get); (3) data flows up through state and registries, not mutualself.get; (4) host-lifetime objects on the server never declare worker types in their factory. -
Failed: a broken part fails the whole object: a throwing factory, document hydration or
migrate, a failed open, a derive that throws on its first computation during activation. After the object is live, a throwing derive fails only its own member (broken, W1). The cause is tagged and decided by Chord from where it failed:cause: { kind: "transient"; error: ChordError } // the object's own effect failed (an open, a follow): backoff | { kind: "broken"; error: ChordError } // its own code (factory, derive, hydration, migrate), a cycle, authority | { kind: "waiting"; error: ChordError } // not its fault (slice 7a): no provider, no fitting version, a // dependency failed; retryAt undefined, woken by an eventOnly an
openmay choosebrokenitself (throw broken(error), e.g. invalid configuration). Transient causes get a backoff per identity (1 s doubling to 60 s; a map holding no object, so a failed object is collected after its last reference while its window runs; concurrent gets share one attempt; success resets). Broken ones are retried only by a trigger. Triggers (for both):ref.retry(), a plugin reload (slice 7b, §21) and wake signals the factory declares withself.retryOn(...readables)(slice 8; anywhere in the factory: the factory specifies, Chord installs the watches before natives open, so a failed open still has them). Every trigger works likeref.retry(): the backoff window is skipped, an attempt starts now, holders are toldloading.retryOnsources must live independently of the attempt (registry values); a reference's member is a type error. A facet cannot fail afterload: its setup only declares (a throwing setup leaves its plugin out ofload, reported inload'sfailed; decision "Per plugin at startup and reload"; owner, 2026-10-26, 2026-10-30).
The factory principle
host.serve(NoteStore, ({ session }, self) => {
const notes = self.state(NoteDoc); // section 9
const index = self.state(Index);
const count = self.derive([index], ([i]) => i.ids.length); // section 12
const watcher = self.open(async (context) => watch(dir, { signal: context.abortSignal }), (w) => w.close()); // section 13
self.onLive(async (ctx) => { for (;;) { await sleep(HOUR, ctx); await cleanup(ctx); } }); // section 14
const imports = self.job(ImportJob, { run, finish }); // section 15
self.contribute(Http, { routes: { … } }); // section 17
const info = self.get(SessionInfo, {}); // start data, section 18
const catalog = self.get(Catalog, {}); // section 7
return { index, count, add: …, startImport: … };
});
- Synchronous and declarative: no awaits, no side effects; async work happens in activation (opens), in calls, in routines and jobs.
- Handles stay valid for their attempt's life (owner, 2026-10-26: documents and the line belong to the object; each attempt's handles carry its seal token and definition version); nothing is "read at use" or re-fetched.
- On a reload the factory runs again: in staging against a recording
self(declarations only), then for real; pure objects swap in place (new methods and derives, same documents), native objects restart after their drain, objects of a removed type end and their holders wait (section 21).
Pattern: shared store, per-principal views
NoteStore {S1} local shared store: the stored notes, once
▲ self.get ▲
Notes {S1} as owner Notes {S1} as read-only guest
variant "editor" variant "reader": derives a filtered view
Heavy or shared parts (a Durable watch, a prompt watcher, a sandbox, a store) live once in a local shared store;
per-principal remote views get it and expose what their principal may see. Authority is the view's; the store is
trusted and acts for the host. Views of the same variant read one value from the store (a redaction is computed once,
not per guest; section 12). Any plugin uses this pattern with the same primitives; base code is not special.
Worker-wide native owners: the facet cannot self.get a type it serves, so an owner of a per-session native (an
inbox watcher, a sandbox) is a local object with empty params (host lifetime) that reads its session from the core's start
data (self.get(SessionInfo, {}), read in its opener). Paths are derived by the plugin from generic start data (the
data directory), never provided per plugin.
Such an object cannot self.get a session-scoped object in its factory (the session is known only after activation):
it gets one with ctx.get in a routine or method (R22). A get with params computed after activation is added only
if a real case needs it.
Natives that follow a relocation: the native is a stable manager (self.open once); a routine on the session
location switches its internal resource (the watched directory) and bumps a generation, so late events from the old
location are dropped.
7. References
| Where | API | Released |
|---|---|---|
| a factory | self.get(Type, params) |
when the object ends |
| a call | await ctx.get(Type, params, { readyTimeout? }) |
when the handler settles |
| outside | await host.get(Type, params, context, { readyTimeout? }) / host.hold(Type, params, context, { readyTimeout? }) |
release() |
-
host.get(andctx.get,host.registry) resolves with a reference once its object is live and hydrated (every state member of its variant has a value). It waits while the reference isloading,failed { transient }orfailed { waiting }, up to itsreadyTimeout(default 60 s, the caller's choice), then rejects with the last cause; onlyfailed { broken }rejects at once (owner, 2026-10-29, decision "Getters wait throughwaiting"). A getter that gives up releases its reference.host.holdreturns a reference at once that follows its object until released (loading, live, failed and retried as its object is, loading again while its link is down) and never rejects; only invalid params throw (checked at once: design audit 7, 2026-10-27), and a use beforeload(not_ready, §3). A type not served where it goes isfailed { waiting }(§3; slice 7a), reportedunbound_longafter 5 s. Itsvariantisundefineduntil its first binding (slice 3b). A UI holds and showsref.state; code that needs the object now gets it, with the patience it passes. There is nostate: falseoption (C8) and nooptionalflag. -
Narrowing by variant:
ref.variantis the discriminant (C15: noinVariant/expectVariant);RefIn<C, V>narrows to some variants (strict unions, never weakened). -
References are the only indirection (owner, 2026-10-29; supersedes 6a's member source per object and 7b's hand-over): a consumer's watches and derives over
ref.memberattach through the reference's member, which passes the subscription straight through to the object's own document or derive (no extra hop, no copy: the commit's value and ops by reference). When what the reference points at changes (a new object instance after a factory re-run: reload, retry, restart; a new object after its type was removed and served again; another variant), the reference moves its subscriptions to the new one and sends one frame, a diff from the last value (consumers' derives recompute, a failed one heals; a member the new variant lacks failsinvalid/member). While it points at nothing, its members keep their last values (a pointer, no copy): while loading (a restart, a retry, a swap whose new instance is not live yet) its watches get no call (ref.statesaysloading); while failed (a live failure, its version refused, its type removed, a failed dependency) they get onefailedcall with the cause's error and stay open (decision "Failures are watch calls"); a reference that never had a value readsnot_readywhile loading and its failure's error while failed, and its watches wait for its first values. A member the new variant lacks is not a failed reference: it failsinvalid/member(reads throw it, watches get onefailedcall) until a later variant brings it back. Keyed watches and pins re-pin on each new instance the same way (a key keeps its last value until the new instance has loaded it); a dependent's factory pin is still loaded before the dependent goes live. Once hydrated, a reference has last values for every state member of its bound variant, read or not: when it detaches it takes them at its version from the outgoing instance (through that instance's mapping for an older version), keeping no node of it. After afailedcall, the next good value arrives as[["r", value]]even when unchanged; a watch opened while failed starts at the last value and gets the failure once. No source, mapping, document handle or pin of an instance outlives it through a reference: a detached reference keeps only plain last values and its failure; releasing it drops them and its bindings; stopping a keyed watch drops its pin. Derives over a failed reference member compute on its last value (a contribution built on it stays); only the member's own watches get thefailedcall, andref.stateshows the failure (owner, 2026-10-29). A re-exposed member is the re-exposing object's reference member, followed the same way. Over a link the same rule: the replica keeps its last values readable while its hold is failed. Nothing outlives its object instance and nothing is handed between objects. -
References across a reload (slice 7b §6): a reference is never ended by a code change. Its object swapped in place or restarted: it follows the new instance (above; a hold over a link gets a fresh
bound, §22). Its version no longer accepted, or a member of it refused (contract_mismatch): it leaves the object and waits unbound (failed { waiting }) with its last values; the object and its other holders stay. Its type removed: it waits unboundnot_servedwith its last values; a publication that serves the name again binds it to the next object (a new object: its in-memory documents start over frominitial(), persisted ones load again). On a removal the references take their last values at the seal: a commit admitted at that moment is stored and loaded by the next object, but not in the last values they show while they wait (accepted: deferring it would need new state on the waiting path, owner, 2026-10-29) (one frame, watches and derives heal, keyed pins re-pin, parked calls flush or reject, presence lists it again). A reference of an older version a provider accepts reads its state members through the object instance's shared mapping for its version (and keyed members per key), and its calls are mapped forwards and their results back, or run the provider's replacement of a removed method (checkArgs). A reference knows its version from the contract token it was made with; one made by outside code keeps the token its caller passed (Chord reloads no caller code). -
Members: each state member of a reference is a watchable of the provider's member (a replica over a link); watches taken through a reference belong to it and end when it is released. Methods are functions (
ref.add(args, ctx),ref.bump(ctx)). -
ref.stateis aNativeWatchable(slice 3b) of a tagged value; watches get each new state:| { kind: "loading" } | { kind: "live" } | { kind: "failed"; cause: FailureCause; retryAt: number | undefined } // retryAt: a transient cause's backoff; // undefined for broken and waiting | { kind: "ended"; error: ChordError } // released by its holder, or its object ended
| Situation | Calls | Values |
|---|---|---|
| link drop | park until reconnect | readable (loading) |
| object loading or restarting | park until live | readable (loading) |
| unbound (not served, another version) or parked on a failed dependency | reject at once with the cause | failed { waiting } (slice 7a) |
object failed |
reject at once with the cause | failed with cause and retryAt |
-
A failed reference stays alive: while held, Chord retries a
transientfailure per the backoff and wakes awaitingone at the event it waits for;ref.retry()tries at once (a Retry button; over a link theretry { hold, hops }message, which also re-runs a refused hold's start: the host address, binding; design audit 2026-10-27; on a parked object it passes on to the failed dependencies, §6); success goesloading, thenlive. A provider tells a pending hold's consumer each failure (failed, with the rest of its backoff window for a transient one), each new cause of an object parked on a failed dependency, and the next attempt (loading). -
Parked calls (slice 3b): calls made while the reference is loading are queued in order, each waiting at most the reference's
readyTimeoutfrom the moment it was made (never renewed), then failunavailable/park_timeout; they are sent in order when it goes live (a new call never overtakes them) and rejected with the cause when it fails. Otherwise a call ends only through its caller's signal; there is no per-call deadline. -
Calls already sent when a link drops fail
unavailableand are not resent; calls running on an instance that ends fail and are not resent. Releasing a reference rejects the calls made through it (released), locally and over links (slice 3a). A consumer receivinggone(h)rejects its own pending calls onh(Q1). -
References survive link drops: one owner of a host's remote references holds them again on every new link of their path; a re-hold may bind another variant (slice 3b).
-
Consumers handle a failed call as they handle a disconnect (error shown, user retries); Chat's own submission
requestIdstays an app concern. -
Optional dependencies are registries owned by the dependent, not optional references: ports contributes its forwards to
radius.forwards; without ports the table is empty and radius works. -
Authority checks live in the per-principal view's methods (and in
route.authorizebefore a host starts); there is no async activation guard. An object for an address that carries authority (LoginFlow { id }) may exist; what a caller can see and do is decided by its view.
8. ctx
Every handler gets a ctx (a Context): telemetry (the current span, section 29), abortSignal (the caller
cancelled or the object ended; there is no per-call deadline), principal (the caller as its link stamped it; on a
local object's handler the host's principal, none on a client), incarnation (the calling host's, section 4), and
get(Type, params, { readyTimeout? }) (references as the caller sees them, released when the handler settles; a
get after that throws released), and trace(name, attributes, fn): fn in a child span of ctx.telemetry with a
child ctx (the same principal, incarnation, signal and get, its references the handler's), so calls inside are
parented under it; a throw marks the span failed (trace_failed) and is rethrown; without an adapter that records,
fn gets ctx itself (slice 9; round-3 cut C11 kept, as this section already stated it); in a job's run also jobId
and state (slice 5). Pass ctx to everything you wait
on. Code outside objects gets a ctx from host.root (slice 8). Durable's Context is the same module
(../context); bridging is explicit. No code versions, no request ids.
Await what you start in a root or a handler (decision "Mistakes that wait are reported at once" (e), owner,
2026-11-03; footgun 10 of the p2 port). What is started with a context ends with it: a root's scope seals when fn
settles, a handler's context aborts when its caller cancels or its object ends, an attempt's at its seal. A document
change or transaction (handle.change, retire, self.tx, job.start) that did not commit because its context
ended first (it waited on its object's line, or fn returned before it committed) rejects with that context's abort
reason, and is also reported through onError as transaction_cancelled { object, error } (error the abort
reason, naming the scope that ended): an empty catch cannot hide a change that never happened.
host.root(async (ctx) => { void handle.change(fn, ctx).catch(() => {}); }, context) loses the change and reports it;
await handle.change(fn, ctx) inside fn commits it. Only the cancelled path pays (the check runs where the
transaction failed); Chord's own transactions run under contexts that never abort.
Contexts are required (owner, 2026-10-22): every function that takes a context requires it, as the parameter after the required ones (context last); optional only on error-reporting paths. A getter that cannot take one (a lazy derive read) uses its owner's.
Call-scoped natives (a native that lives exactly as long as one call: a temporary SSH session for a directory
listing, a file handle for a download) are opened and closed inside the method with try/finally; there is no
ctx.open:
list: async ({ path }, ctx) => {
const session = await ssh.open(config, ctx.abortSignal);
try { return await session.readDir(path, ctx.abortSignal); }
finally { session.close(); }
},
9. Documents
Definition and handles
const Settings = defineState("settings", {
schema: SettingsSchema,
version: 1,
initial: () => ({ theme: "dark" }),
persist: true, // default: in memory
checkpointWhen: () => true, // required for persisted documents; small: a base per write
});
const Draft = defineState("draft", {
schema: DraftSchema,
version: 2,
migrate: (old, from) => ({ body: old.text }), // pure
initial: () => ({ body: "" }),
keyed: true, // many per object
persist: true,
checkpointWhen: (value, ops, info) => info.deltasSinceBase > 50,
});
host.serve(DraftStore, (_, self) => { // local, empty params: may persist (P3)
const settings = self.state(Settings); // THE settings document
const drafts = self.state(Draft); // all Draft documents of this object
return {
settings, // a singleton handle is an exposable Watchable
get: ({ conversation }, ctx) => drafts.get(conversation, ctx),
save: ({ conversation, body }, ctx) =>
self.tx(async (tx) => {
const d = await tx.draft(drafts, conversation);
d.body = body;
return d.body.length;
}, ctx),
drop: ({ conversation }, ctx) => self.tx(async (tx) => tx.retire(drafts, conversation), ctx),
};
});
- Ownership: a document belongs to exactly one object identity, and so does its line (owner, 2026-10-26: across the object's attempts, retries, failures and native restarts; an attempt keeps only its natives, routines, derives and seal token) (persisted ones across restarts; in-memory ones for the object's life, across reloads and native restarts). Facets have no documents (owner, 2026-10-26: C16). Others see it only through exposed members. References to other objects are stored as data (addresses).
- Singleton (no
keyed): loaded at activation, kept in memory while the object lives;handle.valueis synchronous; the handle is an exposableWatchable. - Keyed: addressed by a short key local to the object, loaded on demand;
await handle.get(key, ctx)reads (creates nothing);for await (const key of handle.keys(ctx))pages keys (both take a context, owner, 2026-10-26) (indexed per owner and definition, also in memory). No live key list: a collection shown live is a singleton (an object holding a list or map); large collections are methods returning pages. - Values are immutable by convention (Chord Delta's rule, as in Durable):
handle.valueand published values are shared, never mutated by their readers, and never copied or frozen; a draft belongs only to its transaction.
Writes and transactions
-
Only
tx.draft(as Durable'stx.doc):await tx.draft(handle, key?)insideself.tx(fn, ctx)returns a draft of the current document, or a draft overinitial()if it is absent. Reading is drafting (Delta's overlay: no copy). An unchanged draft (empty ops) commits nothing, so an absent document is never stored by being read.tx.retire(handle, key?)removes a document.keyedmakes the key mandatory at the type level. There is notx.getfor documents (tables have one, section 10). -
Drafts record ops (assignment,
push,Object.assign); the same document returns the same draft within one transaction. Drafts are revoked when the transaction settles (R12, as in Durable): a transaction writes and returns at most plain values (an id, a count), never anything taken from a draft. To return data, read after the commit from the document (handle.value,handle.get), which then holds the committed revision; a later commit may slip in between, and the reader gets that newer value. -
No existence check inside a transaction (R13, as in Durable):
tx.retireof an absent document is a no-op (it never creates);tx.draft(handle, key)creates a missing document when the draft changes; whether a keyed document exists is read outside the transaction (handle.get(key, ctx)givesundefinedfor an absent one). A singleton always has a value (Q11):handle.valueis the written value orinitial(), neverundefined, so an exposed singleton is typedT; singleton existence has no API (unlike Durable'ssnapshot()).Patterns:
// Return data: write in the transaction, read after the commit. await self.tx(async (tx) => { (await tx.draft(notes)).items.push(note); }, ctx); return notes.value.items; // the committed revision (or a newer one) // Remove if present: retire is a no-op for an absent document. await self.tx((tx) => tx.retire(drafts, conversation), ctx); // Create if missing: the draft creates it (only if it changes). await self.tx(async (tx) => { (await tx.draft(drafts, conversation)).text = text; }, ctx); // Branch on existence (keyed documents): read first, outside the transaction. if ((await drafts.get(conversation, ctx)) === undefined) { … } -
Document roots are
JsonObject(as in Durable; lists live in a field); documents hold JSON only (bytes are a value and wire type). A singleton that does not exist yet readsinitial()through its handle; retiring resets it toinitial(). Handles may be used only in transactions of their own object. -
Commit sequence (Durable's): wait for the object's line (per object, never across objects; reads outside a transaction do not take it exclusively); run the callback; an error or an abort before the storage write discards everything; prepare every draft (Delta copies and checks only the placed values); write persisted documents in one storage batch through the store's commit step,
await store.commit(writes, adopt, ctx)without the caller's abort signal (deltas, or a base whencheckpointWhensays so; the line held until the step returned, as Durable's#runCommit, owner, 2026-10-26); adopt by pointer swap and mark dependents in the step, persisted and in-memory documents and staged changes in onehost.batch.commit(errors as Durable's Storage, owner, 2026-10-27: a step that fails beforeCOMMITrolls back and rejects that commit only, nothing adopted, the store goes on; plugin mistakes are refused when a write is staged; a seal-fenced commit is rejectedreleasedbeforeBEGIN); then the owner's synchronous flush recomputes dirty derives, sends one message per link and queues watch callbacks (W1); drafts then throw if touched. In-memory-only commits take no store seq and never enter the commit queue (W12). No revalidation of the whole document, no copy, no freeze. -
Nothing leaves a transaction before it commits (S2): ids and data reach other owners only after the commit; reference what is committed (a reference to a later commit dangles on every replica that stops in between).
-
Durable's rule: inside the callback only
await tx.*: no other objects, models, processes or network, no secondself.tx, no reads pasttx(otherwise a hang, not an error). Fetch first; decide and write inside. -
A transaction covers only its own object's documents and tables: one line, no transactions across owners (facet documents were removed, owner, 2026-10-26; shared plugin state is a
localstore object's, reached by methods). -
Shorthands:
handle.change(fn, ctx)(keyed:handle.change(key, fn, ctx)) (one document, loaded before the synchronousfn),handle.retire(ctx)(keyed:handle.retire(key, ctx)); several steps atomically:self.tx.
Versions
- A shape or meaning change raises
versionwith a puremigrate(old, from). A document migrates on access by the new definition (in memory too); the migrated base is persisted with the next commit. - Schema fingerprints instead of value validation:
defineStatecomputes the fingerprint of its structural schema once (descriptions and titles ignored) and keeps it on the token; every document record stores the fingerprint it was written with (at creation, and with the new base after amigrate). On access Chord compares the token's fingerprint with the record's: the same version with a different fingerprint fails the objectbroken("schema changed without a version bump"). Always on, O(1). Every structural schema change needs a version bump (an additive change gets a trivialmigrate). Values are not walked against the schema in production, with two exceptions (R8):- the result of
migrateis validated against the new schema once, when it is produced (per document per version change, off the commit path); a failure refuses the access, keeps the old data and fails the object (slice 6); initial()is checked once per definition, at its first value (D3, owner, 2026-10-24:initial()takes no input, so a wrong shape is wrong at its first call; checking every call would cost one validation per created document and catch nothing more). A failing singletoninitial()fails its object's activation (broken); a failing keyed one fails only the transaction that drafts the absent document, naming the definition (slice 1).
- the result of
- Old code whose definition is older than the document throws on access. Without
migratethe access is refused and the data kept. A changed meaning without a bump cannot be detected. - Singleton documents migrate during activation and after reload (hydration counts as access). A hydration or migration failure keeps stored data and fails the whole owning object, not only the exposed member. Keyed documents migrate on their next access; a migration failure there also fails their owner. A migrated base is written back by the next commit that changes the document (owner, 2026-10-27); until then a load migrates again (once per object, since documents stay on the object).
Lifetime, persistence
- No tags (owner, 2026-10-21): deleting a session deletes its store and directory; instance data on the server that
refers to a session is a table with a
sessioncolumn, cleaned up by its owner (deleteWhere, reconciling from the catalog). - In-memory documents are instance state (P2, owner, 2026-10-21): attached to the live object, with no storage, no address, no seq and never in the outbox; their commit is prepare and adopt (inside the store's commit step when the transaction also writes persisted data).
- Lifetime: in-memory documents end with their object (last reference released, linger expired, a params change on reload) and survive reloads and native restarts of a live object; long-lived objects creating many keyed in-memory documents retire them themselves. Persisted documents stay until retired.
- Persisted data only in
localobjects with empty params (P3, owner, 2026-10-21): persisted documents and tables live inlocalobjects with empty params (one per host and type); their address is (object type, name, key), never a host instance such as the session id (W7; no host kind either since slice 6b R2: a store belongs to one host), so a params change never orphans data. Per-entity data is keyed documents or table rows there, with an app-chosen key; objects with params keep only in-memory state and read persisted data by key. Renaming a persisted store's type or definition, or changing how keys are formed, is an explicit one-off migration in the store. Why not elsewhere: aremoteobject's identity contains the principal key (provenance, grant revision), so its persisted documents would scatter across access paths and be orphaned after a reconnect; a shared storage key (storeKey) would put two objects with two lines on one document, so it does not exist; params would orphan data on a params change. Enforced at the type level (self.state(…, persist)andself.tableonly in factories oflocaltypes with empty params) and as a declaration error at runtime ("persist needs a stable identity: put the documents into a local shared store"). - Durable plugin state: whatever must survive a host teardown (crash, restart, a DO eviction) is a persisted
document; everything else is rebuilt on activation (in-memory documents, natives, derives, routines, presence,
references re-held by clients; job runs end
lostunless their records persist).
Rules across owners
-
No plugin reads or writes another plugin's documents (Chord or Durable). Data crosses plugins only through state and methods. Data core enforces lives in a format core owns and is handed to core through its API when the data is created (
createConversationwithinit), never written into core's documents directly. -
Invariants across owners are enforced by the owner of the dependent data, which reconciles from the other owner's state with an idempotent routine. No cross-owner transactions, no "notify the other owner" calls that can be lost:
// ports' ForwardStore: "a forward of an archived session is closed" const catalog = self.get(Catalog, {}); self.on(catalog.sessions, async ({ value: sessions }, ctx) => { for (const f of forwards.value.list) if (sessions[f.session]?.archived) await close(f.id, ctx); // idempotent });After a crash the routine runs again with the current value; sessions does not know ports exists.
10. Store and storage
Superseded (owner, 2026-10-21): persisted data lives in one transactional store per session, designed in
packages/chord/docs/store/book.mdand decided in the log ("The store", S1-S17, W1-W14, P2-P4, "Flat columns", "Query runner", "Index definitions are recorded", "Blobs and derived data"). In short: backends SQLite (Node, Durable Objects, sqlite-wasm in the browser; tests on SQLite:memory:, no hand-written memory backend, owner, 2026-10-27); real tables owned by objects with empty params (P3), flat columns (scalars, everystr()as plain UTF-8TEXTin code point order with lone surrogates replaced and NUL refused (owner, 2026-10-26, superseding W8),json(...)with its own version and on-access migration), a small query language, writesput/update/delete/updateWhere/deleteWhere; one documents engine for Durable (StoreStorage) and Chord; one commit queue per store that writes, adopts and marks (S1, W1), withStorage.commit( writes, adopt, context)in Durable; an outbox of ordered operations in the same transaction (no compaction at first); eager migrations by scan with rowvfor eager steps only. In-memory documents are instance state with no storage (P2). The text below describes the per-object lines, Delta trackers and commit sequence, which stay; its storage interface is replaced by the store.
-
What stays from Durable's store design (
durable/src/session/transaction.ts,session.ts): one Delta tracker per document held by its object, prepare and adopt by pointer swap, ops per commit, lines per object, migrate on access,checkpointWhen(required for persisted documents, R14), retire, and the documents engine's invariants (create before change, no change after retire, one live incarnation per address,deltasSinceBase). -
Where persisted documents live: rows of the store's documents engine (S13) with address
(namespace: "chord", owner: "<object type>", kind: <definition>, key)(key""for a singleton), never a host instance (W7); ids from the store's counter (S10). Chord ships no storage of its own: noDocumentStorageinterface, no Chord SQLite or memory document storage and no Chord storage conformance suite (the store's conformance suite covers the engine); the frozennext/storages are not ported. The store is passed to the session's host by base code (owner, 2026-10-26):const store = await openStore(sqliteBackend(await openNodeSqliteDatabase(join(dir, "session.sqlite"))), ctx); const worker = createHost<P>({ address: Session({ id }), principal, store, onError }); // tests: SQLite :memory: … await worker.close(ctx); await store.close(ctx); // the app owns the store; in 6b the Durable Session shares itopenStoreruns the store's own migrations, checks the file is UTF-8 and readslastSeq, the definitions and the id counters once;sqliteBackend(db)takes anySqliteDatabase(Node, Durable Objects, sqlite-wasm later); the backend interface is internal;importStore(backend, archive, ctx)takes the same backends. NoopenNodeStore. -
Loading at activation (owner, 2026-10-26): persisted singletons load right after the factory, sequentially, all singletons of the object with plain reads (owner, 2026-10-27: the object holds its line, so no read transaction is needed), once per object, before its references are bound: find, load (base plus deltas), fingerprint check,
migratewith its result validated once (R8). A storage error isstorage_failed; a fingerprint mismatch, a throwingmigrateor an invalid migration result fails the objectbroken. -
Statements per commit (owner, 2026-10-26): the store keeps
lastSeq(the seq of its last commit) in memory (read at open, persisted with each commit, S9) and folds the documents engine's checks into the writes (unique keys, a partial unique index on the live address, conditionalUPDATE … WHERE id = ? AND version = ?): a delta commit is about three statements (revision, the record's counted conditionalUPDATE,lastSeq), plus oneINSERTwith an outbox. Durable's ids in the store (slice 6b D3): one id allocator per store for every id (Durable'smintIdand Chord's document ids; it starts at 2, saturates atNumber.MAX_SAFE_INTEGER, each commit persists it, outbox commits and archives carry it asnextId);StoreStorage'srecord_idstable only checks an id's record type. Every table and document namespace in the store is claimed once (slice 6b "owner names and claims"): a table by its SQL name"<owner>:<table>", documents by(owner, namespace); an object claims under its type,StoreStorage.open(store, owner, ctx)underowner(two harnesses on one store under two names are fine). -
Tables in a transaction (owner, 2026-10-26): the table handle first, keys as objects with exactly the key columns, reads async, writes synchronous and buffered like a draft's ops (checked and staged, no I/O; written in the commit step or dropped on failure); no write shorthands on the handle:
self.tx(async (tx) => { const old = await tx.get(edits, { id }); // overlays own put/update/delete (W10 after bulk) const { items, next } = await tx.page(edits, q, 50, { after }); // committed rows, stable for the transaction tx.put(edits, row); tx.update(edits, { id }, { at: now }); tx.delete(edits, { id }); tx.updateWhere(edits, (e) => prefix(e.path, from), (e) => ({ path: replacePrefix(e.path, from, to) })); tx.deleteWhere(edits, (e) => lt(e.at, cutoff)); }, ctx); await edits.get({ id }, ctx); // outside: committed state, ctx last -
Live queries in the commit step (owner, 2026-11-03; section 11): a transaction that writes a table with live queries (
self.query) stages one more step write after its own writes: it reads each such query on the step's backend transaction (so it sees the commit's rows, oneSELECTcounted inchord.store.statementsandchord.query.runs), writes nothing and records nothing in the outbox; the rows are adopted with the documents in the commit's batch. A refused step adopts nothing. At activation the queries run right after the tables are prepared, before the references are bound. A transaction writing no table with a live query stages nothing (one map lookup per written table). -
The outbox (owner, 2026-10-26): optional,
openStore(backend, ctx, { outbox: true })(an omitted option keeps the persisted setting; switching needs an explicittrueorfalse), persisted in the store's metadata (turning it on starts sync from an archive at the currentlastSeq); a table in the store's backend, written in the commit step's transaction (atomic for oneINSERT); exposed asstore.outbox.read(afterSeq, limit, ctx)->{ seq, writes }[]andstore.outbox.trim(throughSeq, ctx), no pluggable encoding; versioned by the store's schema migrations (its layout) and aformatper outbox commit (the write vocabulary, the archive'sformat; older formats stepped forward on read, newer ones refused). -
Writes, commits, batches, archives (owner, 2026-10-26): a write (
StoreWrite, tagged bykind) is one change of the store (put,update,updateWhere,table,index,document.create, …); a commit the writes of one seq in the outbox; a batch the commitsfrom..tothat sync uploads and a replica applies; an archive a manifest, the writes from 0 tolastSeqand anend.lastSeqis the seq of the store's last commit. -
Export and import (owner, 2026-10-26):
store.export(ctx)yields the archive's parts, the state after itslastSeqas writes (manifest,table,index,put,document.create,end), holding the commit queue until the iteration ends orctxaborts;importStore(backend, parts, ctx)applies them into an empty backend in one transaction with the replica's applier. Lossless (owner, 2026-10-26): everyputwrite carries its versions ($v, eachjson(...)column's) in the outbox and in archives, atablewrite its state, alatestdocument its current value; the applier never infers a version. The encoding is pluggable; plain JSONL ships first. Sync is not export: it ships outbox batches as commits happen. -
json(schema, { version, migrate? })columns (owner, 2026-10-26): schema and version required,migrateoptional and pure, on access (W11), its result checked once (R8); without it (or failing) the read is refused and the owner failsbroken; each column's version and schema fingerprint are recorded in the table definition and compared at activation (same version, other fingerprint:broken, D9); a read never writes back. -
Table versions:
evolveTable(2026-10-27, the second spec review's majority): a table's history is a base (defineTable) and oneevolveTable(prev, (t) => t.rename(…).add(…).rows(…).drop(…).index(…))per version, each materialized and fingerprinted at declaration, as contracts'evolve; the store replays the recorded version's successors in order (DDL andmigrating, the data step,ready); key columns are immutable inset. -
Eager migrations and addresses (owner, 2026-10-26): the activation attempt's
readyTimeoutdeadline does not run while an eager migration step runs (getters still time out by their own). Store names carry no host kind (slice 6b R2, owner, 2026-10-27): tables are"<object type>:<table>", document owners"<object type>". -
Eager
rowsmigrations commit in chunks of 100 rows (owner, 2026-10-26; a fixed constant: about 1 ms of commit step per chunk, the total time is the same for larger chunks, and every other owner's commit waits for the step). Between chunks the migration yields one macrotask withoutsetTimeout's 1 ms clamp:setImmediatewhere the runtime has it, otherwise aMessageChannelmessage (2026-10-27, slice 6a build reviews). -
Eviction as in Durable, for keyed persisted documents only (persisted singletons and
at(key)documents stay in memory while their object lives:handle.valueis a synchronous read; owner, 2026-10-26): one not read, written or watched for the retention period (default 10 minutes) leaves memory and reloads on the next access. Store errors (owner, 2026-10-27; supersedes in part "Store errors: Durable's rule"): the store has Durable'sStoragesemantics, its owners react as Durable's Session does. A step that fails beforeCOMMITrolls back: that commit rejects, nothing is adopted, the store goes on. The store stops (storage_failed: later calls rejectunavailable/storage_failed, the host closes,onErrorreceives{ kind: "storage_failed" }, the app decides, section 3) only when the outcome is unknown (COMMITorROLLBACKfailed) oradoptthrew, and on a backend error of a read whosectxwas not aborted. A Chord object whose commit the step refused (memory and disk disagree: the counted conditionalUPDATE, the live-address index) fails live (object_failed,broken); anintresult out of range (CHECK) fails only that transaction (invalid/args). Durable's Session fails on any commit error (GuardedStorage), as today.
11. Kinds of state
| Kind | For | API |
|---|---|---|
| document | data the object owns | self.state, self.tx |
| derive | computed values | self.derive(sources, fn) |
| Durable's views | the conversation view, task graph and Durable documents | Watchables through Durable's watches; feed derives and state members |
| paged method | large or queryable collections | a method returning pages (chat history) |
| table | a plugin's own records with queries (owner, 2026-10-21) | self.table, the store's query DSL |
| live query | a bounded window of the object's own table, live (owner, 2026-11-03) | self.query(table, q, limit) |
| plugin database | derived data only: full-text search, caches (rebuilt, never synced) | a native opened by its owner |
App state lives in Chord documents; only transcript-bound data (entries, documents that fork and rewind with a conversation) lives in Durable.
Live queries (decision "Live queries over an object's own tables", owner, 2026-11-03): self.query(table, q, limit) in the factory of the table's owner returns a Watchable of q's rows (at most limit, in its order): a
state member, a derive source, an on source. Chord runs it before the object goes live (with its documents and
tables, section 10) and again in the commit step of every commit of the object whose staged writes touch that table;
the rows are adopted in the commit's batch and published as diffRevisions ops (W1), unchanged rows kept the same
objects so the diff compares them by identity. Rules: only the object's own tables (only it writes them, so its
commits are the exact invalidation: a commit not writing the table runs nothing); the query is fixed for the attempt
(params decide it); a limit is required (invalid/args at declaration; the store's query language keeps the limit
an argument of the runner, as page has it). A run that fails (a row the code cannot read) is a failed member, as a
throwing derive (one failure call, query_failed once per episode, [["r", rows]] at the next good run); the object
stays live. It is no native, so an object with live queries swaps on reload (section 21). Cost: one query per
touching commit, bounded by the limit (100 rows: about 60 µs unwatched, 80 µs watched, benchmarks.md); there may
be dragons for tables written hundreds of times a second. Incremental maintenance of the result (row-level changes
kept in memory, a re-run only when a removal empties the window or a bulk write hides which rows changed) is left for
when a case needs it.
Watchables (owner, 2026-10-21; log "Watchable and one watch API", W1-W4)
There is no replicatedState in v2. Everything observable is one of two kinds, sharing one lifecycle:
interface Watchable<T extends JsonValue> { // JSON only (no bytes); documents, derives, reference members, constant(v), Durable's views
readonly value: T;
watch(ctx: Context): Promise<WatchHandle<T>>; // listener (call, ctx), call: WatchCall<T> (below)
}
interface NativeWatchable<T> { // self.writable(initial) with set(), composed registry values
readonly value: T;
watch(ctx: Context): Promise<NativeWatchHandle<T>>; // listener (call, ctx), call: NativeWatchCall<T>; a change is a new value (!==)
}
type WatchCall<T> = // owner, 2026-10-27: a recoverable failure is a call, the watch stays open
| { kind: "value"; value: T; ops: Op[]; retired: boolean }
| { kind: "failed"; error: unknown };
type NativeWatchCall<T> = { kind: "value"; value: T } | { kind: "failed"; error: unknown };
// const w = await notes.watch(ctx); send(w.value);
// w.start(async (call, ctx) => { if (call.kind === "failed") return showError(call.error); render(call.value); });
// … await w.stop();
- Failures are calls, watches end only for good (owner, 2026-10-27): a source that fails and can recover (a
derive whose compute throws, a replica member a hold lists as failed, a member a new variant lacks, a registry whose
composethrows, slice 8) gives its watches one{ kind: "failed", error }call and keeps them open; its next good value arrives as[["r", value]](folded with the failure for a busy listener). A failed derive recomputes on every later change of its sources and on a re-bind (self-healing). A watch ends only when nothing can come back: its owner ended (ended),stop()or a cancelled context, a throwing listener (listener_error),storage_failed.self.onhandlers are not called for failures: a routine waits for the next good value (the failure is reported where it happens: the derive's owner reportsderive_failed { object, member?, error }once per failure episode, the first failure after a value or after creation, not for a derive failing only because a source failed).ref.statestays a separate watchable of the object's reachability (loading | live | failed | ended), with the same words. A replica member that vanished with a variant is kept and comes back with a later binding; while failed it accepts only[["r", value]]. - Watch handles are Durable's, moved into Chord (Durable imports them):
watch()atomically takes the current value and buffers later commits;start(listener)installs the one listener; commits are delivered in order, async listeners awaited; a listener still busy with an earlier frame gets the commits made meanwhile as one call, from the pending queue (owner, 2026-10-23): ops stay raw and are folded through a leaf index (a map from path to the last pending append or primitive set; a new one on that path merges into it; any structural op clears the map;rdrops everything before it); past 10,000 pending ops only "replace with the current value" is kept (no size, text or time caps; no folding of list appends or trims; ops come only from Chord's tracker anddiffRevisions, so there are no defences against hand-written ops).PendingOpsis Chord's shared Delta code. The same queue holds a link hold's ops while its window is full (section 3). Natives: every value in order, past 100 pending values one call with the newest (W4). Durable's event stream keeps its own queue (events are not state);stop()is idempotent, drops pending frames, never aborts or awaits a running call;stop()andclosedreturn the end, tagged bykind(owner, 2026-10-27):stopped,aborted(its context aborted),ended,listener_error { error },failed { error }(a final failure; a recoverable one is a call). Durable's synchronous observers take the same shapes:advance(call, ctx),end(end). Retirement (owner, 2026-10-26, option A; replaces W2a's[["r", null]]frame and the endretired): one mechanism for Chord and Durable documents, singletons and keyed alike. A watch follows its document's address; a retirement is one last call with aretiredflag (a Chord document:initial()with[["r", initial()]], which a wire replica applies; a Durable document: the last good value, no ops); the watch goes on, and a later write delivers the new incarnation as[["r", value]]. A watch endsendedwith its owner (the object of a Chord document; for Durable the task of a task document, right after its retirement call, or the Session). Listeners take one object before the context (owner, 2026-10-26; tagged since 2026-10-27, above):(call, ctx), aWatchCallorNativeWatchCall. A retirement call carries the address's value after the retirement:initial()for a Chord document, the last value (no ops) for a Durable one, which has none while absent. Watching an absent address givesundefined. On the wire astateentry lists retired members like failed ones. A per-key source in a factory,handle.at(key), is a singleton per key: loaded at activation,initial()while absent, usable as a state member, derive source andonsource (owner, 2026-10-26). A stopped watch releases every reference it held (W3). - Only
Watchable<Json>can be a contract state member or go on the wire, by type and by a kind check when the object is served (Q14); aNativeWatchablenever can, even holding JSON. Mutating a native value in place notifies nobody. - Publication (W1): the commit step writes, adopts (pointer swaps, Durable's touched views dispatched by id) and
marks (dirty derives; ops appended by reference to each touched hold's batch and each watch's queue). Right after
the step, synchronously, the owner's flush recomputes the dirty derives someone holds or watches (dependency order),
encodes one entry per touched hold into one
statemessage per link and origin, and queues watch callbacks. A throwing derive fails only its member; reentrantself.tx/watch/release/get/hold/load/closethrowinvalid/reentrant; a delivery or flush that throws is reported (delivery_failed) and the others still run. The same boundary runs without a store seq for in-memory commits,writable.set, registry changes and inbound wire batches (aboundorstatemessage is applied as one batch before listeners run). There is one batch per host, passed to its sources (no module-level singleton); a dirty derive nobody holds or watches recomputes lazily on its next read. - Consumers: derives and the wire use the internal flush; app code uses watches;
self.onis a watch owned by the object (first call with the current value, then every commit; Chord awaits a running call on reload); clients get ordered ops over the link, folded by the provider's pending queue when they fall behind (section 3); a re-hold gets a fresh snapshot.
12. Derives
self.derive(sources, (values) => value): a synchronous pure function, recomputed when a source changes and
diffed into ops for the wire. Sources are Watchables and NativeWatchables (documents, other derives, reference
members, Durable's views, registry values); the result is always JSON (state is JSON only; bytes only in method arguments and results). A derive is marked dirty in the
commit step and recomputed in the owner's flush right after it, so its change travels in the same message as its
source (W1); a dirty derive nobody holds recomputes on its next read. A throwing derive fails only its member
(broken). There is no previous (W14): mapShared keeps unchanged elements identical.
-
Built on what exists: derives sit on Chord's internal publication primitive (not today's
cell.tsorreplicatedState); the diff is Delta'sdiffRevisionscalled directly (identity short-circuit: unchanged shared subtrees cost one===; bytes are atomic leaves compared by identity; equality never by stringify). -
Container width (R24): structural sharing is O(change) along a path, not across a container's width: changing one item of a flat array copies the array's slots, and
mapSharedvisits every element (one identity comparison each). Large collections are keyed documents or bounded windows; a benchmark of long arrays (allocations, element visits per change) sets the documented limit. -
The footgun: a derive that builds new objects for unchanged data makes every commit cost O(size of the value), per view, because the diff must compare contents. Unchanged parts must be the same objects as last time.
-
mapShared(f)(from@earendil-works/chordstate; it moves into Delta when v2 goes upstream): an identity cache per source element:fruns only for new or changed source elements, unchanged ones return their previous result object. With it a list derive costs pointer comparisons, not content comparisons:const strip = mapShared((n: Note) => ({ text: n.text, at: n.at })); // a mapper over the whole array self.derive([notes], ([n]) => ({ list: strip(n.list) })); // one appended note: one new object, 2,000 identical references, diffRevisions emits one insert -
Cookbook (documentation with examples per case): select a field, look up by key, small aggregates (just compute; the value is small); filter or map a list (
mapShared); redaction (preferably separate documents per visibility, otherwisemapShared; computed once in the shared store, not per view); joins across two sources (mapSharedplus the looked-up source's revision); a window over a large list (take a section of the source elements without rebuilding them, cache per-element measurements by identity, keep the window's start stable so changes arrive as appends). Large combined views from several sources re-path the sources' ops (as Durable'sConversationView); application code never interprets ops.
13. Natives
const watcher = self.open(async (context) => watchDir(path, { signal: context.abortSignal }), (w) => w.close());
self.onLive(async (ctx) => { for await (const f of watcher.value.files(ctx.abortSignal)) await importFile(f, ctx); });
-
self.open(open, close, { closeTimeout? })(slice 2):open(context)gets a context aborted when the attempt is abandoned (a timeout, a cycle, its end);close(value, reason, context)gets one aborted at the close timeout, after which a possible leak is reported (close_timeout) and the rest continues. It returns a handle with.value, not a proxy: reading it before its open resolved throwsnot_ready, after its close startedreleased. A value that arrives after its attempt was abandoned is closed at once. Natives open during activation (the object goes live after all opens) and close in reverse order when it ends. The opened value never changes during that attempt: noself.resource, versions,replace,depsor stale handles. To change a live native, the object restarts. A partly failed activation closes already-open natives with reasonend; a later attempt opens them all anew.retryOnmay appear anywhere in the synchronous factory. Type names:Native<T>(the handle),NativeCloseReason. -
Order: natives open one after another in declaration order (a later opener may use an earlier handle's
.value) and close in reverse. A throwingclosedoes not stop the others: onhost.close()all errors are collected and it rejects with them; an object ending in the background reports them toonError. -
Behaviour around a native goes into routines; listeners on natives outside routines are forbidden.
-
The facet may not open natives.
-
What an old native does after its owner restarts is its owner's
close(a model runtime keeps working until collected; a closed SSH connection fails its in-flightexec). -
An open that fails makes the object
failed(transientwith backoff, orbrokenif it says so). -
closegets a reason:reason: { kind: "reload" } | { kind: "close" } | { kind: "end" }(the plugin is reloaded and the object is native / the host shuts down / the last reference ended or the type was removed). Chord aborts only the object's own calls and jobs; other work using the native (Durable tool executions on a code mode sandbox) is the owner's:closemay drain instead of tearing down, e.g. seal the sandbox (no new scripts) and end it when its running scripts finish or hit their deadline. -
Restart stays sequential (R16): close the old native, then open the new one. Two patterns for natives whose resource has work in flight:
- Drain in the background:
closedetaches the old resource, returns at once, and disposes of it when its work has finished or aftercloseTimeout(30 s at most, Q7: a longer script is aborted); Chord then opens the new one immediately. Code mode's sandbox does this; its tool entry looks up the current sandbox atexecute(). - Swap through a registry: the resource is a registry contribution; readers always find the current one, and a reader that must wait for one uses a cancellable "wait until present" with the park deadline.
- Drain in the background:
14. Routines
Two kinds, declared in the factory (owner, 2026-10-24; C4 decided; slice 5):
self.on(catalog.sessions, async ({ value: sessions }, ctx) => { // reacts to a value: first the current one, then every change
for (const f of forwards.value.list) if (sessions[f.session]?.archived) await close(f.id, ctx); // idempotent
});
self.onLive(async (ctx) => { // called once when the object goes live
for await (const file of watcher.value.files(ctx.abortSignal)) await importFile(file, ctx);
});
self.onLive(async (ctx) => { // periodic: a loop with sleep
for (;;) { await sleep(HOUR, ctx); await dropOld(ctx); }
});
self.onLive(async (ctx) => { // set up, wait for the end, clean up
const timer = setInterval(tick, 1000);
try { await untilAborted(ctx); } finally { clearInterval(timer); }
});
- Lifecycle (both kinds): declared in objects, never in facets (owner, 2026-10-26: everything is done through
objects); they start when the object goes live (in the same turn)
and stop when it ends (their
ctxis aborted, then the drain waits for running calls, bounded bycloseTimeout). Each call gets its ownctx(the object's principal for a remote type, the host's for a local one; the host's incarnation;ctx.getreleased when the call settles). - On reload the old routines'
ctxis aborted; the new routines start once all old routine calls of the object have finished (bounded; a stuck call is reported and the new routines start anyway): a handover per object, so routines need no names. Example it prevents: an old handler midway throughrestart(W1)and a new one seeing W1 still pending and restarting it again. The bound iscloseTimeout(owner, 2026-10-24); calls do not wait for the handover, only the new routines do (slice 7b). A stuck old call past the handover is reporteddrain_timeoutand the whole old attempt is sealed then (audit A12: one seal token per attempt; its method calls had the same bound). on(source, fn): any watchable (a document, a derive, a reference member, a registry value,ref.state). The first call gets the current value ([["r", value]], W2b), then one call per change; calls never overlap (S8); changes made during a call arrive folded in the next (newest value, folded ops); a running call is not aborted by a change (native sources too: a busy call is followed by one call with the newest value). A throwing call is reported and called again with the current value after a backoff (1 s doubling to 60 s; a change during the backoff calls at once; success resets it). The source ending (a released reference, its object's end) ends the routine without a report; a retired document does not end it (the call hasretired: true).onLive(fn): called once when the object goes live. Returning ends it until the next reload or activation; throwing is reported and restarts it after the same backoff (reset after a run of at least a minute); an abort is no error.- Idempotent reconcilers: a routine reconciles from the current value, writes no "in progress" flags and starts a
job for long work. Chord keeps no state for routines across reloads or restarts; what must survive is a document of
the object (e.g. a
lastCleanuptime, which also survives a crash). - Helpers:
sleep(ms, ctx)(rejects whenctxis aborted, leaves no timer or listener) anduntilAborted(ctx). Routines and handlers wait only through calls that takectx; otherwise Chord cannot stop them, and the handover and the drain report them stuck. - Errors:
onErrorgetsroutine_failed { object, routine, error },routineaPosition({ kind: "on" | "onLive", position }; natives use the same type); never uncaught. - Keep-alive: routines do not count in
host.activity(anonLiveruns forever). - No bare timers or callbacks that touch state outside routines, jobs or calls. Gone (C4):
every(anonLivewithsleep),customand itsrun(fn)(anonLivewithtry/finally; callbacks that must run one at a time go through a small queue or the source's async iterator), names, a due time kept across reloads.
15. Jobs
Owned work a call starts that outlives the call (a shell command, a session clone, a peer send, a login's work), in Durable's sandwich: intent (a transaction), run (any effect), finish (a transaction on the current code). Decided with the owner on 2026-10-24 (decision log "Routines and jobs open points", "Jobs keep accepts", "Job crash handling"; C3 settled this way); slice 5 builds jobs in memory, slice 6 persistence, slice 7 their reload rules.
const ShellState = defineState("chat.shell.state", {
schema: obj({ output: str() }), version: 1, initial: () => ({ output: "" }),
});
const ShellJob = defineJob("chat.shell", {
version: 1,
args: obj({ command: str(), author: Author }),
result: obj({ exit: int(), output: str() }),
state: ShellState, // optional: one document per run
concurrency: 1, // optional: at most one active run per object
});
host.serve(ChatStore, ({ conversation }, self) => { // local: no principals; views filter (below)
const entries = self.state(ChatEntries);
const shell = self.job(ShellJob, {
run: async ({ command }, ctx) => {
const out = new OutputBuffer({ maxLines: 2000, retain: "tail" });
const progress = throttle(100, () => ctx.state.change((d) => { d.output = out.snapshot().text; }, ctx));
try {
const r = await runProcess(command, ctx.abortSignal, (chunk) => { out.push(chunk); progress.schedule(); });
return { exit: r.code, output: out.snapshot().text };
} finally {
await progress.flush(); // the last output reaches outcome.state, also after an abort
}
},
finish: async (outcome, tx) => {
const list = (await tx.draft(entries)).list;
list.push(outcome.kind === "done"
? shellEntry(outcome.args, outcome.result)
: shellEntry(outcome.args, { interrupted: outcome.error.kind, output: outcome.state.output }));
}, // the run's state document is retired in this commit
});
return {
shells: shell.running,
runShell: async ({ command, author, requestId }, ctx) =>
({ id: (await shell.start({ command, author }, ctx, { key: requestId })).id }),
abortShell: async ({ id }) => shell.abort(id),
};
});
-
Definition:
defineJob(name, { version, args, result, state?, concurrency?, accepts?, persist? }).argsandresultare schemas;stateis a document definition (a singleton, one document per run);persistarrives with slice 6.self.job(Job, { run, finish? })is declared in an object's factory (not in a facet) and returns the job handle:start(args, ctx, { key? })(aRun { id, outcome(ctx) }after the commit),tx.start(job, args, { key? })insideself.tx(aRunhandle at once, whoseoutcomerejects if the transaction is discarded),outcome(id, ctx)(unsettled runs only),abort(id, ctx)("marked" | "terminal", as Durable'sabortTask: stored, resolves oncerunsettled; slice 6b) andrunning. -
1. Intent: one transaction on the owner's line. Arguments are checked against
args(closed schema, like method arguments). Dedupe first: a run with the samekeythat is not settled is returned as it is (the run whosefinishthis transaction is never counts, sofinishcan start the same job again with the same key). Then theconcurrencycheck: at the limitstartrejects withunavailable/busy(a queue is the owner's, built from a document and a routine). Otherwise a run record{ id: uuidv7, version, args, key }and its state document (initial()) are written in the commit, with whatever else the transaction writes (tx.start: a request entry and the run, both or neither).runstarts after the commit. -
2. Run:
run(args, ctx)may do anything, outside transactions.ctxis aCtx(the object's principal for a remote type, the host's for a local one) plusjobIdandstate:ctx.state.value(synchronous) andctx.state.change(fn, ctx)(a transaction on the owner's line on the run's document, without the run's abort signal). State changes are accepted untilrunsettles, also after an abort (so output written while stopping still reachesoutcome.state), and are refused (released) afterwards, never recreating a retired document. Each change is a commit and, while someone watches, a message: authors throttle frequent changes themselves (Chord does not coalesce them); awaiting or catching each change is the author's (an unhandled rejection is the author's bug). External writes carry keys made fromctx.jobId. Arunthat throws after its signal fired endsaborted, otherwisethrew(reported asjob_failed). -
3. Finish: when
runsettles, Chord records the outcome on the run (phase: "finishing"), then calls the current code'sfinish(outcome, tx)in a transaction (Durable's rule: onlyawait tx.*). In the same commit Chord deletes the run record and its key (Q9: dedupe covers unsettled runs only) and retires the run's state document. Afinishthat throws changes nothing: it is retried with backoff (1 s doubling to 60 s), each failure reported (job_finish_failed); after five consecutive failures the run isblocked { finish_failed }, its outcome, state and key kept, retried on the next reload.finishis optional (then the commit only cleans up).type Outcome<Args, Result, State> = { readonly id: string; readonly args: Args; readonly state: State } & ( | { readonly kind: "done"; readonly result: Result } | { readonly kind: "failed"; readonly error: JobError } ); type JobError = | { readonly kind: "threw"; readonly error: ChordError | PluginError } // run threw (reported) | { readonly kind: "aborted"; readonly reason: "abort" | "reload" | "restart" } | { readonly kind: "restart_limit"; readonly restarts: number }; // persisted runs (slice 6b) // abort(id); a native restart on reload, in-memory runs (slice 7b); "restart": a persisted run abandoned after a crash (slice 6b), // or a run cut off by its attempt's seal while the object continues (slice 6a) -
Abort is a request:
abort(id)firesrun'sctx.abortSignal;rundecides what it returns (a result that already arrived stays a success); a run ignoring the signal keeps its slot. Abort of a finishing run does nothing; abort of a blocked run drops it (nofinish, its record, key and state document go, reported; owner, 2026-10-24). -
Waiting:
run.outcome(ctx)resolves with the outcome oncefinishcommitted; the caller's abort ends only its wait. Across a link nobody waits on a long call: UIs watch state. -
running: a JSON watchable, one entry per unsettled run, tagged by phase:{ id, version, key?, args, state, phase: "running" | "finishing" } | { id, version, phase: "blocked", reason }. A blocked run carries noargsorstate(it may be of another version than the code); views filter it as they filter anything. Per-principal views derive what each principal may see (§6, the shared store): a reader without output, a guest only their own runs. -
Concurrency and keep-alive:
concurrencycounts runs that are running or finishing; blocked runs do not count. Runs belong to the object (owner, 2026-10-26): records, keys, the active count,running, run state documents and pending actions are the object's, across attempts and native restarts; an attempt has the implementation and its running invocations; a run's state changes and outcome are fenced by the run's own invocation;finishruns on the current live attempt. Jobs keep their object alive (D4): running and finishing runs are a count on the object, not references (they never appear inhost.references); an object whose last reference goes stays while the count is above zero, and ends (after its type'slinger) when it reaches zero, dropping blocked runs with a report.host.activity.jobscounts them. -
Object end: revocation (R11),
host.closeand a reload that removes the type end the object at once: runs get the abort signal, the drain is bounded bycloseTimeout; an in-memory run's outcome and state go with the object (nofinish). Jobs inlocalstores are never revoked (no principal): a store that runs work for a principal keeps the grant as data and reconciles with a routine (§25). -
Versions (
accepts, slice 7b §8): a job is versioned like everything else;acceptsmaps an older version's args forwards and its result forwards ({ 1: { args: (old) => current, result: (old) => current } }; a producer's direction, unlike a contract's). Its records keep every run's version: a new attempt'sself.job(Def, impl)at another version takes the records over (the same version with another fingerprint, over args, result and state, is refused at declaration, as documents). A run started on an older version and still running finishes on its old code across a pure swap (its invocation keeps the old implementation);finishruns on the new code with the outcome's args and result mapped forwards (once per run, never per publication);runningshows its args mapped; a run not invoked yet runs on the new code with its args mapped. Its state document keeps its version while the old code writes it and migrates through its ownmigrateonce its run settled or moved to the new code (the outcomefinishgets). Without an adapter, for a run of a newer version than the code (a rollback), or for a job the new code no longer declares, the run isblocked { version }(kept, not invoked) until a reload brings matching code; blocked runs (finish_failedones too) are retried on every reload of their object's plugin; a persisted run of another version or fingerprint stays blocked with its record as stored until the reload that brings its version resumes it. A native restart aborts running in-memory runs (aborted { reason: "reload" }) andfinishruns on the new code; persisted runs are stopped and resumed per theirresume(6b R3, owner, 2026-10-29). Afinishin flight across a reload completes on the code it started with; the next one runs on the new code. A version change aborts nothing and discards nothing. -
Persistence (slice 6):
persist: { resume: "rerun" | "abandon" }keeps the run record and its state document in the owner's store (owner: alocalobject with empty params, P3). How a run resumes after a crash is required, no default: rerun (the currentrun, args mapped forwards, state migrated; only work since the last state change repeats, so it must be idempotent) or abandon (finishgetsfailed { aborted, reason: "restart" }); wording aligned with Durable (replay-safe, abandon on restart). A recorded outcome resumes onlyfinish, neverrun; an abort request is persisted. A restart limit as Durable's (owner, 2026-10-27, slice 6b D6): at activation a run recordedrunningwas cut off by a crash:restarts + 1, stored, then it resumes (rerun or abandon); a clean close writes its stopped runs back so the next activation counts nothing; actx.state.change(progress) resets the count; at five in a row the run ends withfailed { kind: "restart_limit", restarts },finishruns,job_interruptedis reported (no parking, as Durable since #11139). A persisted state document may defer storing a change (ctx.state.change(fn, ctx, { persistence: { kind: "deferred", withinMs? } }), Durable's shape, default 1,000 ms; stored before the outcome is recorded; slice 6b D8). -
Chaining:
finishmaytx.startthe next job, atomically with the outcome (separate steps, branches). -
Not jobs: request/response work (a method), standing behaviour (a routine), input into running work (a flow, §20: the owner's document plus methods), multi-step durable work with sleeps, waits or ownership trees on a host with a harness (a Durable task). Jobs as objects (#9) are rejected: a third lifetime, two objects per run for two principals, a
finishacross owners.
16. The facet
import { defineFacet } from "@app/core"; // base code's defineFacet, bound to the app's P
export default defineFacet((host, self) => { // `self` (contributions) arrives with slice 8
self.contribute(Http, { routes: { "GET /notes/export": exportNotes } });
host.serve(Notes, (params, principal, self) => …);
host.serve(NoteStore, (params, self) => …);
});
- Facets are typed by the app's principal (owner, 2026-10-21): base code exports
defineFacetalready bound to itsP(export const defineFacet = bindFacet<P>(), next to the principal union), and every facet (server, worker, web, TUI) imports it from there;host.loadaccepts only facets typed for the host'sP, so a factory'sprincipalnarrows by variant. Nothing is checked at runtime: the principal a factory receives is the one the link stamped. - A plugin's per-host setup, declarations only (owner, 2026-10-26):
host.serve,self.contribute(slice 8),host.references; no natives, no documents (C16 accepted), no routines, no jobs. Everything a plugin does at runtime is done through objects: shared or persisted state and standing behaviour live in alocalstore object with empty params (host lifetime, its own failure domain; its dependents park while it is failed and go on when it recovers), as p2 does (its 63 facets take onlyhost). A facet cannot fail afterload. host.serve(Type, factory, { linger? }):linger(milliseconds, 0 by default; slice 3b) keeps a live object with params alive that long after its last reference, so a new reference within that time reuses it with its documents and dependencies; it never applies to objects that are not live.host.serveand contributions are valid only while the setup runs. The facet neverself.gets a type it serves itself.- Between plugins only: object types and registries. No natives across plugins except through registry entries.
17. Registries
export const Http = defineRegistry<HttpContribution, Router>("chord.http", { version: 2 }); // shared: identity only
host.serve(Http, { // the defining plugin's facet: the one provider (slice 8 D1)
compose: (table, { report }) => buildRouter(table, report), // pure; always delivers a value; reports conflicts
accepts: { 1: { contribution: (old: HttpContributionV1) => upgrade(old), value: (router) => routerV1(router) } },
});
self.contribute(Http, { routes: { … }, wraps: [ … ] });
const router = self.registry(Http); // a declared dependency; read router.value at use (await host.registry(Http, ctx) outside objects)
-
Each contributor (a facet or an object) contributes at most one value per registry; the table is an immutable, ordered list of
(contributor, value). Contributor order is sorted by plugin name (never load order): deterministic on every host and across reloads, and deliberately meaningless; meaningful order comes from the owner's composition (e.g. HTTP phases). A reload replaces a contribution as a whole. -
composeis a runtime composition, not a staging check: contributions can be dynamic (known only once a native is open, or changing at runtime; contributions may be readables), so no conflict check can be complete in staging.compose(table, { report })is pure and runs whenever the table changes (after a commit, when a contributor goes live or ends, when a dynamic contribution changes). It always delivers a value with its fallback built in (e.g. the first by plugin name wins) and reports conflicts throughreport(owner notices and telemetry events); readers get a plain value and never handle conflicts. Ifcomposethrows, the failure is reported and its watches get afailedcall, while.valuekeeps the last good composition (slice 8), but only while every contribution it was built from is still in the table (dependency model decision 5, slice 7a): when one leaves (its contributor ended, failed or restarted), the last good value is dropped and.valuethrows untilcomposesucceeds, so it never hands out a native whose contributor ended; only before any good composition, or after such a drop, do readers get the error. Work that started earlier keeps the value it read (a pinned Durable phase keeps its old table). Helpers:keyedEntries(table, entriesOf, report)(a map, the first by plugin name wins, a duplicate reported),orderedEntries(table, itemsOf, orderOf?); both keep each item's plugin (named so beside the contracts'keyed, slice 8 build). -
Who contributes (R21): only facets and host-lifetime objects (
local, empty params); their factory'sselfhascontribute, a referenced object'sselfdoes not (a type error). Reading a registry's composed value stays open to every object. -
A contribution is in the table exactly while its contributor is live: a facet's while its plugin is loaded; an object's from
liveuntil it ends, fails or restarts. -
The owner decides conflicts, order and selection (which contributions apply where).
-
One authoritative declaration (Q8): only the facet of the plugin that defines a registry declares it to the host (
compose, version,accepts); other plugins' tokens only identify it by name and types (contribute and read, never bring their owncompose). A reload of the defining plugin replaces the declaration atomically (servereplaces; a removed defining plugin's provider goes, its readers waitnot_served) and the table recomposes. Without a declaration (the defining plugin is not loaded) the registry has no composed value: readers waitfailed { waiting }(unavailable/undeclared), like a request for a type nobody serves, reportedunbound_longafter 5 s (§3); contributions wait in the table, and the registry is reportedregistry_undeclared { registry, plugins }at the publication (decision "Mistakes that wait are reported at once" (b)). -
Versions (Q8, the owner-adapter pattern): a contribution carries the registry version its plugin was built against; older ones are adapted with the defining facet's pure
acceptswhen the table is built. A contribution without a matching version or adapter (older without adapter, or newer) is left out of the table and reported (an owner notice naming plugin, registry and versions); its plugin keeps working. Contributions are in-process values (often functions) and are not schema-checked. -
Use sites guard contributions: a contribution is untrusted code (third-party plugins are a goal of p3). Every use site catches synchronous throws and rejections when it calls an entry, reports naming the contributor (the composed table keeps each entry's plugin name), and continues with the other entries. Durable already does this for extensions (
extension_error; a throwing tool becomes an error result);composereports throughreport. -
Slice 8's rules (decisions "Slice 8 D1" to "Slice 8 S3",
slice-8.md; S2'sbrokenand S3's backoff superseded by slice 7a): the token is shared, the provider serves it (host.serve(R, { compose, accepts }));self.registry(R)in a factory is a declared dependency: the attempt parks until the registry has a usable value at its version (composed; mapped byaccepts[old].valuefor an older reader), otherwise it showsfailed { waiting }naming the registry (not_served:unavailable/undeclared;version: a reader newer than the provider or without a mapping,contract_mismatch;compose: a firstcomposeor the mapping threw;loadingbefore the publication), woken at every change of whether the value is usable;host.registrywaits the same way up to itsreadyTimeout; contributions never fail a registry (a failed one is left out and reported); a throwingcomposekeeps the last good composition (above);retryOnretries on value calls only;host.root(fn, context, { principal? })seals its context at settle, abort andhost.close. -
Registries may carry natives. An entry lives exactly as long as its contributor; consumers look up at each use or follow the composed value and keep nothing from an entry beyond a use.
-
Contributions that change (owner, 2026-10-19; W4): registries are host-local, so contributions are usually in-process values (functions, runtimes, handles). A contribution is fixed for its contributor's life (dynamic parts behind handles read at use; a change means a restart or reload), a
Watchable, or aNativeWatchable; Chord follows the latter two through the internal flush: on every change it replaces that entry (a new immutable table) and runscomposeagain, in the owner's flush, never inside another owner's commit step. The composed value is itself aNativeWatchable; use sites watch it or read its current value. For in-process values that change,self.writable(initial)is aNativeWatchablewithset(identity compared, never ops, never a state member):const ext = self.writable(mcpExtension([])); // e.g. MCP: tools known only at runtime self.contribute(HarnessExtensions, ext); self.on(mcp.tools, ({ value: tools }) => ext.set(mcpExtension(tools)));Tools, sections, hooks, wraps and tasks reach the harness as one Durable
Extensionper plugin contributed to p3's core registry;composebuilds Durable's registry snapshot (Durable applies selection and wraps, and pins a snapshot per task phase). Skills and other lists work the same way. -
A contribution may be another registry's value (it is followed: its change recomposes). A ring of registries reading each other is caught when one composes (audit A2): a registry read while it composes throws
invalid/cycle, the contribution that read it is left out and reportedcontribution_failed, the rest of the registry works. -
Removing a contribution: there is no remove call (
contributeis declarative, in the factory). An entry leaves the table when its contributor ends, or after a reload whose new code no longer contributes. A live contributor that has nothing to offer for now sets its readable to the registry's empty value (an extension without tools, empty routes); the table keeps exactly one entry per live contributor. -
Registries add behaviour (tools, routes, renderers, commands, input gates); they never replace an owner's checks. No lifecycle in entries: anything needing cleanup is a native of the contributing object.
18. Start data and platform bindings
There are no host values. Start data (session id, init, data directory) is a local object with empty params (host lifetime)
and constant state (constant(value): a readable that never changes, R23), served by the base code's core facet from the start message:
export const SessionInfo = local("app.session-info", { version: 1, params: {}, members: { init: InitSchema } });
// base code (apps/<app>/src): the core facet, passed to load() like any facet
export const coreWorkerFacet = (init: Init) => defineFacet((host) => { // base code's own bound defineFacet
host.serve(SessionInfo, () => ({ init: constant(init) })); // local, empty params: host lifetime
});
// any plugin
host.serve(NoteInbox, (_, self) => {
const info = self.get(SessionInfo, {});
const watcher = self.open(async (context) =>
watchDir(join(info.init.value.dataDir, info.init.value.id, "inbox"), { signal: context.abortSignal }), (w) => w.close());
return {};
}); // NoteInbox: local, empty params → host lifetime
Plugins read start data through a reference, after activation (in openers, derives, routines and methods); nothing
reads it synchronously in a factory. Platform bindings the host owns (a Durable Object's state and env) are core
objects whose self.open returns the binding and whose close does nothing. A long-lived native from the host is a
host-lifetime object in a core facet (the Durable harness, section 24).
Core vs. plugin is decided by where the code lives: base code (src/, imported statically by the start code under
a fixed name such as @app/core, not switchable, a change restarts the host) or a plugin directory (found by listing,
loaded through loader D, named by its package.json, switchable, a change reloads it). Core is what the app always
needs and everything builds on (harness, catalog, access, session location); optional features are plugins (code
mode is a plugin). Both use exactly the same primitives.
19. Presence
const viewers = self.derive([host.references(Notes)], ([refs]) => refs.length);
host.references(Type) (slice 4b) is a JSON Watchable of the references to objects of a type on this host:
{ params, principal?, incarnation }[] (named origin until 2026-10-27). It is one rule (owner, 2026-10-24): the incarnation of the host that made
the reference as its caller sees it, so two tabs are two origins, and a guest's ctx.get shows the guest's tab. An
entry is added when a reference binds and removed when it ends, in that batch; a departure discovered while a batch
is sending lands in the next batch (so one commit stays one message per link). The host keeps a type's list from its
first host.references(Type) on (one small object per asked type; entries come and go with references). A link drop is a departure
(accurate and simple); smoothing (a phone switching networks) is the UI's job, e.g. a debounced viewer count. Counts
per person or origin are derived by the app from the principals and origins. There is no consumer-side linger
(a closed tab closes its link); provider-side linger keeps a just-closed object warm and keeps the worker alive
(section 3). Replaces the presence, viewer and login-abandon leases. "Leaving" is never derived from an unmount
(section 21).
20. Input into running work
Flows kept by their owner
start({ provider }, ctx) → { id } // uuidv7
LoginFlow {id}: state | { kind: "running"; step?: { id, … }; notices } | { kind: "done" | "failed" | "cancelled"; notices }
answer({ id, step, value }, ctx) → ok | "step no longer active"
cancel({ id }, ctx)
The UI watches the state; a late or second answer to a step that is gone is refused; a promise-based callback (pi-ai's
prompt) stays inside the owner (its job keeps the resolver). Imperative consumers (tests, scripts, a CLI, an agent
tool) use a generic client helper over the same state, not a second protocol:
async function* steps(flow, ctx) {
let last = 0;
for (;;) {
const s = await until(flow.state, (v) => v.kind !== "running" || (v.step && v.step.id > last), ctx);
if (s.kind !== "running") return s.kind;
last = s.step.id;
yield s.step;
}
}
for await (const step of steps(await ctx.get(LoginFlow, { id }), ctx))
await login.answer({ id, step: step.id, value: answerFor(step) }, ctx);
A long-held server step() call is not used: a link drop fails it and the consumer cannot tell whether it missed a
step; state re-holds by itself and last keeps steps from being lost or repeated.
The Questions service
Another pattern (apps/p2/docs/questions.md): a shared store with per-principal views (asker: ask; viewer: open,
answer), typed questions (text, select, confirm), policies over a live audience (first, quorum, all),
reattachment by key, deadlines, authorization per question, secrets redacted. Fits tool approval and a question the
agent asks everyone present. Its store must live in an object (it predates "natives only in objects").
Decision needed: where a flow lives. Options: (a) a keyed document of its owner; (b) an object
LoginFlow {id}living until done. (Jobs as objects were rejected, §15; a flow kept as its owner's document plus a job is the likely answer.)
21. Reload and rollout
Per host
host.reload(facets, context) (slice 7b; decision "Slice 7b's open points, as recommended" (1)) takes the host's
complete new plugin set after load: a plugin in it and not loaded is added; one loaded with another facet value is
changed (Chord compares no code); one passed as the very same facet object is unchanged and untouched (owner,
2026-10-29: base code passes its core facet value every time); one loaded and not in it is removed. It resolves with a
report once the commit happened and every object took its fate (swaps and retries had their first dependency check;
it does not wait for objects to go live); a plugin whose new code fails keeps its old code and is listed in the
report's failed (per plugin, below).
Reloads run one after another per host; before load resolved it throws, and so does a plugin set with an invalid
name (before anything changes); after close it rejects released/closed;
context bounds it (decision 4: no install watchdog): its abort before the commit rejects aborted and changes
nothing, after the commit it only ends the wait: it rejects aborted with details.committed, a CommittedReload
{ added, changed, removed, failed } naming what the commit applied (the plugins component adopts it, so a later
reload never passes a committed plugin's old facet; owner, 2026-10-30).
type ReloadReport = {
readonly plugins: { readonly added: readonly string[]; readonly changed: readonly string[]; readonly removed: readonly string[] };
readonly failed: readonly { readonly plugin: string; readonly error: unknown }[]; // new code failed: old code kept, an added one left out
readonly objects: readonly ({ readonly object: string } & (
| { readonly kind: "swapped" } | { readonly kind: "restarted" } | { readonly kind: "ended" }
| { readonly kind: "parked"; readonly cause: FailureCause } | { readonly kind: "retried" }))[];
};
1. load new bundles (the app's or loader's: a facet arrives evaluated); a plugin whose build or evaluation
fails keeps its loaded facet (`reload(facets, context, { failed })`)
2. staging every new setup into a staged table, and every affected object's new factory against a recording
`self` (declarations only, nothing activated, bound or opened); a plugin whose new code throws keeps its
old code, staging runs again without it, then the closure rule (below)
3. commit one batch: the host switches to the new code atomically, a publication (7a)
4. after each object takes its fate (below)
-
Per plugin, never all or nothing (owner, 2026-10-30, decision "Per plugin at startup and reload"; supersedes 7b's rollback per host): a plugin whose new code fails at a reload (its build or evaluation, a setup's or factory's throw in staging, a declaration error,
checkAccepts, a type or registry served twice, a remote type on a host without principal or address) keeps its old code (its loaded facet, unchanged) and is reported in the reload's report (failed: [{ plugin, error }], the same listload's report has; an added plugin that fails is left out); staging runs again without it, and every other plugin of the reload applies. The closure rule: a changed plugin whose recorded requests would not bind because the plugin they need was kept old (or is missing) is kept old too, so a consumer edited together with a broken provider keeps working on its old code. At startup the same holds:loadleaves a failing plugin out and resolves with{ failed: [{ plugin, error }] }; its consumers waitfailed { waiting };loadnever closes the host for a plugin (base code decides whether its core failing is fatal). A loader that kept a plugin on its loaded facet because its new code failed before staging (its build, its evaluation) tells the host withreload(facets, context, { failed }): the host lists it and applies the closure rule to it as to a plugin whose staging failed. What no rule detects: a consumer relying on new behaviour of a kept provider whose contract did not change (as in a rollout across hosts). Runtime states (registry conflicts, failed opens, migrations) stay per object after the commit (onErroras always). -
Staging runs every new setup (each plugin into its own table, merged; an unchanged or kept plugin's loaded entries are reused, its setup not run again) into a staged table beside the live one (types with their factory,
lingerand provider,bindProvider(contract, options.accepts); registry providers; facet contributions by plugin), then, for every live, activating, parked or failed object of a changed or removed plugin whose type is still served, its new factory with the object's params (and principal) against a recordingself: it records what the factory opens, the documents, tables and jobs it declares with their versions, its contributions and requests, runs the checks an attempt makes without effects (declared twice; persisted data outside a stable identity; contributions outside a host-lifetime object; a document or job at an older version, or at its version with another schema), and hands out inert handles. Staging is synchronous, so no request or arriving hold meets it half done (audit A1: model §1'sinstallingis cut). -
The commit (one batch): the served types become the staged ones (an unchanged plugin's entries stay as they are); a removed plugin's registry provider goes (its readers wait
not_served), a reloaded one's replaces the old (RegistryRecord.servereplaces), the reloaded plugins' facet contributions are replaced by plugin (object contributions follow their objects), every changed registry composes once; a removed type's presence list goes. After the batch, each object takes its fate, host-lifetime objects of new types start (no barrier), and the publication wakes the requests indexed under the names the reload serves (UnboundIndex.wake(names), per name), refused holds of those names re-run their start. -
Fates (decided in staging; objects of plugins not in the reload are untouched):
The object Fate its type is removed ended: as an end (natives close end, callers of running calls rejectedreleased, handlers drained, in-memory documents gone, persisted ones kept); its holders' requests are not ended: they wait unboundnot_served(§7)failed, parked, activating or waiting for its backoff retried on the new code ( ref.retry()'s rule, whatever broke it); an activating attempt is sealed and its natives closereloadlive, neither its live attempt nor the new factory opens natives, and no table of the new factory is newer than the store's swapped in place (below); live queries are no natives (section 11): an object showing table rows through self.queryswaps, one reading them inself.openrestartslive, otherwise restarted: new calls park (it shows loading), its contributions leave, running calls drain (bounded bycloseTimeout; a call outliving the drain is cut off by the seal fence), the attempt is sealed (in-memory job runs endaborted { reload }, persisted ones are stopped and resumed, R3), natives closereload, the new attempt activates after that teardown (documents stay; eager table migrations run as at activation)any of the above whose new code waits for a dependency parked (decision 4): no overlap of old and new code; reported with the cause A restarted object gets a new instance id (an old activation chain never matches it); a swapped one keeps it; a retried one keeps it as any retry does. Cycles are checked again for the new attempts;
parked_longis unchanged. -
Fates start dependents first (decision "Swaps: dependents first; calls during a swap never see
not_ready"): the removals first, then the other fates in the reverse of the order the objects went live (a dependent goes live after its providers). Example:todo.storeand its viewtodo.todos(groceries), both pure, in one plugin. Started provider first, the view's new attempt would bind the store while its swap showsloadingand park (its contributions leaving, its holds over a link unbinding) until the store's new instance is live. Started first, it binds the store while that is still live, passes its dependency check and reads the store's old instance until the new one is live (one frame), its calls waiting at the store's gate: both are reportedswapped, nothing parks. This is the path a provider-only reload under a live dependent already takes. A removed provider ends first, so a dependent whose new code still needs it parks (decision 4); a provider takes its new served type with its own fate, so a dependent needing a version only the new code serves waits unbound until then and parks on the provider's swap (never reading that version off the old instance). A pure dependent still parks only then, or when its provider went live after it (a provider that failed live and came back), or when its new code needs something that is not live (a new or missing dependency). -
The pure swap (decision "The seal fence": not a replacement while its calls run): a new attempt of the same object (its documents, line and job records) activates at once; calls arriving meanwhile wait at the gate (bounded by
readyTimeout,unavailable/park_timeout). The gate holds a call while any attempt of the object activates (a swap, parked or not, a restart after its drain), until the object is live, failed or ended; a parked attempt showingfailed { waiting }rejects it with that cause. A call never getsnot_readythere: a hold over a link stays bound while the old instance still changes, so its consumer seesliveand sends. When the hold then leaves the old instance (a parked swap's retirement), it tells the consumerloadingand answers the calls still in flightunavailable(§22), as for any object leavinglive. The new attempt's requests are made and bound before the old attempt's are released (a request equal to an old one binds to the same target with no gap, M§3.9). Running calls finish on the old attempt with its handles valid, except R15: an old call touching a document whose version rose throws once (released/version), the document intact, the call not resent; the same for a table whose version rose (old table handles' version fence: old code never issues SQL for a dropped column). Once the swap ended (the new attempt live, parked or failed) and the old attempt's calls settled (bounded bycloseTimeout,drain_timeout), the old attempt is sealed and its references released; its job invocations finish on their old code. Retiring attempts are bounded bycloseTimeouteach; a later swap never waits for them (several may retire at once after quick reloads), while a restart, an end and the host's close do (an end rejects their callers at once). The references move to the new instance's members by name (one frame from the last value; a member the new variant lacks failsinvalid/member; §7); exposed derives are matched by their member names, internal ones are rebuilt; in-memory documents migrate throughmigratewhen the new code declares a higher version; the contributions are replaced in one batch;self.writablestarts at its new initial value (decision 2);retryOnsources are re-declared; the new attempt's live queries run at its activation (the old attempt's are re-run by the commits of its remaining calls until it is sealed, then dropped). Routines hand over per object: the old attempt's routines are aborted; the new ones start once the old routine calls finished, bounded bycloseTimeout(a stuck one is reporteddrain_timeoutand the whole old attempt is sealed then, audit A12: one seal token per attempt); calls never wait. -
Natives are not kept across reloads by name: natives have no line, transactions or schema, so old and new code would interleave unchecked. In practice a restart costs little (the Durable harness is base code, terminal PTYs live in their own process, model runtimes, sandboxes and the search index rebuild in milliseconds); reloading the plugin that owns SSH connections or the relay socket hurts.
-
UI on client hosts (decision "Slice 11, P4", refined by "Tutorial gaps" (1)): a page or terminal applies a new plugin script with
host.reload, as any host; the UI re-renders from its registries and keeps what lives outside the changed plugin. A component of a reloaded plugin is a new function, so React (or a terminal scope reopened by the app) mounts it anew and its own state (useState) starts over. What must survive a plugin update (a half-typed message, a scroll position) is page state: values the client host keeps by key (usePageState(key, initial)inchord/react,scope.pageState(key, initial)on aTuiScope, one store per host), any same-host value, never on the wire, untouched by reloads and gone with the page (a page reload, a terminal's new base bundle). Rejected: React Fast Refresh (a Babel or SWC transform in every web build, fragile when hooks change). -
No factory trial, no migrate dry run, no consumer contract check during staging: staging records declarations; incompatible consumers wait at bind (
failed { waiting },contract_mismatch). The one exception is the closure rule above: when a plugin of the reload failed, a changed plugin's recorded requests are checked as a bind would check them, to keep it old. -
Start barrier (host start and first load; dependency model §6, decision (a), slice 7a):
loadruns the facets' setups (the core facet with its start data among them) and publishes them in one batch (types served, registries served and composed once, contributions in their tables); requests that waited for the publication are placed after that batch (holds andhost.holdwait for the publication only, never for the barrier: two hosts whose objects need each other's types start without a deadlock). Then it activates the host-lifetime objects and resolves when each has its first outcome: live, failed by its own cause, or parked with no blocker that is a local object still without its first outcome (local: executing on this host, a keyed pin still loading here included; a wait on another host or on a failed dependency ends the first pass), or ended (host.closesettles a waitingload). One first-outcome signal per object, separate from the readiness dependents wait for, counted once, never reopened by retries. SoCodeMode → Harnesswaits for the harness's outcome. What it promises: every host-lifetime object here had its first try, and the contributions of those that went live are in their tables; not every tool that will arrive (a parked or slow contributor, tools found inonLive, MCP): what a first turn needs is p3's explicit admission check. Base code callsharness.resume()afterload(the harness is an ordinary host-lifetime object that opened Durable paused during the barrier; owner, 2026-10-21, refines R17). Nothing waits for specific contributions: what is missing shows where it is used (section 24). Failed plugins are reported with their error. Reloads need no barrier.
Rollout across hosts
- The rollout is the app's (decision "Slice 7b's open points" (3)): it calls
host.reloadon each host in order, server, then workers, then clients; Chord guarantees per host (staging, commit, a failing plugin kept on its old code). The plugins component'srollout(slice 7c) runs such steps. An install never waits for activations (a reverse-order rollout would deadlock). - Each host knows only its own install (model §5): until its reload commits (while the app bundles, before
host.reload), a hold that does not fit its running code is answeredfailed { waiting, version }and the commit wakes it (a blip of at most one install; staging itself is synchronous, audit A1). What a consumer on another host sees follows §7: kept and freshly bound while its version is accepted;failed { waiting }(version,not_served) otherwise, bound again by the install that serves it; a consumer whose own code moves to a new version releases its old request and makes a new one. - The app's loader keeps its own duties: the desired code set, a digest over the facet bundles' content hashes (an unchanged server restart reinstalls nothing), serialized installs, a host that starts or reconnects installing the current set before anything is routed to it, a rejected own install failing its own requests with the reason.
Loading plugins and the reload loop (slice 7c)
- No dev/prod split (owner, 2026-10-30): a Chord app is self-modifying (the owner or an agent edits a plugin while
it runs), so finding, building, loading, watching and rolling out plugins is core machinery: one component,
chord/plugins(src/node/plugins/, Node-side; the kernel stays portable and takes facets however they were made: static imports in tests and Durable Objects work the same). Reloads stay explicit: the watcher marks, never applies. - A plugin is a directory with a
package.json(itsnameis the plugin's name, npm style) and one file per facet kind the app names; plugins come from roots found by directory listing (no source parsing; the first root wins a duplicate name). Dependencies between plugins resolve by contract name at runtime; names are prefixed (<plugin>.<name>; tools<plugin>_<name>). A plugin carries the contracts it was written against (decision "Contracts come from where the plugin's code came from"): plugins that change together import each other by relative path within a root (copied into the importer's script); others take contracts as an npm dependency at a fixed version (built in slice 12, which removed slice 10's imports by plugin name). - Loader D: one esbuild script per facet (its own files, copied files of other plugins, its npm
dependenciesinlined; host packages,chord.externalpackages and Node builtins external, passed in at evaluation); each facet kind is defined once,kinds: { <kind>: { platform: "node" | "browser", hostPackages } }(slice 11); evaluated withvm.Scriptunder a stable filename per (plugin, kind, digest); no module map: old generations are reclaimed by the GC; V8's compilation cache turned off when the component is created.pluginFile(path)resolves a file of the calling facet's own plugin. A plugin's dependencies are installed into its directory (installDependencies, as coding-agent does:--omit=dev --legacy-peer-deps --ignore-scripts). esbuild is an optional peer dependency. - The reload loop: a watcher marks plugins dirty from the files each facet used (a copied contract dirties every
user) and flags changes of base code (the app restarts its hosts); a build is per plugin (a failed one keeps its old code);
rolloutruns the app's steps in order (server, workers, clients; decision "Slice 7b's open points" (3)). A reload control (a button, a CLI, an agent tool) is the app's. One base bundle per surface (host packages and the app runtime) is an app's startup optimization, the web's in slice 11. - Project plugins, trust, snapshots, scopes and npm/git package installs:
apps/p2/docs/plugin-loading.md(still the reference for those; not in 7c).
The plugin model (2026-11-01; slice 12)
Decisions "Contracts come from where the plugin's code came from", "Plugins import only the app's versioned API", "The plugin model, points 3, 4, 5 and 8, imports and builds", "Single-file plugins", "Testing a plugin alone". The rule underneath: a plugin carries what it was written against, never the running app's newest file.
-
What a plugin may import: its own files; the app's versioned API (
@acme/app/v3, never Chord directly: Chord is not a host package plugins may name; the API re-exports the Chord surface the app supports); its npmdependencies(bundled at the installed version); other plugins' contract files: by relative path within one repository (../notes/contract.ts, the same commit holds both sides), by package name across repositories (@bob/notes,@bob/notes/web, pinned by an npm version or a git refgithub:bob/notes#<commit>; a plugin inside a multi-plugin repository through a dependency on the whole repository, by path into it or itsexports). A package lists the plugins it provides inchord.plugins(as pi's manifest). One facet imports one API version only. -
Versions: the app lists the API versions it provides; an unlisted one is a build error naming the available ones. The process runs one Chord: an older API version is a layer over today's, re-exporting Chord's compatibility module for its previous API while Chord keeps one; contracts bind through
acceptswhatever Chord runs. -
Split by host kind: the API has a shared module and per-kind modules (
/v3/web,/v3/tui,/v3/server,/v3/worker); a plugin's contract files mirror it (contract.ts,contract.web.tsx,contract.tui.ts, ...). A contract file mentions native things only throughimport type. The build checks every import by the file it resolves to: a per-kind file or module only for its kind, a package the kind does not provide refused. A token orlocalcontract used on one kind lives in that kind's file. Shared UI between plugins: an imported file (inlined, frozen at the importer's build) or a registry entry (one live instance). -
Base code is the API modules, bootstrap (hosts, store, components such as
createHttpandcreateWorkers, which take settings and process values and return facets) and one start-data facet serving process values as core objects. Every implementation, the harness and core registry providers included, is a plugin; the app's own root is required (a failure at startup stops the app). -
Single-file plugins:
<name>.<kind>.ts(x)at a root's top, grouped by name, nopackage.json, importing only the API; a directory of the same name replaces it as the same plugin. -
Installs from git (a branch, tag or commit cloned into a root) or npm. Builds are one esbuild call per facet from source (TS, TSX, JS, JSON, CSS, assets and WASM by URL); plugins bring no build hooks.
-
testPlugin(dir, { kind, modules, stubs, start?, principal? }, ctx): the plugin built as in production on an in-memory host and store, its dependencies served by stub facets, contributions to registries collected for the test, the contracts nobody serves listed. -
UI reload vs. leaving: "leaving" is never derived from an unmount, only from explicit navigation or presence (a dropped reference); a component unmounted by a reload has not left.
Decision needed: project plugins. Per-directory plugins with trust and snapshots (
plugin-loading.md) need their own pass over this design.
22. Wire
Exposed documents, derives and readables are state members: a snapshot in bound when a hold binds, ops per commit
(a draft's recorded ops, no diff), ops from diffs for derives, ops of job state documents (slice 5), and failed
members in the entry of the message that carries them. Commits are atomic for consumers: locally, subscribers are
notified once after every document of the commit is adopted; remotely, one state message per commit per link and
origin carries an entry per touched hold, applied in one batch before listeners run (also when one Durable commit
touches several holds), so remote consumers never see a torn commit. Consumers apply ops with applyImmutable: no
validation, no clone of snapshots or ops.
-
The protocol version travels in
hello; hosts of different versions refuse each other (a web page reloads for a newer server). Error reasons and message kinds are part of it. Until p3 ships, changes keepPROTOCOL_VERSION1 (nothing is deployed); after that every message or reason change bumps it. -
One typed
Message(owner, 2026-10-22, "Wire protocol"): a pi-schema schemaMESSAGE, a tagged union overkind, from which the protocol types derive; every decoded message is checked against it (compiled once) unless the link is trusted (section 3). Kinds:Kind Direction Fields helloboth, first protocol,incarnation,calls(the call window N)holdto provider origin?,hold,type,version,fingerprint,params(part),address,principal?,incarnation?,chain?(activation chain, Q5),limit?(decoded size limit, 4b)revokeparent to child (route link) the revocation value (4b) releaseto provider origin?,holdretryto provider origin?,hold,hops(the retry's hop budget, 1-16: 16 from the first host, one less for each host it passes on from, none sent at 0; audit A5); relays forward it unchangedcallto provider origin?,call,hold,method(index),args?(part; absent: no arguments),segments?abortto provider origin?,call(namedcanceluntil 2026-10-27)ackto provider origin?; one per appliedbound/statemessageclosedrelay to provider origin: the downstream origin endedboundto consumer origin?,holds: [{ hold, variant, members: [{ member, value }], failed }]; one batchstateto consumer origin?,holds: [{ hold, members: [{ member, ops }], failed, retired }]; one batch (retired: members retired since the last message, slice 6a)failedto consumer origin?,hold,cause { kind, error }(kind:transient,brokenorwaiting, slice 7a),retryIn?(a transient cause's backoff; absent: broken, waiting); each new cause of a parked object is a message of its ownloadingto consumer origin?,hold: a new attempt after a failure, or a relay's upstream droppedresultto consumer origin?,call,value?(part; absent:null),segments?errorto consumer origin?,call,error(a ChordError's JSON or a plugin's code, message, details)goneto consumer origin?,hold,error: the held object ended (revocation 4b; a reload never ends a hold, it unbinds it withfailed, slice 7b; relays never make one)ping,pongboth the heartbeat A
boundon a hold that is bound already replaces its replicas in one batch, another variant allowed (re-holds, slice 7b's restarted objects and its swaps to another variant). -
Codecs are pluggable per link (both ends use the same):
binaryCodec(default: a kind byte with a flag for a following origin varint, varints, raw incarnations and fingerprints, length-prefixed strings and parts; about 23 B for a streamed token) andtextCodec(Delta-style JSON tuples without field names, a message with an origin wrapped as["@", origin, tuple]; for debugging;describeMessageprints any message so). Measured: a 2,000-token reply about 46 KiB instead of 196 KiB. No CBOR (revisit only if benchmarks show encoding as a bottleneck). -
Parts are opaque (slice 4a):
params,args,value, snapshot values and ops are aPart(JSON text at the origin, its UTF-8 bytes once a binary codec received it). A part is parsed only where it is owned, right when its message is handled (the provider parses params and arguments, the consumer results, snapshots and ops), so a relay forwards it unparsed and a malformed part is still a protocol error. A message naming an unknown id is ignored before its parts are parsed. The text codec re-serializes parts at decode (one parse per part per hop, accepted for a debugging codec). -
Names travel as indexes into the hold's bound version (each variant's state members and methods sorted; variants sorted). A reload that no longer accepts that version unbinds its holds first (
failed { waiting }, slice 7b). -
Checks by layer: the codec's structural decode, the
MESSAGEenvelope (skipped on trusted links), the bound version (ids, indexes, order), then the owners (checkParams,checkArgs, Delta's decoder). -
Unknown ids are ignored on both sides (Q1): they are normal races (a
resultcrossing anabort, acallcrossinggone, a state part crossing arelease); the side that ended a hold or call settles its own pending work (a consumer receivinggone(h)rejects its pending calls onh; per-direction order guarantees nothing else forhfollows). Protocol errors close the link and are reported (protocol_error): a message failing the schema or the codec, a malformed part, a reused live id, more than N running calls of one origin, a broken handshake (a first message that is nohelloof the link's codec, a secondhello, another protocol version). -
Byte segments: a payload (arguments, a result) whose method's schema has a
bytes()leaf carries eachUint8Arrayas a raw segment referenced by{"$bytes": i}and read back as a view of the received message;$bytesis the one reserved key there (invalid/args). Never base64. State is JSON only. -
Encoders: each member of a hold has its own Delta encoder and decoder (path interning end to end between provider and consumer), restarted with every snapshot. Two holds of the same principal (two tabs) encode the same ops separately (O(change) each). A part is encoded once at its origin; relays never re-encode it.
-
Sending: one
statemessage per commit per link and origin while the window has a slot; otherwise the ops wait in the members' pending queues and leave as one message when anackfrees a slot (section 3). -
Relays read and rewrite only envelopes (section 3, "Relays").
-
Size limits per part (R2, Q2; slice 4b): limits apply to each part (wire and decoded size), configurable per link, checked from the part's length before it is parsed or inflated; the decoded limit is enforced in the one decode function. An oversized part fails only its own hold or call (
too_large/part); the other parts of the message apply; a snapshot over the limit fails its hold with no automatic re-hold. The decoded limit travels with relayed work: the relay checks the admitting link's wire limit itself and stamps its decoded limit on the upstreamhold(honoured on the parent link only, never taken from a client; calls inherit their hold's), and the host that inflates enforces the smaller of its own and the stamped one while inflating, after the part's owner and id are known (parts for unknown ids are never inflated, Q1). A relay checks only what arrives from downstream; answers from upstream (the app's own workers) it forwards unchecked and unparsed, and the end that parses them checks them against its own link's limits (owner, 2026-10-24: each end checks what it parses; a relay check needed a re-hold with the same id, which old messages in flight could mistake for the new hold). So IPC carries no limit of its own. A reference failedtoo_large/partis never held again automatically: it leaves its path, so a reconnect does not hold it; onlyref.retry()puts it back (a new hold). This lives only in the consumer's memory, as long as the reference. The WebSocket'smaxPayloadstays only the transport's hard cap (e.g. 64 MiB; code 1009 closes the connection). Defaults for client WebSockets: 16 MiB on the wire and 64 MiB decoded per part; none on IPC. -
Compression at the origin (owner, 2026-10-19; slice 4b): a JSON part above a threshold (e.g. 16 KiB) is compressed once by a host with a synchronous compressor when it encodes it (
createHost({ compression }), Node's zlib adapter in a Node-only subpath), whatever the link (IPC included), only if it shrinks; hold params are never compressed; byte segments stay raw; the codec is in the part's envelope (the text codec tags every part, so no JSON value is reserved); relays forward it compressed; the end that owns it inflates it. Option (b) (owner, 2026-10-24): hosts without a compressor (browsers) send uncompressed and inflate asynchronously (DecompressionStream), holding later messages of that link in order (bounded by the window and N; acks go out after apply). Child links refuse compressed parts (protocol error): honest clients never send them, and a 65 KB part could inflate to 64 MiB on the server; so asynchronous inflate happens only on a host receiving from its parent or a route. Because a host cannot tell whether its parent sees it as a child (a tab) or over a route (a worker), call arguments sent up a parent link are never compressed; results and state going up are. Compressed hold params are a protocol error on every link. The asynchronous inflate runs one part at a time; the queue behind it is bounded by the sending peer's window and N, so only a misbehaving parent or route peer could grow it (accepted: those are the app's own hosts). The adapter reports "too large" and "corrupt" as a tagged result. Limits and the threshold count bytes. No per-link compression. -
Trace carriers (R5; slice 9) are an optional field
traceofcallandhold: an adapter's string record of at most 8 keys, keys and values of at most 256 characters of printable ASCII (keys without spaces; W3C's headers and Chord's adapters fit); past the bounds, with a control character or not a string record, the message failsMESSAGEas any malformed header and the link closes (a trusted link skips the check; the adapter'sextractthen checks it). A carrier names stored trace and parent ids, so no control character reaches a renderer (andrenderTraceescapes them anyway). Names a relay records unparsed are bounded the same way: a hold'stypeand its address'skindhave at most 256 characters. The binary codec flags it in the kind byte (0x40, beside the origin's0x80) and writes it as JSON after the origin; the text codec writes it as the last element (a hold's afterlimit, absent ones before itnull; a call's after its payload, whose two fields are then positional,nullwhen absent). Relays read it from the envelope and forward their own span's carrier (a relay recording nothing forwards it as it came).PROTOCOL_VERSIONstays 1. -
Adapted bindings get diffs (slice 7b; decision "References are the only indirection" (5)): a consumer bound through
acceptsattaches to the object instance's shared mapping for its version: per member one derive of the instance's member through(current) => old(a changed member), of its whole state (a removed member), or the member itself (unchanged), made on first use, dropped when no reference of that version is left, gone with the instance; one map and one diff per commit per version whatever the number of old references, each hold encoding its own ops, so an old tab never receives a large state whole on every commit. A new instance (a reload) has its own mappings; the references move to them as to any new instance (one frame, never a new mapping applied to the old instance's values). A version the instance does not accept gives no member: the reference waits unbound with its last values. -
A
failedon a bound hold unbinds it (slice 7b, model §5): the consumer applies it as a start-over plus the failure (its calls waiting for a slot reject with it; the provider rejects its calls in flight once); the provider detaches the hold's sources, drops its unsent pending ops and a queuedbound, and keeps the ack slots ofboundandstatemessages already sent until theirackor the link's loss. A reload sends it when the object's type is removed (not_served), its version is no longer accepted (version), or a swap parks on a failed dependency; a restart or a parked swap still loading sendsloading; both are followed by a freshboundwhen the object is live again (applied as a re-binding). A swap in place keeps the hold bound and sends it a freshboundonce the new instance is live (audit A9, 2026-10-30: one rebind path; a tab's watchers may get a frame for an unchanged member).
23. Optional components
The app kit's Node components (slice 10, slice-10.md, decision "Slice 10's points"): chord/http and
chord/workers in src/node/; the web page host and the TUI are slice 11. Each is ordinary code on Chord's
public API; nothing in the kernel depends on them.
HTTP (@earendil-works/chord/http)
The core is one Web-standard handler built from the registry Http (decision "The HTTP core is a fetch handler"):
const http = createHttp(server, { // settings are arguments, fixed for the component's life
listen: { host: "127.0.0.1", port: 8788 }, // Chord's own Node listener; none: the app mounts `fetch`
links: { path: "/chord", options: (request, principal) => ({ limits, maxPayload }) },
closeTimeout: 30_000,
onError, // HttpError
}, ctx);
await plugins.load(server, { kind: "server", core: [{ name: "chord.http", facet: http.facet }, appCore], modules }, ctx);
// an app with its own server: hono.all("/*", (c) => http.fetch(c.req.raw, { localAddress, localPort, remoteAddress }))
// plain Node: nodeRequest(message, res) converts with the listener's checks; server.on("upgrade", http.upgrade)
await http.close(ctx); // before host.close: drains running requests
- The facet (plugin
chord.http, passed as the same object at every reload) serves the registriesHttp(composebuilds the router) andAuthenticators, and withlistenthe listener: a host-lifetime object whose native is Chord's Node server, opened right afterload's publication, so a reload never closes it (decisions "The HTTP server is a native object", "Settings are arguments, extensions are registries").fetchresolves the registries once; requests before the host'sloadanswer 503 at once (host.registrythrowsnot_readythen). - Contributions to
Http:{ routes?, wraps?, sockets?, maxPayload?, hosts?, framers?, relays?, csp? }, a value or a writable (a change recomposes the router). A request uses the router as it was when it started. - The security baseline (decision "The HTTP component's security baseline", on for every request and upgrade
before any plugin code): (1) provenance: local only when the connection's local address is loopback,
Hostis a loopback name with the port and noForwarded/X-Forwarded-*header; a forwarded request on loopback only through a contributed relay whoseverifynames its origin ({ kind: "relayed", relay, origin }); otherwise only a contributed host (https); duplicate or missingHost, userinfo in it, absolute-form targets: 403; an unparsable target 400;TRACE,TRACK,CONNECT501 (a route for them is refused at compose). A mounted handler withoutlocalAddressandlocalPortis never local. (2) Foreign requests (Fetch Metadata) never reach the authenticators: anonymous, except a top-levelGETnavigation to apage: trueroute. (3) A foreignOriginis 403; an unsafe method from a browser needs the app's ownOrigin; acrossSite: trueroute opts out and always runs anonymous. (4) Default headers on every answer, the component's own included:no-store,nosniff,no-referrer,X-Frame-Options: DENY(without framers), a CSP (default-src 'none', scripts'self'and a per-response noncerequest.nonce,connect-srcself and its WebSocket,frame-ancestorsthe framers or'none', plus contributedcspsources); a response's own headers replace same-named ones. (5)cookie(name, value, { secret, request, sameSite?, maxAge? })andreadCookie(request, name, secret):HttpOnly,Path=/,SameSite=StrictorLax,Secureon a remote origin, value and expiry signed. (6)bytesrefuse foreign requests, are attachments unlessinline(then with asandboxCSP), download names stripped of control and bidirectional characters. - Who: the
Authenticatorsregistry, in contributor order (an app needing precedence contributes one that decides); the first principal wins, a throwing one is reported and skipped, none: anonymous. They run raced against the request's signal and stop once it aborts. Who someone is stays app policy (D3); the registry replaces D3's single function so plugins (a device pairing, a share) can add credentials. - A request's scope: one
host.rootwith its principal (an explicitundefined: anonymous; an anonymous scope gets no remote object served here,forbidden→ 403) spans matching, wraps, the route and the response until its body is written or abandoned; its signal aborts on disconnect.HttpRequest:method,path,params,query,headers,origin,provenance,nonce,json(limit?)(1 MiB; 400, 413),body(limit?). - Patterns (D8): exact segments,
:name, a trailing/*; decoded segments with/,\, NUL,.or..are 400; the most specific match wins segment by segment; a pattern from two contributions is a reported conflict, the first by plugin name wins;HEADfalls back toGET; no route: 404. A route is a function or{ handler, page?, crossSite? }. Wraps (D2) as below.
type HttpResponse = { readonly headers?: Readonly<Record<string, string | readonly string[]>> } & ( // D5
| { kind: "json"; value: JsonValue; status?: number } | { kind: "html"; body: string; status?: number }
| { kind: "bytes"; mime: string; size?: number; read(offset, length, ctx): Promise<Uint8Array>; download?: string; inline?: true; ranges?: boolean }
| { kind: "redirect"; to: string; status?: 302 | 303 | 307 } | { kind: "error"; status: number; message?: string });
bytes are pulled in 256 KiB reads as the consumer reads (ranges 206/416, HEAD, chunked without size, R6); no
read starts once the request's scope is sealed; a short or throwing read destroys the connection. 204, 205 and 304
carry no body. fileResponse(path, { download?, inline?, ranges?, mime?, root? }) stats at once (missing: 404; with
root, a real path outside it: 403), opens its handle at the first read, closes it when the scope seals. A throwing
route or wrap is a 500 without its message.
type Wrap = {
name: string;
phase: "observe" | "headers" | "transform"; // the component's phases, outermost first
scope?: "own" | "all"; // default "own": only this contributor's routes
route?: string; // only this route; a missing route is a reported conflict
wrap: (req, next, ctx) => Promise<HttpResponse>;
};
Wraps (D2) run within a phase in contributor order (no numeric priorities: plugins have no single registration order; Fastify's lifecycle phases and encapsulation are the model). Wraps may add a security boundary on top of a route's checks, never instead of them.
- Sockets (D6):
sockets: { "/terminal/:id": (socket, request, ctx) => … }, awsWebSocket after admission and the authenticators (no principal: 403 before plugin code),maxPayloadper contribution (1 MiB), pinged after 15 s of silence and terminated 15 s later. Links:links.pathupgrades tohost.accept(webSocketLink(socket), { principal, limits }); a client that stops reading is cut off past 4 MiB waiting. What is removed is closed at once (decisions Q6, "allowed hosts only through contributions"): a link or socket whose host, relay or socket handler leaves the router is terminated and its scope sealed; an upgrade is checked again against the current router after its authenticators answered. close(ctx): new requests and upgrades refused (503), sockets and links closed (1001, terminated atcloseTimeout), running requests waited for up tocloseTimeout, then sealed; beforehost.close.- Cost (decision "a route's overhead accepted"): +20-21 µs per request over a plain Node handler (standard bench),
+12-13 µs warmed.
wsis an optional peer dependency (D7).
Workers (@earendil-works/chord/workers)
createWorkers(host, Kind, { kind, entry, execArgv?, env?, authorize, principal, start, lock, idleMs?, plugins, onOutput?, onError }, ctx) routes Kind itself (host.route, keeping its RouteHandle): a forked worker per
address over IPC, the link made at the fork, a start message { address, start, scripts, lock } with the app's start
data and the kind's complete current scripts (plugins.scripts(kind)); the 60 s start timer covers fork, lock and
connect only (Q4). workers.state (a JSON watchable per address key), stop, retry, step(name), close;
workerMain(fn, { hostPackages, redact, onError }) is the child's side (fn({ address, start, parent, plugins, onClose }, ctx), plugins a WorkerPlugins evaluating the shipped scripts, closers in reverse order). One live
worker per address (processLock: an abstract socket on Linux, a named pipe on Windows, a socket file elsewhere that
fails closed when stale, the stale file cleared by the component before a start and after a child's exit by a signal,
decision "Stale lock files are cleared by the starter"; a start waits for a draining old holder without polling).
- Keep-alive (D9, R3, Q5): the child reports when
host.activity's references, jobs and lingering objects and the app'sbusycounts flip between all zero and not (local calls do not count; the first state withready, which also carries the load report); the server stops a worker idle foridleMs(5 min). - Never restarted by itself (R2): a crash, a
fatalor a failed start leaves the address failed; its holders seefailed { waiting }and stay on the path (Q1);retry(address)or a rollout reaching workers clears it and starts the held ones (Q2).stopsuspends the address while it has holders (R1, Q3). A running worker's lost link (killed after a 5 s grace) is a stop its holders start a new worker after (worker_link_lost); a second within a minute of the last recovery fails the address (worker lost its link; decision "A lost worker link is recovered from"). - Errors in public and private (decision "worker errors in public"):
stateand holders get a fixed message (worker failed to start,worker exited (SIGSEGV)) or the child's redactedfatal; the full error and the stderr tail go only toonError(worker_failed). Every app hook of a start (start,principal,lock, the fork) is inside that boundary; a throwingauthorizerefuses the hold and is reportedauthorize_failed.worker_exitedis reported only for an exit nobody asked for. - Workers never build (D11): the server's plugins component builds every plugin; workers evaluate shipped
complete sets (
createPluginRuntime, no esbuild);workers.step(name)ships a rollout's set to every worker (D12:{ host, report } | { host, stopped: true } | { host, error }per worker).
The app kit (chord/app; slice 13)
Decisions "The app kit, point 1" to "point 5". startServer(options) on the server, startWorker(options) in a worker
entry. Opinionated and small: an app is its API, its built-in plugins and a start script.
- Shape: the app's layout is fixed:
src/api/v<n>/(shared and per-kind modules) and the built-in plugins rootplugins/. The API is a real package (decision "Tutorial gaps" (3)): a workspace package of the app's repository ("name": "@acme/app","exports": { "./v1": ..., "./v1/web": ... }) that npm links, so Node, TypeScript, tests and plugin builds resolve it alike (a test imports a plugin'scontract.ts). The data directory is astartServeroption (no environment variables), default a dot directory named after the app in the home directory. One server; worker kinds are a list the app defines, each a host kind with its own params (sharding is the app's) and how it starts (entry, start data, principal, idle time); zero kinds is a server with clients only. - Principals and users: the principal is tiny (a kind and an id) and the app's type; the kit needs only its server and worker variants. User data lives in objects keyed by the user's id. Optional kit plugins: users (accounts with an app-supplied profile schema), authenticators (device pairing, invite links, an ephemeral pick-a-name login).
- Authentication: no owner by loopback alone: the start script prints a link with a one-time token that sets a signed owner cookie; deny by default (the HTTP component refuses requests without a principal unless the route or socket is public); a session helper issues and revokes sessions; command-line clients send a token in a header (an owner token file readable only by the app's OS user; a device pairing flow from other machines).
- Storage: one store for the server, a directory per worker under its kind and address with its store; plugins
never use data-directory paths. A files plugin per address (
put, chunked transfers, a watchable index, caps) is the default file storage; raw directories only where a real path is needed, owned by one object, under the worker's directory. Export and import the data a host created (exportData,importData): the store's archive plus its files; export stops nothing (a running worker packs its own store; without one the server reads it under the worker's lock), import refuses an address that has data (decision "App kit naming and export"). - Plugins: the app's root (required) and the user's root in the data directory; installs from git or npm; one "apply changes" call builds and rolls out to the server, then every running worker, exposed by the app as it likes.
Web and TUI (slice 11)
Decision "Slice 11's points", "Slice 11, P3" to "P9", and "Tutorial gaps" (1) (page state).
chord/surfacesserves the client surfaces (web and terminal) from the server;chord/webis a page's client host with its connection loop (p2'skeepConnected), loading and applying the plugins' web facets. A base bundle per client kind built on the server from the app's entry (content-hashed); at page load the kind's current plugin set as one combined file named by its hash, later changed plugins one script each, each a script that registers itself (no eval); new scripts apply in place (host.reloadin the page); pages learn of them through an object the web component serves (the current set); a base code change reloads the page.chord/react:HostProvider,useHold,useWatch,useRegistry,useRefState, andusePageState(key, initial):useState's pair kept by the page's host under a key, so a component remounted by a plugin reload (a new function) keeps it (§21, "UI on client hosts").chord/tui/launcher: a Node client connecting with a token, always running the downloaded code (the app's terminal entry onchord/tui) in a sandbox (decision "The terminal's sandbox is a child process": a child process on pipes with an empty environment,--permissiongranting nothing and no SQLite; the builtin allowlist and a frozen substituteprocessinside; a bridge terminal; the trusted launcher outside, confirming copy, open and attach on its own prompt); a component scope (hold,watch,registry, released on dispose, onechangedcallback for the app's redraw;pageState(key, initial), the host's page state shared withusePageState), no pi-tui dependency.
Messages and languages (built after slice 11)
Decision "Messages and languages in the app kit", refined in the build (2026-11-03). Text people read is rendered per
viewer, never by a server. The shared parts are in Chord's root (src/surfaces/, portable), useMessages in
chord/react, scope.messages in chord/tui, lang and dir in chord/web; the kit serves Translations on the
server, workers, pages and terminals and LocaleChoice on the server. Apps re-export what their plugins use.
// plugins/notes/messages.ts: one shared file every facet that renders imports
export const messages = defineMessages("notes", {
en: { newNote: "New note", count: (n: number) => (n === 1 ? "1 note" : `${n} notes`), saved: (title: string, at: string) => `Saved "${title}" at ${formatTime(at)}` },
de: { newNote: "Neue Notiz", count: (n) => `${n} Notizen`, saved: (title, at) => `„${title}“ um ${formatTime(at)} gespeichert` },
});
// plugins/notes/web.tsx
const { t, locale } = useMessages(messages); // re-renders on a locale or translation change
self.contribute(Translations, messages.translations); // in the facet: the plugin's own catalogs
// plugins/notes/worker.ts: a message as data (state member `notice: union([MessageSchema, nul()])`)
draft.notice = messages.message("saved", note.title, note.at); // { namespace: "notes", key: "saved", params }
// any client: t.render(notes.notice.value)
- Messages in code:
defineMessages(namespace, catalogs)(a namespace is a name like a type's; catalog keys are language tags, canonicalized) returnst, typed by the first catalog: the others take their functions' parameter types from it, and a missing key, an unknown one or a wrong argument is a type error (when only strings differ, TypeScript may report a missing key at the first catalog's line). Values are strings or functions;formatTime(at, options?)(aDate, milliseconds or an ISO string;timeStyle: "short"by default) andformatNumber(value, options?)useIntlwith the locale being rendered, the platform's outside a render (a language's default formatters are made once, kept in aWeakMapby the language). tis per host (refinement (3)):useMessages(t)returns{ t, locale }bound to theHostProvider's host and its language;scope.messages(t)returns atreading the host's language at each call, its change callingchanged;t.in(language)binds to anyLanguage. The plaint(key, ...args)renders in the language of the render under way (a render scope: synchronous, set while a boundtcalls a message function, so a nested plaintorformatTimefollows it), and outside one in the first catalog (servers, tests, logs). No module-level current language: one process runs many hosts, and a server renders per recipient.- Rendering a key: down the language's chain (
pt-BR,pt): the host'sTranslationscatalog for the namespace and locale, else the plugin's own; else the first catalog. Each key found further down is reported (fallback), a contributed message that throws is reported (failed, naming its plugin) and the chain goes on (use sites guard contributions, §17). - Translations (refinements (1), (2)): a registry (
chord.translations, version 1) whose contribution is a list of{ namespace, locale, messages, override? }(a contributor contributes once per registry, and one translation plugin translates several namespaces). A plugin's own catalogs are its built-in contributions literally: each rendering facet contributest.translations, so the registry knows every locale offered (for the device default and a settings list);tstill renders its own catalogs if a facet forgot. The composedTranslationIndexkeeps one catalog per namespace and canonical locale: the first by plugin name wins and the other is reported (registry_conflict),override: truewins over a catalog without it; an invalid locale is left out, reported;localeslists every locale offered. Catalogs may be partial (MessagesOf<typeof t>is the full shape a translation fills); contributions are per host, so a translation plugin keeps them in a shared file and contributes them from each facet that renders. Catalogs never cross links. - The locale:
LocaleChoice(remote on the server, params{}, per principal:pick, a language tag of at most 35 characters ornull, andchoose), kept like the theme choice: both are a preference (preferenceContract,preferenceFacet: one persisted document of picks by principal key, the last 10,000 choosers). A client'sfollowActiveLanguage(host, preferred, changed, report, ctx)resolves the locale: the pick when a catalog offers it (or a base or another region of its language), else the first of the device's languages offered (navigator.languageson a page, followed onlanguagechange; the system locale in the terminal: the launcher'slocales, by defaultLANGUAGE,LC_ALL,LC_MESSAGES,LANG, thenIntl's, passed in the bridge's start), else English (the source of truth), else the first offered. The result, aLanguage({ locale, dir, chain, translations, report };dirfrom the locale's likely script), is the host's page state underchord.language, whichuseMessagesandscope.messagesfollow; it changes only when the locale or the index does, nothing reloads. A page writeslanganddiron its root element. Reports go to the client'sonErroronce each ({ kind: "messages", report }). - Messages as data:
t.message(key, ...args)returnsMessageData<"notes", "saved">({ namespace, key, params };Messageis the link protocol's), only for keys whose arguments are JSON scalars (MessageParam), so aDateargument does not compile. A contract member usesMessageSchema(any namespace and key).t.render(message)refuses another literal namespace at the type level and checks at runtime what crossed a link: another namespace, a key the first catalog lacks or params that are no list show the key (reportedunknown), a message that throws on its params shows the key (reportedfailed). Prompts, model output, logs and error codes are never translated.
Channels
WebSocket byte channels with acknowledged windows, separate from object links, wait for a second user after the terminals (slice 10 D6); they would be built on top of plugin sockets.
Chunked transfers
Large byte transfers are chunked by the owning object: begin, append({ upload, offset, bytes }), finish,
read({ id, offset, length }), about 1 MiB per chunk. The awaited append is the backpressure; after a drop the
writer asks for the stored offset and resumes; unfinished uploads expire (the owner's routine). A helper wraps the
loop. Chord has no stream type (it would need its own rules for drops, cancellation, relays and restarts).
24. Durable integration and portability
Durable
-
Durable keeps importing today's Chord's
context,delta,json,outputandsqlite;next/imports the same modules, soContext,DraftandJsonValueare one type. Durable's conversation view, task graph and documents areWatchables through Durable's watches (Durable'sWatchHandlenow lives in Chord); Durable adopts and dispatches them synchronously inside the storage's commit (S1, W1; withStoreStoragethat is the store's commit step), so a derive over a Durable view travels with it. This needs Durable to publish a commit when it adopts it (owner, 2026-10-26; a pi change, upstream first; explainerdurable-observers/): insideadopt, its trackers, telemetry events and internal observers (view mounts, task graph, scheduler, submissions) run, and the hostobserveCommitsobservers are dispatched in one microtask queued before the observers, so they run before every wait the commit resolves and every abort it queues, but after the storage's synchronous step, never inside Chord's batch; host listeners and watch listeners have no order between them (owner, 2026-10-26). Durable's public primitive for adapters is a synchronous observer,observeConversation,observeTaskGraph,observeDoc(itsadvance(call, ctx)gets aWatchCalland runs insideadopt: record only, no awaits, no Session calls, no throws);watchXXXis built on it for app code. Chord'sself.follow(attach)(slice 6b D9) is a source over it. Otherwise a derive over two Durable views computes between their frames of one commit. WithStoreStorage, Durable'sadoptruns inside the host's batch; the store knows nothing of Durable (no special cases) and needs only its step and flush to stay synchronous. Durable's state getters (documentState,viewState,taskGraph()) and thereplicatedStatefamily are removed (W14). Only plain data crosses between Durable and v2 objects. -
The harness is an object in the core facet (base code): a
Harnesslocalobject with empty params, so host lifetime (it runs for the worker's whole life, whether or not anyone holds it) opens Durable paused withself.openduring the barrier and gets model access and execution environments from registry entries, looked up at each use:const models = self.registry(HarnessModels); // declared dependencies (slice 8 S3): activation waits const envs = self.registry(HarnessEnvironments); const extensions = self.registry(HarnessExtensions); const harness = self.open(async (context) => Harness.open(await StoreStorage.open(store, Harness.name, context), { models: () => models.value, // read at use env: (target) => envs.value(target.cwd), settings: () => currentSettings(), registry: registryReader(extensions, context), // Durable's RegistryReader over the composed value (slice 8) }, context), (h, _reason, context) => h.close(context));Durable's
modelsandsettingsaccept functions (pi7ec1f648e). Durable opens with scheduling paused; base code callsharness.resume()afterawait host.load(...); the worker connects to its parent first (W6), so host-lifetime stores can reference server types during the barrier; requests arriving duringloadpark like any call to a loading object (refines R17). A missing tool gives its call an error result the model sees; a missing model makes the generation report "model unavailable" and wait (not fail) until a model contribution appears; a task whose definition is missing stays blocked (missing_task) until the plugin is installed or the task is aborted, as Durable does. -
Durable's views from other objects (slice 8 §5): the harness object contributes its
Native<Harness>to a registry of p3's; another object declares that registry (self.registry), wakes on it (self.retryOn) and follows a view through its value (self.follow). A Session close ends Durable's synchronous observersfailed("session closed"; decision "Slice 8 S1"; app watches still endended), so the follower failstransientand follows the next Harness once its contribution arrives.registryReader's listener runs inside Chord's batch: it only wakes Durable's scheduler (a Chord call there throwsinvalid/reentrant). -
HarnessModelsandHarnessEnvironmentsstart with one contribution each, from the models and envs plugins' objects that own the natives; several providers or environment kinds are theircompose. After a models reload the next request uses the new runtime; a running stream finishes on the old one. -
Chord from Durable code (Q6): tools, hooks and sections get Durable's
ToolExecutionApiand aContext, never a Chordctx. p3's core opens the harness with a base context holding the host under a typed key (withContextValue(ChordHostKey, host, …)); every phase context inherits it. A core helper opens a call scope from any Durable context:chord(async (ctx) => …, context)=host.root(fn, context)(host principal; attribution as data; references released whenfnsettles; slice 8 D3). No wrapping of tools and no p3defineTool. Clients are never reachable this way (they are not addressable): a tool that needs the user goes through an owner's state, e.g. the Questions service (apps/p2/docs/questions.md): the model-facingask_usertool and any other tool callQuestions.ask/wait, tabs render the open questions andanswerthem. Documented with this example. -
Code mode (a plugin) keeps owning its
pi-codemodesandbox; its tool entry looks up the current sandbox whenexecute()starts, so a call pinned by a phase before a reload (e.g. waiting in an approval hook) starts on the new sandbox; onreloadandcloseitsclosedetaches the old sandbox, returns at once and disposes of it when its scripts finish or aftercloseTimeout(30 s at most, R16, Q7); after a worker stop Durable replays as today. -
Telemetry:
HarnessOptions.telemetry; submissions and tasks store an optional carrier (section 29).
Portability
Adopted in the kernel: host = identity plus incarnation; the loader is an adapter; values independent of the
transport; durable state only in persisted documents. A Durable Object deployment
(apps/p2/docs/research/chord-on-durable-objects.md) would map a logical host to a DO, compose plugins at build time
(Workers forbid eval), replace the relaying tree with edge routing and use alarms for routines.
Decision needed: a Durable Object profile. Problem: a DO can lose its memory while clients stay connected (hibernation); routines need wake-ups (one alarm per DO); links between DOs are RPC stubs. Hot reload is not needed there; plugin composition is. For later.
25. Apps: ownership and porting
- Every thing has one owner, the smallest object it belongs to; ownership nests; rules set at creation are written
in the creating commit (Durable
createConversationwithinit). - Core owners are always present; a plugin owner may be missing (optional dependencies are registries owned by the dependent, section 7). Avoid a core god object: each core owner owns only what belongs to it.
- Revocation of work in shared stores: a store that runs work on behalf of a principal stores the grant (or device) as data with the work and reconciles from the grant owner's live grants (a routine aborts jobs, withdraws questions, drops queued input whose grant no longer exists); no revocation hook. The window between a revocation and the routine's run is accepted (R10): a step started in it still runs; there is always some window.
- Reading without starting a worker is plain pub/sub in an ordinary plugin: a host-lifetime object of its worker facet
records the summary locally and publishes it to the plugin's server object (a method call from a routine on the source); clients read
the server object, which never starts a worker; it keeps the latest value per session (a document if it must
survive a server restart) and ignores reports from an older worker incarnation. The caller's incarnation is
stamped by Chord (R20): a call arriving over a link carries the calling host's incarnation from the link handshake
(
ctx.incarnation, next toctx.principal, not part of the principal or its key, so object identities do not change with restarts); nobody sends it as data and it cannot be forged. On relayed calls it is the origin host's incarnation (Q13, owner, 2026-10-21): the relay takes it from the link the hold came in on and sends it in the upstreamhold(a client's value is ignored, like its principal); calls inherit their hold's (section 4). Session list, status, children, unread and artifact lists follow this. - Porting guideline: what core enforces on data lives in a format core owns, written by the plugin that creates
the data; a plugin's private document is read only by that plugin. Example: subagents mark one-shot and
cwd-following child conversations in a core per-conversation policy document (
readOnly,followsSessionCwd); chat and relocation read it;ReadOnlyConversationsandSessionCwdFollowersgo away. - p3 is built next to p2, fully isolated (
apps/p3, own data directory), without migrating p2 data; plugins are derived from the functionality map (apps/p2/docs/p3-functions/), not ported one to one. - Session lifecycle: archive retires nothing (stops the worker, ends guest access, closes forwards); a session delete removes the whole session (tagged documents, the worker's databases, files, the catalog entry).
26. Decisions needed
Open, decided with the slice named or a concrete case (numbers kept stable; everything else is decided and folded into this document):
| # | Topic | Section | When |
|---|---|---|---|
| 11 | where a flow lives | 20 | when login is ported |
| 14 | project plugins | 21 | own pass over plugin-loading.md |
| 16 | a Durable Object profile | 24 | when a DO target becomes concrete |
| 24 | a per-type drain override: a host option or fixed (no install watchdog: reload takes the app's context, decision "Slice 7b's open points" (4); the routine handover uses closeTimeout; a failing job finish backs off 1-60 s and blocks after five failures, decided) |
3 | if a real case needs it |
| 28 | Astra's circuit breaker (a hard transport-buffer cap plus a stall deadline); the heartbeat is built, the cap was never decided | 3 | if a real case needs it |
Apps (p3), from the functionality map, decided per domain when p3 is designed:
| # | Topic | Problem and options |
|---|---|---|
| 17 | session files | one owner SessionFiles {session} on the server for uploads, sent-file snapshots and artifact versions (caps per kind, index readable without a worker, copy on clone/fork, delete with the session); proposed, sub-points: one preview limit, fork's artifact list, copying attachments into remote environments |
| 18 | session delete | per-session objects plus SessionTag plus a session directory, or owner hooks through a registry; search index per session or global plus a hook |
| 20 | connection pools | SSH connections to one environment exist four times: one owner with a pool, or per-use connections |
| 21 | config files with a single writer | settings, models and auth are watched by every process and written by two: one owner on the server, others follow its state |
| 22 | file formats as versioned interfaces | envs.json, downloads, uploads have no version: version them like documents, or keep each internal to one owner |
| 23 | the terminal host process | a detached process with its own code: part of the revision (reloaded or restarted with it) or deliberately outside |
27. Versions and adapters
| Surface | Version | On change |
|---|---|---|
| object type | version plus contract fingerprint |
every change bumps; forward evolution and checkAccepts (Q10); otherwise contract_mismatch |
| registry | version |
accepts adapter on contributions |
| document | version plus schema fingerprint |
pure migrate, on access; a schema change without a bump fails the object broken |
| job | version |
accepts maps older args and results forwards (finish, running); the state document's own migrate; blocked without an adapter or for a newer version |
| native | none | the object restarts on reload |
| Durable tasks and documents | version |
Durable's migrate |
| link protocol | protocol version | refused; hosts update together |
| UI components, Chord and app APIs | none yet | exposed as they are; versioned with an SDK later |
Contract evolution in code (Q10; implemented in packages/chord/src/contracts/, see its README):
export const NotesV1 = remote("notes", { version: 1, params, variants: {
editor: { state: { notes: arr(NoteV1) }, methods: { add: method(NoteV1, Id) } },
} });
export const NotesV2 = evolve(NotesV1, (e) => e.state("editor", "notes", arr(NoteV2)).args("add", NoteV2));
const accepted = checkAccepts(NotesV2, {
1: { state: { editor: { notes: (current) => current.map(toV1) } }, methods: { add: { args: (old) => toV2(old) } } },
});
- A history starts with a base written in full with an explicit version (
remote()/local();localhas flatmembersand no variants).evolve(prev, (e) => …)always continues it with structured edits:state,addState,removeState,args,result,method(name, args, result),addMethod,removeMethod(local: no variant argument), and for a local contract's keyed memberskeyed,addKeyed,removeKeyed(slice 7b; owner, 2026-10-26: keyed members' versions are mapped per key). Every version is materialized and fingerprinted at declaration, so an old plugin's own copy of v1 computes the same fingerprint; no build step, no generated files, no hand-copied fingerprints. - A new history base (a params change or a change of the variant set) is a new
remote()/local()with an explicit version and no accepts for older versions: old plugins getcontract_mismatchand must update. - Mappings live with the provider, never in the contract: the pure
checkAccepts(contract, accepts)(called by the objects layer) requires, at type level and again at runtime, a mapping for every member that exists in the old version and changed or was removed since: changed state(currentMember) => oldMember, removed state(current variant state) => oldMemberor"refuse",args: (old) => current,result: (current) => old, removed methods{ call }or"refuse", changed keyed members(current document) => old documentor"refuse"(mapped per key for an older local consumer), removed keyed members"refuse"; added members need nothing. No-op edits need identity mappings. Untyped JavaScript providers fail at declaration naming the member; unknown keys and wrong shapes are rejected. - The fingerprint is a hash (
hash64, portable, not a security boundary) of a schema-aware, data-only normal form (NORMAL_FORM_VERSION, tied to the protocol): annotations dropped only on schema nodes,required/enumsorted, union and intersection order kept (it is meaning: defaults), a whitelist of JSON Schema keywords, executable features rejected; bytes are a{ type: "bytes" }data marker. Schemas are deep-copied into a private history (never frozen; immutable by convention). - Schema helpers:
str num int bool nul lit oneOf union obj arr map opt bytes unknownandtype Static. - Chord wraps state mappings as a derive and diffs per object and version (section 22); members an old consumer does not know do not appear.
28. Building: slices
Chord v2 is built in vertical slices; the plan, the feature map (every feature of this document assigned to the slice
that builds it) and the carry-over of deferred obligations are in slices.md. Slice specs (slice-N.md) stay as the
history of how each slice was specified; this document states the current rules. The first layered build (kernel steps
1-6) was deleted on 2026-10-22 and survives only in git history. Done in pi: pi-telemetry (propagation, links,
metrics, the store interface, the renderer), chord/context (Context.telemetry), pi-ai and Durable telemetry,
chord/telemetry-sqlite (section 29), Chord's shared Watch/NativeWatch/PendingOps, Delta's appendText.
Rules for every slice (owner, 2026-10-22): the code follows the root AGENTS.md section "Chord v2 Code Style";
one test file per source file, one describe per mechanism, the spec's test list case by case; cost guards through the
in-memory telemetry adapter (section 30); GC tests for every cache and binding; one or more runnable examples
(packages/chord/test/examples/NN-topic.ts); hot-path slices add a light benchmark script
(test/bench/, by hand) with results in benchmarks.md; npm run check and the slice's tests before reporting.
Caches are WeakMaps keyed by their owner; no module-level strong caches; no hot-path work no decision asked for. The
function doing counted work counts it through its context; call sites never count for others; plain totals without
site labels.
Testing the core: every slice spec contains an explicit test list, one group per mechanism with its edge cases, tested individually and exhaustively: activation and parked dependencies (the dependency model: unbound requests, waits without timers, wakes on every change, retry passing on, a dependency failing while live, cycles, the start barrier's first outcomes), revocation over running jobs (R11), compatibility (R6), params and arguments for every caller (R7), migration results and old calls on migrated documents (R8, R15), flow control (the window per origin, folding, relays forwarding and returning acks), the incarnation fence (R20), the harness start after the barrier (R17), native restart patterns (R16), and W1's publication list (a throwing derive, a chain A→B→C where B throws, two owners alternating with a derive over both, reentrancy, a watch started after 150 frames, stop during a running call, a retire with a derive over it, a reload during a routine call, GC counts of retained revisions).
29. Telemetry
Chord reports traces, events and metrics through @earendil-works/pi-telemetry; one adapter per host
(createHost({ telemetry }), NOOP by default) decides sampling, buffering, export and filtering.
-
Types: the adapter is a
TelemetryContext(startSpanplus optional readonlymetricsandpropagationcapabilities); only hosts hold it, to start roots.Context.telemetryis always aTelemetrySpan(the NOOP span by default); every span carries its adapter'smetricsandpropagation, so code with a context can record metrics and inject carriers.withTelemetry(span, context),traceIn(options, fn, context)(context last; pi change with S1);value()and keys stay.CtxextendsContext, soctx.telemetryis the current span; app values such as the principal stay out of Durable's context.ctx.trace(name, attributes, fn)creates a child span and givesfna childctx. -
Adapters trust typed inputs: no validation of malformed caller values and no try/catch around recording; only carriers from another process are checked (keys present, strings), else the root. Callers never protect themselves against broken adapters.
-
Across links:
callandholdcarry the caller's carrier as a bounded header field (section 22); the provider extracts it and runs the method under it; relays add achord.relayspan.chord.call Notes.add [tab A] └─ chord.relay [server] └─ chord.method Notes.add [session:S1] principal=guest · event chord.commit docs=2 ops=5 -
Shared work (one activation for several getters, a worker start) is its own root span linked to its first trigger; each waiting span links to it. Routine runs, job runs, eviction, backoff retries and background closes are root spans linked to their trigger where one exists.
-
Tiers: spans for calls, methods, relays, holds, activations, opens, closes, loads, revocations, resolves, routine runs and job runs; events for commits inside methods, retries and parking; metrics for state messages, WebSocket bytes, commit durations, in-flight calls, active holds, open links, park durations and backpressure waits. IDs are attributes of spans, never of metrics.
-
Nothing is measured or walked for telemetry: only sizes the code already has. Errors carry a fixed category and a message truncated to 500 characters; cancelling is not an error.
-
A typed telemetry schema ships with Chord, as pi-ai (
AI_TELEMETRY_SCHEMA) and Durable (DURABLE_TELEMETRY_SCHEMA) have; call sites that only have a context use the genericaddEvent. -
Storage and reading:
@earendil-works/chord/telemetry-sqliteoverchord/sqlite(Node and Cloudflare): one row per settled span (events and links inline), metrics aggregated per series per 10 s, a 1 s batched flush with retention (7 days), a bounded queue (dropped and counted). The shared read interfaceTelemetryStore(Jaeger style):findTraces(name, attributes, errors, time window, limit, cursor) returns complete traces,getTrace,findMetrics. Spans carry global ids and a trace id; links carry trace and span id. One renderer (pi-telemetry/render) prints links and never follows them. A web trace viewer in the playground reads the store. -
Durable (done): submit and phase spans, stored carriers on submissions and tasks (
TaskOptions.trace, creation links), events after commit, report events; pi-ai emitspi.ai.request/pi.ai.pollfrom one observer inlazyStream. Persisted Chord jobs store their start carrier. A worker's Harness gets its host's adapter (HarnessOptions.telemetry) and a method that submits input passes itsctx(itstelemetrythechord.methodspan), sopi.durable.submitlinks to it and starts its own trace, as decided.
Built (slice 9, slice-9.md; src/telemetry.ts):
- The schema:
CHORD_TELEMETRY_SCHEMA(root export,defineTelemetrySchema) declares every span, event and metric Chord records with its attributes; the cost counters (chord.data.*,chord.link.*,chord.store.*,chord.query.runs,chord.cycle.visits,chord.derive.*,chord.registry.*,chord.contract.adapts) are recorded through it (chordMetrics(metrics): typed instruments made once per adapter's metrics object and name, aWeakMap; the NOOP metrics share one set). Ids (object labels, run ids, address keys) are span attributes only. - Spans:
chord.call(a consumer's call of a remote member, until its reply),chord.method(a provider running a method: under the call's carrier over a link, the caller's span on the same host; inAttempt.call, so local and remote calls alike),chord.relay(a relayed hold until it is held upstream, a relayed call until its reply is forwarded; the member isholdor#<index>: a relay never knows names),chord.hold(a remote reference's hold until its firstboundor failure; its carrier rides on the reference's first hold only: a re-hold after a reconnect is a freshchord.holdroot of the host's adapter linked to the first, and a relay's re-hold upstream after a drop a freshchord.relayroot linked to its first, so a page kept open for days never adds children under a span long ended),chord.activate,chord.open(under the activation),chord.close(a root),chord.load,chord.reload(achord.reload.fateevent per object),chord.resolve,chord.revoke(recorded after the synchronous revocation, itscount),chord.routine(a root per call),chord.job(intentunder the starter,runandfinishunder the start's carrier),chord.http. Outcomes are attributes; a failure isstatus: "error"with a fixed category (call_failed,method_failed,relay_failed,hold_failed,activation_failed,open_failed,close_failed,load_failed,reload_failed,resolve_failed,routine_failed,job_failed,http_failedfor a status of 500 or more,trace_failed) and the message cut at 500 characters; a cancel (the caller's signal,aborted, an open its attempt abandoned) sets status ok explicitly (an adapter marks a span failed when its callback throws). String attributes a peer chose (an object's label with its params key, an address key, a principal's kind, names from the wire) are cut at 256 characters: stores keep them for days. - Activations: one span per attempt on the host's adapter, linked to the object's first trigger (the span that
created it, or a hold's carrier), a retry's to the attempt before it; it ends
live,failed(a transient failure's backoff achord.retryevent) orended, orparkedwith achord.park { cause }event, and a parked attempt's own steps resume in a new span linked to the parked one (spans stay short); one that fails or ends while still parked records it in a short span linked to the parked one. A getter binding to an object while it activates links its span to the activation (shared work); a route's first hold links to its resolve and back. - Parents across processes are carriers, not spans: a settled span starts no recorded children, so whatever runs later under work that has ended (a job's run and finish, a re-hold) goes through a carrier extracted with the host's adapter. Every job run keeps its start's carrier (a persisted one stores it): a run resumed in a new attempt or process stays in its trace.
ctx.tracegivesfna child of the same kind (a job run's keepsjobIdandstate), and a synchronous throw offnrejects whether or not anything records: recording changes nothingfnsees but its span.- Routines: each call's span links to the span of the commit that changed its source when the watch frame's
context names one (the newest folded frame's; the first call, with the current value, has none), and a call that
threw carries its backoff as a
chord.retryevent. - Cost (§30): a span site checks
untraced(parent)(the parent starts spans as the NOOP context does: no adapter can record) and then starts no span, builds no attribute object and injects no carrier; with the NOOP adapter a call over a link is within noise of the code before slice 9 (benchmarks.md). A recording host's call costs its spans (OpenSpan, a span kept open until its work's promise settles). - Events and metrics:
chord.commit { documents, ops, duration }on the span of the context a transaction ran under (a method, a routine, a job phase, an HTTP request), only for a commit that changed something; the host-wide metrics go to the host's adapter (host.metrics), not to a context:chord.link.bytes { direction }(every encoded message of the host's links),chord.link.state_messages,chord.commit.duration,chord.calls.in_flight,chord.holds.active,chord.links.open,chord.park.duration,chord.backpressure.wait. The cost counters still count through the context of the work; a host with an adapter gives its links' and root scopes' work, when the app passed a context without a span (BACKGROUND_CONTEXT, as the kit does), a span that records nothing but carries the adapter's metrics (countingOnly), so they reach the adapter in an app too. - Components:
createHttp({ telemetry })makes each routed request achord.httproot (method, route pattern, status; never the path), its scope'sctxunder it; a W3Ctraceparent(with itstracestate) is extracted (as the carrier{ traceparent, tracestate }) only from a request a contributed relay verified, each header at most 256 and 512 characters of printable ASCII (else ignored). A verified relay vouches for the proxy, not the browser behind it: an app's proxy must striptraceparentandtracestatefrom browsers' requests (or set its own), or a browser chooses the trace its requests join.startServer({ telemetry })passes the adapter to its host and HTTP;startWorker({ telemetry })to its host. Each process exports to the store it is given; a shared SQLite file per data directory is the app's choice (example 62). The SQLite store orders spans of one millisecond as their recorder started them (by start time, then id length and id).
30. Cost model
As little compute and memory as possible while staying correct; Delta was built for this (its own primitives are
practically free: diffRevisions on shared subtrees 0.018 ms, applyImmutable per small change 0.004 ms). Measured
before/after per hop: apps/p2/docs/p3-reviews/v2-cost-model.md.
| Hop | Work |
|---|---|
| commit | Delta's prepare (copies and checks only placed values), adopt by pointer swap; storage writes the delta |
| reads in a transaction | the draft overlay, no copy; unchanged = no write |
| subscribers on the same host | the same value and ops by reference |
| derives | compute plus diffRevisions; unchanged parts stay the same objects (mapShared, cookbook) |
| send | one encode per touched hold member per commit at its origin (the member's Delta encoder); while the window is full, per op one path key and one map lookup (leaf index), and one encode per waiting member per freed slot |
| relay, per message | decode the envelope, swap the origin (one shallow copy), encode the envelope; parts copied once into the outgoing buffer, never parsed; per bound/state one FIFO entry; two bytes of origin per message upstream |
| relay, per hold | the address's params checked once (route admission, authorize) |
| receive | the compiled MESSAGE check on untrusted links; arguments validated once at the provider (compiled closed schema), params checked; one ack per bound/state message |
| heartbeat | one timestamp write per received message; one timer per host |
| apply | applyImmutable, structure shared |
| new hold | the value encoded once (unavoidable) |
| memory | one authoritative value per document per host; replicas and derived values share structure; one cached value per adapted member, object instance and version (while a reference of the version holds it) |
Cost guards: Chord reports full-value walks, deep copies, encodes, schema validations and materializations as
ordinary telemetry metrics, always on (no test mode; with the NOOP adapter they cost an empty call where such work
happens). Tests use the in-memory adapter and assert them per hop (e.g. one appended note to a 500 KB document with
10 viewers: no walks, no copies, one encode per hold part at its origin and none at relays, no validations); a vitest bench suite measures the hops.
31. Rejected
replicatedState and its source family as public API; a separate IndexedDB backend; generic JSON rows and
store-maintained index entries; last-write-per-row compaction; recording raw SQL statements or SQLite changesets;
plugins' own databases holding source data; rollback after losing the driver role; JSON-field operands in bulk
updates (until needed); a lazy-lifetime flag; a lifetime option; derive previous. Drain-based handover; version fences without transactions; object restart for state changes; Durable's Session as the
object store; volatile documents; per-group lines; generic call or request ids; optimistic transactions; an outbox for
transient delivery errors; revoking proxies on .value; resources replaced in place (replace, deps, stale
handles); registries created at runtime and entries resolved through mutable indirection (contributions that change
with their contributor, readable contributions and readers looking up the current value at use are fine, R25); generic
registry keys with a conflict flag;
restart_required; detached references counting toward presence; contracts taken from the running app; ceremony
(native/ directories, defineNative, lints); weakening type checking; "magic" input-field matching for delete; a
factory trial; a migrate dry run; principal field patterns and re-stamping for revocation; storeKey; a consumer contract check during staging; reload votes; defaultProjectTrust: "always"; loader variants A and B; a topology layer above the host API; a stream type in
Chord; callbacks as method arguments; opaque members; numeric middleware priorities; host addresses as data naming an
input field; host-name strings in app code; host values (start data and platform bindings are core objects);
consumer-side linger; registry composition as a staging check; a JSONL document storage; tx.get for documents; validating state and documents on commits or reads (migration results are validated once, R8); copying values on same-host calls; op-rewriting projections and a
view algebra; keeping natives across reloads by name; Chord ending the process on a storage failure; an optional
flag on references; cross-relay reset coordination; count limits that refuse calls or holds; relays acking per hop, relay resets
(reset { hold }, reset { holds }), relay recovery and id renumbering at relays (replaced by flow control per
origin); a byte window, credit messages, snapshot admission and debt; reset from a consumer; a round-robin of
relayed calls at the relay; state: false on references (C8); inVariant/expectVariant (C15); per-call deadlines.