From the local-store durability study (three-lens fable analysis, 2026-07-25). Decision recorded in ADR-0014 (#610).
Decision
The local store is per-call committed durability, not per-event atomic: each set/delete is an individually fsync-durable redb transaction, program-order, read-your-writes, and a trap (fault, panic, OutOfFuel, deadline, crash) freezes state at the last completed call and never rewinds it. Per-event atomicity is rejected: it would roll back the keeper Journal's RESERVED marker on exactly the trap it exists to survive, breaking the reserve/commit/reconcile design (#583-#587, nullislabs/shepherd#574), and it defends against nothing (dispatch is single-actor serialised, redb single-process). The sanctioned atomicity scope is a single opt-in batch verb.
API surface: no new host verbs beyond apply
The host primitive is complete: get/set/delete/list-keys/count/contains/len plus apply(set|delete). set is already an upsert (insert or overwrite). No compare-and-swap, atomic increment, or prefix-delete host verb is warranted, because every module is key-isolated by a keccak prefix (ADR-0003) and dispatch is single-actor serialised, so no two writers ever touch the same key: get-then-apply is effectively atomic, and prefix-delete composes as list-keys + apply(deletes). WIT variants stay additively extensible, so a conditional write-op can be added later if a concrete need appears; not now. Enrich the SDK, not the seam: with the three-repo cleave done, every WIT addition is a versioned cross-repo change, so a minimal host seam is itself the low-effort path.
Build
Host (Wave 1, done)
apply WIT verb + ModuleStore::apply (#611): additive apply(list<write-op>) with write-op = set(key,value) | delete(key), one begin_write/commit, whole-batch net quota projection, op-count and byte caps, all-or-nothing. Landed.
SDK ergonomics (Wave 2, after nullislabs/shepherd#611 merges, needs apply in the bindings)
The pain is hand-serialising every list<u8> and rewriting get-modify-set and batch patterns; all pure SDK sugar over the primitives, zero host surface. A module should write typed, intention-revealing code and never touch raw bytes or hand-roll a batch.
TypedCell<T>: a borsh-typed key (cell.get/cell.set). The single biggest win; kills manual serialisation.
- Typed keyed collection: list-by-prefix plus typed values, for sets of things (watches, orders), so modules stop hand-rolling indexes.
- Counter / accumulator helper (get-inc-set, safe under single-actor dispatch).
WriteBatch builder over apply (stage in guest memory, flush in one host call; a pre-flush trap discards for free) and a clear_prefix helper (list-keys + apply-deletes, atomic).
- Journal guard combinator: reserve, run the submit closure, then commit or park, enforcing reserve-before-effect ordering by API shape. The Journal's single-key markers stay on plain
set (they span an await, so they structurally cannot ride one apply).
Test kit (Wave 1, done)
- Trap-injection harness (#612): a mock local store that traps after the Nth write, sweeping every torn prefix of the keeper reserve/commit flow and asserting reconcile converges exactly-once. Test-only. Plus the review rule: no in-store invariant may span two
set calls unless the intermediate state is self-healing or the writes use apply.
Constraints
apply is an additive nexum:host@0.1.0 verb, already landed; with the three-repo cleave done, any further WIT change ripples across repos, which is exactly why the seam stays minimal. The Journal and the reserve/commit/reconcile code stay untouched; the SDK helpers add no host surface.
Folded from #12. If a conditional write-op is ever added, reuse the entry_cost and counter quota discipline exactly: mutate the quota counter only after commit, on the landed branch only, or the quota drifts. The SDK trait method for any such verb must bind directly in every WitBindgenHost adapter, never via a non-atomic default body that would ship into production.
Acceptance
apply lands additive, quota-projected, capped, all-or-nothing (done, nullislabs/shepherd#611); the trap-injection harness proves crash convergence from every torn prefix (done, nullislabs/shepherd#612); ADR-0014 and the docs state the per-call contract with no rollback claim (#610, nullislabs/shepherd#606); the Wave 2 SDK helpers give module authors typed cells, collections, counters, atomic batches and a Journal guard, so no module reimplements store atomicity or serialisation by hand.
Decision
The local store is per-call committed durability, not per-event atomic: each
set/deleteis an individually fsync-durable redb transaction, program-order, read-your-writes, and a trap (fault, panic, OutOfFuel, deadline, crash) freezes state at the last completed call and never rewinds it. Per-event atomicity is rejected: it would roll back the keeper Journal'sRESERVEDmarker on exactly the trap it exists to survive, breaking the reserve/commit/reconcile design (#583-#587, nullislabs/shepherd#574), and it defends against nothing (dispatch is single-actor serialised, redb single-process). The sanctioned atomicity scope is a single opt-in batch verb.API surface: no new host verbs beyond apply
The host primitive is complete:
get/set/delete/list-keys/count/contains/lenplusapply(set|delete).setis already an upsert (insert or overwrite). No compare-and-swap, atomic increment, or prefix-delete host verb is warranted, because every module is key-isolated by a keccak prefix (ADR-0003) and dispatch is single-actor serialised, so no two writers ever touch the same key:get-then-applyis effectively atomic, and prefix-delete composes aslist-keys+apply(deletes). WIT variants stay additively extensible, so a conditionalwrite-opcan be added later if a concrete need appears; not now. Enrich the SDK, not the seam: with the three-repo cleave done, every WIT addition is a versioned cross-repo change, so a minimal host seam is itself the low-effort path.Build
Host (Wave 1, done)
applyWIT verb +ModuleStore::apply(#611): additiveapply(list<write-op>)withwrite-op = set(key,value) | delete(key), onebegin_write/commit, whole-batch net quota projection, op-count and byte caps, all-or-nothing. Landed.SDK ergonomics (Wave 2, after nullislabs/shepherd#611 merges, needs
applyin the bindings)The pain is hand-serialising every
list<u8>and rewriting get-modify-set and batch patterns; all pure SDK sugar over the primitives, zero host surface. A module should write typed, intention-revealing code and never touch raw bytes or hand-roll a batch.TypedCell<T>: a borsh-typed key (cell.get/cell.set). The single biggest win; kills manual serialisation.WriteBatchbuilder overapply(stage in guest memory, flush in one host call; a pre-flush trap discards for free) and aclear_prefixhelper (list-keys+apply-deletes, atomic).set(they span an await, so they structurally cannot ride oneapply).Test kit (Wave 1, done)
setcalls unless the intermediate state is self-healing or the writes useapply.Constraints
applyis an additivenexum:host@0.1.0verb, already landed; with the three-repo cleave done, any further WIT change ripples across repos, which is exactly why the seam stays minimal. The Journal and the reserve/commit/reconcile code stay untouched; the SDK helpers add no host surface.Folded from #12. If a conditional
write-opis ever added, reuse theentry_costand counter quota discipline exactly: mutate the quota counter only after commit, on the landed branch only, or the quota drifts. The SDK trait method for any such verb must bind directly in everyWitBindgenHostadapter, never via a non-atomic default body that would ship into production.Acceptance
applylands additive, quota-projected, capped, all-or-nothing (done, nullislabs/shepherd#611); the trap-injection harness proves crash convergence from every torn prefix (done, nullislabs/shepherd#612); ADR-0014 and the docs state the per-call contract with no rollback claim (#610, nullislabs/shepherd#606); the Wave 2 SDK helpers give module authors typed cells, collections, counters, atomic batches and a Journal guard, so no module reimplements store atomicity or serialisation by hand.