feat(vault): add ./vault/server shared mutation-safety policy (#331) - #351
Conversation
Extract gtm-agent's vault safety guard (gtm#612) into a shared, server-safe, zero-import policy subpath: assessVaultDeletionBatch (empty-manifest / all-files / blast-radius deletion refusal, gtm's 0.75 ratio + 10-file floor exported as constants) and compareIncarnationBaseline (fail-closed filesystem-incarnation verdict). Structural types only — no sandbox import.
An unchecked policy override could silently neutralize the guard: refusalRatio NaN (a config parse failure) makes deletionRatio >= NaN always false, so a 15-of-20 batch comes back allowed; refusalRatio 0 spuriously refuses a zero-deletion batch (0 >= 0). Throw RangeError on non-finite/out-of-range thresholds (ratio must be in (0, 1], floor finite >= 0) and require wouldDelete > 0 on the ratio branch.
|
Review finding addressed (ff0387f): unchecked policy overrides could silently disable or spuriously trigger the guard. Validated both failure modes against the code before fixing:
Fix (fail-loud, never a silently disabled guard): Six new tests (36 total): NaN / 0 / >1 ratio throw, non-finite/negative floor throws, plus the valid boundaries ( Prove-can-fail cycle 10 — validation removed: restore (diff-verified byte-identical) → Gates ( |
Summary
Extracts gtm-agent's shipped vault safety guard (gtm#612 — 48 production vault files tombstoned by a half-mounted sandbox that scanned cleanly) into a shared, server-safe, zero-import policy subpath
./vault/server, per the #332 audit verdict that these two checks are universal shell mechanism:assessVaultDeletionBatch— blast-radius deletion refusal: refuses an empty-manifest batch, an all-files wipe (at ANY count), or a ratio/floor breach (>= 0.75of>= 10live files — both>=, byte-matching gtm). gtm's constants are exported (VAULT_DELETION_REFUSAL_RATIO,VAULT_DELETION_REFUSAL_MIN_LIVE_FILES) so gtm can delete its local copies. Refusal is a verdict, not a throw — what to do with it stays the caller's.compareIncarnationBaseline— fail-closed filesystem-incarnation comparison over structural fields (FilesystemIncarnationLike, no@tangle-network/sandboximport):unidentified→not-ready→no-baseline→mismatch→match, each earlier check short-circuiting the later ones.New entry wired in
tsup.config.ts+package.jsonexports+knip.json(no root barrel exists).tests/browser-safe-subpaths.test.tsauto-excludes it (server-only; deliberately NOT added toBROWSER_NONREACT).API refinements vs the issue's proposed surface
Three deliberate deltas from #331's sketch (its surface is explicitly a proposal):
manifestEmpty?: booleaninput — the two-list shape{baselinePaths, proposedDeletions}cannot distinguish'empty-manifest'from'all-files'(an empty scan structurally implies all-files). One boolean recovers gtm's diagnostic distinction; whentrue, the FULL baseline is treated as would-delete regardless ofproposedDeletions(fail-closed — an empty scan means nothing survived). gtm's drop-in stays one call:{ baselinePaths: liveBaselinePaths, proposedDeletions: wouldDeletePaths, manifestEmpty: manifest.entries.size === 0 }; deletion-list-first adopters (legal/creative) simply omit it.wouldDeletePaths: string[]output (sorted, mirroring gtm's.sort()) — gtm logs these on refusal; callers need them for surfacing. Reason precedence when several fire:empty-manifest>all-files>ratio-exceeded.{ verdict: 'no-baseline' }added as a 5th verdict — gtm treatsbaseline_missingas a distinct state with distinct remediation; forcing callers to pre-gate onbaselineId === undefinedreintroduces a forgettable judgment call (baselineId ?? ''would misroute to'mismatch'). An exhaustiveswitchnow gets compile-time coverage.Test plan
tests/vault/server.test.ts(27 tests): gtm-parity fixture matrix reproducing gtm's own equivalence-test fixtures (1-live all-files, 11-live blast-radius with 10 refused paths, 2-live legit delete, empty manifest, 95-live nearly-empty asratio-exceededNOTall-files, 20-live single delete, 3-live ratio-floor) + operator-exactness boundaries (ratio exactly 0.75 refused; 0.7 allowed; 0.889 at count 9 allowed) + precedence + policy override + constants + incarnation matrix incl. both fail-closed precedence properties.tests/vault-browser-safe.test.ts(3 tests): vault client graph (VaultPane/lazy/ConfirmDialog) never reachesserver.ts;server.tshas zero import statements; exports run with no DOM globals.knip-entries-fresh,browser-safe-subpaths(untouched),test-quality,codemap-fresh(docs regenerated viapnpm docs:gen)./tmpsymlink,~/.bash_profile-sourcing sandbox profile tests) reproduced identically on baseline with this change stashed.tsup, 8 GiB heap) clean; smoke import ofdist/vault/server.jsexports exactly the four symbols;knipclean.Prove-can-fail evidence (repo bar)
Nine break → red → restore (byte-identical,
diff-verified) → green cycles, one per guarded condition. Each RED failed exactly the fixture guarding the broken condition; every restore returned 30/30.All nine transcripts
Cycle 1 —
>=→>in the ratio checkrestore →
Tests 30 passed (30)Cycle 2 — count floor added to
refusesAllFilesrestore →
Tests 30 passed (30)Cycle 3 —
manifestEmptyfull-baseline override deletedrestore →
Tests 30 passed (30)Cycle 4 — reason precedence swapped (all-files first)
restore →
Tests 30 passed (30)Cycle 5 — provenance validation dropped
restore →
Tests 30 passed (30)Cycle 6 —
readiness !== 'ready'→=== 'transitioning'restore →
Tests 30 passed (30)Cycle 7 —
no-baselinebranch deletedrestore →
Tests 30 passed (30)Cycle 8 — baseline-compare reordered before readiness
restore →
Tests 30 passed (30)Cycle 9 — unused
import { EOL } from 'node:os'added to server.tsrestore →
Tests 30 passed (30)Out of scope
Adoption PRs are follow-ups tracked in #332: gtm deletes its local guard (reference-consumer proof, tracked inside #331), legal#320, creative#459. This PR only makes those adoptions clean drop-ins.
Closes #331. Part of #332.
🤖 Generated with Claude Code