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
UnitOfWorkport and legacy adapter.src/domain/datastore/unit_of_work.tsdefinesStagedChangeas 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.tsholdscreateLegacyUnitOfWork(markDirty, { flush }). Itsstageforwards each change at once, in order, to the repository'sMarkDirtyHook:writeandremovebecomemarkDirty(path), andbulkbecomesmarkDirty(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 themarkDirtycontract,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)andchangeFor(relPath, reason).signalChangestages 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 doessignalChange(this.<hook>, changeFor(relPath, "<Repo>.notifyDirty")). changeFormaps every path towrite, becausenotifyDirtycan't tell a write from a remove.- No production code opens a scope;
integration/datastore_write_seams_rules_test.tspins 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:
writewhen the path exists after the operation (a file or directory created or changed);removewhen the path is gone after it;bulkwith 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 barethis.notifyDirty()inPER_PATH_WIRED_REPOS.integration/datastore_peer_propagation_test.tsandintegration/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_UNMARKEDgap. 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:
cleanupOldPathsin the definition repository;- the removals in
save, and the extrapathsToTryremovals indelete, in the workflow and both evaluated repositories.
Don't add marks for them; that would change behaviour.
Work
- Replace each
this.notifyDirty(path)with a typed staged change throughsignalChange(this.<hook>, { kind, path }). A small private helper per repository is fine.The kind follows the effect on disk after the operation:
writeif the path exists afterwards, such as a saved yaml;removeif it's gone, such as a deleted yaml, or a directoryclear,clearAllordeleteForRunremoves.
If
clearorclearAllleaves the directory in place and only empties it, usewritefor 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.
- Delete the private
notifyDirtyfrom all four repositories, and stop importingchangeForthere. - Keep the constructor hook parameters and everything in
repository_factory.tsunchanged. The evaluate use cases build evaluated repositories without a hook (src/libswamp/models/evaluate.ts,src/libswamp/workflows/evaluate.ts, pinned inPINNED_UNHOOKED_WRITERS). Leave them as they are; with no hook, staging sends nothing, exactly as today.
Shared test and fitness work (both moves)
- A disk-effect check in
integration/repository_dirty_coverage_test.ts(create it if it isn't there yet).- Add a
MOVED_REPOSITORIESset, 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: awritepath exists, and aremovepath is absent. - Rows of unmoved repositories are skipped, because their changes are all still
write. - This proves each call site picked the right kind.
- Add a
- Unchanged expectations. The #2971 equivalence test, the marks-with-and-without-a-scope test, and
KNOWN_UNMARKEDall pass unchanged. - Update
integration/datastore_write_seams_rules_test.ts. This issue's repositories drop out ofPINNED_MARK_CALL_SITESonce theirnotifyDirtyis gone, so the staged changes need their own pin. AddPINNED_STAGED_CHANGESif 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. - Update
integration/datastore_sync_rules_test.ts. Drop the moved repositories fromPER_PATH_WIRED_REPOS, since they no longer havenotifyDirty. Add a rule that a moved repository stages nobulkchange without a non-emptyreason. Pin the moved repositories' bulk changes as an explicit list; it's empty for this issue. - The rest pass unchanged:
datastore_peer_propagation_test.ts,datastore_remote_failure_test.ts, allusecase_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_UNMARKEDis unchanged. PINNED_STAGED_CHANGESlists the four repositories' write and remove counts.PINNED_MARK_CALL_SITESno 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_UNMARKEDgap.
Related
- Tracking: swamp-club#2865 (Phase 1).
- Built on: swamp-club#2970, swamp-club#2971.
Shipped
Click a lifecycle step above to view its details.
Sign in to post a ripple.