pi-mono/apps/p2/docs/chord-walkthrough.md
Mario Zechner 05dc8d239d feat: add apps/p2 (imported from pi-private at 7dadff697)
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.
2026-11-03 13:39:06 +00:00

44 KiB
Raw Blame History

Chord and p2: a guided walkthrough

A step-by-step introduction to how p2 plugins work on top of Chord, for someone who knows nothing about either. It describes the design (target-design.md), not the state of the implementation.


For the agent guiding the user

You walk a user through this document. Follow these rules:

  1. One concept at a time. Explain it in your own words, in the user's language, with the example from the document. Don't paste the whole section; keep it short and concrete.
  2. Then ask the check questions of that concept (2–3) and wait for the answers.
  3. Judge the answers against the model answers (they are for you, not for the user). If an answer is wrong or half right, say precisely what is missing, explain that part again differently, and ask again. Don't move on while a concept is not understood.
  4. Counter-questions come first. If the user asks something, answer it completely before anything else. If the answer lies in a later concept, say so, give a short answer, and offer to continue there.
  5. Move on only when the user says so.
  6. No terms before they are introduced. If you need one, introduce it first.
  7. Teach the design, not the implementation status. Don't say what exists today and what is planned. When a question goes beyond this document, answer from these sources, in this order: target-design.md (the design; it wins on conflicts), changes.md (the reasoning behind each change), packages/chord/docs/tutorial/ (Chord's tutorial: plugin files, the app's API, how an app loads plugins), packages/chord/docs/design.md (Chord's API and links). The code lives in packages/chord and apps/p2; it may lag behind the design.
  8. Keep a short list of what the user found hard, and come back to it at the end.

Start by telling the user the note below, then begin with concept 1.

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

p2 is a coding-agent app built from plugins. It runs as several processes. Chord calls each process a host, and every backend host has a name:

  • the server (name server): exactly one; serves the web UI, holds data shared by all sessions, starts the workers;
  • a worker per session (name session:S1 for session S1): runs the agent and works on the session's files;
  • clients: browsers (the web UI) and TUIs (a terminal UI), any number.

How they connect:

browser ──┐  WebSocket          IPC  ┌── worker session:S1
browser ──┼───────────── server ─────┤
TUI ──────┘                          └── worker session:S2
  • Each connection is a link. Clients connect only to the server, over a WebSocket. The server starts a worker as a child process when its session is first needed and talks to it over IPC (the pipe between a parent process and its child).
  • Clients never talk to a worker directly; the server relays. A browser call to something in session S1 travels browser → server → worker session: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 (its cookie) and remembers that for the whole link. Concept 2 builds on this.

Chord is the runtime under p2: it lets plugins define objects that live on one host and lets any host use them across links.

The goal that shapes everything: reload. You change a plugin's code, and the new code takes over inside the running app. No host restarts: the server, the workers and the browsers keep running, and so do open terminals, running scripts, the agent's current answer and connected browsers. Only the changed plugin's code is swapped. This is a reload; the opposite is a restart, where a process ends and starts again and everything it held in memory is lost.

Much of what follows exists to make reloads safe: what must survive a reload (data, open files), what must be replaced (behaviour), and how running work is handed over. Concept 11 shows how a reload works step by step; until then it is enough to know: on a reload, the plugin's factories run again with the new code, in the same running process.

A plugin is a folder under plugins/. Each file is meant for certain hosts:

File Runs on Holds
contract.ts every host that imports it descriptions only: names, shapes, method signatures
server.ts the server code for the server
worker.ts every session worker code that needs the agent or the session's files
web.tsx the browser UI components
tui.ts the TUI terminal UI

The running example is a notes plugin:

  • notes per session, stored in SQLite;
  • a text file dropped into an inbox folder becomes a note;
  • notes older than 30 days are deleted every hour;
  • a web panel shows the notes; the owner and invited guests with write access can add notes, read-only guests can only read.

Concept 1: Object types and objects

The problem. Several places need "the notes of session S1": the owner's browser tab, a guest's tab, an HTTP export handler. If each built its own copy, they would drift apart, and nobody would know when to clean up.

The idea. There is one object "notes of S1". Everyone asks Chord for it by its address and gets the same one.

  • An object type describes what such an object is: its name, what its address looks like (the input), on which host it lives, and what you can do with it (its members: data you can read and methods you can call). It contains no code that runs; it is a description every host can share. 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:

// plugins/notes/contract.ts: the object type, imported by the worker and the browser
export const Notes = remote("notes", {               // a type usable across links, named "notes"
  version: 1,                                        // the contract's version (concept 12)
  host: sessionHost,                                 // lives on the worker "session:" + input.session
  input: Type.Object({ session: Type.String() }),    // the address: { session: "S1" } (a TypeBox schema)
  variants: { /* the members, concept 2 */ },
});
// plugins/notes/worker.ts: the factory, runs on every session worker
export default defineFacet((host) => {               // runs when the plugin loads; host = this worker (concept 7)
  host.serve(Notes, (input, principal, self) => {    // "this host builds Notes objects like this"
    // input = { session: "S1" }; principal: concept 2; self: concept 3
    return { /* members */ };
  });
});
// plugins/notes/web.tsx: a caller, in the browser
const notes = await host.get(Notes, { session: "S1" });   // browser → server → worker session:S1
  • Where an object lives is decided by the type alone. p2 defines two placements (src/runtime/chord.ts):

    • sessionHost = hostFamily("session:", "session"): the host name is the prefix "session:" plus the input's session field. For { session: "S1" } that is session:S1. The input must have that field;
    • serverHost = hostNamed("server"): always the host server.

    The caller only names the type and the input; Chord computes the host and sends the request there through the server.

  • Who builds it: the host where it lives runs the factory. That is why the factory is in worker.ts: server.ts could not serve Notes, and Chord refuses that at startup.

  • One object per address: the first time anyone asks for { session: "S1" }, Chord runs the factory. Everyone who asks later for the same address gets the same object (concept 2 refines this). { session: "S2" } is another object, on another worker.

  • local and remote: a local(...) type can only be used on its own host; a remote(...) type can also be used from other hosts across links (here: the browser using an object on the worker).

Check questions

  1. Two tabs ask for the notes of S1. How many objects exist, and how often did the factory run?
  2. What is the difference between Notes and "Notes for S1"?
  3. Where does the object for S2 live, and who decides that?

Model answers: (1) one object, the factory ran once (same address; concept 2 refines this when the callers are different people). (2) Notes is the description (type); "Notes for S1" is one instance (object). (3) In the worker of session S2; the type's host declaration decides, derived from the input.


Concept 2: Who is calling (principals and variants)

The problem. The owner may do everything; a read-only guest may only read; a guest of another session may do nothing. The object must know who calls, and the caller must not be able to lie.

The idea. Every call carries a principal, the caller's identity. It is never taken from the message: the link stamps it, from what the server learned when the client connected (its cookie, see the setting). When the server relays a call to a worker, it passes the principal along; a worker accepts a passed-along principal only from its parent link, the server. So nothing a client sends, at any hop, changes who it is.

p2's principals, each with a kind:

kind Who
owner the person running p2
guest someone invited into one session: { kind: "guest", session: "S1", mode: "view" | "full", … }
worker a session worker calling the server
server the server calling a worker
host a host's own code calling an object on the same host

The factory gets the principal and decides what the caller gets. An object type can have variants: named sets of members. The factory returns one of them, named by variant.

// plugins/notes/contract.ts: the variants of Notes
variants: {
  reader: { state: { notes: NotesSchema } },                                     // can read the notes
  editor: { state: { notes: NotesSchema }, methods: { add: method(AddArgs) } },  // can also add one
}
// plugins/notes/worker.ts: the factory picks the variant per caller
host.serve(Notes, ({ session }, principal, self) => {
  if (!canView(principal, session)) throw forbidden();      // refuse: the caller gets an error, no object
  const notes = loadNotes(session);                         // simplified; see the problem below
  if (!canSend(principal, session)) return { variant: "reader", notes };
  return { variant: "editor", notes, add: (args, ctx) => addNote(session, args, ctx) };
});

canView and canSend are p2 helpers (src/runtime/principals.ts):

  • canView: the owner, or a guest of this session;

  • canSend: the owner, or a guest of this session with mode: "full".

  • A reader has no add at all. Chord refuses calls to members that are not in the caller's variant, so add needs no check of its own.

  • The caller sees which variant it got. A reference is typed as one of the variants, told apart by variant. TypeScript lets you use add only after checking notes.variant === "editor"; at runtime, using a member of another variant throws not_in_variant:

    // plugins/notes/web.tsx: a caller
    const notes = await host.get(Notes, { session: "S1" });
    notes.add({ text: "hi" });            // type error: `add` exists only on the editor variant
    if (notes.variant === "editor") {
      await notes.add({ text: "hi" });    // fine: narrowed to editor
    }
    
  • One object per (type, input, caller). The factory builds something different per caller, so each caller gets its own object. Concept 1's "one object per address" holds per caller: two tabs of the owner share one Notes; two guests get one each.

The problem this creates. If each caller has its own Notes, and the notes were loaded inside it, there would be one copy per caller again: exactly what concept 1 wanted to avoid.

Store plus view. The data goes into a second object type that everyone shares, and the per-caller object only checks and forwards:

// plugins/notes/worker.ts: a local type, defined where it is used
const NoteStore = local("notes.store", {
  input: Type.Object({ session: Type.String() }),
  members: { notes: …, add: method(AddArgs) },
});
host.serve(NoteStore, ({ session }, principal, self) => ({ /* loads and holds the notes of one session */ }));

host.serve(Notes, ({ session }, principal, self) => {
  if (!canView(principal, session)) throw forbidden();
  const store = self.get(NoteStore, { session });           // get another object from inside a factory (concept 3)
  if (!canSend(principal, session)) return { variant: "reader", notes: store.notes };
  return { variant: "editor", notes: store.notes, add: (args, ctx) => store.add(args, ctx) };
});
  • NoteStore is local: only code on this worker can get it, so no browser ever reaches it directly.
  • Every Notes factory asks for it with the same principal, host (this worker's own code), so there is one NoteStore per session, shared by all Notes objects of that session.
  • This "store plus view" pattern is everywhere in p2.

Check questions

  1. A read-only guest calls add. What happens, and where is it decided?
  2. Could a malicious browser claim to be the owner? Why not?
  3. The owner has a tab open, two guests are in S1. How many Notes and how many NoteStore objects exist?

Model answers: (1) The guest got the reader variant (the factory decided), which has no add; Chord refuses the call. (2) No: the link stamps the principal from the server's authentication of the connection, never from a message; when relayed, the worker accepts it only from the server. (3) Three Notes (owner, guest A, guest B; a second owner tab would share the owner's), one NoteStore (every Notes gets it as host).


Concept 3: References

The problem. When can an object be cleaned up? Not while someone uses it, but not never either.

The idea. Asking for an object gives you a reference, not the object. While at least one reference exists, the object lives. When the last one is released, Chord cleans it up (and the object releases the references it holds itself).

There are three ways to get a reference; they differ in when it is released:

Where you are How Released when
in a factory self.get(Type, input) the object holding it ends
in a method await ctx.get(Type, input) that call ends
outside Chord (startup code, UI) await host.get(Type, input) you call ref.release()

ctx is the context every method call gets as its last argument (concept 10); here it only matters that ctx.get binds a reference to one call.

Which one: ask how long the object needs the other object.

// plugins/notes/worker.ts
host.serve(Notes, ({ session }, principal, self) => {
  const store = self.get(NoteStore, { session });         // needed for the object's whole life: factory, self.get
  return {
    variant: "editor",
    notes: store.notes,
    add: async (args, ctx) => {
      const filter = await ctx.get(SpamFilter, {});       // needed for this call only: method, ctx.get
      if (await filter.check(args.text, ctx)) await store.add(args, ctx);
    },
  };
});

self.get belongs in the factory only: it declares what the object keeps. Called in a method, it would take another long-lived reference on every call.

A trace:

  1. The owner opens the notes panel; the browser calls host.get(Notes, { session: "S1" }). The worker builds the owner's Notes; its factory's self.get builds NoteStore. Chain: browser → owner's Notes → NoteStore.
  2. A guest joins and gets their own Notes, which references the same NoteStore.
  3. The owner closes the tab. The browser's reference is released, the owner's Notes ends and releases its reference to NoteStore. The guest's Notes still holds it: the store stays.
  4. The guest closes too. The guest's Notes ends, NoteStore has no reference left and ends.

A lost link counts as closing: when a link dies, everything held through it is released.

More rules:

  • A reference you hold is usable: the object is live and its data is there, whether it lives on this host or another one.
  • self.get in a factory is synchronous: it only declares. Chord makes the object live after the objects it references are ready.
  • host.serve(Type, factory, { linger: 30_000 }) keeps an unreferenced object 30 seconds longer: closing and reopening the panel quickly rebuilds nothing.
  • host.serve(Type, factory, { lifetime: "host" }) makes the object live as long as its host, referenced or not. It is created when the plugin loads, not at the first get. Such a type is local and has no required input, so there is exactly one per host (each worker has its own). For services every plugin needs.

Check questions

  1. The owner closes the tab; a guest still has it open. Does NoteStore end? And when the guest closes too?
  2. Notes.add checks every note against SpamFilter. (a) add is rare and the filter is expensive to keep alive; (b) every Notes uses the filter constantly. Where and how do you get the reference in each case?
  3. What does lifetime: "host" change?

Model answers: (1) No, the guest's Notes still references it; when the guest closes too, it ends (after linger if set). (2) (a) ctx.get in add: alive only while a call needs it; (b) self.get in the factory: alive as long as the Notes, no call waits for it to start. self.get never goes into a method. (3) The object exists from loading until its host stops, independent of references; one per host.


Concept 4: State

Where we are. The notes plugin so far:

// plugins/notes/contract.ts
export const NoteSchema = Type.Object({ id: Type.Number(), text: Type.String() });
export const NotesSchema = Type.Array(NoteSchema);
export const AddArgs = Type.Object({ text: Type.String() });

export const Notes = remote("notes", {
  version: 1,
  host: sessionHost,
  input: Type.Object({ session: Type.String() }),
  variants: {
    reader: { state: { notes: NotesSchema } },
    editor: { state: { notes: NotesSchema }, methods: { add: method(AddArgs) } },
  },
});
// plugins/notes/worker.ts
const NoteStore = local("notes.store", { input: Type.Object({ session: Type.String() }), members: { /* notes, add */ } });

export default defineFacet((host) => {
  host.serve(NoteStore, ({ session }, principal, self) => ({ /* holds the notes: this concept */ }));

  host.serve(Notes, ({ session }, principal, self) => {
    if (!canView(principal, session)) throw forbidden();
    const store = self.get(NoteStore, { session });
    if (!canSend(principal, session)) return { variant: "reader", notes: store.notes };
    return { variant: "editor", notes: store.notes, add: (args, ctx) => store.add(args, ctx) };
  });
});

Two things are missing: what notes is inside NoteStore, and how a browser shows it and stays current.

The problem. The notes change: someone adds one. Every open panel, on any host, should show the change at once. A plain variable can't do that:

  • nobody is told when it changes;
  • it can't travel over a link;
  • the factory runs again on every reload (see the setting), so a variable created there starts empty again.

The idea. State is a named, observable value with a schema (a cell). The object that declares it owns it and is the only one that writes it. Anyone holding a reference to the object can read it and is told when it changes, on any host.

Declaring and writing (the owning object)

For now the notes live only in memory:

// plugins/notes/worker.ts
host.serve(NoteStore, ({ session }, principal, self) => {
  const notes = self.state("notes", NotesSchema, { init: () => [] });   // name, schema, first value

  return {
    notes,                                              // returned: holders of a reference can read and watch it
    add: async ({ text }, ctx) => {
      const id = (notes.value.at(-1)?.id ?? 0) + 1;
      notes.change(ctx, (draft) => {
        draft.push({ id, text });
      });
    },
  };
});
  • self.state(name, schema, { init }) declares the cell. init gives its first value, here an empty list; it runs before the object goes live. (Concept 5 loads the first value from a database instead.)
  • Writing: notes.change(ctx, (draft) => …) edits a draft: atomic (all or nothing if the callback throws), and only the paths you touch change. notes.replace(ctx, value) sets the whole value.
  • Every write needs a ctx, so writes happen only inside calls. The factory gets no ctx: it only declares.
  • notes.value is immutable: never mutate what you read.
  • What the factory returns is visible to holders of a reference; a cell it doesn't return is private.

Passing state on (the view)

Notes returns notes: store.notes: the store's cell becomes a member of every Notes object. It is not a copy: all Notes of S1 show the same value, with no code to keep them in sync.

Reading and watching (the consumers)

Every state member of a reference has .value (the current value) and .subscribe(fn) (called on every change; returns a function that stops it). An awaited reference already has the first value.

Without UI (any host, a script, a test):

const notes = await host.get(Notes, { session: "S1" });
notes.notes.value;                                         // [{ id: 1, text: "buy milk" }]
const stop = notes.notes.subscribe((list) => console.log(list.length));
// …
stop();
notes.release();

In the browser, p2's React hooks (src/runtime/web.tsx) do the same for a component:

// plugins/notes/web.tsx
function NotesPanel({ session }: { session: string }) {
  const notes = useObject(Notes, { session });   // host.get while mounted, release on unmount
  const list = useReadable(notes?.notes);        // the current value; re-renders on every change
  if (!notes || !list) return <p>Loading…</p>;
  return (
    <section>
      <ul>{list.map((note) => <li key={note.id}>{note.text}</li>)}</ul>
      {notes.variant === "editor" && (            // TypeScript knows: `add` exists only for editors
        <button onClick={() => void notes.add({ text: "hi" })}>Add</button>
      )}
    </section>
  );
}

Inside an object, reacting to state (for example, writing a file whenever the notes change) is not a subscribe in the factory but an effect (concept 6).

One add, step by step

The owner and a full guest both have the panel open. The guest clicks Add:

  1. The guest's browser calls notes.add({ text: "hi" }). The server relays it to worker session:S1, to the guest's Notes (variant editor), which forwards to store.add.
  2. store.add appends the note with notes.change.
  3. Chord sends the change (the one new element, not the whole list) to every host holding a reference with that member: the owner's browser and the guest's browser, both through the server.
  4. In each browser useReadable re-renders the panel.

A new reference gets the whole value once, then only changes; after a reconnect or a reload it gets the whole value again. The call's result and the state change travel separately: when await notes.add(…) returns, the panel may not show the note yet. Wait for the state, not for the call.

Derived data

const count = self.derive([notes], (list) => list.length);
return { notes, count, add };

A derivation is computed from other state and recomputed when it changes. Nobody writes it, and it needs no name: after a reload it is simply computed again.

Why cells have names

On a reload the factory runs again; self.state("notes", …) then returns the existing cell with its current value, and init does not run again. So the notes survive a reload. They do not survive a restart (the process ends): the cell lives in memory. Concept 5 fixes that.

If the schema or meaning of a cell changes, you raise its version; concept 5 shows what then happens.

Check questions

  1. Why can't the factory write state?
  2. You reload the notes plugin, then later the worker process restarts. What happens to the notes each time?
  3. A guest should see only the number of notes. What do you change, and on which objects?
  4. The owner and a guest have the panel open; the guest adds a note. What reaches the owner's browser, and how?

Model answers: (1) The factory only declares and has no ctx; it runs again on every reload (first as a trial), so a write there would overwrite the surviving value or happen in a discarded trial. (2) Reload: kept, the named cell is returned with its value. Restart: gone, the cell was only in memory. (3) NoteStore derives count and returns it; Notes gets a variant with only count (for example counter: { state: { count: Type.Number() } }) and returns count: store.count for such guests. (4) Only the change to notes (the new note), sent from the worker through the server; useReadable re-renders. No call and no full list.


Concept 5: Resources

Where we are. NoteStore keeps the notes in a cell (concept 4):

// plugins/notes/worker.ts
host.serve(NoteStore, ({ session }, principal, self) => {
  const notes = self.state("notes", NotesSchema, { init: () => [] });
  return {
    notes,
    add: async ({ text }, ctx) => {
      const id = (notes.value.at(-1)?.id ?? 0) + 1;
      notes.change(ctx, (draft) => { draft.push({ id, text }); });
    },
  };
});

The problem. A cell lives in memory: when the worker restarts, the notes are gone. They must be stored in a file, here SQLite. But where do you open the file?

// wrong
host.serve(NoteStore, ({ session }, principal, self) => {
  const db = openDb(notesPath(session));   // runs on every reload: a new handle each time, the old ones never closed
  // …
});

The factory runs again on every reload. Each run would open another file handle, and nothing ever closes them, not even when the object ends.

The idea. A resource is a native thing the object opens once and closes once: a database handle, a socket, an SSH connection, a sandbox. It is declared by name, like a cell, and survives reloads.

const db = self.resource("db", { version: 1 },
  () => openDb(notesPath(session)),    // open: runs once, before the object goes live
  (db) => db.close());                 // close: runs once, when the object ends
db.value;                              // the open handle (in methods)

The store, now persistent:

// plugins/notes/worker.ts
host.serve(NoteStore, ({ session }, principal, self) => {
  const db = self.resource("db", { version: 1 }, () => openDb(notesPath(session)), (db) => db.close());
  const notes = self.state("notes", NotesSchema, {
    version: 2,                                         // was: 1 (the default); see "Reloading into this version"
    migrate: (old) => old,                              // see "Reloading into this version"
    init: async ({ previous }) => {                     // was: () => []
      if (previous) db.value.insertMissing(previous);   // only on the reload from concept 4
      return db.value.all();
    },
  });

  return {
    notes,
    add: async ({ text }, ctx) => {
      const id = db.value.insert(text);                                   // 1. the truth: SQLite
      notes.change(ctx, (draft) => { draft.push({ id, text }); });        // 2. the live copy
    },
  };
});

What changed:

  • db is a resource: opened before the object goes live, closed when it ends.
  • init no longer returns [] but loads the notes from SQLite before the object goes live, so no caller ever sees an empty list that fills a moment later. It runs when the object is built (first use, after a restart). On a reload a kept cell keeps its value and init does not run.
  • add writes SQLite first, then the cell. SQLite is the truth; the cell is the live copy others watch. If the process dies between the two, the note is still in SQLite, and init loads it next time.
  • The ids now come from SQLite.
  • The cell has version: 2, a migrate and an init that takes previous. That is for one moment only: the reload from concept 4's code to this one.

Reloading into this version. The app is running the in-memory store from concept 4 and holds a few notes. You reload into this code. The cell notes keeps its name and schema, but its meaning changed: before, the cell was the data; now it is a copy of SQLite.

Without a bump, the reload keeps the cell with its value, and init does not run. The new code would serve the in-memory notes as if they came from SQLite, but they are not in SQLite: the next restart loses them. Chord cannot notice this: same name, same schema, only the code inside init changed. Raising the version is your job.

With version: 2, the reload treats the cell as changed, in two steps:

  1. migrate(old) converts the old value to the new schema. It must be pure: it runs once as a trial while the reload is checked, and once for real. The schema did not change, so here it returns old unchanged. Without a migrate (or if it returns undefined), there is no previous, and the old notes are dropped.
  2. init({ previous }) then runs once, with the migrated value as previous. Unlike migrate it may write: here it inserts the in-memory notes into SQLite, then loads everything. It must be safe to run twice (it reruns if the reload is retried), hence insertMissing.

Meanwhile NoteStore is handed over (concept 11): running calls finish first and new calls wait, so no add runs while init copies; then db is open, init runs, the waiting calls continue, and browsers get the whole value again. After a restart previous is absent and init only loads.

The rule: when the schema or the meaning of a cell changes, raise its version. Chord refuses a schema change without a bump; a meaning change (a different init) it cannot see.

Three kinds of cells. Cells are the live view, not the store. Most cells are one of these:

Kind Example init On a version bump
mirrors a source (SQLite, Durable) notes from now on reads the source, ignores previous nothing to carry over: no migrate; bump freely
exists only in memory concept 4's notes, drafts, UI state previous ?? fallback migrate converts the old value
large data a transcript loads a bounded window (the latest entries) as a mirror
  • The notes cell changed kind in this concept: from memory-only to mirror. The migrate and the previous branch in init exist only for that one transition; once every running worker has reloaded past it, they can go, and the cell is a plain mirror: init: () => db.value.all().
  • Large data stays in its source. The cell holds only a window, kept current by an effect (concept 6), and methods return older pages on request (p2's chat has a window cell and a history method). Clients cache the pages they fetched.

Resource rules:

  • On a reload, the same name and version keep the resource open; the new code uses the same handle.
  • Chord closes resources when the object ends, in reverse order of declaration.
  • Only native values, no behaviour. An event handler or timer set up inside open would keep running the old code after a reload, because open doesn't run again. Behaviour goes into effects (concept 6).
  • Raise version when what open produces changes (another file, other options). Two kinds:
    • parallel (default): old and new can exist side by side; the new one opens first, the old one closes once nothing uses it anymore;
    • exclusive (a port, a lock): the old one must close before the new one opens (concept 11).

Check questions

  1. Why not open the DB directly in the factory?
  2. You reload from the in-memory store to this one but forget version: 2. What happens to the notes, now and at the next restart? Why doesn't Chord stop you?
  3. The worker restarts. Walk through what happens to the notes until a browser sees them again.
  4. You change a query inside a method. Do you raise the resource's version? And if you change the file name?
  5. Someone put a file-change handler inside open. What happens after you fix that handler and reload?

Model answers: (1) The factory runs on every reload; each run would open another handle, never closed. (2) The reload keeps the cell with the in-memory notes; init doesn't run, so they never reach SQLite, and the next restart loses them. Chord sees the same name and schema; a changed init is invisible to it, so the bump is the author's job. (3) The cell is gone with the process; the notes are in SQLite. When a browser asks again, NoteStore is built: open opens the file, init loads the notes, then the object goes live and the browser gets the whole value. (4) No for the query (method code is new anyway); yes for the file name (what open produces changes). (5) Nothing: open doesn't run again, the old handler keeps running; that's why behaviour belongs in effects.


Concept 6: Effects and operations

The problem. An object must react (an inbox file appears), run background work (hourly cleanup), or keep a long task going (an OAuth login that takes minutes). Done directly in the factory, every reload would add another handler or timer.

The idea. An effect is behaviour Chord starts and stops for you:

self.effect(onEvents(watchDir(inbox), async (file, ctx) => {
  db.value.insert(await readFile(file, { signal: ctx.signal }));
  notes.replace(ctx, db.value.all());
}));
self.effect(every("cleanup", HOUR, () => db.value.deleteOld()));
  • An effect starts when the object is live and stops when it ends.
  • When the object's code changes, the old effect is stopped first, then the new one starts, so they never run side by side. Handlers already running finish.
  • A handler on state gets the current value again after a restart, so it must reconcile ("make the world match this value"), not blindly act.
  • A named timer (every("cleanup", …)) keeps its next deadline across reloads.

An operation is long-running work an object owns, like a login flow:

const flow = self.operation(async (ctx) => runOAuth(provider, ctx));

It keeps its own code until it finishes, so a reload of unrelated code doesn't interrupt it.

Check questions

  1. Why does a handler on state have to reconcile?
  2. You change the cleanup interval's callback and reload. Does the hourly schedule restart from now?
  3. Effect or operation for "send a daily report"? For "an OAuth login in progress"?

Model answers: (1) It receives the current value again after a restart; a blind action would run twice. (2) No, the named timer keeps its deadline; only the callback changes. (3) Daily report: an effect with a named every; OAuth login: an operation (long-lived, must not be interrupted by an unrelated reload).


Concept 7: The facet and registries

The problem. A plugin must contribute things to the app it can't edit: an HTTP route, a tool for the agent, a panel in the UI. And the plugin's objects need to share things like the DB.

The idea.

  • A facet is a plugin's code for one process (its server.ts, worker.ts, web.tsx or tui.ts). The facet's setup is itself an object that lives as long as the process: it can declare resources and state, and its served objects share them.
  • A registry is a named table someone owns (the app or a plugin) and others contribute to:
export default defineFacet((host, self) => {
  const db = self.resource("db", { version: 1 }, () => openDb(host.value(DataDir)), (db) => db.close());
  self.register(Routes, { "GET /notes/export": (req, res, access, ctx) => exportNotes(db.value, res) });
  host.serve(NoteStore, (input, principal, self) => ({ /* uses db */ }));
});
  • The owner declares the registry and reads it (host.registry(Routes).table()); plugins register entries.
  • Entries live as long as the plugin is loaded; a reload swaps the whole table at once, so there is never a moment with a missing route.
  • Registries are per process: a worker facet can't add a server route. Things that cross processes go through objects.
  • Only the facet and lifetime: "host" objects register (an object that comes and goes would make its entries flicker).

Check questions

  1. Why can't an ordinary (referenced) object register a route?
  2. Your worker facet wants an HTTP route. How?
  3. What happens to a plugin's routes when the plugin is removed?

Model answers: (1) Its entries would exist only while someone references it: the route would flicker. (2) The server facet registers the route; the handler calls an object in the worker. (3) They disappear with the plugin's facet.


Concept 8: Host values

The problem. Some things exist once per process and belong to the app, not to any plugin: the data folder, the agent runtime (Durable harness), the session's current directory.

The idea. The app provides them as host values, typed by a key:

const dir = host.value(DataDir);           // a string
const harness = host.value(Harness);       // the Durable harness
  • They are there before any plugin loads; no waiting, no !.
  • Each key has a version; a plugin built against an older version gets an adapted value or a clear error at load.

Check questions

  1. Why are host values not ordinary objects?
  2. What happens when a plugin asks for a key the process doesn't provide?

Model answers: (1) They are native, process-wide things owned by the app, not data or methods, and must exist before plugins load. (2) It fails with a clear error when the plugin loads.


Concept 9: Who is here (host.references)

The problem. The owner wants to see which guests are in a session; the worker should stay running while someone looks at it.

The idea. Chord knows which references exist, so it can tell you:

const viewers = self.derive([host.references(Chat)], (refs) => refs.length);

host.references(Type) lists who (input, principal, how many clients) holds references to objects of a type in this process. A disconnect counts as leaving.

Check questions

  1. Why is this better than each client registering itself in a list?

Model answer: Chord already tracks references and updates them on connect, release, revocation and disconnect; no extra objects, no enter/leave races on reload.


Concept 10: ctx and call trees

The idea. Every method, handler and operation gets a ctx:

  • ctx.signal: fires when the call is cancelled (the caller gave up, the object ended, or a reload needs it to stop);
  • ctx.get: references that live for this call;
  • the process's own principal.

A call tree is everything one starting point causes (a click in the browser, an event), across objects and processes.

The rule: pass ctx or ctx.signal to everything you wait on (fetch(url, { signal: ctx.signal })). Then a long call can be stopped cleanly.

Check questions

  1. A method waits on fetch without the signal. What goes wrong when it must be stopped?

Model answer: It can't be interrupted; it keeps running until the fetch returns, and anything that has to wait for it (a reload's handover) waits longer.


Concept 11: Reload

The problem. You change a plugin's code. Everything should keep running: open terminals, running scripts, the agent's current answer, connected browsers, without restarting any process.

The idea, in one sentence: behaviour is replaced at once; storage is handed over.

On each process:

  1. Prepare: the new code is loaded and checked: factories run as a trial (no side effects), contracts are checked against every current caller, new parallel resources open. If anything fails, nothing changes.

  2. Publish: in one step, every call from now on reaches the new code. Objects whose storage changes (a state version bump, an exclusive resource) are fenced in that same step: new calls to them wait. All other objects are done: their effects restart with the new code; calls already running finish their old code. An object that was still being built at that moment starts over with the new code.

    Objects that merely hold a reference to a fenced object are not fenced: their calls run, and wait only when they reach the fenced object. (Fencing them would deadlock: their running call waits for the handover, which would wait for them to finish.)

  3. Hand over each fenced object:

    1. its running calls, operations and handlers finish (after a deadline they are asked to stop via ctx.signal; nothing is closed while they still run; a call that re-enters the object from inside is let through);
    2. bumped cells get their new value in a staged copy: migrate converts the old value (it already ran once in prepare as a trial, so it must be pure), then init({ previous }) runs once with the result and may do I/O (concept 5). exclusive resources are closed and reopened;
    3. on success the new state, code and effects are installed and the waiting calls continue.

If a handover fails (a port is busy, a migration throws), only that object fails; you fix the code and reload, and it comes back in place.

What callers see: usually nothing; during a handover, a short wait; a call running on that object past the deadline is cancelled.

Example: you add tags to the note schema (and a column to the SQLite table) and raise the cell to version 3. No migrate is needed: the truth is in SQLite. The NoteStore objects are handed over: their calls finish, init loads the notes with their tags, and the waiting calls continue. Browsers get the new value. Nothing else stops.

Check questions

  1. You fix a bug in a method and reload. What happens to a call to that method that is running right now?
  2. You raise the version of a sandbox resource (parallel) while a script runs in the old sandbox. What happens?
  3. Why can old code never write state that was just migrated?

Model answers: (1) It finishes its old code; new calls get the fix. (2) The new sandbox opens; new scripts use it; the running script keeps the old one, which closes when the script ends. (3) The object's calls are drained before the migration, and new calls wait until it is done.


Concept 12: Versions and adapters

The problem. A plugin written for an older p2 should keep working when p2 changes. And a plugin's own state may change shape over time.

Two kinds of versions:

  • Interface versions, between parties that change independently: object types, registries, host values. The consumer brings the version it was built against; the owner provides an adapter for old versions; anything else fails with a clear error when the plugin loads.
host.serve(Notes, factory, { accepts: { 1: (current) => oldView(current) } });   // old callers keep working
  • Internal versions, between old and new code of the same object: state and resource versions. Nobody adapts them; a bump triggers a handover (concept 11) with migrate or a reopen.

Check questions

  1. A plugin was built against Notes version 1; p2 now serves version 2. When does it keep working?
  2. Why don't state versions get adapters?

Model answers: (1) If version 2's provider declares an adapter for version 1 (or the change was compatible and the version didn't change); otherwise it fails at load. (2) They aren't shared with anyone else: only the object's own old and new code; a migration converts the value once.


Glossary

Term Meaning
facet a plugin's code for one process; itself a long-lived object
object type the description of objects: name, input, members, version, host
object one instance per (type, input, caller)
principal who calls, stamped by the server
variant the set of members a caller gets
reference what get returns; keeps the object alive
state named, observable data; survives reloads
resource a named native thing; survives reloads
effect behaviour restarted when code changes
operation long-running owned work
registry a declared, versioned table of contributions
host value a native value the app provides
handover replacing an object's storage in place during a reload