Skip to main content
← Back to list
01Issue
FeatureShippedSwamp CLIPublic
Assigneesstack72

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 serve leader 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, abandon and staged;
    • StagedChange is write | remove (an absolute path) or bulk (a reason);
    • a legacy adapter (src/infrastructure/persistence/legacy_unit_of_work.ts) forwards each staged change to today's markDirty hook;
    • 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 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.

  1. 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 latest per data name.
    WorkflowRun run--{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}).
    AutoDefinition definition--{id} Identity is stable.
    Grant and Group model--swamp-grant--…, model--swamp-group--… Only admin actions change them.

    A PartitionKey value 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.

  2. Factories remove contention. Writers only compete when they change the same aggregate instance:

    Aggregate Identity Contention
    WorkflowRun A new ULID per run (WorkflowRunFactory) None
    AutoDefinition A new UUID (DefinitionFactory) None
    Content chunk Its SHA-256 None
    Model Its existing id Real: a lease (Phase 5)
    Definition Its 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".

  3. 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.

  4. 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[];
    }

    BlobRef is { 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.

  5. Partition invariants, enforced by the aggregate when a commit is appended:

    • seq is gapless: a partition accepts only current + 1;
    • a commit whose txnId is 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 expect precondition 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.

  1. 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 on StagedChange or a sibling port. Don't change existing callers or the legacy adapter.
  2. How a commit-log unit claims a repository context. Today signalChange stages into the ambient unit only when legacyUnitOfWorkTarget(uow) === markDirty, by hook identity, so it only recognises legacy units. Design the binding a v3 unit will use, such as a binding registry.
  3. 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.
  4. 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.
  5. Suspended and cancelled workflow runs. Today a root pushes on every outcome, but a use-case unit abandons on suspended and cancelled. Decide what a commit-log unit commits for each run outcome.
  6. 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.
  7. 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, and create() rejecting invalid input across random inputs;
    • the partition aggregate, over random command sequences: gapless seq, txnId idempotency, one latest per name, versions only grow, a failed expect changes nothing, a lower fence never lands.
  • 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/serve or src/libswamp imports the new modules yet, with a pinned, empty allowlist. That keeps this change dormant.

Out of scope

  • SQLite, commits.db and 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.

02Bog Flow
✓OPEN✓TRIAGED✓IN PROGRESS✓SHIPPED+ 1 MOREASSIGNED+ 10 MOREREVIEW+ 22 MOREPR_LINKED+ 2 MORESESSION_SUMMARIZED

Shipped

10/11/2026, 12:41:36 AM

Click a lifecycle step above to view its details.

03Sludge Pulse
stack72 assigned stack7210/10/2026, 11:02:41 PM

Sign in to post a ripple.