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

918 lines
44 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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:
```ts
// 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 */ },
});
```
```ts
// 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 */ };
});
});
```
```ts
// 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`.
```ts
// 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
}
```
```ts
// 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`:
```ts
// 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:
```ts
// 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.
```ts
// 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:
```ts
// 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) } },
},
});
```
```ts
// 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:
```ts
// 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):
```ts
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:
```tsx
// 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
```ts
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):
```ts
// 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?
```ts
// 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.
```ts
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:**
```ts
// 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:
```ts
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:
```ts
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:
```ts
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:
```ts
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:
```ts
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.
```ts
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 |