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

Relationships

#2980 Datastore rework Phase 1 move B: definition, workflow and evaluated 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 B: the definition, workflow, evaluated definition and evaluated workflow 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/yaml_definition_repository.ts: YamlDefinitionRepository, with notifyDirty at :136.

Line Method / context Marked path
:752 save target yaml
:974 delete resolved path
:984 delete each other path it removes

src/infrastructure/persistence/yaml_workflow_repository.ts: YamlWorkflowRepository, with notifyDirty at :96.

Line Method / context Marked path
:276 save target yaml
:373 delete resolved path

src/infrastructure/persistence/yaml_evaluated_definition_repository.ts: YamlEvaluatedDefinitionRepository, with notifyDirty at :105.

Line Method / context Marked path
:365 save target
:441 delete resolved path
:508 clearAll base dir

src/infrastructure/persistence/yaml_evaluated_workflow_repository.ts: YamlEvaluatedWorkflowRepository, with notifyDirty at :124.

Line Method / context Marked path
:279 save target
:337 delete resolved path
:357 clear dir
:381 saveForRun target
:457 deleteForRun dir

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

Known gaps you must leave alone. KNOWN_UNMARKED pins rename and legacy cleanups in these repositories that are never marked:

  • cleanupOldPaths in the definition repository;
  • the removals in save, and the extra pathsToTry removals in delete, in the workflow and both evaluated repositories.

Don't add marks for them; that would change behaviour.

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.
    • The kind follows the effect on disk after the operation:

      • write if the path exists afterwards, such as a saved yaml;
      • remove if it's gone, such as a deleted yaml, or a directory clear, clearAll or deleteForRun removes.

      If clear or clearAll leaves the directory in place and only empties it, use write for the directory. The disk-effect check decides.

    • 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 all four repositories, and stop importing changeFor there.
  3. Keep the constructor hook parameters and everything in repository_factory.ts unchanged. The evaluate use cases build evaluated repositories without a hook (src/libswamp/models/evaluate.ts, src/libswamp/workflows/evaluate.ts, pinned in PINNED_UNHOOKED_WRITERS). Leave them as they are; with no hook, staging sends nothing, exactly as today.

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#2979 (Phase 1 move A). It edits the same three test files; see Background. Blocks: the Phase 1 step "repository marks ratcheted to zero", with move A and the run and vault/lockfile moves (not filed yet).

Done when

  • None of the four repositories has a notifyDirty. Every call site stages a typed change, and the disk-effect check passes for every row of the four repositories.
  • The marks sent are identical: the equivalence test and every suite listed in Background pass with no expectation changes, and KNOWN_UNMARKED is unchanged.
  • PINNED_STAGED_CHANGES lists the four 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 data and output repositories (move A).
  • Run, vault config and lockfile repositories.
  • The unhooked evaluate use cases.
  • 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+ 7 MOREFINDINGS+ 14 MOREPR_MERGED+ 2 MORESESSION_SUMMARIZED

Shipped

10/2/2026, 8:34:41 PM

Click a lifecycle step above to view its details.

03Sludge Pulse
stack72 assigned stack7210/2/2026, 7:06:07 PM

Sign in to post a ripple.