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

Relationships

#3056 Datastore rework Phase 2: find, wrap and warn on hooked writes outside a unit of work (route 2), without removing it (no behaviour change)

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

Background (read this first)

swamp is reworking its datastore layer (tracking: swamp-club#2865). Phase 1 is complete. Phase 2 has shipped:

  • swamp-club#3025: use cases open units of work;
  • swamp-club#3032: root units per command or request;
  • swamp-club#3033, swamp-club#3034 and swamp-club#3035: the CLI and serve push through roots;
  • swamp-club#3053: checkpoints;
  • swamp-club#3055: one flush path, so every push is a root's flush or checkpoint, with PINNED_DIRECT_PUSHES listing the deliberate exceptions.

Phase 2 must change no behaviour.

The hook fallback ("route 2"). signalChange(markDirty, change) in src/infrastructure/persistence/unit_of_work_scope.ts (main at 94baeb4c) is how every hooked repository signals a change:

  1. Route 1: if the ambient unit wraps this exact hook, stage into it.
  2. Route 2: otherwise, if there is a hook, call it directly.
  3. Route 3: otherwise, do nothing (filesystem datastores have no hook).

Route 2 is the safety net. Any hooked write made outside a unit still reaches the sync service through it. The Phase 2 plan is to remove route 2, so repositories reach the hook only through units.

Removing it now isn't safe, because the failure is silent. A missed write path outside a unit would never be marked: it stays on one machine, and nothing errors. Tests only find paths they exercise. Candidates for writes outside a unit include:

  • serve start-up migration and token GC;
  • serve background GC (bookkeeping_gc.ts, worker_gc_service.ts);
  • datastore setup's migration;
  • datastore sync;
  • libswamp code that isn't a wrapped use case;
  • rarely used CLI commands and error paths;
  • the workflow step-lock paths.

So route 2 is removed in two issues. This one finds, wraps and observes, and changes no behaviour. A later issue removes route 2, once the observation here has stayed quiet through dogfooding and the swamp-uat suites. This issue must not remove route 2.

Goal

  1. Find every hooked write that takes route 2.
  2. Give each a root, so it takes route 1.
  3. Make any remaining route-2 use visible: a warning in production, and a failure in tests.

The marks and pushes stay identical.

Work

  1. A route-2 reporter in signalChange. When route 2 runs, call the hook exactly as today, then report the change. The report runs after the hook call, so a failing reporter can never stop a mark.

    • Production: log at warn, once per distinct call site per process, under ["datastore", "unit-of-work"]. Include the change's kind and path, and the first stack frame outside src/infrastructure/persistence/, so the caller is named. Capture the stack only on route 2, never on route 1.
    • Tests: add a seam, for example useUnscopedChangeReporterForTesting(fn), in the style of useUnitOfWorkFactoryForTesting: process-global, refusing a second install, and allowed only from tests by the fitness rule.
  2. Find the writes. Locally, install a reporter that throws, and run the whole test suite: deno run test, both unit and integration. Collect every distinct caller. List them in the PR, with the test that hit each one.

  3. Wrap each one in a root, so it takes route 1:

    • where the path pushes today (it's in PINNED_DIRECT_PUSHES or reaches a push path), its existing push is the root's flush, with the same pushWhen behaviour;
    • where it doesn't push today, use a root with no flush. Its marks then wait for whatever push already picks them up, exactly as now;
    • don't nest a root with its own flush inside another, because that throws (#3032).

    The rules from #3033, #3034 and #3055 apply: record characterization or remote-failure rows from the current code in a commit before wrapping any path whose ordering a test can observe, then wrap it.

  4. What can't be wrapped yet. If a path can't take a root without changing behaviour, leave it on route 2 and pin it in a new PINNED_UNSCOPED_WRITERS list in integration/datastore_write_seams_rules_test.ts, with the reason. The production warning still fires for it, and its test-seam handling must allow exactly the pinned callers.

  5. A permanent test guard. Install the throwing reporter, minus the pinned callers, in the shared harnesses:

    • integration/usecase_sync_fixtures.ts;
    • the root-unit harness;
    • integration/datastore_remote_failure_test.ts;
    • integration/serve_root_unit_test.ts;
    • integration/datastore_peer_propagation_test.ts;
    • integration/repository_dirty_coverage_test.ts, apart from its deliberate no-scope runs.

    A new unscoped write in a covered path then fails CI. Don't mutate process-global state across test files: install per test, and dispose in finally.

  6. Docs. In design/enablers/datastores.md, describe the warning and the guard. Record the condition for the follow-up removal issue: no route-2 warnings in dogfooding or in the swamp-uat datastore and serve suites against a release that contains this change, and PINNED_UNSCOPED_WRITERS empty.

Tests

  1. Unit tests for signalChange:
    • route 2 marks exactly as before, then reports;
    • a reporter that throws doesn't stop the mark;
    • routes 1 and 3 never report;
    • the warning fires once per call site and names a caller outside the persistence layer.
  2. Equivalence:
    • all five characterization files, the root-unit harness, remote failure, serve root-unit, peer and dirty coverage pass with no expectation changes, now with the guard installed;
    • every wrapped path has a before-change row, recorded first.
  3. Rules: PINNED_UNSCOPED_WRITERS is populated, ideally empty. The seam is test-only. PINNED_UNIT_OF_WORK_SCOPES and PINNED_DIRECT_PUSHES are updated for any new roots.
  4. These pass unchanged: src/cli, src/libswamp, src/serve, src/infrastructure, and the swamp-uat datastore and serve suites.

Dependencies

Blocked by: nothing. swamp-club#3055 is merged. Blocks: "remove the route-2 fallback" (not filed). It should be filed only once the removal condition in Work item 6 holds.

Done when

  • Route 2 still marks exactly as before, warns once per call site in production, and fails tests through the seam.
  • Every route-2 caller found is wrapped in a root, or pinned in PINNED_UNSCOPED_WRITERS with a reason. The PR lists them all, with the tests that found them.
  • The guard is installed in the shared harnesses.
  • The suites pass unchanged, with before-change rows recorded.
  • Verification passes.

Out of scope

  • Removing route 2 or the hook constructor parameters (the follow-up).
  • The lockfile publish, deferred to the start of Phase 3.
  • Precise per-path marks.
  • Tracking: swamp-club#2865 (Phase 2).
  • Built on: swamp-club#3025, #3032, #3033, #3034, #3035, #3053, #3055.
02Bog Flow
✓OPEN✓TRIAGED✓IN PROGRESS✓SHIPPED+ 1 MOREASSIGNED+ 5 MOREREVIEW+ 21 MOREPR_MERGED+ 2 MORESESSION_SUMMARIZED

Shipped

10/6/2026, 11:19:26 AM

Click a lifecycle step above to view its details.

03Sludge Pulse
stack72 assigned stack7210/6/2026, 12:40:42 AM

Sign in to post a ripple.