# Slice 6a: the store (spec)
Status: **built** (2026-10-27: four build-review rounds and six fix rounds; later rules are in the decision log's
2026-10-27 entries and `design.md`, which win over this spec). Was: **ready to build** (2026-10-27; the second spec review decided by its majority, section 15), written by the redesign session after
the first spec review (`/tmp/s6a-review-{astra,fable,opus,sol}.md`) and the owner's decisions of 2026-10-26 and
2026-10-27, including the general-model discussion (facets, attempts, the seal fence, keyed members). Guiding rules
(owner): simple, fast, correct; Chord and Durable aligned as much as possible.
The single source for building 6a, together with: `design.md` read in full; the store book `store/book.md`; the
decision log `packages/chord/docs/decisions.md` (later entries win; superseded parts are marked), in particular "The
store", S1-S17, W1-W14, P2, P3, "Flat columns", "Query runner", "Index definitions are recorded", R8, R12-R15, D9, D10,
"Slice 6 first decisions", "One retirement mechanism", "Text is plain UTF-8, in code point order", and every entry of
2026-10-26 and 2026-10-27 from "The host's store and how it is opened" to "Slice 6a small items"; the feature map and
the carry-over of `slices.md` (slice 6a); the root `AGENTS.md` section "Chord v2 Code Style". Builds on slice 5
(`4a5b45c45`), the adaptation to the shared `watch.ts` (`cc2d61f53`) and pi `8025c414d` (`runCounted`).
6a builds the store and puts Chord's persisted data on it (tables, persisted documents, keyed state members), and
reworks three parts of the built slices that the store needs: facets only declare, objects own their documents, line
and job runs across attempts, and the seal fence. Durable on the store (`StoreStorage`), persisted jobs and
`durableView` are 6b. Sync is not a slice: the store keeps an optional outbox the sync layer will read.
**Words.** An **object** is one identity (type, params, principal key); an **attempt** is one try at activating it
(factory, references, natives, members, derives, routines). A **seq** numbers a commit, one counter per store;
**`lastSeq`** is the seq of the store's last commit. A **write** (`StoreWrite`) is one change of the store (`put`,
`update`, `updateWhere`, `table`, `index`, `document.create`, …); a **commit** in the outbox is the writes of one seq; a
**batch** is the commits `from..to` that sync ships; an **archive** is a manifest, the writes from 0 to `lastSeq` and an
`end`. A **line** serializes one object's transactions; the **commit queue** serializes only the store's commit step. A
**pin** keeps one key of a keyed document loaded.
## What an app can do after this slice
```ts
// the start code of a session's worker
const db = await openNodeSqliteDatabase(join(dir, "session.sqlite")); // chord/sqlite/node
const store = await openStore(sqliteBackend(db), ctx, { outbox: false }); // omitted: keep the persisted setting
const worker = createHost
({ address: Session({ id }), principal, store, onError });
…
await worker.close(ctx);
await store.close(); // the app owns the store
// contracts
const NoteSchema = obj({ title: str(), body: str(), author: str() });
export const NoteStore = local("notes.store", {
version: 1, params: {},
members: {
notes: keyed(NoteSchema), // a keyed state member
create: method(obj({ title: str() }), str()),
rename: method(obj({ id: str(), title: str() })),
remove: method(obj({ id: str() })),
},
});
export const NoteView = remote("notes.view", {
version: 1, params: { session: str(), id: str() },
placement: ({ session }) => Session({ id: session }),
variants: {
editor: { state: { note: NoteSchema }, methods: { rename: method(obj({ title: str() })) } },
reader: { state: { note: obj({ title: str(), body: str() }) } },
},
});
// a plugin: the facet only declares
const Note = defineState("notes.note", {
schema: NoteSchema, version: 1, initial: () => ({ title: "", body: "", author: "" }),
keyed: true, persist: true, checkpointWhen: (_v, _ops, info) => info.deltasSinceBase >= 64,
});
const Edits = defineTable("edits", {
version: 1,
columns: { id: str(), path: str(), at: int(), note: opt(str()), meta: json(EditMeta, { version: 1 }) },
key: ["id"],
indexes: { by_path: ["path", "at"] },
});
export default defineFacet((host) => {
host.serve(NoteStore, (_, self) => { // local, empty params (P3): host lifetime
const notes = self.state(Note); // persisted, keyed, evictable
const edits = self.table(Edits);
self.on(notes.at("pinned"), async ({ value, retired }, ctx) => { … }); // routines live in objects
return {
notes,
create: async ({ title }, ctx) => {
const id = uuidv7();
await self.tx(async (tx) => {
tx.put(edits, { id: uuidv7(), path: `notes/${id}`, at: Date.now(), meta });
(await tx.draft(notes, id)).title = title;
}, ctx);
return id;
},
rename: ({ id, title }, ctx) => notes.change(id, (d) => { d.title = title; }, ctx),
remove: ({ id }, ctx) => notes.retire(id, ctx),
};
});
host.serve(NoteView, ({ id }, principal, self) => { // a view with params: only in-memory state of its own
const store = self.get(NoteStore, {});
const note = store.notes.at(id); // pins key `id` while this view lives
if (principal.kind === "owner") {
return { variant: "editor", members: { note, rename: ({ title }, ctx) => store.rename({ id, title }, ctx) } };
}
return { variant: "reader", members: { note: self.derive([note], ([n]) => ({ title: n.title, body: n.body })) } };
});
});
// a browser tab: an ordinary hold of the view
const view = await tab.get(NoteView, { session: "S1", id: "n-42" }, ctx);
const watch = await view.note.watch(ctx);
watch.start(async ({ value, retired }) => (retired ? showDeleted() : show(value)));
// tables
await self.tx(async (tx) => {
const old = await tx.get(edits, { id });
tx.update(edits, { id }, { note: "fix" }); // plain values
tx.updateWhere(edits, (e) => prefix(e.path, "a/"), (e) => ({ path: replacePrefix(e.path, "a/", "b/") }));
}, ctx);
const page = await edits.page(query(Edits).where((e) => eq(e.path, p)).orderBy((e) => e.at, "desc"), 20, ctx);
// export and import
await pipeline(Readable.from(encodeJsonl(store.export(ctx))), createWriteStream(archivePath));
const copy = await importStore(sqliteBackend(otherDb), decodeJsonl(createReadStream(archivePath)), ctx);
```
## 1. Changes to the built slices
### 1.1 Facets only declare (decisions "Facet documents are removed", "Facets only declare")
A facet's setup declares only `host.serve` and `host.references` (`self.contribute` comes with slice 8). Removed:
`FacetSelf` (the setup takes `host` alone until slice 8), facet documents (`FacetSelf.state`), facet routines
(`FacetSelf.on`/`onLive`), the facet's `Documents` and line, the lock order across two lines, `Documents.facet`,
`Transaction.#facetLine`/`close()`, the facet hydration and routine start in `host.ts#setUp`, the facet drain in
`#close`, `ChordHost.#facets` beyond the names, and their tests (`facet.test.ts`, the facet cases of
`routines.test.ts` and `documents`/`transaction` tests, `support.ts` helpers). A facet cannot fail after `load` (a
throwing setup still rejects `load`, slice 2). A transaction covers only its own object.
### 1.2 Objects own documents, line and job runs (decisions "Documents and the line belong to the object", "Job runs belong to the object")
- **Slots and handles.** The object (`ChordObject`) holds one slot per declared document name (trackers, the stored
version and fingerprint, pins) and the line. Each attempt's `self.state(def)` returns a thin handle bound to the
attempt's seal token and the definition's version; old handles keep their token and version (R15: old code touching
a slot migrated past its version throws, one integer comparison per read and draft). The same name with a higher
version migrates on access (in memory too); the same version with another fingerprint is `broken`; a flipped
`keyed` or `persist` is `broken`. "Declared twice" is checked per attempt.
- **Lifetime.** Slots live from the object's first activation to its end, across retries, failures and native
restarts. `initial()` is taken once per slot (again when the declaring definition changes). In-memory slots the new
factory no longer declares are dropped when the factory returns (never on a throwing factory); persisted ones drop
their trackers only. Persisted singletons load once per object, keyed data once per residency, again after a failed
load or a drop (section 15). In-memory state
survives a failure and retry; ending the object clears it.
- **Job runs.** Per job name the object holds the records, keys, active count, `running`, run state documents and
pending actions; the attempt holds the implementation (`run`, `finish`) and the running invocations (its drain, its
seal). A run's state changes and its outcome are fenced by the run's own invocation; `finish` runs on the current live
attempt (a finishing run waits for one); a run of a job the new code no longer declares stays `blocked`; a run
waiting for a live attempt keeps its object alive only while an attempt is live or activating. `Job.seal` fires
signals only; waiters reject at the object's end.
- **Derives and watches.** Derives detach with their attempt and the next attempt's derives attach to the same slots.
Routine watches end by their attempt's ctx cancellation, never by a slot drop. Local references re-bind every state
member by name when the object's new attempt goes live, as a remote reference re-holds and gets a fresh snapshot:
their member watches go on across attempts (documents and derives alike; one frame from the last value), and a
consumer's derive over a member recomputes from the new attempt's member. While the object is failed or activating
again, the members keep their last values and get no frames; the builder states this in one rule in `members.ts`.
Keys a reference holds on a keyed member (`at(key)`, `watch(key)`) follow the object's attempts the same way (fix
round 3). A failed derive heals on every later change of its sources and on a re-bind; failures reach watches as
`failed` calls (decision "Failures are watch calls", fix round 4).
### 1.3 The seal fence (decision "The seal fence")
- **Tokens.** Every attempt has a seal token; every job run invocation its own (for `ctx.state.change` and its
outcome).
- **At use:** after the seal, new work through the attempt's handles is refused with `released`: `self.tx`, `ctx.get`
(it checks the call's abort signal first, before anything is created), a job start, a routine start. The attempt's
references stay usable for its own natives' closes, then are released.
- **At commit:** a commit is checked when it enters the store's commit step (sealed: rejected `released`, nothing
written, not a storage failure) and at adopt (admitted while current, sealed during the SQL: durable, adopted into
the object's slots, its staged job changes adopted into the object, their invocations deferred to the next live
attempt; the handler's `self.tx` resolves with `fn`'s result, Durable's rule). In-memory commits are checked inside
the batch. The store gets the check through its interface: `store.commit(writes, { admit, adopt }, ctx)` (section 15).
- **The line:** at the seal, a transaction of the attempt that has not entered the step yet is aborted and the line
released; a step already started finishes before the attempt's teardown completes.
- **Cleanup still runs** after the seal: `ctx.get` references of stuck calls released at teardown, presence entries,
`host.activity` counts. The existing start guards stay (`ChordObject.#run`'s attempt identity, `Job.#invoke`, a
`ctx.get` resolving after its call settled).
- **Not a replacement:** a pure swap (slice 7) keeps the attempt. Natives' effects outside Chord are the plugin's;
their `close` is honoured.
### 1.4 A live object can fail (decision "A live object can fail")
A runtime failure of the owner (a `migrate` failing on access, a stored version newer than the code) seals the live
attempt and makes the object `failed` with a `broken` cause: calls reject at once with the cause (the triggering call
too), state members keep their last values (same-host reads return them) and get no frames after the commits already
admitted to the step (those adopt and publish once), natives close, slots stay; `onError` gets `object_failed { object, cause }`
once; recovery by reload or `ref.retry()` (a new attempt; only the failed load is redone). Live dependents stay live;
their calls into it fail.
### 1.5 Smaller changes
- `ctx.get` checks the call's abort signal first (an ended handler creates nothing).
- **Names** (decision "Restricted names instead of assigned table names"): object type names and host kind names use
`[a-z0-9@/._~-]+`, lowercase, checked at declaration (`contracts/declare.ts`, `host-kind.ts`); `hostKind("client")`
is refused.
- **Errors:** a new `HostError` kinds `storage_failed { error }` and `object_failed { object, cause }`; a new reason
`unavailable/storage_failed` in the protocol's reason list (the test pinning the list changes; `PROTOCOL_VERSION`
stays 1 until p3 ships).
- **Keyed handles** take a context: `get(key, ctx)`, `keys(ctx)` (code point order, in memory too), `watch(key, ctx)`
(decision "Slice 6a's three code questions").
- **`retired` on the wire and in sources** (decision "One retirement mechanism"): `Source.advance` takes `retired`;
`Subscriber.next` gets it; `Singleton.remove` sends `{ value: initial(), ops: [["r", initial()]], retired: true }`;
a `state` entry lists retired members (`retired: [index]`), kept by the hold while its window is full (ORed until a
message carries it, as `#failed`), validated as member indexes; a derive over a retired source carries the flag on;
a `bound` snapshot carries no flag.
## 2. The store
- **Opening:** `openStore(backend, ctx, { outbox? })` with `sqliteBackend(db)` over any `SqliteDatabase` (Node through
`chord/sqlite/node`, Durable Objects through `chord/sqlite/cloudflare`, sqlite-wasm later). There is no hand-written
memory backend: tests use SQLite `:memory:` through `chord/sqlite/node`. The backend interface is internal; the
portable core imports no Node module. No `openNodeStore` shortcut.
- `openStore` runs the store's own schema migrations (`applySqliteMigrations`), checks that the file uses the UTF-8
encoding, and reads the metadata once: `lastSeq`, the outbox setting, the table definitions (version, state, columns
with `json(...)` versions and fingerprints, index definitions) and the document id counter (S10); in memory from then
on (one writer per store), persisted with every commit that changes them (S9). In-memory metadata changes only after
`COMMIT` returned.
- **Lifetime:** the app owns the store; the host never closes it (6b's Durable Session shares it). `store.close()` (no
context, as Durable's `Storage.close()`) waits for a running step, rejects queued commits, running exports and reads
with `released`, closes the backend; idempotent, also after `storage_failed`. A host without a store fails an object
declaring persisted data at activation (`broken`, "persist needs a store").
- **`storage_failed` reaches the host** through an internal listener the store calls once; the host reports
`{ kind: "storage_failed", error }` once and closes itself.
- **The outbox** (decisions "The outbox is optional, internal and versioned", small items 3-4): opened with
`{ outbox: true }`, off with `{ outbox: false }`, an omitted option keeps the persisted setting (new stores: off).
Turning it on records "outbox from seq N+1"; turning it off drops its commits. A table `outbox(seq, format, writes)`
in the backend, written in the step's transaction; `store.outbox` is `undefined` without an outbox (typed). The store
persists a floor (N when turned on at N, advanced by `trim`): `store.outbox.read(afterSeq, limit, ctx)` returns
`{ seq, writes }[]` in the current `format` (older ones stepped forward, a newer one refused with the data intact) for
`floor <= afterSeq <= lastSeq` and rejects any other `afterSeq` (`invalid/args`); `store.outbox.trim(throughSeq,
ctx)` is idempotent, rejects beyond `lastSeq`, takes no seq and runs outside the commit queue (serialized by the
backend). No pluggable encoding.
## 3. The commit step
- **One commit queue per store** serializes the step only; each object keeps its own line for the whole transaction.
- **A step:** the seal-fence check; `BEGIN`; the writes (rows, revisions, the outbox commit when there is an outbox,
the new `lastSeq`); `COMMIT`; then the owner's `adopt(seq, ids)` in one `host.batch.commit(...)` (W1), so every source
the commit touched is marked in the step and the flush right after sends one message per link. The next step starts
only after `adopt` returned (S1). Document ids are assigned in the step (monotonic with seq) and passed to `adopt`.
- **Interface** (internal, Durable's shape): `store.commit(writes, adopt, ctx): Promise`.
- **seq:** one counter per store, strictly increasing; an empty commit takes none.
- **Errors** (decision "Store errors: Durable's rule"): any backend error in a step is `storage_failed`: the store
stops, every later commit and read rejects `unavailable/storage_failed`, the host closes. Plugin mistakes are refused
when a write is staged (`invalid/args`) and never reach the step. A seal-fenced commit is rejected `released` before
`BEGIN`. A backend error on a read is `storage_failed` unless the read's `ctx` was aborted (`aborted`); invalid
arguments (a foreign cursor, a bad key) are checked before the backend (`invalid/args`). An `adopt` that throws is
`storage_failed`. Accepted: integer overflow past 2^63 in a bulk `add` is fatal.
- **Counts** through `chord/sqlite`'s `runCounted` (pi `8025c414d`), only where a count matters: bulk writes, the
engine's conditional `UPDATE`, single `update`/`delete`.
- **Statements per commit:** a delta commit of one document is about three statements (revision, the record's
`UPDATE … WHERE id = ? AND version = ?` counted, `lastSeq`), plus one `INSERT` with an outbox, plus one `DELETE` on a
checkpoint (pruning).
- **In-memory commits** never enter the queue and take no seq: a transaction with only in-memory writes commits
synchronously in a batch of its own (no extra await, no promise; a flag set at the first persisted write, no scan).
## 4. Transactions (decision "A Chord transaction enters the commit step as Durable's does")
```
take the object's line
-> run fn (only await tx.*)
-> abort or error: discard everything, reject
-> prepare every draft; checkpointWhen picks base or delta per persisted document
-> nothing persisted written: batch.commit(adopt) synchronously (W12)
-> otherwise: await store.commit(writes, adopt, ctx without its abort signal)
queue; seal check; BEGIN; revisions, rows, outbox, lastSeq; COMMIT
adopt(seq, ids) = host.batch.commit(() => { persisted slots; in-memory slots; staged changes })
-> release the line; settle the staged changes; drafts revoked (R12)
```
- The line is held until the step returned; once admitted, a commit runs to its end (the caller's abort no longer
counts); the in-memory part is adopted inside the same step (P2).
- **Cold keyed loads** (decision "Cold keyed loads on the line"): a cold `get`, `watch` or pin of a keyed document is a
job on the object's line, as Durable's `snapshot`; cached reads take no line; `tx.draft` loads inside its
transaction; one load in flight per key, shared, each caller's `ctx` ending only its own wait; a load installs its
tracker only while the key is still absent. `tx.retire(handle, key)` of an unloaded keyed persisted document finds it
inside the transaction (one read; absent: a no-op, R13).
- **A migrated base** is written back by the next commit that changes the document; until then a load migrates again
(once per object).
## 5. Tables
- `defineTable(name, { version, columns, key, indexes? })` (later versions through `evolveTable`, section 9). `columns` is a flat record of L1's helpers as
a restricted subset: `str()`, `int()`, `bool()`, `num()`, `opt(...)` of those, and `json(schema, { version,
migrate? })`; defaults as `int({ default: 0 })`; no nesting outside `json(...)` (types and declaration). Keys, index
columns and `orderBy` columns are non-`opt` `str` or `int` columns. Every index ends with the primary key.
- `self.table(Def)` only in the factory of a `local` type with empty params (P3; type error and declaration error),
once per table name per object.
- **Names** (decision "Restricted names"): table names `[a-z0-9@/._~-]+`, lowercase; the SQL table name is
`":