p2 (Pi Mobile on Chord v2) moves from pi-private into pi as one commit. Root scripts that loop all workspaces use --if-present; npm test, ./test.sh and the release script cover packages/* only (p2: cd apps/p2 && npm test); sync-versions also updates apps' workspace pins.
103 KiB
Chord v2 and its plugins: a guided walkthrough
A step-by-step introduction to how plugins work on Chord v2 (packages/chord/src, used by the playground and
p3), for someone who knows nothing about either. It teaches the design: the target design
packages/chord/docs/design.md together with the decision log packages/chord/docs/decisions.md (the log is
newer and wins where they differ). It does not describe the state of the code. API names are the design's working
names; where the kernel already has a name (packages/chord/src), the examples use it.
Points the design has not settled are marked Open and list their options. Nothing in an Open block is decided.
For the agent guiding the user
You walk a user through this document. Follow these rules:
- One concept at a time. Explain it in your own words, in the user's language, with the document's example. Keep it concrete; don't paste whole sections. Explain in full sentences in this document's tone, not as terse notes; define every word the user may not know when it first appears (e.g. IPC, relay).
- Then ask the check questions of that concept and wait for the answers.
- Judge the answers against the model answers (they are for you). If an answer is wrong or half right, say precisely what is missing, explain that part differently, and ask again. Don't move on while a concept is unclear.
- Counter-questions come first. Answer a user's question completely before anything else. If the answer lies in a later concept, give a short answer and say where it continues.
- Move on only when the user says so.
- No terms before they are introduced. If you need one, introduce it first.
- Teach the design, not the implementation status. For questions beyond this document, answer from, in this
order:
redesign-decisions.md(newest decisions, wins on conflicts),packages/chord/docs/design.md(the target design),packages/chord/docs/kernel.mdandstep-*.md(names the kernel already uses),plugin-loading.md(loading, project plugins),questions.md(the Questions service),research/chord-on-durable-objects.md.target-design.mdandp3-walkthrough.mdare superseded. - Open is open. Present every Open point with its options; never present one option, or your own idea, as decided. If a question has no answer in the sources, say so plainly.
- If you find a gap or a contradiction in the sources while teaching, tell the user and note it for whoever maintains the design.
- Keep a short list of what the user found hard; come back to it at the end.
Start by telling the user the note below, then begin with "The setting". The setting has no check questions: ask whether the user has questions, then move on when they say so.
For the user
Keep asking until you really understand a thing. There are no silly questions. The concepts build on each other; a gap early on makes everything after it harder. If an example doesn't click, ask for another one. If a word is unclear, ask what it means.
The setting and the running example
Chord is the runtime under an app: it lets plugins define objects that live in one place and lets every part of the app use them, also across process boundaries.
An app on Chord runs as several participants. Chord calls each participant a host. In a Node app (the playground, p3) there are three roles:
browser browser TUI clients: any number, not addressable
└─────────┼───────┘
server exactly one
┌─────────┼─────────┐
worker S1 worker S2 worker S3 one per session, started on demand
- The server (exactly one) serves the UI, holds data shared by all sessions and starts the workers.
- A worker (one per session) runs the agent and works on that session's files. The server starts it as a child process when the session is first needed.
- Clients are browser tabs and TUIs (terminal UIs). They connect by themselves; nobody can send a request to a
client, so clients are not addressable. A client's own objects are therefore only ones nobody outside it needs
(concept 6 names them:
localtypes).
How they connect. Each connection between two hosts is a link:
- Clients connect only to the server, over a WebSocket.
- The server talks to each worker over IPC (inter-process communication: the pipe between a parent process and the child it started).
- Clients never talk to a worker directly; the server relays (passes requests on). A browser call to something in session S1 travels browser → server → worker S1, and the answer comes back the same way. Workers reach each other through the server too.
- When a client connects, the server checks who it is (for example its cookie) and attaches that to the whole link. Concept 3 builds on this.
The goal that shapes everything: reload. You change a plugin's code and the new code takes over inside the running app. Two words matter:
- a reload swaps a plugin's code while the hosts keep running: data, open connections and running work survive where they can;
- a restart ends a process and starts it again: everything it held in memory is gone.
Reloads are always explicit (a button or a command), never on save. Changes to Chord itself or to the app's runtime are base code: they restart the hosts. Much of the design exists to make reloads safe; concept 23 shows the reload step by step.
Base code and plugins. Whether something is "core" or a plugin is decided by where its code lives:
- base code (the app's
src/): imported directly by the start code under a fixed name such as@app/core; always there, not switchable; a change restarts the hosts. Core is what the app always needs and everything builds on (the agent harness, the session list, access, the session location). - a plugin (its own directory): found by listing, loaded by the plugin loader, switchable; a change reloads it. Optional features are plugins.
Both use exactly the same building blocks; base code is not special to Chord.
A plugin is a folder with a package.json. Its name (globally unique and stable, like an npm package name, e.g.
@playground/notes) is the plugin's identity: Chord identifies the plugin's facets by it (their stored documents,
reloads, error messages), never by a directory name or load order. Base code's core facet gets a fixed name the same
way (@app/core). Each other file is meant for certain hosts; the part of a plugin for one kind of host is a
facet:
| File | Runs on | Holds |
|---|---|---|
package.json |
(read by the loader) | the plugin's name |
contract.ts |
every host that imports it | descriptions only: names, shapes, method signatures |
server.ts |
the server | the server facet |
worker.ts |
every session worker | the worker facet |
web.tsx |
the browser | the web facet (UI) |
tui.ts |
the TUI | the TUI facet |
The running example: a notes plugin. The whole walkthrough builds this one plugin, piece by piece:
- notes per session, kept across restarts;
- a web panel shows the notes; the owner and guests with write access can add and remove notes; read-only guests can only read, and don't see who wrote a note;
- a text file dropped into the session's
inbox/folder becomes a note; - notes older than 30 days are deleted every hour;
- importing a large file runs in the background and can be cancelled;
GET /s/:session/notes.txtdownloads all notes;- the panel shows how many people are looking at the notes.
Concept 1: Object types and objects
The problem. Several places need "the notes of session S1": the owner's tab, a guest's tab, the download route. If each built its own copy, the copies would drift apart, and nobody would know when to clean them up.
The idea. There is one object for "the notes of S1". Everyone asks Chord for it by its address and gets that same object.
- An object type describes what such an object is: its name, what its address looks like (the input), where it lives, what you can do with it (its members: data you can read and methods you can call) and a version. It contains no code that runs; every host can share it. Another word for it is contract.
- A factory is the code that builds an object of that type, on the host where it lives.
- An object is one concrete instance: "Notes for
{ session: "S1" }".
The three pieces live in three files of the notes plugin:
// contract.ts: the object type
export const Notes = remote("notes", {
version: 1,
host: /* where it lives: concept 2 */,
input: Type.Object({ session: Type.String() }), // the address: { session: "S1" } (a TypeBox schema)
variants: { /* the members: concept 3 */ },
});
// worker.ts: the factory, on every session worker
export default defineFacet((host, self) => {
host.serve(Notes, (input, principal, self) => ({ /* members */ }));
});
// web.tsx: a caller in the browser
const notes = await host.get(Notes, { session: "S1" }); // browser → server → worker S1
Read host.serve as "this host builds Notes objects like this". principal comes in concept 3, self in concept 7.
- Who builds it: the host where the object lives runs the factory. That is why the
Notesfactory is inworker.ts. - One object per address: the first
getfor{ session: "S1" }runs the factory; every latergetfor the same address gets the same object.{ session: "S2" }is another object, on another worker. (Concept 3 refines this.) - Methods return data, never objects. If a method wants to point you to an object, it returns its address (
{ session: "S1" }) and yougetit yourself.
Check questions
- Two tabs ask for the notes of S1. How often does the factory run?
- What is the difference between
Notesand "Notes for S1"? - Why does the factory live in
worker.ts, not inserver.ts?
Model answers: (1) Once: same address, same object (concept 3 refines this when the callers are different people).
(2) Notes is the description (the type); "Notes for S1" is one instance (an object). (3) Notes of a session live on
that session's worker, and the host where an object lives runs its factory.
Concept 2: Where objects live: host kinds and placement
The problem. A caller asks for "Notes for S1". Chord must find the host that holds it, without the caller knowing host names, and the worker must be sure the object really belongs to it.
The idea. The app describes its addressable hosts as typed host kinds; one concrete host of a kind is an
address. A type's host field is a placement function: input in, address out.
// the app's runtime (base code, not a plugin)
export const Server = hostKind("server"); // one host
export const Session = hostKind("session", Type.Object({ id: Type.String() })); // one host per id
Session({ id: "S1" }) // an address: the worker of session S1
Server() // the address of the server
// notes/contract.ts
export const Notes = remote("notes", {
version: 1,
host: ({ session }) => Session({ id: session }), // typed: `session` must exist in the input
input: Type.Object({ session: Type.String() }),
variants: { … },
});
A call, step by step:
browser get(Notes, { session: "S1" })
runs the placement function → Session({ id: "S1" })
has only a link to the server → sends the request there, with the address
server the address is not mine; the app told me how to reach Session hosts (concept 4)
→ link to worker S1 (started if needed), forward
worker S1 runs the placement function too → Session({ id: "S1" }) = me → runs the factory
- The caller computes, the provider checks. The caller runs the function on the normalized input and sends the
result. The provider runs it too and refuses an object that is not its own with the error
invalid/placement; a worker for S2 never builds "Notes for S1". (During a rollout, concept 24, such a refusal is handled like a mismatch: a provider on another revision makes the call park; on the same revision it fails.) - No host-name strings in app code. Addresses are typed values; Chord serializes them internally.
- Placement is not part of the type's fingerprint (the hash Chord uses to compare two versions of a type, concept 26). If the rule changes between versions, the provider's refusal surfaces it.
- A function is type-checked: a
Sessionplacement on a type without asessionfield does not compile.
Check questions
- A tab asks for
Notes { session: "S2" }. Which host builds it, and who computed that? - Why does the worker run the placement function again, if the caller already did?
- What does app code never write when it names a host?
Model answers: (1) The worker Session({ id: "S2" }); the caller computed the address with the type's placement
function, the server routed by it. (2) To refuse objects that are not its own (a wrong or outdated rule at the caller
must not create an object in the wrong place). (3) A host-name string; it calls the host kind (Session({ id })).
Concept 3: Who is calling: principals and variants
The problem. The owner may add and remove notes; a read-only guest may only read and should not even see who wrote what. The object must know who calls, and the caller must not be able to lie about it.
The idea. Every request carries a principal: who calls. The app defines its principal type; every variant has
a kind. p3's principals:
kind |
Who |
|---|---|
owner |
the person running the app (access: "local" | "relay" | "device"; a paired device has its device id) |
guest |
someone invited into one session (session, mode, its grant and the grant's revision) |
server |
the server calling a worker |
worker |
a session worker calling the server (session) |
- Links stamp principals. A principal is never taken from a request's data: the server authenticates a client when it connects and stamps that principal on the whole link; the server tells Chord who each worker is when it connects it (concept 4). A caller cannot claim to be someone else.
Where principals come from. Chord never sees cookies, share links or grants; that is app code (in p3 the sharing
feature, p3-functions/sessions-access.md). Chord only receives the finished principal and stamps it on the link:
owner: "copy share link" for S1, read/write
sharing (app code on the server) creates a link token for (S1, write)
guest opens the link → join page → enters a name
sharing stores a guest record (grant g1) and sets a guest cookie
guest's tab connects (WebSocket)
entry point (app code): authenticate(req): cookie → guest record g1
→ { kind: "guest", session: "S1", mode: "write", grant: "g1", revision: 1 }
server.accept(link, { principal }) ← Chord: stamped on the whole link
A grant is the app's stored record "this person may enter S1 with this mode"; authenticate checks it on every new
connection.
What principals do in Chord (all of them pick something):
- identity: they decide which object a caller gets (next point);
- variant: the factory receives the principal and picks what the caller may see (below);
- admission:
route.authorize(principal, address)decides whether a caller may reach a worker at all (concept 4); - inside methods
ctx.principalsays who calls (concept 12).
The caller is part of the object's identity. One object exists per (type, input, principal key). The
principal key is a string Chord computes from a principal: same key, same object; different key, different
object. By default it is the principal as canonical JSON (fixed field order). The app can supply its own function
(the principalKey option when it creates its hosts, concept 4) and so decide which fields split objects. An
illustration (the design does not fix p3's function):
principalKey: (p) =>
p.kind === "owner" ? `owner:${p.access}` :
p.kind === "guest" ? `guest:${p.grant}:r${p.revision}` :
p.kind
| Caller | Principal key | Notes {S1} object |
|---|---|---|
| owner, tab A, local | owner:local |
1 |
| owner, tab B, local | owner:local |
1 (shared) |
| owner via relay | owner:relay |
2 |
| guest g1, revision 1 | guest:g1:r1 |
3 |
| guest g1 after a grant change, revision 2 | guest:g1:r2 |
4 (new) |
The design wants the key to include how the client is connected (access paths never share objects) and the grant's
revision (after a change from write to read the guest gets a new object, never the old editor one).
Variants: the type lists named sets of members; the factory gets the principal and returns the variant that principal may see.
// contract.ts
variants: {
editor: { state: { notes: NotesSchema },
methods: { add: method(AddArgs, NoteId), remove: method(RemoveArgs) } },
reader: { state: { notes: PublicNotesSchema } }, // no authors, no methods
},
// worker.ts
host.serve(Notes, ({ session }, principal, self) => {
if (canWrite(principal, session)) return { variant: "editor", notes: …, add: …, remove: … };
return { variant: "reader", notes: … }; // filled in, concept 10
});
- A reader has no
addat all. Chord refuses calls to members that are not in the caller's variant, soaddneeds no check of its own. - The caller sees which variant it got and narrows before using variant members; the type checker enforces it:
const notes = await host.get(Notes, { session: "S1" });
notes.add({ text: "hi" }); // type error: `add` exists only in "editor"
if (inVariant(notes, "editor")) await notes.add({ text: "hi" });
expectVariant(ref, "editor") asserts instead (throws not_in_variant).
The types know nothing about who calls. Owner and guest run the same web.tsx; the type checker only forces every
caller to narrow before using variant members. Which variant a caller has is known only at runtime (inVariant is
false for the guest). The protection is Chord refusing the call on the provider; a malicious client that ignores the
types gets the same error.
Check questions
- Why can't a guest become the owner by sending
{ kind: "owner" }in a request? - The owner has two tabs open; two guests are in S1. How many
Notesobjects exist? - A read-only guest calls
addanyway. What happens, and where was that decided?
Model answers: (1) The link stamps the principal from the server's authentication; request data never sets it. (2)
Three: one for the owner (both tabs share it), one per guest. (3) Chord refuses it at runtime and the caller gets
not_in_variant: the guest got the reader variant, which has no add; the factory decided when it picked the
variant. (Not "it doesn't compile": the guest's code is the same as the owner's; types only force narrowing.)
Concept 4: Starting hosts: the low-level host API
The problem. Someone must create the hosts, connect them, authenticate clients and start workers on demand. These things differ between deployments (a loopback WebSocket, a relay, a test in one process), so Chord must not hard-code them.
The idea. Hosts are created by the app's own startup code (base code). Chord offers a small API; starting processes, authentication, init data and keep-alive are app code.
// app/server.ts
const server = createHost<P>({
address: Server(), principal: { kind: "server" },
storage: serverStorage, // a DocumentStorage (concept 13); in memory if omitted
});
await server.load([ // facets with their plugin names
{ name: "@app/core", facet: coreServerFacet(config) }, // base code's core facet
{ name: "@playground/notes", facet: notesServer }, // a plugin
]);
wss.on("connection", (ws, req) => { // an entry point: app code
const principal = authenticate(req); // cookie → owner or guest
if (!principal) return ws.close(4401);
server.accept(webSocketLink(ws), { principal }); // requests on this link act as `principal`
});
server.route(Session, { // how to reach hosts of kind Session
authorize: (p, { id }) => p.kind === "owner" || p.kind === "server" || (p.kind === "guest" && p.session === id),
resolve: async ({ id }, { signal }) => {
const principal: P = { kind: "worker", session: id };
const child = fork("worker.ts", [], { serialization: "advanced", signal });
child.send({ id, principal, init: await initFor(id, signal) }); // the app's own start message
return { link: ipcLink(child), principal }; // Chord stamps `principal` on that link
},
});
// app/worker.ts
const { id, principal, init } = await startMessage(process);
const worker = createHost<P>({ address: Session({ id }), principal, storage: storageFor(id) });
await worker.load([
{ name: "@app/core", facet: coreWorkerFacet(init) }, // serves the start data (concept 5)
{ name: "@playground/notes", facet: notesWorker },
]);
worker.connect(ipcLink(process), { principal: { kind: "server" } }); // the parent, and who it is
// a browser tab
const client = createHost<P>(); // no address: a client
await client.load([{ name: "@playground/notes", facet: notesWeb }]);
client.connect(webSocketLink(new WebSocket("/chord")), { principal: { kind: "server" } });
const notes = await client.get(Notes, { session: "S1" });
| Call | Meaning |
|---|---|
createHost<P>({ address?, principal?, storage?, ... }) |
with an address: addressable, its own code acts as principal; without: a client. P is the app's principal type; storage holds the host's persisted documents (concept 13; in memory by default) |
load([{ name, facet }]) |
the facets for this host, already imported, each with its plugin's package.json name (a loader reads it; a test or static setup passes it; base code passes its core facet the same way); duplicate or invalid names are refused |
connect(link, { principal }) |
the link to the parent (at most one; replaced after a reconnect); principal says who the parent is |
accept(link, { principal }) |
a link from a client; every request on it acts as principal |
route(Kind, { authorize, resolve }) |
how to reach hosts of a kind: authorize(principal, address) runs before anything starts; resolve(address, { signal }) returns { link, principal } |
The first get for S1, step by step:
browser Notes {S1} → Session({ id: "S1" }) → over its link to the server
server not me; route(Session): authorize(owner, { id: "S1" }) ok
no link to S1 yet → resolve({ id: "S1" }): fork, start message, IPC link
stamp { kind: "worker", session: "S1" } on that link → forward
worker reads its start message, createHost(Session({ id: "S1" })), load, connect
→ serves the request: runs the Notes factory as the owner asked
resolveruns once per address while its link lives, and again after the link drops (the worker crashed or exited). While references to that worker still exist, Chord re-resolves by itself (with the per-address backoff of concept 7), so a tab that only watches recovers without anyone clicking. A worker start that already produced a link finishes even if the request that triggered it went away.- Incarnations: a host is a logical identity (its address) plus an incarnation, a fresh id Chord mints on every start and sends in the link handshake. The other side sees a restart as a new incarnation of the same identity, and references to it re-hold (concept 8).
- Entry points vary: a loopback or tunnel WebSocket server, the Radius relay delivering channels, a Durable Object
edge, a memory link in a test. So Chord has no
listen: each entry point authenticates and callsaccept. - Tests run everything in one process:
resolvecreates the worker host in memory and returns one end ofmemoryLinkPair(). - Links only deliver frames, in order, both ways. Heartbeats and recovery are the adapter's policy: IPC needs none; a WebSocket through a relay needs a heartbeat (TCP does not notice a sleeping laptop).
host.root(fn)gives code outside objects (a CLI command, a Durable hook) a call scope (concept 12).- Worker keep-alive: the worker counts, the app decides (as in p2). Core code on the worker reports viewers
(concept 21), active calls and running jobs (Chord's counters), Durable tasks and objects kept warm by
linger(concept 7); the server's supervisor (app code) stops the worker when all are zero for the idle time (p2: 5 minutes). Chord provides the counts, no idle logic. - Other options of
createHost:principalKey(concept 3; the same function on every host),revokedBy(concept 11),telemetry(concept 29),dev(development checks),onError(background errors, including a storage failure: Chord never ends the process, the app's start code decides; concept 13), and timeouts: host defaults with per-thing overrides (closing a native value 30 s, activation and awaitedget60 s, parked calls 60 s, routine handover 10 s, a rollout install 60 s).
Check questions
- Who decides that the worker for S1 is a separate process, and where is that written?
- The worker for S1 crashes. A tab calls
Notes {S1}again. What happens? - Why does Chord have no
listen(port)?
Model answers: (1) The app: its route(Session, { resolve }) forks the process; Chord only calls resolve when it
needs a link. (2) The link dropped, so the next request calls resolve again: a new worker starts, a new incarnation;
the tab's reference re-holds against it. (3) Entry points differ (loopback, tunnel, Radius, DO edge, memory); each
authenticates and calls accept.
Concept 5: Start data and core objects
The problem. A worker's code needs things that exist before any plugin: the session's id, its init data, the data directory. The start code got them in the server's start message (concept 4). How do plugins get them?
The idea. There is no special mechanism. Start data is an ordinary object: a local type (usable only on its own
host, concept 6) with lifetime: "host" (it lives as long as the host, concept 7) and constant state, served by base
code's core facet from the start message:
// base code (the app's src/): a local type and the core facet
export const SessionInfo = local("app.session-info", {
version: 1, input: Type.Object({}), members: { init: InitSchema },
});
export const coreWorkerFacet = (init: Init) => defineFacet((host) => {
host.serve(SessionInfo, () => ({ init: constant(init) }), { lifetime: "host" });
});
// app/worker.ts (concept 4)
await worker.load([{ name: "@app/core", facet: coreWorkerFacet(init) }, …plugins]);
// any plugin
const info = self.get(SessionInfo, {}); // a reference, declared in the factory
// later (in an opener, derive, routine or method): info.init.value.id, info.init.value.dataDir
- Read after activation, never in the factory. A factory only declares (concept 7); it gets a reference to
SessionInfoand readsinfo.init.valuelater: in the opener of a native value, a derive, a routine or a method. Example: the inbox watcher (concept 14) builds its path fromdataDirand the session id in its opener. - Paths are derived by the plugin from generic start data (the data directory), never provided per plugin.
- Platform bindings the host owns (on a Durable Object its
stateandenv) are core objects too: theirself.open(concept 14) returns the binding, theirclosedoes nothing. - A long-lived native value from the host (the Durable harness, concept 28) is a
lifetime: "host"object in the core facet. - Why no special "host value" mechanism: core objects already have everything (a type, a version, references), and base code and plugins use the same primitives.
Check questions
- How does a plugin on the worker learn its session id?
- Why may a factory not read
info.init.valuedirectly?
Model answers: (1) It declares self.get(SessionInfo, {}) and reads info.init.value.id after activation (in an
opener, derive, routine or method); base code's core facet serves SessionInfo from the start message. (2) A factory
only declares; reading a reference's value happens after activation, when the reference is live and hydrated.
Concept 6: remote and local types
The problem. A browser must reach Notes across links, with checked arguments and per-caller views. But two
objects on the same worker (the notes view and the store behind it) shouldn't pay for checks and principals.
The idea. Two kinds of types:
export const Notes = remote("notes", { version, host, input, variants });
const NoteStore = local("notes.store", { version, input, members });
remote |
local |
|
|---|---|---|
| reachable from | any host, across links | its own host only |
| identity | (type, input, principal key) | (type, input) |
| principals, variants | yes | no |
| factory | (input, principal, self) |
(input, self) |
| arguments | validated once at the provider when the caller is not a host principal; passed by reference on the same host | neither validated nor copied |
| results | not validated in production; passed by reference on the same host | not validated, not copied |
| members | state and methods: data (JSON plus bytes) | plain-data state and methods |
ctx.principal in methods |
the caller | none |
- Validation by trust. Arguments (and the input of a new reference) are checked against the provider's TypeBox schema whenever the call originates from someone who is not a host principal: a guest, the owner, any client, also when the server relays it over IPC. Calls from server and worker code itself are not validated in production. Never with a schema a peer sent. The handler receives exactly its declared shape (undeclared fields are stripped). Schemas are compiled once, at first use.
- Values are immutable by convention, never copied. On the same host a value is passed by reference, for both kinds; nobody mutates a value it got. Development mode freezes values to catch mistakes. (Copies would cost time and memory on every call; the convention is the same as in Durable.)
localis the trusted path: no principals, no checks. Attribution ("who wrote this note") is passed as data, because a local object has no principal.- Variants stay compatible: a member name has the same kind (state or method) in every variant that has it, and a method the same signature; only state schemas may differ per variant (the reader's redacted notes).
- Clients serve only
localtypes: nobody can address a client, so nothing remote can live there. A client's local calls have no principal; what a client calls on the server is stamped with the client's authenticated principal (concept 3). - Members are data only, on both kinds: no callbacks as arguments, no native values. Concept 19 explains why and what to use instead.
Check questions
- Why does
NoteStore'saddtake{ text, by }whileNotes'addtakes only{ text }? - Owner and guest both use
NoteStore {S1}. How manyNoteStoreobjects exist?
Model answers: (1) A local object has no principal; the per-principal Notes view knows who calls and passes the
author as data. (2) One: a local identity is (type, input), with no principal.
Concept 7: Objects and their lifecycle
The problem. An object needs references, data, maybe an open file watcher, before it can serve calls. If the factory did all that itself (awaiting, opening), a reload would rerun it and open everything twice, and a failure halfway would leave half-open things behind.
The idea: factories declare, Chord activates. The factory is synchronous and only declares what the object
needs, through self; Chord performs it:
host.serve(NoteStore, ({ session }, self) => {
const list = self.state(NoteList); // a document: concept 13
const count = self.derive("count", [list], ([l]) => l.items.length); // concept 9
const catalog = self.get(Catalog, {}); // a reference: concept 8
self.routines.every("cleanup", HOUR, (ctx) => dropOld(ctx)); // concept 15
const imports = self.job(ImportJob, { run, finish }); // concept 17
return { notes: list, count, add: …, remove: … };
});
The lifecycle:
(first get) → activating → live → ending → ended
└→ failed (backoff) → activating …
-
Activating: Chord binds the declared references (and waits until they are live), loads documents, opens native values (concept 14), computes derives, starts routines. Calls wait behind the object's gate until it is live.
-
Waiting for dependencies: an object whose references are not live yet stays
loadingand goes live as soon as they are (at most 60 s, then it fails). So when a dependency recovers, its dependents recover at once. -
Ending: routines stop, the object's calls and jobs are aborted as their rules say, native values close in reverse order, references are released.
-
Lifetime:
"referenced"(default) lives while someone holds a reference, plus an optionallingerafter the last one (set by the type's provider: keeps a just-closed object warm);"host"lives as long as the host (only for types without a required input, checked by the type system). A running job counts as a reference (concept 17). -
Failed: any broken part fails the whole object (a half-working object could break rules that rely on its documents changing together). Chord tags the cause by where it failed:
cause: { kind: "transient"; error } // an open threw, a dependency was unreachable: automatic backoff | { kind: "broken"; error } // factory, derive, document loading or migrate threw: no automatic retryTransient causes get a backoff per address: 1 s, doubling up to 60 s; concurrent gets share one attempt; success resets it. Broken ones (a code bug, invalid data) would fail the same way again, so they are retried only by a trigger. Triggers, for both: a plugin reload,
ref.retry()(a Retry button), and wake signals the factory declares withself.retryOn(...readables)(sources that live independently of the attempt, such as the facet's documents). Only an opener may declare its own failurebroken(throw broken(error), e.g. invalid configuration). -
Handles stay valid for the object's whole life: nothing is re-fetched or "read at use".
Check questions
- Why may a factory not
awaitanything? NoteStore {S1}fails to activate at 10:00:00. A tab asks again at 10:00:00.5. What happens?
Model answers: (1) It only declares; async work happens in activation, calls, routines and jobs. A reload reruns the
factory, so side effects there would repeat, and a half-finished factory would leave things behind. (2) It depends on
the cause. Transient (an open threw): the get is inside the 1 s backoff, it gets the stored failure (failed with
retryAt), and the next attempt runs at 10:00:01 unless a trigger comes first. Broken (the factory threw): no
automatic retry at all; only a reload, retry() or a retryOn signal starts a new attempt.
Concept 8: References
The problem. A consumer uses an object that may be on another host, may not exist yet, and may go away (link drop, restart, failure). It needs one handle that keeps the object alive and tells it what is going on.
The idea. get returns a reference. While held, it keeps its object alive. Awaited, it is live and
hydrated: every state member of its variant has a value. Three ways to get one, differing in when it is released:
| Where | API | Released |
|---|---|---|
| in a factory | self.get(Type, input) |
when the object ends |
| in a call | await ctx.get(Type, input) |
when the call ends |
| outside (UI, tests) | await host.get(Type, input) |
ref.release() |
{ state: false } binds for methods only (no values are sent).
State members are readables. A readable is a value plus its ordered changes: .value is the current value,
.subscribe(fn) is called on every change. A reference's state members (notes.notes) are readables, on any host.
ref.state says what is going on, as a tagged value; ref.onState(fn) gets each new one:
| { kind: "loading" } // first hold, reconnecting, restarting
| { kind: "live" }
| { kind: "failed"; cause: FailureCause; retryAt: number | undefined } // undefined: broken, no automatic retry
| { kind: "released" }
What calls and values do:
| Situation | Calls | Values |
|---|---|---|
| link drop | park until reconnect | readable, loading |
| object loading or restarting | park until live | readable, loading |
| rollout: provider ahead or installing (concept 24) | park until installed | readable |
object failed |
reject at once with the cause | readable, failed with retryAt |
-
Parked calls share one default deadline (60 s, overridable per reference:
get(…, { parkTimeout })): after it they failunavailable. Otherwise a call ends only through its caller's signal; there is no per-call deadline. -
A failed reference stays held: for a transient cause Chord retries per the backoff;
ref.retry()tries at once; success goesloading, thenlive. -
Calls that were running on an instance that ends fail with an error and are not resent.
-
Errors look like disconnects. No request ids, no special protocol: a consumer handles a failed call as it would a dropped link (show the error, the user retries).
-
Authority checks live in the per-principal view's methods (and in
route.authorizebefore a host starts). There is no async check before an object goes live. -
No optional references. If the other side's plugin may be missing (radius uses ports' forwards, but ports is optional), the dependency is turned around: the dependent owns a registry and the other plugin contributes to it (ports contributes its forwards to
radius.forwards; without ports the table is empty and radius works). Registries come in concept 19.gethas nooptionalflag.
Check questions
- The worker restarts while a tab shows the notes. What does the tab see, and what happens to an
addclicked meanwhile? - When do you use
self.getand whenctx.get?
Model answers: (1) The values stay readable with ref.state loading; the add parks; when the new incarnation is
up, the reference re-holds, gets fresh values, goes live, and the parked call runs. (A call that was already running
on the old worker fails.) (2) self.get in a factory for something the object needs its whole life; ctx.get in a
method for something one call needs.
Concept 9: Derived values
The problem. The reader variant needs the notes without authors; the panel shows a count. Storing copies would go stale.
The idea. self.derive(name, sources, (values, previous) => value) computes a value from readables (documents,
reference members, other derives), recomputes when a source changes, and sends viewers only the difference.
const count = self.derive("count", [list], ([l]) => l.items.length);
previousis the derive's last value, for incremental work. It is kept across reloads by the derive's name. It is only a cache (empty after a restart), so a derive must be correct without it.- A derive that throws fails its whole object (concept 7).
- How the difference is found: Chord compares the new value with the old one (Delta's
diffRevisions). Parts that are the same objects as last time cost one===each; parts that are new objects must be compared by content.
The footgun. A derive that builds new objects for unchanged data makes every change cost the size of the whole value, per view. Example: 2,000 notes, the reader's view strips authors:
self.derive("public", [list], ([l]) => ({ items: l.items.map((n) => ({ text: n.text, at: n.at })) }));
// one note added: 2,001 new objects, all compared by content
mapShared(f) (from @earendil-works/chord/delta) keeps one result per source element: f runs only for new or
changed elements; unchanged ones return their previous result object.
const strip = mapShared((n: Note) => ({ text: n.text, at: n.at }));
self.derive("public", [list], ([l]) => ({ items: l.items.map(strip) }));
// one note added: one new object, 2,000 identical references, the diff emits one insert
Cookbook (patterns with examples): 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, otherwise
mapShared, computed once in the shared store, concept 10); joins across two sources; a window over a large list
(keep its start stable so changes arrive as appends).
Check questions
- Why does a derive have a name?
- The reader view maps every note to a new object on every change. What does one added note cost, and how do you fix it?
Model answers: (1) So Chord recognizes it across reloads and can hand the new code its previous value. (2) The
whole list: every object is new, so the diff compares all of them by content, per view. Use mapShared: unchanged
notes keep their result objects, and the diff finds the one insert by identity.
Concept 10: Pattern: shared store, per-principal views
The problem. Each principal has its own Notes object (concept 3). If each one loaded the notes, or computed the
reader's redaction itself, there would be one copy per caller again: exactly what concept 1 wanted to avoid.
The idea. Heavy or shared parts live once in a local shared store; the per-principal remote views get it
and expose what their principal may see. Views of the same variant read one value from the store: the redaction is
computed once, not per guest.
host.serve(NoteStore, ({ session }, self) => {
const list = self.state(NoteList); // concept 13
const strip = mapShared((n: Note) => ({ text: n.text, at: n.at }));
const pub = self.derive("public", [list], ([l]) => ({ items: l.items.map(strip) })); // once, for all readers
return { notes: list, public: pub, add: …, remove: … };
});
host.serve(Notes, ({ session }, principal, self) => {
const store = self.get(NoteStore, { session }); // one per session
const by = authorOf(principal);
if (canWrite(principal, session)) return {
variant: "editor",
notes: store.notes,
add: ({ text }, ctx) => store.add({ text, by }, ctx),
remove: (args, ctx) => store.remove(args, ctx),
};
return { variant: "reader", notes: store.public }; // the store's one redacted value
});
NoteStore {S1} local shared store: the notes and their redaction, once
▲ self.get ▲
Notes {S1} as owner Notes {S1} as read-only guest (and every other reader)
variant "editor" variant "reader": store.public
- Authority is the view's; the store is trusted and acts for the host.
- The same pattern holds a Durable watch, a file watcher, a sandbox: once in the store, never per principal. Any plugin uses it with the same primitives; base code is not special.
Check questions
- Owner and two read-only guests view S1. How many
Notes, how manyNoteStore, how many redacted lists? - Where is it checked that the guest may not add notes?
Model answers: (1) Three Notes (one per principal key), one NoteStore, one redacted list (public in the store,
shared by both readers). (2) In the Notes factory, which gives the guest the reader variant without add; the store
does not check.
Concept 11: Revocation
The problem. The owner removes guest g1 from S1, archives S1, or signs out a paired phone. The open tabs concerned
must lose access at once, also on the worker where their Notes {S1} objects live, and reconnecting must not bring the
old access back.
What we build on. The app turns a cookie into a principal (authenticate, concept 3); Chord stamps it on the link;
every object on a host exists per principal key, and Chord keeps the principal next to each object (it went into the
factory); every link has its stamped principal.
The idea: labels. The app defines, once in its runtime, which labels a principal carries. Revoking means revoking one label; everyone carrying it is hit.
type Revocation = { grant: string } | { device: string } | { session: string }; // the labels
const revokedBy = (p: P): Revocation[] => // the labels principal p carries
p.kind === "guest" ? [{ grant: p.grant }, { session: p.session }]
: p.kind === "owner" && p.access === "device" ? [{ device: p.device }]
: [];
const server = createHost<P, Revocation>({ address: Server(), principal: { kind: "server" }, revokedBy });
// every worker is created with the same revokedBy
revokedBy(p) is not a yes/no question: it returns the list of labels p carries.
| Principal | Labels |
|---|---|
| guest g1 of S1 | { grant: "g1" }, { session: "S1" } |
| guest g2 of S1 | { grant: "g2" }, { session: "S1" } |
| owner on the paired phone | { device: "phone" } |
| owner, local | none |
| Call | Hits |
|---|---|
revoke({ grant: "g1" }) (remove one guest) |
g1 |
revoke({ session: "S1" }) (archive S1) |
g1 and g2 |
revoke({ device: "phone" }) (sign out the phone) |
the phone |
What host.revoke(r) does with one label r:
for each live object and each link on this host:
r in revokedBy(its principal)? (compared as canonical JSON; never the host's own principal)
→ end the object / close the link
then send r to every child host, which does the same with its own revokedBy
On worker S1, for revoke({ grant: "g1" }):
| Object | Principal | revokedBy(p) |
Hit? |
|---|---|---|---|
Notes {S1} |
owner, local | none | no |
Notes {S1} |
guest g1 | grant g1, session S1 |
yes |
Notes {S1} |
guest g2 | grant g2, session S1 |
no |
NoteStore {S1} |
none (local) | none | no |
Only guest g1's Notes ends; ending it releases its reference to NoteStore, which keeps serving the others. Why a
label and not a yes/no function: revocation is an event that must travel from the server to the workers, and a label
is plain data that can be sent; a function could not.
Removing guest g1, step by step:
owner removes guest g1
sharing (app): marks grant g1 blocked in its stored guest records
server.revoke({ grant: "g1" })
server the guest's WebSocket link carries grant g1 → close it; g1's objects on the server end
send { grant: "g1" } to the workers
worker S1 g1's Notes {S1} ends
guest tab link gone; reconnects → authenticate: grant g1 is blocked → refused
- Chord persists nothing here.
revokeonly cuts live access. Lasting denial lives in the app's data, whichauthenticatechecks on every new connection. After a server restart there is nothing live to revoke; every client must authenticate again, and blocked grants are refused there. - No re-stamping. A changed grant (mode write → read) is a revocation of the old grant: the link closes, the tab reconnects and authenticates with the new grant (new revision, so a new principal key), and its references reconnect by themselves (concept 8), now as a reader.
- Work running in a shared store on behalf of a principal (a queued question, a job a guest started) is not ended
by
revoke: the store has no principal (concept 10). Such a store keeps the grant as data with the work and reconciles from the grant owner's live grants: a routine (concept 15) aborts jobs and drops queued input whose grant no longer exists. No revocation hook; it also holds after a crash.
| Chord | The app |
|---|---|
| stamps principals on links | what a grant is, share links, cookies, authenticate |
| keeps one object per principal key | defines Revocation and revokedBy |
on revoke(r): ends matching objects and links, passes r to children |
decides when to revoke; stores who is blocked |
Check questions
- Why must the label be passed on to the worker?
- The owner archives S1. Which call does the app make, and whom does it hit?
- The server restarts the day after guest g1 was removed. What keeps g1 out?
Model answers: (1) Guest g1's Notes {S1} lives on the worker; ending objects only on the server would leave the
view alive there. (2) revoke({ session: "S1" }): every guest of S1 (each carries the label { session: "S1" }); the
owner carries no such label. (3) Not Chord: after the restart nothing live exists; the guest's tab must authenticate
again, and the app's stored record of grant g1 is blocked, so authenticate refuses it.
Concept 12: ctx
The idea. Every call gets a ctx as its last argument:
signal: fires when the caller cancelled or the object ended (there is no per-call deadline);principal: the caller (remote types only);get: references that are released when the call ends;telemetry: the current trace span;ctx.trace(name, attributes, fn)runsfnwith a childctxunder a child span (concept 29);- in a job (concept 17) also
jobIdandprogress.
Pass ctx or ctx.signal to everything you wait on (fetch(url, { signal: ctx.signal })). Code outside objects gets
a ctx from host.root(fn). There are no code versions and no request ids in it. Durable's Context is the same
module, so passing between them needs no conversion.
Check question: a caller closes its tab while exportText runs. How does the method learn it? Model answer:
ctx.signal fires; everything it awaits with the signal is cancelled.
Concept 13: Documents and transactions
The problem. Notes must be stored, changed safely while others read them, sent to viewers as small changes, and survive restarts.
The idea. An object's data lives in documents: named JSON values the object owns, changed only in transactions.
const NoteList = defineState("notes.list", {
schema: NoteListSchema, // { items: Note[] }: a document's root is an object
version: 1,
initial: () => ({ items: [] }),
persist: true, // stored; default: memory only
});
host.serve(NoteStore, ({ session }, self) => {
const list = self.state(NoteList); // a handle to THE list document of this object
return {
notes: list, // a singleton handle is a readable: expose it as a member
add: ({ text, by }, ctx) => self.tx(async (tx) => {
const d = await tx.draft(list, undefined, { tags: [SessionTag(session)] }); // the current list, as a draft
const id = uuidv7();
d.items.push({ id, text, by, at: Date.now() });
return id;
}, ctx),
remove: ({ id }, ctx) => list.change((d) => { d.items = d.items.filter((n) => n.id !== id); }, ctx),
};
});
- Singleton or keyed: a singleton (no
keyed) is loaded at activation and kept in memory;list.valueis synchronous. Akeyed: truedocument exists many times per object, addressed by a short key, loaded on demand (await handle.get(key);handle.keys()pages keys from storage). There is no live key list: a collection you show live is a singleton; large collections are methods returning pages. - Shape: a document's root is always a JSON object (as in Durable); a list lives in a field (
items). Documents hold JSON only. - Only
tx.draft(as Durable'stx.doc):await tx.draft(handle, key?, { tags? })returns a draft of the current document, or a draft overinitial()if it is absent. Reading is drafting: the draft is an overlay, nothing is copied. A draft you don't change commits nothing, so reading never stores an absent document.tx.retire(handle, key?)removes one; a retired singleton reads asinitial()again. There is notx.get. Shorthands:handle.change(fn, ctx)(keyed:handle.change(key, fn, ctx)) andhandle.retire(ctx)(keyed:handle.retire(key, ctx)). - Values are immutable by convention:
list.value, values read in a transaction and published values are shared; nobody mutates them, and nothing is copied or frozen outside development mode. - Handles belong to their object: they may be used only in transactions of their own object (or of the facet that serves it, concept 18).
- Commit (Durable's sequence): wait for the object's line (one transaction at a time per object), run the callback, write all persisted documents in one storage batch (the change as a delta, or a full base now and then), adopt the new values by pointer swap, notify local subscribers once with the commit's operations, send one batch per link. An error or abort before the write changes nothing. Nothing is revalidated or copied.
- Durable's rule: inside the callback only
await tx.*. No other objects, network, models or processes, no secondself.tx(otherwise the object's line hangs). Fetch first; decide and write inside. - Versions: a changed shape or meaning raises
versionwith a puremigrate(old, from); documents migrate on access. Old code meeting a newer document throws; withoutmigrateaccess is refused and the data kept. A failing load ormigratefails the whole owning object (broken, concept 7). - Schema fingerprints instead of value checks: each definition computes a fingerprint (a hash) of its schema once;
every stored document records the fingerprint it was written with. On access Chord compares the two: same version
but a different fingerprint fails the object
broken("schema changed without a version bump"). That costs nothing per change; values are not walked against the schema in production. So every schema change needs a version bump (an additive one gets a trivialmigrate). A changed meaning without a schema change still cannot be detected. - Tags (
defineTag<T>(name), e.g.SessionTag) are set on create; deleting a session retires every document with its tag. persistonly inlocalobjects andlifetime: "host"objects (identities without a principal). Aremoteobject's identity contains the principal key (how the client connected, the grant revision), so its stored documents would scatter across access paths and be orphaned after a reconnect. The type checker enforces it (self.statein a remote factory accepts only non-persisted definitions), and at runtime the declaration fails with "persist needs a stable identity: put the documents into a local shared store".- Lifetime: an in-memory document ends with its object (last reference released, linger expired); it survives
reloads and native restarts of a live object. What must outlive the object is persisted, or lives in a
lifetime: "host"object. - What survives a restart is exactly what is persisted. Everything else (in-memory documents, native values, derives, routines, references) is rebuilt on activation.
- The store is a port of Durable's store design: lines, commits, migrations, tags and eviction (a keyed persisted
document unused for 10 minutes leaves memory). Behind it is a
DocumentStorage, passed tocreateHost({ storage })(in memory by default). Chord ships two: in-memory and SQLite (on Chord's SQLite drivers, which moved there from Durable; a Node driver, a Durable Object driver). Storages check only metadata and write the delta; they never rebuild or validate content on a commit. The server has one shared storage; each worker one for its session. - A storage error ends the store and closes the host;
onErrorgets{ kind: "storage_failed" }and the app's start code decides (exit with its own code for its supervisor, or reopen). Chord never ends the process.
| Kind of state | For | API |
|---|---|---|
| document | data the object owns | self.state, self.tx |
| derive | computed values | self.derive |
| source readable | external data (Durable views, files, logs) | value plus ordered changes |
| paged method | large collections | a method returning pages |
| plugin database | full-text search, large blobs | a native value (concept 14) |
Check questions
- Why must a network call that fetches a link title happen before
self.tx, not inside it? - A viewer watches the list while
addruns. How many updates does it get? - Why may the per-principal
Notesnot persist documents, whileNoteStoremay?
Model answers: (1) Inside a transaction only tx operations are allowed; anything else can hang the object's line.
(2) One, after the commit. (3) Notes is remote: its identity includes a principal key that changes (connection,
grant revision), so stored data would be orphaned; NoteStore is a local shared store, its identity is (type, input).
The type checker refuses a persisted document in a remote factory.
Concept 14: Native values (self.open)
The problem. The inbox needs a file watcher: a native value (an OS-level thing: a file handle, a socket, a process, a database connection). It must be opened, closed exactly once, never leak, and cannot be JSON or copied.
The idea. Native values live only in objects. An object declares one with self.open(async ({ signal }) => value, (value, reason) => close); it gets back a handle whose .value is the native value (reading it before activation
throws). Chord opens natives one after another in declaration order during activation (the object goes live after all
opens; a later opener may use an earlier handle's .value) and closes them in reverse order when the object ends.
The value never changes during the object's life.
// worker.ts: one inbox per worker
host.serve(NoteInbox, (_input, self) => {
const info = self.get(SessionInfo, {}); // start data (concept 5)
const watcher = self.open(async ({ signal }) => {
const { id, dataDir } = info.init.value; // read in the opener, after activation
return watchDir(join(dataDir, id, "inbox"), { signal });
}, (w) => w.close());
self.routines.loop("import", async (ctx) => { // concept 15
const store = await ctx.get(NoteStore, { session: info.init.value.id });
for await (const file of watcher.value.files(ctx.signal))
await store.add({ text: await read(file), by: "inbox" }, ctx);
});
return {};
}, { lifetime: "host" });
- Why
lifetime: "host"and no input: the inbox must run as long as the worker, whether anyone looks or not, and the facet cannotself.geta type it serves (concept 18). So a worker-wide owner of a native value is alifetime: "host"object without input that reads its session from the core's start data (SessionInfo). - An object with
self.openis a native object; one without is a pure object. The difference decides what a reload does (concept 23). - Behaviour around a native value goes into routines; listeners on native values outside routines are forbidden.
- An open that fails makes the object
failed:transientwith backoff, orbrokenif the opener says so (throw broken(error)). A partly failed activation closes the natives already open; the next attempt opens all of them anew. closegets a reason:{ kind: "reload" }(the plugin is reloaded and the object restarts, concept 23),{ kind: "stop" }(the host shuts down) or{ kind: "end" }(the last reference ended, or the type was removed). Chord aborts only the object's own calls and jobs; other work using the native value is the owner's business, soclosemay drain instead of tearing down (code mode seals its sandbox and ends it when running scripts finish). Eachcloseis bounded (30 s by default, then reported as a possible leak); a throwingclosedoes not stop the others.- Natives that follow a relocation (the session moves to another directory): the native is a stable manager opened 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.
- No native values across plugins, except through registry entries (concept 19). The facet may not open any.
- What an old native value 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). - A native value for one call (a temporary SSH session for a directory listing) is opened and closed inside the
method with
try/finally; there is noctx.open.
Check questions
- Why is the watcher not opened at the top of
worker.ts? - What does "the value never changes" spare the author?
Model answers: (1) Native values live only in objects, which open and close them with their life; a module-level one would leak across reloads and have no owner. (2) No stale handles, no re-reading at use, no replacement logic: if it must change, the object restarts.
Concept 15: Routines
The problem. Delete old notes every hour; import inbox files as they arrive. Bare timers and callbacks would keep running old code after a reload and touch state from nowhere.
The idea. Standing behaviour is declared as a routine:
self.routines.every("cleanup", HOUR, (ctx) => dropOlderThan(30 * DAY, ctx)); // due time survives reloads
self.routines.on(settings, (value, ctx) => applyLimit(value, ctx)); // react to a readable or event
self.routines.loop("import", async (ctx) => { /* runs until the object ends */ });
self.routines.custom("ticker", ({ signal, run }) => {
const t = setInterval(() => run(tick), 1000);
return () => clearInterval(t); // cleanup undoes the setup
});
- Started when the object goes live, stopped when it ends, restarted with the new code on every reload of its plugin.
Routines are named, so Chord recognizes them across reloads. Each run gets its own
ctx; errors are caught and reported. - Serialized per routine: the new instance starts only after the old handler in flight has settled (with a deadline, 10 s by default; a stuck handler is reported). Without this, an old handler midway through a restart and the new routine could both act on the same item.
- A routine reconciles from the current value ("make the world match this"), writes no "in progress" flags, and starts a job for long work. A handler that only cares about the latest value compares with the current value itself.
- No bare timers or callbacks that touch state outside routines, jobs or calls.
Check question: a reload happens while cleanup is halfway through. What happens? Model answer: the new routine
waits until the old handler finishes (or the deadline passes), then starts with the new code; the due time is kept.
Concept 16: Rules across owners
The problem. Every thing has exactly one owner: the smallest object it belongs to, which holds its data,
enforces its rules in its methods and offers the operations others need. But some rules span two owners: "notes of an
archived session are read-only" involves the catalog (which knows archiving) and NoteStore (which holds notes).
The idea.
- No plugin reads or writes another plugin's documents (Chord or Durable). Data crosses plugins only through state and methods. Within a plugin, the facet's documents are readable and writable by the objects that facet serves.
- The owner of the dependent data enforces the rule by reconciling from the other owner's state with an idempotent routine. No cross-owner transactions, no "notify the other owner" calls that can be lost:
// NoteStore: "an archived session's notes are read-only"
const flags = self.state(NoteFlags); // { archived: boolean }, NoteStore's own document
const catalog = self.get(Catalog, {}); // the server's session list (another owner)
self.routines.on(catalog.sessions, async (sessions, ctx) => {
const archived = sessions[session]?.archived ?? false;
if (flags.value.archived !== archived) await flags.change((d) => { d.archived = archived; }, ctx); // idempotent
});
// add/remove refuse while flags.value.archived
After a crash the routine runs again with the current value. The catalog does not know the notes plugin exists.
-
Reading without starting a worker is plain publishing between two objects of one plugin, no Chord mechanism: the plugin's worker facet keeps a summary (say, the note count) and a routine reports it to the plugin's server object by a method call; clients read the server object, which never starts a worker. The server object keeps the latest value per session (a document if it must survive a server restart) and ignores reports from an older worker incarnation. The session list, status, unread marks and artifact lists work this way.
-
Data core enforces belongs to core: it is handed to core through core's API when the data is created (Durable
createConversationwithinit), never written into core's documents directly.
Check questions
- Why doesn't the catalog call
notes.archive(session)when a session is archived? - The worker crashes right after the session was archived. Is the rule still enforced afterwards?
Model answers: (1) The catalog would have to know every plugin, and a call can be lost (crash, link drop); the
dependent owner reconciles instead. (2) Yes: on activation the routine sees the current catalog state and sets
archived.
Concept 17: Jobs
The problem. Importing a 50 MB file takes a minute. The call that started it may end (tab closed); the work must go on, be cancellable, not run twice on a retry, and its result must be recorded.
The idea. A job is owned work started by a method, in three steps (Durable's "sandwich"):
const ImportJob = defineJob("notes.import", {
version: 1, args: ImportArgs, progress: ImportProgress, result: ImportResult, concurrency: 1,
});
const imports = self.job(ImportJob, {
run: async ({ path }, ctx) => { // 2. effect: may do anything; keeps its code on reload
let n = 0;
for await (const line of readLines(path, ctx.signal)) {
await add({ text: line, by: "import" }, ctx);
ctx.progress({ n: ++n });
}
return { n };
},
finish: async (outcome, tx) => { /* 3. outcome, in the current code, Durable's rule */ },
});
return {
imports: imports.running, // live: [{ id, version, args, progress, phase }]
startImport: ({ path, requestId }, ctx) => imports.start({ path }, ctx, { key: requestId }), // 1. intent
abortImport: ({ id }) => imports.abort(id),
};
- 1. Intent is a transaction: the busy check (
concurrency) and deduplication (key: the same key returns the existing run) are atomic;runstarts only after the commit. Run ids are uuidv7. - 2. Effect:
runmay do anything; external writes carry a dedupe key derived fromctx.jobId. On reload it keeps its old code. - 3. Outcome:
{ ok: true, result } | { ok: false, error: "threw" | "aborted" | "lost" }. Chord records it, then calls the current code'sfinish(outcome, tx)and acknowledges in the same commit. A throwingfinishis retried. - Abort is a request: it fires
ctx.signal; the run decides what it returns. - Waiting:
startreturns a run withoutcome(ctx); the caller's abort ends only its wait, never the run. phaseinrunning:running,finishing(outcome recorded,finishnot yet committed),blocked(no adapter for an old version, concept 26).- Jobs keep their owner alive: a running, finishing or blocked job counts as a reference to its object; the object ends only once all its jobs are settled. Starting an import and then closing the tab does not abort it.
- Persistence: in memory by default.
persist: truekeeps the record: a run live at a crash endslostat the next activation. Nothing is rerun automatically. - Not jobs: request/response work (a method), input into running work (concept 22), work that must resume after a crash (a Durable task).
Open: jobs as objects. A login or a long relocation is a job whose state consumers want to address and watch (
LoginFlow { id }). Options: (a) jobs stay records inside their owner, watched throughrunning; (b) a job is an object of its own, addressed by its id, living until done.
Check questions
- The tab sends
startImporttwice with the samerequestId(retry after a link blip). How many imports run? - A reload of the notes plugin happens during an import. Which code runs the rest of
run, which runsfinish?
Model answers: (1) One: the key dedupes atomically. (2) run keeps its old code; finish runs in the new code.
(NoteStore is a pure object; in a native object the run would end aborted, concept 23.)
Concept 18: The facet
The problem. A plugin needs per-host setup: serve its types, contribute to extension points, maybe keep some plugin-wide data.
The idea. The facet is the plugin's setup for one host, and itself a pure object that lives as long as the host:
// notes/server.ts
export default defineFacet((host, self) => {
const prefs = self.state(NotesPrefs); // plugin-wide documents
self.contribute(Http, { routes: { "GET /s/:session/notes.txt": exportNotes } }); // concept 19
host.serve(SomeServerType, (input, principal, self) => …);
});
- It may hold documents, references, routines and contributions; no native values.
- Its identity is the plugin's
package.jsonname (setting): its persisted documents are found by that name, so they survive restarts and reloads, and never depend on a directory or the load order. - Served objects go live after the facet and may read and write the facet's documents; one transaction may span an
object's own documents and its facet's (lock order: own line, then the facet's). The facet never reaches into a
served object's documents, and never
self.gets a type it serves itself. host.serveand contributions are valid only while the setup runs.- Between plugins only two things pass: object types and registries.
Check question: why does the facet not open the inbox watcher? Model answer: the facet is a pure object; native values live only in objects with a lifetime of their own, which open and close them.
Concept 19: Registries
The problem. The notes plugin adds a download route; a logs plugin wants to time every request; several plugins add model providers. Someone must combine these contributions, decide conflicts, and replace a plugin's part as a whole on reload.
The idea. A registry is an extension point owned by someone (a Chord component, the app, a plugin). Each
contributor (a facet or an object) contributes at most one value per registry; the table is an immutable, ordered
list of (contributor, value); the registry's compose turns it into what consumers use:
export const Http = defineRegistry<HttpContribution, Router>("chord.http", {
version: 2,
accepts: { 1: (old) => upgrade(old) },
compose: (table, { report }) => buildRouter(table, report), // pure; always delivers a value, reports conflicts
});
self.contribute(Http, { routes: { … }, wraps: [ … ] }); // a contributor
const router = host.registry(Http).value; // a consumer: live
composeruns at runtime, whenever the table changes: after a reload's commit, when a contributor goes live or ends, when a contribution changes (contributions may themselves be readables, known only once a native value is open). Because contributions can change at any time, no check before a reload could be complete, so conflicts are not a reason to roll back.- It always delivers a value, with its fallback built in (e.g. the first by plugin name wins), and reports
conflicts through
report(they become notices to the owner and telemetry events). Readers get a plain value and never handle conflicts. Only ifcomposethrows is the registry in an error state; readers then get an error when they use it. Work that started earlier keeps the value it read. - 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 which contributions apply where. Helpers:
keyed(...),ordered(...). - Optional dependencies are registries owned by the dependent (concept 8): ports contributes its forwards to
radius.forwards; without ports the table is simply empty. - Contributor order is sorted by plugin name (the
package.jsonname): the same on every host and across reloads, and deliberately meaningless. Meaningful order comes only from the owner's composition (for HTTP: phases, concept 20). - Versions: a contribution carries the registry version its plugin was built against; older ones are adapted with
accepts, newer ones are refused at load. - Registries may carry native values. 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. This is the only way native values
reach other plugins. Example: the models plugin's object contributes
() => runtime; the agent harness looks it up at every request; after a reload the next request gets the new runtime, while a running stream finishes on the old one. - Registries add behaviour (tools, routes, renderers, commands); they never replace an owner's checks. Entries have no lifecycle: anything needing cleanup is a native value of the contributing object.
No callbacks between objects. Members carry data only, on both kinds of types. A callback would carry code and
whatever it closes over from one object into another. Example: NoteInbox passes onFile to another object, which
keeps it. After a reload of the inbox's plugin, that object still calls the old onFile; after NoteInbox ends,
it calls into an object whose native values are already closed. The replacements: behaviour from outside goes through a
registry (the entry lives exactly as long as its contributor and is looked up at each use), multi-step work through a
job, input from a person through a flow (concept 22).
Check questions
- Notes and files both contribute
GET /download. When is that noticed and what happens? - A route handler stores
const r = entry()in a module variable and uses it for days. What's wrong? - Object A passes a callback to object B, which keeps it. What does B call after A's plugin is reloaded, and after A ends? What do you use instead?
Model answers: (1) When compose runs after the second contribution enters the table (at runtime, not in a reload
check): the router still works, the first by plugin name wins, and the conflict is reported to the owner and as a
telemetry event. Nothing is rolled back. (2) It keeps a value from an entry beyond a use; after the contributor
restarts it holds a stale or closed native value. Look up at each use. (3) After the reload, A's old code; after A ends, code of an object
that no longer exists, with its native values closed. Instead: a registry entry, a job, or a flow.
Concept 20: HTTP routes and wraps
The problem. Every plugin that serves a download would repeat ranges, HEAD requests, download headers, streaming and abort on disconnect, and get some of it wrong.
The idea. HTTP is an optional Chord component (@earendil-works/chord/http) with a registry. It is built
after the kernel and a first real proof (harness, one tool with an approval pause, a code mode reload); until then the
playground serves its pages with plain app code. The design below is the target. A route gets a
plain request summary (method, path params, query, principal, provenance such as local or relayed, selected headers,
parsed and capped JSON bodies, uploads in chunks) and a ctx whose principal is the request's principal (from the
request resolvers, which decide who sent a request: cookie, loopback, relay). So ctx.get(Notes, …) gives exactly the
view that person may see. The route returns a description; the component does the HTTP work once:
| { kind: "json"; value } | { kind: "page"; title; body }
| { kind: "bytes"; name; mime; size; read(offset, length) }
| { kind: "redirect"; to } | { kind: "error"; status }
// notes/server.ts: a route is (req, ctx) => Promise<HttpResponse>; ctx.principal is the request's principal
self.contribute(Http, {
routes: {
"GET /s/:session/notes.txt": async (req, ctx) => {
const notes = await ctx.get(Notes, { session: req.params.session }); // the requester's view
const text = encode(render(notes.notes.value));
return { kind: "bytes", name: "notes.txt", mime: "text/plain", size: text.byteLength,
read: (offset, length) => text.subarray(offset, offset + length) };
},
},
});
// logs/server.ts: a wrap (middleware) around every route
self.contribute(Http, {
wraps: [{
name: "access-log", phase: "observe", scope: "all",
wrap: async (req, next, ctx) => {
const t = Date.now();
const res = await next(req);
log(req, res, Date.now() - t);
return res;
},
}],
});
- Routes are composed with
keyed: two contributors with one pattern is a reported conflict; the first by plugin name wins. - Wraps run in phases the owner defines (
observe,headers,transform; outermost first), within a phase in contributor order. That order is deterministic but meaningless, so wraps in one phase must not depend on each other. No numeric priorities (Fastify's lifecycle phases are the model). - Scope: a wrap applies by default only to its own contributor's routes (
scope: "own");scope: "all"is explicit;routenarrows it to one route, and aroutethat does not exist is a reported conflict. - Wraps may add a security boundary on top of an owner's checks, never instead of them.
- A request uses the router as it was when the request started.
- A channel component provides WebSocket byte channels (
send,onData,close) for terminals, with their own flow control, separate from object links.
Check questions
- Who handles a
Range: bytes=1000-request fornotes.txt? - The logs plugin adds a wrap without
scope. Does it see requests tonotes.txt?
Model answers: (1) The HTTP component: it calls read(offset, length) and sends 206; the route only describes the
bytes. (2) No: the default scope is own, only the logs plugin's own routes; it needs scope: "all".
Concept 21: Who is here: presence
The problem. Show "2 people viewing"; keep the worker alive while someone looks; know when a login flow's owner left.
The idea. host.references(Type) is a readable of who holds references to objects of a type on this host (input,
principal, client). Each tab is its own client (an origin id the client cannot set).
const viewers = self.derive("viewers", [host.references(Notes)], ([refs]) => refs.length);
- A link drop is a departure: accurate and simple. Smoothing (a phone switching networks flickers the count) is the UI's job, e.g. a debounced viewer count.
- Counts per person are derived by the app from the principals and origins in the list; two tabs of one person are two clients.
- No lingering on the consumer side: a closed tab closes its link, so it cannot keep anything. What keeps a
just-closed object warm is the provider's
linger(concept 7), and that also keeps the worker alive (concept 4). - "Leaving" is never derived from a component unmounting (concept 23), only from navigation or presence.
Check question: the owner closes the notes tab and reopens it ten seconds later. Is the viewer count briefly
lower, and does Notes {S1} have to be rebuilt? Model answer: yes, the count drops while the link is gone (the UI
may smooth it); no rebuild if the type has a provider-side linger longer than ten seconds: the object stayed warm.
Concept 22: Input into running work: flows
The problem. A login asks for a pasted code, then an account choice; a tool asks for approval. The running work waits for a person who may be on any tab, may reconnect, may answer twice.
The idea: the owner keeps the flow as state.
start({ provider }, ctx) → { id } // uuidv7
LoginFlow {id}: state { status: "running" | "done" | "failed" | "cancelled", step?: { id, kind, … }, 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); nothing crosses an object boundary. - Imperative consumers (tests, scripts, a CLI, an agent tool) use a generic client helper over the same state, not a second protocol:
for await (const step of steps(await ctx.get(LoginFlow, { id }), ctx))
await login.answer({ id, step: step.id, value: answerFor(step) }, ctx);
- Another pattern is the shared Questions service (
questions.md): typed questions, policies over a live audience (first,quorum,all), reattach bykey, deadlines. Its store must live in an object.
Open: where a flow lives. Options: (a) a keyed document of its owner (
Login); (b) an objectLoginFlow {id}living until done (tied to "jobs as objects", concept 17).
Check question: why not a server method step() that blocks until the next step? Model answer: a link drop fails
a long call and the consumer can't tell whether it missed a step; the helper reads state that re-holds by itself and
remembers the last step id.
Concept 23: Reload
The problem. New code must take over without losing data, open watchers, running imports or connected tabs, and a broken change must not leave a host half-updated.
The idea, per host:
1. load the plugin's new bundles; an import or syntax error rejects
2. staging facet setups and every live object's new factory run with a recording `self`
(declarations only, nothing activated); a throw rejects
3. commit the host switches to the new code atomically
4. after per object, below
- Rollback is all-or-nothing per host: any code error in load or staging (an import error, a throwing factory) leaves the whole host on its previous code. A host is always completely at one revision, so its status is one value. Runtime states (a registry conflict, a failed open, a migration) are not reasons to roll back.
- Pure objects are swapped in place: new methods and derives; documents, references and jobs stay; routines restart (serialized); new references become live; then the gate opens. Running calls finish on the old code and keep their old handles valid until they settle (their transactions keep working); only new calls get the new handles.
- Native objects of the reloaded plugin restart: their running work ends
aborted, native values close (reasonreload), the new factory runs, native values reopen. Documents stay (they belong to the identity). In practice this costs little: the Durable harness is base code, terminal processes live in a separate host process, and model runtimes, sandboxes and the search index rebuild in milliseconds; only reloading the plugin that owns SSH connections (running remote commands are killed) or the relay socket (relayed connections drop) hurts. - Why natives are not kept across a reload by name: a native value has no line, no transactions and no schema, so old and new code would use it interleaved without any check.
- After the commit, errors stay with one object: a failed open makes it
failed(backoff); a migration that throws refuses that access; a method bug fails that call. - Removing a type ends its objects; in-memory documents go; persisted ones stay until retired.
- Consumers on other hosts see a restarting object like any object that briefly goes away.
- No factory trial, no migrate dry run, no consumer check in staging: an incompatible consumer fails at bind with
contract_mismatchand a notice naming plugin and type. - Start barrier (host start, first load): load the facets (the core facet with its start data among them), wait
until every facet and every
lifetime: "host"object is live or failed, then resume dependent work (the Durable harness, concept 28). An object that must contribute at start (the model runtime) is thereforelifetime: "host".
In the notes plugin: you change the cleanup interval and the reader's filter, and reload. Notes and NoteStore
are pure: swapped in place, the notes stay, open tabs keep their references, a running import keeps its old run.
NoteInbox has a native value: it restarts, the watcher closes and reopens.
Loading and the dev loop. Plugins come from built-in, global, package and project roots, found by directory
listing; the server selects; dependencies resolve by contract name at runtime; names are prefixed (<plugin>.<name>).
The loader is an adapter: one base bundle per surface plus one script per facet (loader D, which reads each plugin's
package.json name), or static imports (tests, Durable Objects), where the app passes the same name to load. A
source watcher only marks plugins dirty; a reload control (web shell, CLI, agent tool) reloads them or restarts all
hosts for base code.
UI reload vs. leaving: a reload of a UI facet unmounts and remounts components. "Leaving" (abort a login, close a TUI overlay) is therefore never derived from an unmount, only from explicit navigation or presence (a dropped reference).
Open: project plugins. Per-directory plugins with trust and snapshots (
plugin-loading.md) need their own pass over this design.
Check questions
- A typo in the new
NoteStorefactory throws in staging. What runs afterwards? - Which notes objects restart on a reload, and why only those?
- Why is rollback per host enough, instead of per plugin?
Model answers: (1) The whole host keeps its previous code; nothing changed. (2) Objects with a native value
(NoteInbox): a native value can't be swapped safely in place; pure objects are swapped. (3) A host is then always
completely at one revision, so its status is one value per host.
Concept 24: Rollout across hosts
The problem. A reload touches the server, every worker and every client. Calls between hosts keep flowing while they update one by one.
The idea. A revision is (server incarnation, sequence) and names the complete desired code set (every facet
with its digest). The rollout goes server first, then workers, then clients. Each host reports installed,
installed_failed (named failed objects) or rejected (with the reason; it stays on its previous code). A server that
rejects stops the rollout. A host that starts or reconnects installs the current revision before anything is routed
to it.
A call that reaches a provider on another revision gets the provider's revision and status:
| Provider | The call |
|---|---|
| ahead, or behind and installing | updating: values readable, calls park (60 s deadline, concept 8) |
| rejected | fails at once (retried on the next revision) |
| same revision | contract_mismatch: a real mismatch, final |
- A host checks its own install status first: if its own install was rejected, a call fails at once with the reason instead of waiting on a provider.
- Installs are serialized per host.
- A revision carries a digest of the code set (the hash over the facet bundles' content hashes, which the loader computes anyway). Hosts compare it on reconnect, so a server restart with unchanged code triggers no reinstall.
Check question: the server installed revision 8; worker S1 is still installing. A tab calls Notes {S1} with the
new contract. What happens? Model answer: the call parks until S1 reports revision 8, then runs.
Concept 25: The wire
The idea. Exposed documents, derives and readables are state members on the wire: snap when a reference is held,
ops per commit (the draft's recorded operations, no diff), ops from diffs for derives, throttled ops for job
progress, reset after a migration, error for a failed member.
- Commits are atomic for consumers: one
stateframe per hold carries all member updates of a commit, applied before listeners run; a commit that touches several holds on one link travels as one frame. No consumer ever sees half a commit. - Compression: large JSON envelopes are deflated on WebSocket links; byte segments stay raw; IPC never compresses.
- Frame size limit per link adapter (e.g. 16 MiB on client WebSockets, none on IPC): an oversized frame fails that
call or member with
too_large; the link stays up. - One encoding per frame: a frame is encoded once per link and its size taken from the encoded bytes. Relays read only the frame header (hold id, kind, route) and forward the body without decoding it. Receivers check a frame once by its header; only untrusted callers' arguments are validated (concept 6). Consumers apply changes with structural sharing; nothing is validated or cloned.
- Contract descriptions travel once per link: a new reference sends only its type's fingerprint; the provider asks for the full description once per (link, fingerprint) and caches it for that link.
- Flow control per link: a byte window on WebSockets, a window of frame counts on IPC, applying to every frame. Calls are throttled by credits the provider grants; beyond them they wait locally; nothing is refused for being "too many". A relay that falls too far behind releases the hold and holds again; the provider sends a fresh snapshot.
- Adapted bindings get diffs: a consumer bound through an
acceptsadapter (concept 26) is served like a derive: Chord keeps the last adapted value per binding, applies the adapter to the new value and sends the difference. - The link protocol has a version; hosts of different versions refuse each other (a web page reloads).
Check question: a guest adds a note; the owner's tab holds both Notes {S1} and a counter on the same worker. How
many frames reach the owner's tab for that one commit? Model answer: one state frame with both holds' batches.
Concept 26: Versions
The problem. Parts change independently: a plugin built last month talks to today's provider; a document stored a year ago meets new code.
The idea. Two kinds of versions:
-
Interface versions (between parties that change independently) have adapters: object types (
version; same version binds if the provider covers the caller; otherwiseacceptskeeps older consumers working, or they getcontract_mismatch; the fingerprint, a hash of the type's description, covers input, variants and members, not placement), registries (acceptson contributions), jobs (acceptson outcomes; without an adapter the run isblocked, not refused). -
acceptsmaps values per member, pure functions:accepts: { 1: { state: { notes: (v2) => v1 }, methods: { add: (argsV1) => argsV2 } } }Chord wraps a state mapping like a derive and sends diffs per binding (concept 25); members an old consumer doesn't know don't appear.
-
Internal versions (old and new code of the same owner) migrate: documents with a pure
migrate, guarded by the schema fingerprint (concept 13).
| Surface | On change |
|---|---|
| object type | accepts adapter; coverage check |
| registry | accepts adapter on contributions |
| document | pure migrate, on access; a schema change without a bump fails the object broken |
| job | accepts; blocked without an adapter |
| native value | none: the object restarts |
| Durable tasks and documents | Durable's migrate |
| link protocol | refused; hosts update together |
Check question: why do documents get migrate but object types get adapters? Model answer: a document is shared
only between its owner's old and new code, so it is converted once; an object type is used by consumers that update on
their own schedule, so the provider keeps serving old shapes.
Concept 27: Bytes and big transfers
The problem. A user uploads a 50 MB file to import. Base64 inside JSON grows it by a third; one giant message blocks the link and fails as a whole on a drop.
The idea.
- Bytes are a schema type (
bytes(), aUint8Array), carried natively: references on memory links, structured clone on IPC, binary frames on WebSockets. Never base64. - Big transfers are chunked by the owner, not by Chord:
beginUpload({ name, size }, ctx) → { upload }
append({ upload, offset, bytes }, ctx) // about 1 MiB; awaited = backpressure
finishUpload({ upload }, ctx) → { id }
read({ id, offset, length }, ctx) → bytes
- The awaited
appendis the backpressure; after a link drop the writer asks for the stored offset and resumes; an unfinished upload expires (the owner's routine). A small helper wraps the loop. - Chord has no stream type: it would need its own rules for link drops, cancellation, relays and restarts, which the owner's offset protocol already survives with plain calls.
Check question: the link drops after 30 of 50 chunks. What happens? Model answer: the append in flight fails
like any call on a drop; after reconnecting the writer asks the owner for the stored offset and continues from chunk
31.
Concept 28: The Durable harness
The problem. The agent runs on Durable (the transcript and task engine). It needs model access and execution environments that come from plugins, and those plugins reload.
The idea. The harness is an object in the core facet (base code, so it restarts only with the host): a
Harness object with lifetime: "host" (it runs for the worker's whole life, whether or not anyone holds it; its
activation is the start barrier) opens Durable with self.open and gets models and environments from registry
entries, looked up at each use:
const harness = self.open(async () => Durable.open(storage, {
models: () => host.registry(HarnessModels).value, // current entry at each use
env: (target) => host.registry(HarnessEnvironments).value(target.cwd),
settings: () => currentSettings(),
registry,
}), (h) => h.close());
- Durable's
modelsandsettingsaccept functions, so each generation gets the current value. - Its activation is the start barrier: open Durable without scheduling, wait for the required contributions, check cwd and model, then resume.
- After a models reload the next request uses the new runtime; a running stream finishes on the old one.
- Durable and Chord v2 share
Context,DraftandJsonValue(same modules); only plain data crosses between them. - Hooks such as tool approval open a Chord call scope with
host.rootinside Durable's hook. - Code mode (a plugin) owns its sandbox as a native value. Its tool entry looks up the current sandbox when a
call starts, so a call that was waiting (e.g. in an approval hook) during a reload starts on the new sandbox; the old
one is sealed and drained in
close(reasonreloadorstop). - Telemetry: Durable gets the same
Context.telemetry; submissions and tasks store a carrier (concept 29). - App state lives in Chord documents; only transcript-bound data (entries that fork and rewind with a conversation) lives in Durable.
Open: a Durable Object profile. Deploying Chord apps to Cloudflare Durable Objects: a DO can lose its memory while clients stay connected; 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.
Check questions
- Why is
Harnesslifetime: "host"and not referenced likeNotes? - Why does the harness look up
HarnessModelsat each use instead of reading it once when it opens?
Model answers: (1) It must run for the worker's whole life, also while nobody holds it (Durable tasks continue), and
the start barrier waits for lifetime: "host" objects. (2) The models plugin may reload; its entry is replaced, and an
entry's value must not be kept beyond a use. The next generation gets the new runtime.
Concept 29: Telemetry
The problem. A guest's add is slow. The call crossed the browser, the server and the worker. Where did the time
go?
The idea. Chord reports traces, events and metrics through @earendil-works/pi-telemetry. Each host gets one
adapter (createHost({ telemetry }), nothing by default) that decides sampling, buffering, export and filtering.
-
The trace travels with the call.
ctx.telemetryis the current span.callandholdframes carry the caller's carrier (an opaqueRecord<string, string>the adapter writes and reads); the provider continues the trace under it, and a relay adds its own span. An unknown or missing carrier just starts a new trace, never an error.chord.call Notes.add [tab A] └─ chord.relay [server] └─ chord.method Notes.add [session:S1] principal=guest · event chord.commit docs=1 ops=1 -
Own spans:
ctx.trace(name, attributes, fn)runsfnwith a childctxunder a child span, so calls inside are parented correctly. -
Shared work (one activation awaited by several callers, a worker start) is its own trace, linked to its first trigger; each waiting span links to it. Routine runs, job runs, eviction and background closes are their own traces, linked to their trigger where one exists.
-
Tiers: spans for calls, methods, relays, holds, activations, opens, closes, loads, revocations, routine and job runs; events for commits inside methods, retries and parking; metrics for high-frequency background work (state frames, bytes, commit durations, in-flight calls, open links, park durations).
-
Nothing is measured just for telemetry: only sizes the code already has (encoded frames,
byteLength). -
Durable gets the same
Context.telemetry; submissions and tasks store a carrier, so a task run links to its submission even after a restart. Persisted Chord jobs store their start carrier. -
Debugging: a JSONL trace file adapter, a text renderer and a trace viewer in the playground.
Check question: a routine in NoteStore runs every hour. Whose trace does its run belong to? Model answer: its
own (a root span), not a caller's; it is linked to its trigger where one exists.
Concept 30: The cost model
The problem. A session's notes grow to 500 KB; ten tabs watch. If every added note made Chord walk, copy, validate or re-encode the whole value per viewer, the app would crawl.
The idea. As little compute and memory as possible while staying correct. Values are immutable revisions that share structure (Delta, as in Durable), so each hop does only the work for what changed:
| Hop | Work |
|---|---|
| commit | Delta's prepare (copies and checks only the 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 a diff; unchanged parts stay the same objects (mapShared, concept 9) |
| send | one encoding per frame and link |
| relay | header only, body forwarded unread |
| receive | header check; untrusted callers' arguments validated once at the provider |
| apply | structural sharing |
| new hold | the value encoded once (unavoidable) |
| memory | one authoritative value per document per host; replicas and derived values share structure |
That is why several earlier concepts look the way they do: values are passed by reference and never copied (concept 6), documents are guarded by a schema fingerprint instead of value checks (concept 13), and derives must keep unchanged parts identical (concept 9).
Cost guards: in test mode Chord counts full-value walks, deep copies, encodes, schema validations and materializations, and tests assert them per hop (one note appended to a 500 KB document with 10 viewers: no walks, no copies, one encode per link, no validations). A benchmark suite records the numbers.
Check question: a guest adds one note to a 500 KB list watched by ten tabs over one WebSocket link each. What does Chord encode? Model answer: the commit's small ops, once per link; the 500 KB value is neither walked, copied nor re-encoded.
Open points at a glance
| Topic | Concept |
|---|---|
| jobs as objects | 17 |
| where a flow lives | 22 |
| project plugins | 23 |
| a Durable Object profile | 28 |
App-level open points (p3, design.md section 26): session files, session delete, connection pools, config files with
a single writer, file formats as versioned interfaces, the terminal host process.
Glossary
| Term | Meaning |
|---|---|
| host | one participant (a process, a tab): a logical identity plus an incarnation |
| link | a connection between two hosts; delivery only |
| relay | the server passing requests between clients and workers |
| host kind / address | typed description of addressable hosts / one host of a kind (Session({ id })) |
| placement | a type's host function: input → address |
| incarnation | a fresh id per host start; a restart is a new incarnation of the same identity |
| facet | a plugin's code for one host kind; a pure object living as long as the host |
| object type / contract | description: name, input, placement, members, version |
remote / local |
across links, per principal, untrusted arguments validated / own host, no principal, no checks; both pass values by reference |
| object | one instance per (type, input, principal key); local: per (type, input) |
| principal | who calls; stamped by links |
| principal key | maps a principal to an object identity |
| revocation | a small typed value the app revokes ({ grant }); revokedBy(p) says which ones hit a principal |
| variant | what a principal may see and do |
| shared store | a local object holding shared or heavy parts once, behind per-principal views |
| base code / core facet | the app's own code (src/), loaded under a fixed name such as @app/core; a change restarts the hosts |
SessionInfo |
the core's local, host-lifetime object holding the start data |
| reference | what get returns; keeps its object alive; live and hydrated when awaited |
| readable | a value plus its ordered changes |
ref.state |
loading, live, failed (cause, retryAt), released |
| failure cause | transient (automatic backoff) or broken (retried only by a trigger) |
ctx |
per call: signal, principal, call-scoped get, telemetry span |
| document | named JSON value an object owns, written in transactions |
| derive | value computed from readables |
mapShared |
keeps one result object per unchanged source element, so derives stay cheap |
| schema fingerprint | hash of a document's schema, stored with every record; catches schema changes without a version bump |
| native value | an OS-level value opened with self.open; lives only in objects |
| pure / native object | an object without / with self.open |
| routine | standing behaviour: on, every, loop, custom |
| job | owned work: intent, run, finish |
| registry | extension point; one contribution per contributor; compose turns the table into a value |
| wrap | HTTP middleware, in owner-defined phases |
| flow | input into running work, kept as the owner's state, step by step |
| staging | reload step: facet setups and factories run as declarations only, nothing activated |
| revision | (server incarnation, sequence): the complete code set a host runs |
| carrier | opaque trace context carried in call and hold frames |