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

Relationships

#2979 Datastore rework Phase 1 move A: data and output repositories stage typed changes (no behaviour change)

Opened by stack72 · 10/2/2026· Shipped 10/2/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). The rework replaces "write a file, mark it dirty, push later" with "stage typed changes into a unit of work and commit it". Phase 1 must change no behaviour.

Already on main for Phase 1:

  • swamp-club#2970, the UnitOfWork port and legacy adapter.
    • src/domain/datastore/unit_of_work.ts defines StagedChange as one of:

      • { kind: "write"; path }, for a write about to happen;
      • { kind: "remove"; path }, for a removal about to happen;
      • { kind: "bulk"; reason }, for a change that can't be attributed to one path.

      Paths are absolute.

    • src/infrastructure/persistence/legacy_unit_of_work.ts holds createLegacyUnitOfWork(markDirty, { flush }). Its stage forwards each change at once, in order, to the repository's MarkDirtyHook: write and remove become markDirty(path), and bulk becomes markDirty(undefined). Write and remove reach the hook identically: the sync service decides "delete" at push time when the path is absent on disk (rule 2 of the markDirty contract, src/domain/datastore/datastore_sync_service.ts).

  • swamp-club#2971, the ambient unit of work (src/infrastructure/persistence/unit_of_work_scope.ts).
    • runInUnitOfWork, currentUnitOfWork, signalChange(markDirty, change) and changeFor(relPath, reason).
    • signalChange stages into the ambient unit when that unit wraps this repository's own hook. Otherwise it calls the hook as before, or does nothing when there's no hook.
    • Each of the seven hooked repositories has a private notifyDirty(relPath?) that does signalChange(this.<hook>, changeFor(relPath, "<Repo>.notifyDirty")).
    • changeFor maps every path to write, because notifyDirty can't tell a write from a remove.
    • No production code opens a scope; integration/datastore_write_seams_rules_test.ts pins that list at zero.

What a "repository move" is. The three Phase 1 moves give repositories typed changes at their call sites. Each this.notifyDirty(path) becomes a staged change of the right kind:

  • write when the path exists after the operation (a file or directory created or changed);
  • remove when the path is gone after it;
  • bulk with a reason only where no single path covers the change.

The private notifyDirty then goes away. The legacy adapter forwards every kind identically, so the marks sent to the sync service do not change at all. What changes is that the unit of work now knows what each change is, which Phase 3's commit-log adapter needs.

The tests that prove nothing changed are on main:

  • integration/repository_dirty_coverage_test.ts:
    • every file a repository changes must be marked;
    • today's gaps are pinned in KNOWN_UNMARKED;
    • a #2971 test runs every row with and without a legacy scope and requires identical marks, plus one staged change per mark.
  • integration/datastore_write_seams_rules_test.ts: pinned lists of mark call sites, repository constructions, unhooked writers and scope openers.
  • integration/datastore_sync_rules_test.ts: bans a bare this.notifyDirty() in PER_PATH_WIRED_REPOS.
  • integration/datastore_peer_propagation_test.ts and integration/datastore_remote_failure_test.ts.
  • integration/usecase_sync_characterization_*_test.ts.
  • The swamp-uat datastore suite, which runs against the PR binary.

Rules:

  • Every test above passes with no change to its expectations. Pinned lists change only as described in this issue.
  • Do not fix any KNOWN_UNMARKED gap. Don't add, remove or reorder marks, and don't change when a mark happens relative to its write: it stays before the write.
  • Follow AGENTS.md: named exports, no any, license headers, no fire-and-forget promises, no fixed sleeps or wall-clock assertions, and the verification workflows before the PR.

Running in parallel. Two moves, A (data and output) and B (definition, workflow and evaluated), run at the same time. They change different repository files, but both edit the same test files: datastore_write_seams_rules_test.ts, datastore_sync_rules_test.ts and repository_dirty_coverage_test.ts. Whichever lands second rebases and keeps both sides. Where this issue says "create X if it does not exist yet", the first move to land creates it and the second adds its entries.

Goal

Phase 1 repository move A: the data and output repositories stage typed changes (write, remove, bulk) at each call site instead of calling notifyDirty(path). The marks the sync service receives stay exactly the same.

Repositories and call sites (line numbers from main at d7714fef)

src/infrastructure/persistence/unified_data_repository.ts: FileSystemUnifiedDataRepository, with notifyDirty at :158 and 15 call sites:

Line Method / context Marked path
:620 save data-name dir
:705 saveDeferred data-name dir
:790 append data-name dir
:921 delete (one version) version dir
:975 delete (all), per version version dir
:977 delete (all) latest marker
:981 delete (all) data-name dir
:1004 removeLatestMarker data-name dir
:1035 rename old data-name dir (tombstone, content, latest)
:1197 allocateVersion data-name dir
:1238 finalizeVersion version dir
:1301 finalizeVersionDeferred version dir
:1826 collectGarbage version dir
:1885 collectGarbage data-name dir, removed with its contents
:1969 pruneExcessVersions version dir

src/infrastructure/persistence/yaml_output_repository.ts: YamlOutputRepository, with notifyDirty at :98 and 4 call sites:

Line Method / context Marked path
:394 save output yaml
:620 removeOutputFiles yaml
:622 removeOutputFiles log
:692 sweepOrphanLogs orphan log

The table's "marked path" column is a starting point. Read each method to decide the kind.

Work

  1. Replace each this.notifyDirty(path) with a typed staged change through signalChange(this.<hook>, { kind, path }). A small private helper per repository is fine, for example private stage(change: StagedChange).
    • The kind follows the effect on disk after the operation:

      • write if the path exists afterwards. This covers a directory whose contents were created, changed or partly removed: rename's old-name dir still holds a tombstone, and delete(version) leaves the data-name dir in place.
      • remove if the path is gone afterwards, such as a version dir removed by delete, collectGarbage or pruneExcessVersions, or a data-name dir removed by delete(all).

      Where a call site's effect depends on a branch, pick the kind for the branch that runs at that call, and move the mark into the branch if needed. Calls must not move relative to their writes.

    • Keep every path, every call, the order and the pre-write timing exactly as they are.

    • No call site is bulk today. Don't introduce one.

  2. Delete the private notifyDirty from both repositories, and stop importing changeFor there. changeFor stays in unit_of_work_scope.ts while other repositories still use it.
  3. Keep the constructor hook parameters and everything in repository_factory.ts unchanged.

Shared test and fitness work (both moves)

  1. A disk-effect check in integration/repository_dirty_coverage_test.ts (create it if it isn't there yet).
    • Add a MOVED_REPOSITORIES set, and add this issue's repositories to it.
    • For every row of a moved repository, in the scoped run, check each staged change against the disk after act: a write path exists, and a remove path is absent.
    • Rows of unmoved repositories are skipped, because their changes are all still write.
    • This proves each call site picked the right kind.
  2. Unchanged expectations. The #2971 equivalence test, the marks-with-and-without-a-scope test, and KNOWN_UNMARKED all pass unchanged.
  3. Update integration/datastore_write_seams_rules_test.ts. This issue's repositories drop out of PINNED_MARK_CALL_SITES once their notifyDirty is gone, so the staged changes need their own pin. Add PINNED_STAGED_CHANGES if it doesn't exist yet: keys "<file>: <owner> <kind> (xN)" per repository and kind, counted the same way as the existing lists. Add this issue's entries. A later change to how a repository signals then shows up in review.
  4. Update integration/datastore_sync_rules_test.ts. Drop the moved repositories from PER_PATH_WIRED_REPOS, since they no longer have notifyDirty. Add a rule that a moved repository stages no bulk change without a non-empty reason. Pin the moved repositories' bulk changes as an explicit list; it's empty for this issue.
  5. The rest pass unchanged: datastore_peer_propagation_test.ts, datastore_remote_failure_test.ts, all usecase_sync_characterization_*_test.ts, src/cli/repo_context_test.ts, and each moved repository's own unit tests.

Dependencies

Blocked by: nothing. Both #2970 and #2971 are merged. Runs in parallel with: swamp-club#2980 (Phase 1 move B). It edits the same three test files; see Background. Blocks: the Phase 1 step "repository marks ratcheted to zero", with move B and the run and vault/lockfile moves (not filed yet).

Done when

  • Neither repository has a notifyDirty. Every call site stages a typed change, and the disk-effect check passes for every row of both repositories.
  • The marks sent are identical: the equivalence test and every suite listed in Background pass with no expectation changes.
  • PINNED_STAGED_CHANGES lists both repositories' write and remove counts. PINNED_MARK_CALL_SITES no longer lists them. The bulk pin is empty.
  • Verification workflows pass on the final commit.

Out of scope

  • The definition, workflow and evaluated repositories (move B).
  • Run, vault config and lockfile repositories.
  • Removing hook parameters, opening scopes (Phase 2), and fixing any KNOWN_UNMARKED gap.
  • Tracking: swamp-club#2865 (Phase 1).
  • Built on: swamp-club#2970, swamp-club#2971.
02Bog Flow
✓OPEN✓TRIAGED✓IN PROGRESS✓SHIPPED+ 1 MOREASSIGNED+ 5 MOREREVIEW+ 16 MOREPR_MERGED+ 2 MORESESSION_SUMMARIZED

Shipped

10/2/2026, 8:16:40 PM

Click a lifecycle step above to view its details.

03Sludge Pulse
stack72 assigned stack7210/2/2026, 7:05:54 PM

Sign in to post a ripple.