Relationships
#3233 Datastore rework Phase 3: the v3 commit and partition domain model, dormant (no behaviour change)
Opened by stack72 · 10/10/2026· Shipped 10/11/2026
Background (read this first)
swamp is reworking its datastore layer (tracking: swamp-club#2865). Today, repositories write files into a local cache and mark them dirty, and a sync service (in the S3 and GCS extensions) pushes and pulls files, _index shards and _meta.json.
The target design is called v3:
- The system of record. Every change is an explicit commit recorded in a single-writer SQLite database,
commits.db, which Litestream continuously replicates to object storage. - Content. Content is stored as immutable, content-addressed chunks.
- Solo mode. The CLI's embedded datastore service is the single writer.
- Team mode. One elected
swamp serveleader is the single writer, and the CLI forwards commits to it.
The public design write-up is the "Swamp Datastore Redesign" page. The parts this issue needs are restated below.
Where we are (main at cccdef9b):
- Phases 1 and 2 are done. Repositories stage every change into a unit of work through the port in
src/domain/datastore/unit_of_work.ts:- the methods are
stage,commit,abandonandstaged; StagedChangeiswrite | remove(an absolute path) orbulk(a reason);- a legacy adapter (
src/infrastructure/persistence/legacy_unit_of_work.ts) forwards each staged change to today'smarkDirtyhook; - every CLI command and serve request runs in a root unit (
runInRootUnitOfWork,src/infrastructure/persistence/repo_unit_of_work.ts) that pushes once when it ends.root.checkpoint()pushes partway through.
- the methods are
- The format guard has shipped. Every datastore open refuses a datastore marked with a newer format (#3189, #3191).
- Phase 3 builds the v3 engine, dormant. It is reachable only when a user opts a new, empty datastore into v3. Later issues build the commit store and read model, the chunk store and
storage/1, and the opt-in. This issue is the first one: the domain model they all share.
Goal
Add the v3 datastore domain model to src/domain/datastore/ as pure domain code. That means value objects, the partition aggregate with its invariants, factories, typed intents and the commit envelope. Record the domain decisions the later Phase 3 issues depend on.
Nothing in production calls it yet, so no behaviour changes.
The model
Follow the ddd skill (.claude/skills/ddd/) for building blocks and the tests each needs.
Partitions are aggregates. A partition is the storage of one aggregate instance. One commit is one transaction on one partition, and consistency between partitions is eventual. Partition keys come only from aggregate identity:
Aggregate root Partition key Invariants within one commit Model(its data names, versions, outputs, evaluated definition)model--{type}--{modelId}Versions only grow. Exactly one latestper data name.WorkflowRunrun--{workflowId}--{runId}Status transitions are valid. Step records belong to the run. Definition(a model, workflow or vault file under managedConfig)config--{kind}--{name}An edit applies to the version it was loaded from ( expect: {sha256}).AutoDefinitiondefinition--{id}Identity is stable. GrantandGroupmodel--swamp-grant--…,model--swamp-group--…Only admin actions change them. A
PartitionKeyvalue object builds and parses these keys.partitionOf()is the only way to build one from an aggregate's identity. Add a fitness rule that rejects partition-key strings built by hand anywhere else, with a pinned allowlist.Factories remove contention. Writers only compete when they change the same aggregate instance:
Aggregate Identity Contention WorkflowRunA new ULID per run ( WorkflowRunFactory)None AutoDefinitionA new UUID ( DefinitionFactory)None Content chunk Its SHA-256 None ModelIts existing id Real: a lease (Phase 5) DefinitionIts existing name Real: optimistic concurrency ( expect)New model outputs get fresh ULIDs. Check what swamp already uses for run ids and auto-definition ids (
src/domain/workflows/,src/domain/models/). Reuse those generators, and record whether today's ids already satisfy "cannot collide".Typed intents. Each op carries the domain operation it performs, which serve will later authorise on, as well as the path it projects to:
type Intent = | { kind: "data.save"; model: ModelRef; name: string; version: number } | { kind: "data.delete"; model: ModelRef; name: string; version?: number } | { kind: "definition.edit"; definition: DefinitionRef } | { kind: "run.update"; run: RunRef } | { kind: "grant.revoke"; grant: GrantRef } | …; // complete the list from every libswamp write use case
The path (
RecordKey, namespace-relative with forward slashes, e.g.data/aws-ec2/<id>/vpc/7/raw) is a projection detail. It is used only to materialise real files. Keep today's cache layout so every consumer that reads real paths keeps working.Ops and the commit envelope:
type Op = | { kind: "put"; intent: Intent; key: RecordKey; blob?: BlobRef; inline?: string /* ≤ 8 KiB, base64 */; expect?: "absent" | { sha256: string } } | { kind: "tombstone"; intent: Intent; key: RecordKey; expect?: "present" } | { kind: "data-version"; key: RecordKey; metadata: DataMetadata } | { kind: "lease"; holder: string; fence: number }; interface Commit { v: 3; seq: number; partition: string; namespace: string; txnId: string; // the outbox entry id; makes a retry idempotent writer: { host: string; pid: number; instance: string }; executedAt?: string; clockOffsetMs?: number; principal?: string; // set by serve only fence?: number; // team mode at: string; ops: readonly Op[]; }
BlobRefis{ sha256, size, legacyKey? }. Tombstones name every key explicitly; there are no prefix tombstones. A delete of a name removes only versions the writer has seen.Partition invariants, enforced by the aggregate when a commit is appended:
seqis gapless: a partition accepts onlycurrent + 1;- a commit whose
txnIdis already in the partition is a no-op, not a second commit; - data versions only grow, and each data name has exactly one
latest; - an
expectprecondition that doesn't hold rejects the whole commit; - a commit carrying a fence lower than the partition's highest fence is rejected.
The model is in-memory and pure. Persistence is the commit-store issue.
Decisions to record (in design/enablers/datastores.md, a new "v3 domain model" section)
The commit store, the chunk store and the opt-in build on these. Each needs a short decision with its reason. Also turn each one into types where that's natural.
- From staged changes to commits. The port carries paths today (
StagedChange). Decide how a commit-log unit gets typed ops (intent plus content) from repositories. Write the proposal as types, for example optional fields onStagedChangeor a sibling port. Don't change existing callers or the legacy adapter. - How a commit-log unit claims a repository context. Today
signalChangestages into the ambient unit only whenlegacyUnitOfWorkTarget(uow) === markDirty, by hook identity, so it only recognises legacy units. Design the binding a v3 unit will use, such as a binding registry. - Unscoped writes on v3. On v2, a hooked write outside any unit still marks the hook directly (route 2 in
signalChange; its removal is deferred). A v3 datastore has no such fallback, so a write outside a unit must be refused on v3. Also note that the CLI's managed-lockfile root (#3192) is bound to the transaction's own hook, so a hooked write inside it would count as unscoped. - Abandon. A legacy unit flushes on abandon, because its changes already reached the hook. A commit-log unit discards its staged ops. Record that, and what happens to content already chunked.
- Suspended and cancelled workflow runs. Today a root pushes on every outcome, but a use-case unit abandons on
suspendedandcancelled. Decide what a commit-log unit commits for each run outcome. - Checkpoints.
root.checkpoint()pushes partway through an operation. Decide what a partial commit means for a commit-log unit: commit the partitions touched so far, and keep the unit open. - Transactions across partitions. A workflow run that writes several models commits each partition on its own, in a fixed order: data partitions first, the run record last. Record that order, and that there's no atomicity across partitions.
Tests
Follow the ddd skill's table:
- Property tests (
*_property_test.ts, fast-check):- value objects (
PartitionKey,RecordKey,BlobRef,Intent,Op,Commit): round-trip serialisation, equality, andcreate()rejecting invalid input across random inputs; - the partition aggregate, over random command sequences: gapless seq,
txnIdidempotency, onelatestper name, versions only grow, a failedexpectchanges nothing, a lower fence never lands.
- value objects (
- Unit tests next to each file, named
functionName: describes behaviour. - The fitness rule for partition keys, with its allowlist.
- An architecture rule that nothing in
src/cli,src/serveorsrc/libswampimports the new modules yet, with a pinned, empty allowlist. That keeps this change dormant.
Out of scope
- SQLite,
commits.dband the read model (the commit-store issue). - Chunks and
storage/1(the chunk-store issue). - Litestream (the embedding spike runs in parallel).
- The opt-in, serve, leases and the outbox.
- Changing any existing repository, use case or adapter.
Done when
- The model and its tests are on main, and no production code imports it.
- The seven decisions are in
design/enablers/datastores.md, with types where natural. - The ubiquitous-language terms (partition, commit, seq, intent, op, chunk, fence, txnId) are documented.
- All existing suites pass unchanged.
Blocked by: nothing. It runs in parallel with the Litestream embedding spike.
Shipped
Click a lifecycle step above to view its details.
Sign in to post a ripple.