Relationships
#2995 Datastore rework Phase 1 move C2: the vault config repository stages typed changes, serve stops marking vault files by hand (no behaviour change)
Opened by stack72 · 10/2/2026· Shipped 10/3/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). Repositories stage typed changes into a unit of work instead of marking files dirty for a later push. Phase 1 must change no behaviour.
Already on main:
swamp-club#2970: the
UnitOfWorkport and legacy adapter.StagedChangeis one of:write: a path that exists after the operation;remove: a path that is gone after it;bulk.
- The legacy adapter forwards each change straight to the repository's
MarkDirtyHookasmarkDirty(path).
swamp-club#2971: the ambient unit of work (
src/infrastructure/persistence/unit_of_work_scope.ts) andsignalChange(markDirty, change).The repository moves stage typed changes at every call site with
signalChange(this.<hook>, { kind, path }):- swamp-club#2979: data and output;
- swamp-club#2980: definition, workflow and evaluated;
- swamp-club#2992: workflow run.
No repository defines
notifyDirtyany more, which is pinned inintegration/datastore_sync_rules_test.ts.The tests that pin all this:
integration/repository_dirty_coverage_test.ts: every changed file must be marked, gaps are pinned inKNOWN_UNMARKED, and an equivalence test compares runs with and without a scope.MOVED_REPOSITORIESandUNSTAGED_ROWSadd a disk-effect check that awritepath exists after the act and aremovepath is gone, and that each non-dry-run row stages at least one change.integration/datastore_sync_rules_test.ts:MOVED_REPOS(bulk changes need a reason), plus the no-notifyDirtyguard.integration/datastore_write_seams_rules_test.ts:PINNED_MARK_CALL_SITES,PINNED_REPO_CONSTRUCTIONS,PINNED_UNHOOKED_WRITERS,PINNED_STAGED_CHANGES,PINNED_UNIT_OF_WORK_SCOPES, and a self-check that hook argument positions match the constructors.integration/usecase_sync_characterization_vault_test.ts: what the vault use cases mark and push, through the CLI and through serve.
This issue is move C2, the last Phase 1 repository move: the vault config repository.
The lockfile is deliberately not part of it. It was originally grouped with vault config. But its only mark, in ManagedLockfileTransaction.publish (src/libswamp/extensions/managed_lockfile_transaction.ts), is a publish signal rather than a write signal:
- the transaction marks the lockfile and then requires the push to upload something (
mustUpload); - a failed publish stays pending and is retried by a later extension change, sometimes in a process that wrote nothing.
That mark has to stay. Having LockfileRepository also stage its writes would add marks rather than move them, which breaks Phase 1's identical-marks rule. LockfileRepository never calls markDirty itself, so it doesn't block the Phase 1 ratchet. The lockfile moves to Phase 2, when the managed lockfile transaction becomes a unit-of-work commit (noted on swamp-club#2865).
How vault config marks today (main at 285b0a5a)
YamlVaultConfigRepository (src/infrastructure/persistence/yaml_vault_config_repository.ts) has no hook:
- the constructor is
(repoDir, eventBus?, baseDir?); savewrites{baseDir}/{type}/{id}.yamlwithatomicWriteTextFile;deleteremoves it.
Serve marks the written files by hand, after the write, just before it pushes:
| Handler | Site | Marks | Repository the use case writes through |
|---|---|---|---|
| vault create | src/serve/handlers/vault_handlers.ts (around line 890) |
the created config path | its own unhooked one, from createVaultCreateDeps(ctx.repoDir) |
| vault edit | same file (around line 1090) | the edited config path | the factory-built ctx.repoContext.vaultConfigRepo, injected through createVaultEditDeps |
| vault migrate | src/serve/handlers/admin_handlers.ts (around lines 1333-1338) |
the target-type config path, then the source-type config path | its own unhooked one, from createVaultMigrateDeps(repoDir) |
Migrate's use case calls saveConfig and then deleteConfig (src/libswamp/vaults/migrate.ts).
The factory builds ctx.repoContext.vaultConfigRepo in createRepositoryContext (src/infrastructure/persistence/repository_factory.ts) without a hook. Elsewhere in serve it's only used for reads: findAll, findByName, getPath, and the doctor's vault list.
The CLI vault commands stay as they are. vault create, vault edit and vault migrate build their own unhooked repositories and publish with pushManagedConfigChanges (src/cli/managed_config_sync.ts). That function sends a bare mark, which also covers model and workflow writes, and removing it is Phase 2 work.
Other vault use cases build unhooked repositories and only read vault configs: delete (of a secret), annotate, put, inspect, get, read-secret and list-keys. Don't hook them.
Work
- Give
YamlVaultConfigRepositoryan optional hook, as a new final constructor parametermarkDirty?: MarkDirtyHook.savestages{ kind: "write", path }withsignalChange(this.markDirty, …)beforeatomicWriteTextFile.deletestages{ kind: "remove", path }beforeDeno.remove.- With no hook, nothing is sent, exactly as today.
- Wire the hook in
createRepositoryContextonly: passmarkDirtyto the factory'sYamlVaultConfigRepository. LeavecreateVaultConfigRepositoryand every other construction unhooked. - Route serve create and migrate through the hooked repository.
- Add an optional injected repository to
createVaultCreateDepsandcreateVaultMigrateDeps, ascreateVaultEditDeps(repoDir, injectedRepo?)already does. Keep the existingoptionsparameter on migrate. - In serve, pass
ctx.repoContext.vaultConfigRepoto all three. Edit already does this. - The CLI callers keep their current calls with no injected repository.
- Check the injected repository resolves the same vaults dir as the one each use case built itself. All of them use the effective vaults dir, which is the config tier under managedConfig, but confirm it.
- Add an optional injected repository to
- Delete the three hand marks in serve (create, edit, migrate). The repository now sends the same marks, for the same paths in the same order (migrate: target then source), and before the push as before. They move from after the write to before it, which is what the
markDirtycontract requires (rule 1) and doesn't change what is pushed. Keep each handler'spushChangedcall and its error handling exactly as they are. - Change nothing else. Leave
pushManagedConfigChanges, the CLI vault commands,LockfileRepository,ManagedLockfileTransaction, and every other hand mark (access, device auth, grant tracking, the serve definition migration) untouched.
Tests and fitness
integration/usecase_sync_characterization_vault_test.tspasses with no expectation changes. The serve rows for create, edit and migrate must record the same marks, with the same paths and order, and the same pushes and remote state. If the recorded marks differ in any way, stop: the move isn't behaviour-preserving.integration/repository_dirty_coverage_test.ts:- Add
VaultConfigrows forsave(new),save(update) anddelete, built with the hook, andVaultConfigtoMOVED_REPOSITORIES. - Each row must mark every changed file, pass the disk-effect check, and stage at least one change.
- The equivalence test (with and without a scope) must cover the new rows.
KNOWN_UNMARKEDis unchanged.
- Add
integration/datastore_sync_rules_test.ts: addyaml_vault_config_repository.tstoMOVED_REPOS. The bulk-reason rule and the no-notifyDirtyguard then cover it.integration/datastore_write_seams_rules_test.ts:- remove the three serve hand-mark entries from
PINNED_MARK_CALL_SITES; - add the vault repository's write and remove to
PINNED_STAGED_CHANGESunder// swamp-club#2995, move C2.; - remove the factory's vault construction from
PINNED_UNHOOKED_WRITERS(it now has a hook), keeping every other vault construction pinned; - update the hook-argument-position self-check for the new constructor parameter.
- remove the three serve hand-mark entries from
src/infrastructure/persistence/yaml_vault_config_repository_test.ts: add scoped unit tests usingrecordingUnitOfWorkfromsrc/infrastructure/persistence/test_helpers/staged_change_helpers.ts:savestages awritebefore the file exists on disk (assert the mark arrives before the write, for example from inside the hook);deletestages aremove;- with no hook, nothing is sent.
src/libswamp/vaults/create_test.tsandmigrate_test.ts(or the existing test files for these deps): an injected repository is used for saves and deletes, and the default path still builds its own.- These pass unchanged:
- every other use-case characterisation file;
integration/datastore_peer_propagation_test.tsandintegration/datastore_remote_failure_test.ts;src/cli/repo_context_test.ts;- the serve vault handler tests;
- the swamp-uat suite.
Dependencies
Blocked by: swamp-club#2992 (move C1). It edits the same shared lists, so start once it has merged.
Blocks: the Phase 1 step "repository marks ratcheted to zero", which removes changeFor and the hook fallback in signalChange and closes Phase 1.
Done when
YamlVaultConfigRepositorystageswriteinsaveandremoveindelete, before the disk change. Only the factory-built instance has a hook.- Serve create, edit and migrate write through the factory repository, and their hand marks are gone.
usecase_sync_characterization_vault_test.tsand every other suite in Background pass with no expectation changes.- The new dirty-coverage rows and the unit tests pass, and the pinned lists are updated as above.
- Verification workflows pass on the final commit.
Out of scope
LockfileRepositoryandManagedLockfileTransaction(Phase 2; see Background).pushManagedConfigChangesand the CLI vault commands' bare mark (Phase 2).- Hooking any other vault use case.
- Opening scopes (Phase 2), and fixing any
KNOWN_UNMARKEDgap.
Related
- Tracking: swamp-club#2865 (Phase 1).
- Built on: swamp-club#2970, #2971, #2979, #2980, #2992.
Shipped
Click a lifecycle step above to view its details.
Sign in to post a ripple.