Skip to content

feat(vault): add ./vault/server shared mutation-safety policy (#331) - #351

Merged
vutuanlinh2k2 merged 2 commits into
mainfrom
feat/331-vault-server-policy
Jul 30, 2026
Merged

feat(vault): add ./vault/server shared mutation-safety policy (#331)#351
vutuanlinh2k2 merged 2 commits into
mainfrom
feat/331-vault-server-policy

Conversation

@vutuanlinh2k2

Copy link
Copy Markdown
Contributor

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.75 of >= 10 live 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/sandbox import): unidentifiednot-readyno-baselinemismatchmatch, each earlier check short-circuiting the later ones.

New entry wired in tsup.config.ts + package.json exports + knip.json (no root barrel exists). tests/browser-safe-subpaths.test.ts auto-excludes it (server-only; deliberately NOT added to BROWSER_NONREACT).

API refinements vs the issue's proposed surface

Three deliberate deltas from #331's sketch (its surface is explicitly a proposal):

  1. manifestEmpty?: boolean input — 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; when true, the FULL baseline is treated as would-delete regardless of proposedDeletions (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.
  2. 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.
  3. { verdict: 'no-baseline' } added as a 5th verdict — gtm treats baseline_missing as a distinct state with distinct remediation; forcing callers to pre-gate on baselineId === undefined reintroduces a forgettable judgment call (baselineId ?? '' would misroute to 'mismatch'). An exhaustive switch now 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 as ratio-exceeded NOT all-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 reaches server.ts; server.ts has zero import statements; exports run with no DOM globals.
  • Gates green: knip-entries-fresh, browser-safe-subpaths (untouched), test-quality, codemap-fresh (docs regenerated via pnpm docs:gen).
  • Full suite: 3858 passed / 25 skipped; 7 failures are pre-existing local-environment issues (knowledge-loop /tmp symlink, ~/.bash_profile-sourcing sandbox profile tests) reproduced identically on baseline with this change stashed.
  • Build (tsup, 8 GiB heap) clean; smoke import of dist/vault/server.js exports exactly the four symbols; knip clean.

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 check

× 12 live / 9 deleted (ratio exactly 0.75) -> refused
AssertionError: expected true to be false
Tests  1 failed | 29 passed (30)

restore → Tests 30 passed (30)

Cycle 2 — count floor added to refusesAllFiles

× 1 live, 0 survive -> refused all-files (below both floors)
AssertionError: expected true to be false
Tests  1 failed | 29 passed (30)

restore → Tests 30 passed (30)

Cycle 3 — manifestEmpty full-baseline override deleted

× 2 live, manifestEmpty -> refused empty-manifest, ratio 1
AssertionError: expected +0 to be 1
Tests  1 failed | 29 passed (30)

restore → Tests 30 passed (30)

Cycle 4 — reason precedence swapped (all-files first)

× 2 live, manifestEmpty -> refused empty-manifest, ratio 1
× manifestEmpty + full proposedDeletions -> empty-manifest wins over all-files
AssertionError: expected 'all-files' to be 'empty-manifest'
Tests  2 failed | 28 passed (30)

restore → Tests 30 passed (30)

Cycle 5 — provenance validation dropped

× unidentified: invalid provenance
× fail-closed precedence: unidentified short-circuits before baseline compare
AssertionError: expected { verdict: 'match' } to deeply equal { verdict: 'unidentified' }
Tests  2 failed | 28 passed (30)

restore → Tests 30 passed (30)

Cycle 6 — readiness !== 'ready'=== 'transitioning'

× not-ready: readiness is undefined
AssertionError: expected { verdict: 'match' } to deeply equal { verdict: 'not-ready', …(1) }
Tests  1 failed | 29 passed (30)

restore → Tests 30 passed (30)

Cycle 7 — no-baseline branch deleted

× no-baseline: no baselineId recorded
+ Received: { "baselineId": undefined, "currentId": "inc-123", "verdict": "mismatch" }
Tests  1 failed | 29 passed (30)

restore → Tests 30 passed (30)

Cycle 8 — baseline-compare reordered before readiness

× fail-closed precedence: not-ready beats mismatch
AssertionError: expected 'mismatch' to be 'not-ready'
Tests  1 failed | 29 passed (30)

restore → Tests 30 passed (30)

Cycle 9 — unused import { EOL } from 'node:os' added to server.ts

× src/vault/server.ts has zero import statements (import-free by construction)
AssertionError: server.ts must have zero import statements: expected 1 to be +0
Tests  1 failed | 29 passed (30)

restore → 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

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.
@vutuanlinh2k2

Copy link
Copy Markdown
Contributor Author

Review finding addressed (ff0387f): unchecked policy overrides could silently disable or spuriously trigger the guard.

Validated both failure modes against the code before fixing:

  • refusalRatio: NaN (config parse failure) → deletionRatio >= NaN is always false → a 15-of-20 batch (one survivor, so not all-files) returned allowed: true. Confirmed.
  • refusalRatio: 00 >= 0 → a zero-deletion batch on any baseline at/above the floor was refused ratio-exceeded. Confirmed.

Fix (fail-loud, never a silently disabled guard): assessVaultDeletionBatch now throws RangeError when refusalRatio is not finite-in-(0, 1] or minLiveFiles is not finite ≥ 0 — validation happens after defaulting, so the default path is unchanged and gtm parity is untouched. The ratio branch also gains wouldDelete > 0 (per the review); note it is unreachable-false under the validated domain (a positive ratio can never be met by a 0 ratio), so it is commented as belt-and-braces against the validation ever being relaxed rather than claimed as tested behavior.

Six new tests (36 total): NaN / 0 / >1 ratio throw, non-finite/negative floor throws, plus the valid boundaries (refusalRatio: 1, minLiveFiles: 0) proving validation doesn't over-reject.

Prove-can-fail cycle 10 — validation removed:

× refusalRatio: NaN throws instead of silently disabling the guard
× refusalRatio: 0 throws instead of refusing a zero-deletion batch
× refusalRatio above 1 throws (a ratio the guard can never reach)
× non-finite or negative minLiveFiles throws
Tests  4 failed | 29 passed (33)

restore (diff-verified byte-identical) →

Tests  36 passed (36)

Gates (test-quality, knip-entries-fresh, codemap-fresh) and typecheck green.

@vutuanlinh2k2
vutuanlinh2k2 merged commit ddbeef6 into main Jul 30, 2026
1 check passed
@vutuanlinh2k2
vutuanlinh2k2 deleted the feat/331-vault-server-policy branch July 30, 2026 07:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEAT] /vault/server: shared vault mutation-safety policy — blast-radius deletion refusal + incarnation baseline comparison

1 participant