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

Relationships

#3189 Datastore rework Phase 3: format-guard release, so swamp refuses datastores with a newer format and writes nothing

Opened by stack72 · 10/8/2026· Shipped 10/8/2026

Background (read this first)

swamp is reworking its datastore layer (tracking: swamp-club#2865; design: design/enablers/datastore-commit-log.md on the datastore-rework branch). Phases 1 and 2 are complete apart from the deferred route-2 removal. Phase 3 introduces a v3 datastore format:

  • a SQLite commit log (commits.db) replicated with Litestream;
  • content-addressed chunks;
  • reachable only when a user opts a new, empty datastore into v3.

Today's format is v2. An S3 or GCS datastore holds _index/ shards, a _meta.json sequence counter, data objects and a .datastore.lock. A filesystem datastore is plain files under its path.

The danger the format guard prevents. A v3 datastore has no v2 index. A swamp binary that only knows v2, opening a v3 datastore, would see an empty datastore. It would then:

  1. pull nothing;
  2. take its lock, writing a .datastore.lock;
  3. push v2 objects into the v3 bucket.

That mixes formats and can corrupt the datastore. Old binaries stay in the wild, on teammates' machines, in CI and in pinned versions, so old binaries must refuse a datastore marked with a newer format, and write nothing to it. That logic has to exist in released binaries before v3 can be switched on anywhere. So this guard ships first, as its own release, ahead of any v3 code. The Phase 3 opt-in issue will require at least one release containing this guard.

How an old binary can see a marker today (main at 03ca42ca):

  • S3 and GCS (@swamp/s3-datastore, @swamp/gcs-datastore) implement controlPlaneStore() and advertise capabilities().controlPlane: true. ControlPlaneStore (src/domain/datastore/control_plane_store.ts) reads and writes small records straight from the bucket under _control/, scoped by namespace when one is configured, bypassing the sync index. Core already uses it for heartbeats, pending runs and continuation claims (src/cli/repo_context.ts, around lines 1569-1700).
  • Filesystem datastores use a repo-local FileSystemControlPlaneStore(swampPath(repoDir)), which isn't shared with other clones. For them the marker must be a file at the datastore path itself, which core reads directly.
  • Third-party datastore extensions may not advertise controlPlane.

The rest of the plan:

  • v3 writes the marker when it creates a datastore (the Phase 3 opt-in issue).
  • The Phase 6 migration writes it when it converts one.
  • This issue only reads it. It never writes a marker to any existing datastore.

Goal

Every way swamp opens a datastore first reads a format marker, and refuses, before writing anything, when the marker names a format this binary doesn't support. Datastores without a marker are v2 and behave exactly as today.

Design

  1. The marker:

    • On S3, GCS and any extension with controlPlane: a control-plane record at a fixed key, for example format, holding JSON like {"format": 3, "minReaderFormat": 3, "writtenBy": "<swamp version>"}.
    • On filesystem datastores: a file at the datastore root, for example <path>/datastore-format.json, with the same JSON.
    • A missing marker means format 2.
    • Namespaces. The marker belongs to the whole datastore, not a namespace. Find out how each extension maps control-plane keys when a namespace is configured, and make the read find the datastore-wide marker from a namespaced client too, for example with an un-namespaced read path or a documented key both use. Record what you chose in the design doc. Never guess: test it with a namespaced client against ministack and fake-gcs.
  2. The check: assertSupportedDatastoreFormat(...) in the datastore domain, with the reading side in infrastructure.

    • Supported formats: this binary supports [2].
    • When to refuse: if minReaderFormat (or format, when that's absent) is greater than 2, refuse.
    • Unreadable markers: if the marker exists but can't be parsed, refuse too, naming the key or file. Treating it as v2 would be the unsafe direction.
  3. Where it runs: before anything writes. That means before taking any lock (the .datastore.lock and per-model locks), before any pull or push, and before any migration or setup step. Cover every entry point:

    • requireInitializedRepo, requireInitializedRepoUnlocked, requireInitializedRepoReadOnly;
    • acquireModelLocks;
    • swamp serve start-up, before hydration and pollers;
    • worker start;
    • swamp datastore setup (filesystem and extension). Setting up onto a v3 datastore must refuse;
    • swamp datastore sync, datastore namespace commands, datastore config migrate, datastore migrate-index, doctor datastores --repair;
    • anything else that builds a sync service for a datastore. Use a call-graph sweep from createSyncService and the providers' createLock, as #3056 did, and list them in the PR.

    Read-only commands are guarded too, because reading v3 as v2 shows wrong data.

  4. The error. A UserError, rendered cleanly in log and JSON modes:

    This datastore uses format 3 (written by swamp X). This version of swamp supports format 2 only. Upgrade swamp to use it. Nothing was changed.

    Give it a stable error code for --json. The exit code is the one other UserErrors use.

  5. When the marker can't be read. If the marker read fails for reasons other than "not found" (network, permissions), don't block. Proceed as today and log at debug. Today's commands would surface the same outage themselves, and blocking offline or degraded use would be a behaviour change. Document this trade-off.

  6. Extensions without controlPlane: skip the check and log at debug. Document that v3 will require extensions that can carry the marker. The Phase 3 storage/1 contract must include it, so note that requirement in the design doc.

  7. Cost: one small control-plane GET per datastore open. Measure it against the swamp-uat performance baseline (uat#496) if it exists, otherwise against a local ministack run. Report the added latency in the PR. Don't cache the result across processes: a datastore can be migrated between runs.

Tests

  1. Unit tests for marker parsing and the decision:

    • missing marker means v2 and passes;
    • format 2 passes;
    • format 3, or minReaderFormat 3, refuses;
    • a garbled marker refuses;
    • a read error proceeds;
    • an extension without control plane proceeds.
  2. Integration tests in integration/:

    • Remote: use the in-memory remote. It doesn't model the control plane today, so add a minimal controlPlaneStore to packages/testing/in_memory_remote.ts, behind an option so existing behaviour is unchanged.
    • Filesystem: use a real shared directory.
    • Refusal: with a planted format-3 marker, every guarded entry point refuses.
    • Nothing written: the remote's files, control-plane records, lock objects and op log are byte-identical before and after. On filesystem, compare the directory listing and contents.
    • No marker: every entry point behaves exactly as today. All existing characterization suites pass with no expectation changes.
  3. swamp-uat. File a companion issue in swamp-club/swamp-uat. Against ministack, fake-gcs and a shared filesystem, plant a format-3 marker with putObject under _control/ (or the file), then run representative commands:

    • data list, model method run, datastore sync, datastore setup onto it, and serve start-up;
    • assert the error, the exit code and --json;
    • fingerprint the backend before and after, using listObjects and content hashes.

    Also check a namespaced repo sees the datastore-wide marker.

  4. Fitness: a rule listing every production call of createSyncService and createLock, each paired with the guard. A new datastore entry point without the guard then fails.

Dependencies

Blocked by: nothing.

Blocks: the Phase 3 opt-in (format: v3 for new datastores), which must not merge until a released swamp version contains this guard.

Related: swamp-uat#494 (two swamp versions on one datastore). The full "old binary opens a v3 datastore" check runs with that harness once v3 exists. It isn't needed here, because the guard's own tests use a planted marker.

Done when

  • Every datastore entry point checks the marker before any write, found by sweep and pinned by the new rule.
  • A newer-format marker is refused with a clear, stable error, and nothing is written, proven for remote and filesystem.
  • No marker behaves exactly as today, with the suites unchanged.
  • Namespaced clients see the datastore-wide marker.
  • The marker spec (keys, JSON, namespace handling, and the extension requirement for v3) is in design/enablers/datastores.md.
  • The swamp-uat companion issue is filed.
  • Verification passes, and the release that contains it is recorded on swamp-club#2865.

Out of scope

  • Writing markers. That happens in the v3 opt-in and the migration.
  • Any v3 code.
  • Changing behaviour for v2 datastores.
  • Tracking: swamp-club#2865 (Phase 3, first item, ships early).
02Bog Flow
✓OPEN✓TRIAGED✓IN PROGRESS✓SHIPPED+ 1 MOREASSIGNED+ 5 MOREREVIEW+ 14 MOREPR_MERGED+ 2 MORESESSION_SUMMARIZED

Shipped

10/8/2026, 8:13:14 PM

Click a lifecycle step above to view its details.

03Sludge Pulse
stack72 assigned stack7210/8/2026, 6:55:30 PM

Sign in to post a ripple.