Relationships
#3240 Datastore rework Phase 3: the commit-log unit of work and binding registry (no v2 behaviour change)
Opened by stack72 · 10/11/2026· Shipped 10/11/2026
Background (read this first)
swamp is reworking its datastore layer (tracking: swamp-club#2865). In the target design, v3, every change is an explicit commit in a single-writer SQLite commits.db (replicated by Litestream), and content is stored as content-addressed chunks. Phase 3 builds the v3 engine dormant: nothing reaches it until a user opts a new, empty datastore into v3, which is a later issue.
How writes are routed today (main at 1b48d5b4), from Phases 1 and 2:
- Staging. Repositories stage every change through
signalChange(markDirty, change)(src/infrastructure/persistence/unit_of_work_scope.ts).StagedChangeiswrite | remove(a path) orbulk(a reason). It has three routes:- if the ambient unit of work is bound to the repository's own hook (
legacyUnitOfWorkTarget(uow) === markDirty), stage into it; - otherwise call the hook directly and warn once per call site. That's route 2: it still exists, and its removal is deferred;
- with no hook (filesystem datastores), do nothing.
- if the ambient unit of work is bound to the repository's own hook (
- Legacy units.
createLegacyUnitOfWork(legacy_unit_of_work.ts) forwards each staged change to the hook at once.runInRootUnitOfWork(repo_unit_of_work.ts) opens one root per CLI command or serve request: it flushes (pushes) once at the end, andcheckpoint()pushes partway through. Use cases open child units that roll up into the root. - The behaviour suites pin all of this:
integration/usecase_sync_characterization_{access,data,extension,model_workflow,vault}_test.ts;usecase_sync_root_unit_harness_test.ts,serve_root_unit_test.tsandcli_root_unit_failure_test.ts;repository_dirty_coverage_test.tsanddatastore_peer_propagation_test.ts;- the
unscoped_write_guard; - the fitness rules in
datastore_write_seams_rules_test.ts.
What's on main for v3, all dormant. The design is in design/enablers/datastores.md under "v3 domain model", "v3 commit store" and "v3 chunk storage":
The domain model (swamp-club#3233,
src/domain/datastore_v3/):PartitionKey,Intent, the ops,CommitandPartition.commit_log_unit_of_work.tsalready proposes the ports this issue implements:TypedChange { partition, op };CommitLogUnitOfWork extends UnitOfWork, addingstageOp,stagedOpsandcheckpoint;UnitOfWorkBinding, withbindandboundHook.
The domain-model section also records the decisions this issue implements:
- (1) typed changes come through the sibling port;
- (2) a binding registry generalises the hook-identity check;
- (3) writes outside a unit are refused on v3, including a hooked write inside the CLI's managed-lockfile root (swamp-club#3192), whose root is bound to the transaction's own hook;
- (4) abandon discards staged ops;
- (5) a run's unit commits on
completed,failed,suspendedandcancelled, and discards on an error or an early stop; - (6)
checkpointcommits the partitions touched so far and keeps the unit open; - (7) data partitions commit first and a run record last, with no atomicity across partitions.
The commit store (swamp-club#3236):
CommitStore.append(commit)returnsappendedorduplicate, and refuses withCommitRejectedError.The chunk store (swamp-club#3237):
ChunkStore.write(namespace, content, signal)returns aBlobRef. Chunks are uploaded before any commit names them.
A decision this issue builds on: on v3 in Phase 3, repositories keep writing local files as today. The read model, a parallel issue, applies commits from the store, and applying one twice changes nothing. So this issue never calls the read model, and the read model never calls into this one.
Goal
Implement the commit-log unit of work and the binding registry, so that a unit bound to a v3 repository context commits typed changes to commits.db. Writes outside a unit are refused on v3. v2 behaviour doesn't change at all. Repositories don't stage typed ops yet: that comes next, in its own issues.
Design
- The binding registry. Implement
UnitOfWorkBinding, and changesignalChangeto ask it instead oflegacyUnitOfWorkTarget. The legacy adapter becomes one registrant, binding each unit it builds to its hook exactly as today. That covers roots, children, nested roots over another hook, and units built with a wrapped hook, which must still not match. Every v2 routing decision must come out identical. KeeplegacyUnitOfWorkTarget's behaviour available, or delete it only if nothing else needs it. CommitLogUnitOfWork, in infrastructure (e.g.src/infrastructure/datastore_v3/commit_log_unit_of_work.ts):Staging.
stageOp(typedChange)records typed changes in order.stage(StagedChange), the path-only form, is refused on v3 with a clear error naming the change: there's no path-to-op guessing.commit():- groups ops by partition;
- uploads content that needs a chunk first, through
ChunkStore.write, so that no commit names a missing chunk; - builds one
Commitper partition:seqfromCommitStore.headplus one, a freshtxnIdper commit, the writer identity, and the fence from the writer's lease or lock when one is given; - appends in decision 7's order.
Retrying the same
txnIdafter a failure is a no-op through the store's duplicate rule. A refused commit surfacesCommitRejectedErrorunchanged. Document what happens to partitions already committed in the same unit: no atomicity across partitions.abandon()discards staged ops (decision 4).checkpoint()commits the partitions touched so far and keeps the unit open (decision 6).Children and run outcomes. Child units roll up into their root, as legacy units do. Wire the run-outcome rules (decision 5) the way the legacy units handle
pushWhen.No outbox yet. In Phase 3 the unit appends straight to the local
CommitStore. The outbox is Phase 4: leave a seam for it.
- Unscoped writes on v3. With a v3 binding in place,
signalChangemust refuse a write that has no bound unit, rather than take route 2. On v2, route 2 behaves exactly as today. Add a test for the #3192 case: a hooked repository write inside a CLI lockfile transaction on v3 is refused. - Factory. Provide a way to open a commit-log root over a v3 repository context, mirroring
runInRootUnitOfWork:commitStore,chunkStore, namespace, writer identity, optional fence. It is used only by tests until the opt-in. - Dormancy. Extend
integration/datastore_v3_rules_test.tsso no production code outside the v3 directories imports the commit-log unit. The binding registry is the one shared production change; pin it indatastore_write_seams_rules_test.tsthe waysignalChangeandcreateLegacyUnitOfWorkare pinned.
The gate: no behaviour change on v2
- Record first. Before changing
signalChange, add rows to the characterization suites for the routing cases: a root, a child, a nested root over another hook, a wrapped hook, and the #3192 lockfile root. Commit them on their own. Show in the PR that they pass on that commit. - Then change nothing in them. Every suite listed in the background must pass with no expectation edits after the registry lands.
- Prove it on every route. Run the whole suite with the route-2 recorder approach used for #3056 and the route-2 research: a temporary patch in
signalChangethat logs every write made with no bound unit, or with a unit bound to another hook. Show that the recorded set is identical before and after the change.
Tests
- Unit tests for the registry: bind, look up, wrapped hooks not matching, nested roots over two hooks.
- Integration tests on a real temp directory, with the SQLite commit store and the filesystem
storage/1:- staging and commit: typed changes staged and committed land as the expected commits;
- ordering: data partitions land before the run record;
- content: multi-chunk content is uploaded before its commit;
- retries: a retry after an injected failure lands once;
- refusals: a refusal surfaces unchanged;
- unit lifecycle: abandon discards, checkpoint commits and keeps the unit open, children roll up, and each run outcome follows decision 5;
- unscoped writes: an unscoped hooked write on v3 is refused, and the #3192 case is refused.
- The v2 gate above.
Out of scope
- Repositories staging typed ops. That's the next issues, split into three groups: data and output; definition, workflow and evaluated; run and vault config.
- The read model (a parallel issue), the outbox, Litestream, serve and the opt-in.
- Removing route 2 on v2, which is deferred.
Done when
- The registry is live with identical v2 behaviour, proven by the gate.
- The commit-log unit and its factory are on main, dormant, with these tests.
datastores.mdrecords the registry, the commit order and failure semantics, and the outbox seam.
Blocked by: nothing (#3236 and #3237 have merged). It runs in parallel with the v3 read-model issue.
Shipped
Click a lifecycle step above to view its details.
Sign in to post a ripple.