Repository navigation
#42: verify every listed manifest, and lock rollback #97
Description
Question
Two safety gaps in RollbackService become reachable the moment manifest ids are shown to a user. Kriday's decision during charting: fix them inside #42, ship them together, do not ship #42 and leave the hole open in between.
Gap 1: listManifests verifies nothing.
src/core/organize/rollback.ts:346 reads and parses every .json in the rollback directory with no signature or hash verification. getManifest (:276) does verify, via manifestIntegrityService.verifyManifest (src/core/organize/manifest-integrity.ts:121), which checks version, hash match, and HMAC-SHA256 with timingSafeEqual.
The doc block at rollback.ts:331 states this plainly: callers acting on a listed manifest's paths must verify first. It is survivable today because manifest ids are internal. The pass manifest ticket makes them user-visible, so an unsigned manifest file becomes a value a user can paste back in.
Every caller that acts on a listed id must route through getManifest. Audit the current callers:
src/tools/rollback.ts:78takeslistManifests()[0].idwhen nomanifest_idis given, then passes it torollback().rollback()callsgetManifestfirst (rollback.ts:463), so this path is already safe. Confirm it, do not assume it.QuarantineService.latestQuarantineId()(src/core/organize/quarantine.ts:379) reads the latest id from the list. Check whether it then reads contents without verifying.QuarantineService.readManifest(id)(quarantine.ts:390) does verify. Good precedent, follow it.
What to decide. Whether listManifests itself should skip unverifiable manifests, or keep listing them and push verification onto every caller. Skipping in the list means a tampered file becomes invisible rather than reported, which is arguably better and hides evidence. Reporting it means the list carries a verified: false flag, which is more honest and more work. Both are defensible; the choice is a policy one, not an implementation one.
Gap 2: RollbackService takes no lock at all.
AGENTS.md records this as a known gap. HistoryLoggerService serializes every writer through operations.lock (src/services/history-logger.service.ts:85, :171), and that is the only lock in the server. RollbackService reads and unlinks manifest files unguarded (rollback.ts:464, :729), so two processes undoing at once race on the same manifest file.
This was theoretical while only the newest manifest was reachable by id. Listing ids to a user makes it real.
Do not bolt the history lock onto RollbackService. That serializes two different resources and makes the two files contend. If this gets fixed, the separate shared state comes first.
Implementation detail.
- Add a manifest lock in the rollback directory, sibling to
operations.lockin the history directory. Reuse the token-and-staleness pattern fromhistory-logger.service.ts:188rather than inventing a second one. manifest_idhas no format validation at the Zod layer (src/schemas/organize.ts:259is a barez.string().optional()).getManifestchecksMANIFEST_ID_PATTERNinternally (rollback.ts:280). Hoist that to the schema so a malformed id fails as a validation error rather than a filesystem miss.- Keep
listManifests' signature stable if you choose the flag approach. If you choose to filter, note thatQuarantineService.latestQuarantineIddepends on listing order, which currently sorts bytimestamp(rollback.ts:373). Filtering changes which manifest is newest.
Tests that must fail without this change.
- A manifest file whose
signatureis corrupted is not acted on by any listed id. - A manifest file whose
signatureis corrupted still appears in a list that reports it as unverified, if the flag approach is chosen. Assert the chosen behaviour, not both. - Two concurrent
undo_last_operationcalls on the same id: exactly one succeeds, the other reports a clean refusal. Never a torn manifest. undo_last_operationwithmanifest_id: "not-a-uuid"returns a validation error, not a lookup failure.
Proof. Drive it through node $C call file_organizer_undo_last_operation in the verify sandbox, then corrupt a manifest on disk and drive it again.
Relates to. The retention ticket introduces manifest deletion, which is a new writer on the same unguarded resource. Gap 2 has to be closed before pruning ships, or pruning becomes a second race.
Activity
fix(rollback): flag every listed manifest, and lock the directory it …
kridaydave commentedon Oct 11, 2026
Decision
Close both gaps. Verify at list time with a flag, and give
RollbackServiceits own lock.The lock is not new machinery.
HistoryLoggerServicealready implements the exact pattern needed:acquireLockatsrc/services/history-logger.service.ts:171,tryAcquireLockat:188,releaseLockat:217. Token-checked release, staleness at 2x the timeout. Rollback gets a sibling of that pattern against its own lock file.A separate lock file, not a shared one.
AGENTS.mdis explicit: do not bolt the history lock ontoRollbackService. History and rollback are different resources. Sharing one lock would makeoperations.jsonland the manifest files contend, and pruning from #102 would become a second writer on the history lock. Separate resources, separate locks.New file:
manifests.lockin the rollback directory, sibling tooperations.lockin the history directory.Gap 1: listManifests verifies
Report unverified manifests, do not hide them.
listManifests()gets averifiedflag and keeps listing everything.A tampered manifest file is a security event. Filtering it out means nobody ever learns it happened.
findNewerConflictsalready takes this position atsrc/core/organize/rollback.ts:404: an unverified manifest counts as a conflict rather than being ignored, because ignoring it would let a forged manifest hide a real overlap, and the failure mode of not ignoring it is a refused undo, not a damaged one.New return type, not a field on
RollbackManifest.RollbackManifeststays the on-disk shape. This matters: its hash coversactionsandtimestamp(src/core/organize/manifest-integrity.ts:83), and averifiedfield on the object that gets signed and hashed would be a footgun.Nothing is filtered out, so the sort by timestamp at
:373is unchanged. That matters forQuarantineService.latestQuarantineId()(src/core/organize/quarantine.ts:379), which reads the list and depends on that order.Every caller updates. This is a signature change, so it breaks at compile time rather than silently:
findNewerConflictsat:395, unpacks.manifestand uses the existing verify call it already does at:402.QuarantineService.latestQuarantineId()atquarantine.ts:379.findNewerConflictscallers.src/tools/andsrc/core/organize/quarantine.tsthat lists.The verification itself is
manifestIntegrityService.verifyManifest(manifest-integrity.ts:121), which checks version, hash match, and HMAC-SHA256 withtimingSafeEqual. No new verification code.Gap 2: rollback takes a lock
Hold the lock for the whole
rollback()call. Acquire at the top afterensureStorage, release in afinally.Fine-grained locking would let two processes start the same undo. That is the exact disaster. Duration is bounded by file moves, not by minutes of work, and
operations.locksets the precedent.<rollbackDir>/manifests.lockoperations.lock:196LOCK_RETRY_MScapped at timeout/4, matching:183The reasoning for not lengthening the timeout: a waiter that cannot acquire within 5s is looking at a crashed process, not a slow one. Change the number only when there is a real undo that takes over 5 seconds to acquire.
createManifesttakes the same lock. It writes a manifest file into the same directory, so it is the same resource.This is what unblocks #102. Pruning deletes manifest files. Without this lock, a prune can unlink a manifest that an undo in flight is reading, and
rollbackunlinks on success at:729. Pruning would turn a rare race into a scheduled one.Gap 3: hoist the manifest id pattern
manifest_idis a barez.string().optional()atsrc/schemas/organize.ts:259. The format is only checked insidegetManifestatrollback.ts:280, so a malformed id fails as a filesystem miss rather than a validation error.Move
MANIFEST_ID_PATTERNinto the Zod schema sonot-a-uuidis rejected at the boundary with a clear message. Import the pattern from one place, do not copy the string.Also update the hand-written JSON
inputSchemaatsrc/tools/rollback.ts:32to describe the format. Two sources of truth, nothing keeps them in step.AGENTS.md.Tests that must fail without this change
signatureappears inlistManifests()withverified: falseand a non-emptyerror. Assert the flag, not both behaviours.findNewerConflictsstill counts it.rollback()calls on the same id: exactly one succeeds, the other reports a clean refusal. Never a torn manifest.rollback()throws mid-batch, so a retry can acquire it.manifests.lockolder than 10s is reclaimed.undo_last_operationwithmanifest_id: "not-a-uuid"returns a validation error, not a lookup failure.QuarantineService.latestQuarantineId()still returns the newest manifest when an older one is corrupt.Proof
Drive through the verify sandbox, then corrupt a manifest on disk and drive it again.
Never a unit test alone. The concurrency case needs two real processes.
Ordering
Ships with #42 so the cascade does not land on an unlocked, unverified manifest set. #102 cannot start until this is done.
kridaydave commentedon Oct 11, 2026
Correction: this landed differently, and better
Shipped in PR #103 and #105. Two things in the comment above are now wrong. Read this before implementing anything from it.
The flag is
signatureValid, notverifiedI specified
verified: boolean. The implementation named itsignatureValid, and the reason is correct and I did not think of it.IntegrityReport.verifiedalready exists insrc/core/organize/manifest-integrity.tsand means the opposite thing: whether the files are still where the manifest put them. Not whether the manifest is this machine's own record.Two flags named
verifiedin the same subsystem, meaning different things, is a trap.signatureValidwins.The lock renews, because a whole-undo critical section is not bounded
I specified a 5s timeout with staleness at 2x, copied from
operations.lock. That is wrong for rollback and the implementation fixed it.HistoryLoggerServicecan copy that protocol because its critical section is oneappendFile.applyRollbackmoves every file a manifest names and takes exactly as long as that takes. Under a fixed 5s staleness threshold, a legitimate holder looks abandoned partway through a large undo, a second undo of the same id reclaims the directory, and both spend the batch twice.What shipped instead:
ManifestLockServiceatsrc/core/organize/manifest-lock.ts, holdingmanifests.lock.rollbackandremoveManifestrun insiderunExclusive, so read-check-apply-delete is one critical section.lockTimeoutMs / 4and receives aManifestLockLeaseto check between files, so one reclaimed anyway stops before the next file rather than at the end.FileHandlethe lock was created with, so it touches the inode and never refreshes a replacement that took the path.That is a stricter fix than the one I specified, and the tombstone rename is the part I would not have thought of.
What this ticket still owes
Nothing. Both gaps are closed. #102 is now unblocked and can hook its prune into
runExclusive.One consequence for #102: the prune must run inside
runExclusive, and it must be a lease-aware operation likeapplyRollbackif it can take more than one lock acquisition. Prune reads the directory and unlinks a bounded set of files, so a single acquisition is fine.kridaydave commentedon Oct 12, 2026
Both gaps shipped.
listManifestsreports asignatureValidflag per entry rather than filtering, so an unverified manifest stays visible as evidence, andgetManifestis still the only path that hands paths to a caller.ManifestLockServiceholds amanifests.lockof its own in the rollback directory, with renewal for a holder whose undo outlives the window and a rename-to-tombstone reclaim so two waiters cannot both take one dead lock.manifest_idis validated as a UUID at the schema layer in bothundo_last_operationandverify_integrity, so a typo is a validation error rather than a filesystem miss. The four tests the issue asked for are intests/unit/core/organize/manifest-verification-and-lock.test.ts.