StoreStorage (Durable's storage on Chord v2's store, @earendil-works/pi-durable/storage/store) passes Durable's storage conformance suite and runs Durable's harness tests (npm run test:store). The store gets owner names and claims, one id allocator, Durable's Storage semantics for refused steps, history none/latest/rewindable with reads at a point, archive revisions and applyBatch. self.follow follows Durable's synchronous observers as Chord sources. Persisted jobs: records and state in the owner's store, restarts as Durable's tasks, abort stored, deferred state. Chord's ./next export returns for the adapter surface.
19 KiB
Chord v2: the slice plan
Status: approved (owner, 2026-10-22); kept current. design.md states the rules; this file says which slice builds
what, and (section "Carry-over") what a slice deferred to a later one.
Why slices
Building bottom-up made each layer guess the needs of callers that did not exist yet; L2 grew generic machinery (nodes, sinks, deliveries, publications) that nobody could follow, and was deleted. A slice is the thinnest end-to-end path through every layer a feature needs, so every API has a real caller from the first line. Later slices extend earlier code; reworking it when a real need appears is expected, guessing ahead is not.
Per slice (owner, 2026-10-22): a one-page spec written by this session (slice-N.md, including the carry-over
entries it takes), the owner's approval, a build by a fresh Opus 5.5 subagent, reviews of the uncommitted code by this
session and by the reviewers Astra and Fable (v2-review-astra, v2-review-fable), fixes by the builder, the owner's
review of the uncommitted diff, commit. Deferred work goes into the section "Carry-over" below, under the slice
that must do it, in the same change (owner, 2026-10-22; moved here from carry-over.md on 2026-10-24). Every slice that touches a hot path (links, the store, relays) also adds a light benchmark script
(packages/chord/test/next/bench/, run by hand) for timing, memory and wire size of realistic payloads, with results
in benchmarks.md (owner, 2026-10-22). Every slice adds one or more runnable
examples (packages/chord/test/next/examples/NN-topic.ts, numbered across slices, in the format of Durable's
test/examples/), each on one topic: for the owner to run and dig around in, and as references for Chord's users
(owner, 2026-10-22). Implementers and reviewers read
the slice spec, the feature map below and the whole of design.md in full, and check the work against all three; a
slice spec's section citations are for orientation, not a reading limit (owner, 2026-10-22).
What exists
- L1 (
src/next/contracts,src/next/data): contracts, params and identity, argument checks, principal keys, errors.index.tsfor apps,internal.tsfor Chord's own code. - Slices 1, 2, 3a, 3b, 4a, 4b, 5, 6a, 6b (committed; 5
4a5b45c45, 6a and 6b 2026-10-27/28):src/next/documents,state,host,jobs,link,remote,store; examples 01-25; benchmarks inbenchmarks.md. - Chord's shared code (upstream pi):
src/watch.ts(Watch,NativeWatch,WatchHandle,WatchEnd,NativeWatchable), Delta (track,diffRevisions,applyImmutable, encoders,PendingOps,appendText), context, JSON, output, SQLite drivers.
The slices
Order (owner, 2026-10-22: routines and jobs before the store and reload).
| # | Slice | In short |
|---|---|---|
| 1 | One host, local objects | host, facets, local objects, references, in-memory documents, transactions, derives, watches |
| 2 | Failures and natives | self.open, activation failures and recovery, backoff, the drain, timeouts |
| 3a | Remote objects over one link | remote contracts, principals, variants, a memory link, the message format, holds, calls, one message per commit (no relay, no drops) |
| 3b | Surviving drops | re-holds, parking, ref.state, host.hold, ref.retry(), linger |
| 4a | Links and relays | host kinds, placement, routes, two-sided links, relays and principals, IPC and WebSocket, flow control, heartbeat |
| 4b | Authority and housekeeping | revocation, cycles across hosts, host.references, host.activity, size limits, compression |
| 5 | Routines and jobs | self.routines, self.job |
| 6a | The store | the store (backends, tables, queries, writes, commit queue, outbox, documents engine, migrations, export), persisted documents, self.table, keyed watches and retirement |
| 6b | Durable and jobs on the store | persisted jobs, Durable's StoreStorage, the Chord source over Durable's views (self.follow) |
| 7 | Reload and rollout | plugin loading, staging, swaps and restarts, revisions, accepts in use |
| 8 | Registries and host.root |
registries, self.writable, retryOn, a RegistryReader for Durable, host.root (chord(fn, context) and the harness object are p3's base code) |
| 9 | Telemetry | spans, events, metrics across hosts |
The playground (apps/chord-playground) moves to the new code once slice 4 exists. Round-3 cuts (C3-C16) and
carry-over entries are decided in the slice that first needs them.
Feature map
Every feature of design.md, by section, assigned to the slice that builds it. A slice's spec covers every entry
assigned to it; implementers and reviewers check the slice spec, this map and the whole design (owner, 2026-10-22). A feature missing here is a planning bug: add it before the slice that needs it. "Doc" means a documented
pattern, no code. Decided cuts are noted where they apply.
| § | Feature | Slice |
|---|---|---|
| 3 | createHost with telemetry, onError; host without address |
1 |
| 3 | createHost with principal, principalKey (3a), address (4a), revokedBy (4b), the store (6a) |
3a, 4a, 4b, 6a |
| 3 | load([{ name, facet }]) with static facets; start barrier (host-lifetime objects live before load resolves) |
1 (failed plugins reported: 2) |
| 3 | connect, accept, memory link pair, incarnations in the handshake, protocol-version handshake |
3a |
| 3 | host kinds, addresses, route (authorize, resolve), relayed holds and calls, re-resolve after a drop |
4a |
| 3 | IPC and WebSocket links, trusted links, the ack-clocked window per origin (K unacked state messages, pending queues, call window N per origin), relays with flow control per origin (origin, closed, senders FIFO), heartbeat |
4a |
| 3 | timeouts: closeTimeout, call drain, readyTimeout (C13: two host timeouts) |
2 |
| 3 | provider-side linger (C7: kept) |
3b |
| 3 | park deadline (= readyTimeout, C13) |
3b |
| 3 | host.activity (keep-alive counts) |
4b |
| 3 | host.root(fn, …) for code outside objects |
8 |
| 4 | principal type P, links stamp principals, principal keys in identity |
3a |
| 4 | revocation (revokedBy, host.revoke, propagation to children) |
4b |
| 5 | local types: params, members, checked arguments (Q15), results unchecked |
1 |
| 5 | version and fingerprint match at acquisition; reserved member names (then, release, state, retry) |
1 |
| 5 | remote types, variants chosen by the factory ({ variant, members }), identity with principal key, calls checked against the binding's variant |
3a |
| 5 | accepts mappings at runtime (old consumers), contract_mismatch/updating |
7 |
| 6 | lifecycle, one object per identity, lifetime from kind and params (Q-A), factory, activation, ending | 1 |
| 6 | failure causes, backoff per identity, dependency recovery (R18) | 2 |
| 6 | retryOn |
8 (first independently living source: registries) |
| 6 | cycle rejection on one host (Q5) | 1 |
| 6 | cycle rejection across hosts (activation chain in hold headers) | 4b |
| 6 | shared store with per-principal views; worker-wide native owners; natives following a relocation | doc (1, 3, 2) |
| 7 | self.get, host.get, hydrated references, members as watchables, release |
1 |
| 7 | per-reference watches (released with their reference) | 1 |
| 7 | ctx.get (call-scoped references) |
3a |
| 7 | ref.state (tagged), ref.retry(), host.hold, calls parking, failed references |
3b |
| 7 | ref.variant as discriminant, RefIn (C15: no inVariant/expectVariant) |
3a |
| 7 | state: false (C8: cut) |
- |
| 7 | optional dependencies as registries | doc (8) |
| 8 | ctx with telemetry and signal |
1 |
| 8 | ctx.principal, ctx.incarnation (R20), ctx.get (3a); relayed incarnations (Q13, 4a) |
3a, 4a |
| 8 | ctx.trace (C11) |
9 |
| 8 | a job's ctx.jobId, ctx.state |
5 |
| 9 | defineState, singleton and keyed handles, self.state, in-memory documents (P2) |
1 |
| 9 | self.tx, tx.draft, tx.retire, drafts revoked (R12), no existence check (R13), lines per object |
1 |
| 9 | handle.change/handle.retire shorthands (C14: kept) |
1 |
| 9 | initial() checked once per definition (R8, D3) |
1 |
| 9 | persist, checkpointWhen (required for persisted documents), persisted only in local empty-params objects (P3), eviction |
6a |
| 9 | keyed watches (handle.watch(key, ctx)), handle.at(key), retirement as a flag (decision "One retirement mechanism") |
6a |
| 9 | versions, migrate, schema fingerprints on records |
6a (in-memory migrate on reload: 7) |
| 9 | 1, removed in 6a | |
| 9 | rules across owners, reconciling routines | doc (5) |
| 10 | the store: backends, tables, query runner, writes, commit queue, outbox, migrations, export/import | 6a |
| 11 | Watchable, watches (Chord's Watch), the batch (W1), reentrancy |
1 |
| 11 | NativeWatchable, self.writable |
8 |
| 11 | Durable's views as sources (self.follow over Durable's observeXXX) |
6b |
| 12 | self.derive, failures per member, lazy when unwatched, mapShared |
1 |
| 12 | container-width benchmark (R24) | 9 |
| 13 | self.open, close reasons, order, restart patterns (R16) |
2 (restarts on reload: 7) |
| 14 | self.on (W2b), self.onLive, sleep, untilAborted (C4 decided); the handover per object on reload |
5 (handover: 7) |
| 15 | defineJob, self.job, intent/run/finish, state documents, dedupe (Q9), busy, running, blocked finish (C3 decided); persistence, resume, restart limit; accepts in use |
5 (persistence: 6b; accepts and reload: 7) |
| 16 | defineFacet<P>, facet as declarations only (documents and routines removed in 6a) |
1, 6a |
| 17 | registries: defineRegistry, contributions, compose, versions (Q8), use-site guards |
8 |
| 18 | constant(value), start data as a core object (SessionInfo) |
1 (core facet pattern: 4a) |
| 18 | platform bindings as core objects | doc (8) |
| 19 | host.references (presence) |
4b |
| 20 | flows kept by their owner, the steps helper, the Questions service |
doc (5); deferred decisions #9, #11 |
| 21 | loading plugins (loader D), staging, atomic commit, pure swaps, native restarts, rollout, dev loop | 7 |
| 22 | message format, header schema, unknown ids, one message per commit per link, consumers apply ops, plugin errors | 3a |
| 22 | relays, size limits per part, compression at the origin, adapted bindings get diffs | 4a (relays), 4b (limits, compression), 7 |
| 22 | trace carriers (R5) | 9 |
| 23 | HTTP, channels, chunked transfers | after slice 9 (with the first real proof) |
| 24 | Durable integration: chord(fn, context) (Q6), a RegistryReader over composed registries; the harness object and its model and env registries are p3's (base code, owner, 2026-10-27) |
8 (Chord parts), p3 |
| 24 | StoreStorage |
6b |
| 27 | versions table: contracts (L1 done), registries 8, documents 6, jobs 5, link protocol 3a | as listed |
| 29 | telemetry spans, events, metrics, carriers | 9 (cost counters: every slice) |
| 30 | cost guards per hop | every slice |
Size
About 9-10k source lines in total, as estimated for the layers (owner: fine, on the order of Durable).
Carry-over
Obligations a slice deliberately left to a later one, under the slice that must do them, with where they came from.
Rules (owner, 2026-10-22): whoever defers something (this session, a subagent, a reviewer) adds it here in the same
change; a slice's spec includes every entry of its section, and its build and review check them off; a done entry is
deleted (the slice's spec and code are the record). Moved here from carry-over.md (owner, 2026-10-24).
Any slice (protocol and checks)
- Bump
PROTOCOL_VERSIONfor any message or reason change once p3 has shipped (slice 3b addedretry,loadingandfailed'sretryIn; slice 4a parts, the listbound,hello'scalls,ack,ping,pong,closed,originand the hold'saddress,principalandincarnation; all without a bump: nothing is deployed). (Owner, slice 3b.) - The park deadline's per-type override is not built: a parked call waits the reference's
readyTimeout. Build it only if a real case needs it. (Slice 3b spec.) - Failed-check paths are TypeBox's instance paths (ambiguous for keys containing
/or~); documented, not fixed. (L1.) - No CBOR; revisit only if benchmarks show encoding as a bottleneck (slice 3a's and 4a's do not). (L2 decisions.)
- One N on every host: a relay forwards calls at once, so a worker announcing a smaller N than the relay's could see more running calls of one origin than it accepts (a protocol error). Every host announces 256; per-link N, if ever needed, must bound the relay's forwarding too. (Slice 4a rework.)
- The Durable conversation view's size on re-hold is a known cost until pi #11327 (windowed view) lands. (Slice 4.)
map(bytes())is refused at declaration ("unsupported keyword~refine"): TypeBox'sRecordcopies the bytes schema's check as an enumerable key, which the snapshot walk refuses. An L1 bug found in slice 3a, not fixed yet.
Slice 7: reload and rollout
-
A native restart drains first (owner, 2026-10-26): park new calls, await running calls (bounded by
closeTimeout), then close the natives and restart; a call outliving the drain is cut off by the seal fence. Running job runs on a native restart: decide with the runs now owned by the object. -
From slice 6a's second spec review (decision "Second spec review: the majority"): remote holds and remote objects leaving
live(Hold.movedfor bound and binding holds,RemoteObject.failedfor a live object; not reachable in 6a, P3); old table handles' version fence (old code issuing SQL for a dropped column); the routine handover fenced per routine invocation; holds unsubscribed at seal and re-bound by member name; keyed members' versions mapped per key; in-memorymigratewired to reload (6a builds the slot version check and the migrate);rebuild(key and column-type changes) if a case needs it. -
From slice 6b: a persisted run of another job version or fingerprint stays
blocked { version }with its record as stored (resolve it on reload); blocked persisted runs are retried on reload; runs of jobs the code no longer declares (below). -
A run whose job the new code no longer declares (slice 6a builder, 2026-10-27): 6a keeps it in its phase, waiting, not invoked (unreachable without reload); slice 7 decides
blockedwith a reason (design §15'sblocked { version }family). -
A job's new version on a live object (slice 6a build review, 2026-10-27): 6a keeps the first declaration's job definition in the object's records and refuses a later declaration with another version, so a host-lifetime object (
local, empty params) stays broken until the host restarts; slice 7 maps the records to the new version (or blocks the old runs) on reload. -
Routines on reload (from slice 5): the handover per object, bounded by
closeTimeout; calls do not wait for it. -
Jobs on reload (from slice 5):
acceptsin use (args and result mapped forwards once per run;blocked { version }without an adapter or for a newer version); when an older run's state document may migrate while its old code still writes it, and an older run keeping its old handles valid until it settles; a native restart aborts running runs (aborted, reasonreload) andfinishruns on the new code; afinishin flight across a reload; (run records and state documents belong to the object: built in 6a); blocked runs retried on reload; a job declaration removed while runs exist; a job fingerprint over args, result and state (a schema change without a version bump); anonroutine whose source failed and recovers after a provider reload. -
Instance ids on restart (from slice 4b): a retry keeps an object's instance id; a reload that restarts an object must give the new instance a new id, so an old chain never matches it.
-
host.referencesand removed types (from slice 4b): a type's presence list lives from its firsthost.references(Type)for the host's life; a reload that removes the type drops it. -
Holds across reloads (from slice 3a): a hold's indexes belong to its bound version. A reload that still accepts that version keeps the hold: a pure object re-subscribes the hold through the version's mappings and sends
[["r", value]]for each member whose value changed; a restarted object sends a fresh snapshot for the same hold (a secondboundon a bound hold, which 4a's consumer applies as a re-binding). A served hold whose object leaveslivewhile itsboundwaits for a slot goes back to pending (cut from 4a, where nothing leaveslive; owner, 2026-10-24: slice 7 builds and tests it inHold.movedandProviderSide.unbind). A reload that drops the version ends its holds withgone(contract_mismatch) before the new code serves anything. A consumer whose own code moves to a new version re-holds (new hold id) and releases the old. -
Old-version calls (L1):
checkArgsreturns{ kind: "method"; args; result? }or{ kind: "call"; args; call }; invoke the handler or the provider'scall, and map the result back withresultfor an old-version caller. -
Derives on reload: derives have no names (owner, 2026-10-22); exposed derives are matched by their member name, internal ones are rebuilt; add matching only if a real need appears. (W1.)
-
Retrying broken failures: reload retries a broken object as
ref.retry()does (ChordObject.retry: the backoff skipped, an attempt now; holders are toldloading). (Slices 2, 3b.)
Slice 8: registries and host.root
-
Durable's views from other objects (from slice 6b): the harness carried in a registry entry, so an object other than the one that opened Durable can
self.followits views. -
Specified in
slice-8.md(decided 2026-10-27):host.root(D3),retryOn(D2), registries recomposing in the owner's flush (W4);host.activityon the facet's host dropped (nothing needs it).
Slice 9: telemetry
-
Spans for routine calls and job runs (from slice 5).
-
The use of a persisted run's stored start trace carrier (
trace, stored since slice 6b). -
Round-3 cut to decide: C11 (
ctx.trace).
When v2 goes upstream
mapSharedlives insrc/next/state/derive.tsand moves into Delta. (L2 decisions.)withLinkedAbortSignallives insrc/next/host/ctx.tsand moves next towithAbortSignalinsrc/context(pi first): a call's context linked to long-lived abort signals until the call settles, instead ofAbortSignal.any, which keeps cleanup entries per call while its sources live before Node 24.16.0 (nodejs/node#62367). (Slice 4a.)- The
./nextpackage export returns when the new code has an entry point (removed with the frozen-kernel move). - A
./next/nodeexport fornodeCompression(src/next-node/), with./next. (Slice 4b.)
The sync layer (not a slice; when Radius sync is built)
- Upload, acknowledgement and
store.outbox.trim, epochs, pull-back, the split (S5, S7); a store opened with an outbox (decision "The outbox is optional, internal and versioned") starts its sync from an archive. - Snapshot reads for large exports on a second SQLite connection (S4), if exports get large.
Outside the slices
- The playground's runtime tests fail on a stale
node_modulescopy of chord 1.3.0; relink withnpm install --ignore-scriptswhen the playground is used again. (Frozen-kernel move.)