[object Object]

← back to Homesonspec

Harden snapshot offload verification boundaries (TK-10809)

cece23b9a135802b0453e2aa1fc5eeef3c02f10b · 2026-08-30 09:38:19 -0700 · Steve

Files touched

Diff

commit cece23b9a135802b0453e2aa1fc5eeef3c02f10b
Author: Steve <steve@designerwallcoverings.com>
Date:   Sun Aug 30 09:38:19 2026 -0700

    Harden snapshot offload verification boundaries (TK-10809)
---
 apps/workers/src/snapshot-path.test.ts     |  5 ++
 apps/workers/src/snapshot-path.ts          | 15 +++++-
 ops/lib/snapshot-resolve.mjs               | 32 ++++++++++---
 ops/lib/snapshot-resolve.test.mjs          | 37 +++++++++++++++
 ops/offload-snapshots.mjs                  | 57 ++++++++++++++++++-----
 ops/offload-snapshots.test.mjs             | 73 ++++++++++++++++++++++++++++++
 ops/verify-snapshots.mjs                   | 70 +++++++++++++++++++++++-----
 verification/TK-10809-codex-e2e-proof.json | 38 ++++++++++++++++
 8 files changed, 295 insertions(+), 32 deletions(-)

diff --git a/apps/workers/src/snapshot-path.test.ts b/apps/workers/src/snapshot-path.test.ts
index 2879c2af..a9de2069 100644
--- a/apps/workers/src/snapshot-path.test.ts
+++ b/apps/workers/src/snapshot-path.test.ts
@@ -52,6 +52,11 @@ describe("resolveSnapshotPath", () => {
       "/mnt/vol/snapshots/k/h.html",
     );
   });
+  it("rejects a relative path that escapes the configured snapshot root", () => {
+    expect(() => resolveSnapshotPath("../../outside.html", "/mnt/vol/snapshots")).toThrow(
+      /escapes snapshot root/,
+    );
+  });
 });
 
 describe("round-trip: write to disk, store relative, resolve, read back", () => {
diff --git a/apps/workers/src/snapshot-path.ts b/apps/workers/src/snapshot-path.ts
index 8e3f46fd..ae84f687 100644
--- a/apps/workers/src/snapshot-path.ts
+++ b/apps/workers/src/snapshot-path.ts
@@ -1,4 +1,4 @@
-import { isAbsolute, join } from "node:path";
+import { isAbsolute, join, relative, resolve } from "node:path";
 
 // Single source of truth for the raw-snapshot store location. Prod offloads this
 // to a dedicated volume by setting SNAPSHOT_DIR (see ops/offload-snapshots.mjs);
@@ -23,5 +23,16 @@ const relTail = (p: string) => p.replace(/^(?:\.\/)?var\/snapshots\//, "");
 // resolving verbatim. New rows store a POSIX-relative path resolved against the
 // active snapshot dir, so moving the volume + repointing SNAPSHOT_DIR is exact.
 export function resolveSnapshotPath(storagePath: string, snapshotDir = SNAPSHOT_DIR) {
-  return isAbsolute(storagePath) ? storagePath : join(snapshotDir, relTail(storagePath));
+  if (isAbsolute(storagePath)) return storagePath;
+  const root = resolve(snapshotDir);
+  const candidate = resolve(root, relTail(storagePath));
+  const rel = relative(root, candidate);
+  if (
+    rel === ".." ||
+    rel.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) ||
+    isAbsolute(rel)
+  ) {
+    throw new Error(`relative snapshot path escapes snapshot root: ${storagePath}`);
+  }
+  return candidate;
 }
diff --git a/ops/lib/snapshot-resolve.mjs b/ops/lib/snapshot-resolve.mjs
index 26cb1fba..3b13fc28 100644
--- a/ops/lib/snapshot-resolve.mjs
+++ b/ops/lib/snapshot-resolve.mjs
@@ -1,7 +1,7 @@
 // Shared snapshot-path resolver for the ops/*.mjs + scripts/*.mjs snapshot tooling
 // (TK-10809). ONE source of truth so offload, verify, and backfill can't drift.
 // apps/workers/src/snapshot-path.ts mirrors relTail() on the TS side (JS/TS boundary).
-import { isAbsolute, join, basename } from "node:path";
+import { isAbsolute, resolve, relative } from "node:path";
 
 const stripTrailingSlash = (p) => p.replace(/\/$/, "");
 
@@ -9,9 +9,23 @@ const stripTrailingSlash = (p) => p.replace(/\/$/, "");
 // local dw rows carry) so the tail resolves under the active snapshot dir directly.
 export const relTail = (p) => p.replace(/^(?:\.\/)?var\/snapshots\//, "");
 
+const resolveContained = (dir, tail) => {
+  const root = resolve(dir);
+  const candidate = resolve(root, tail);
+  const rel = relative(root, candidate);
+  if (
+    rel === ".." ||
+    rel.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) ||
+    isAbsolute(rel)
+  ) {
+    throw new Error(`relative snapshot path escapes snapshot root: ${tail}`);
+  }
+  return candidate;
+};
+
 // Resolve a stored storagePath against an arbitrary snapshot dir (verify path).
 export const resolveAgainstDir = (storagePath, dir) =>
-  isAbsolute(storagePath) ? storagePath : join(dir, relTail(storagePath));
+  isAbsolute(storagePath) ? storagePath : resolveContained(dir, relTail(storagePath));
 
 // Resolve a stored storagePath against the DEST for the offload copy check.
 // Returns { path, absNotUnderSrc } so the caller can tally the "wrong --src"
@@ -20,13 +34,17 @@ export const resolveAgainstDir = (storagePath, dir) =>
 export const resolveAgainstDest = (storagePath, src, dest) => {
   const destBase = stripTrailingSlash(dest);
   if (!isAbsolute(storagePath)) {
-    return { path: join(destBase, relTail(storagePath)), absNotUnderSrc: false };
+    return { path: resolveContained(destBase, relTail(storagePath)), absNotUnderSrc: false };
   }
   const srcBase = stripTrailingSlash(src);
   if (storagePath.startsWith(srcBase + "/")) {
-    return { path: join(destBase, storagePath.slice(srcBase.length + 1)), absNotUnderSrc: false };
+    return {
+      path: resolveContained(destBase, storagePath.slice(srcBase.length + 1)),
+      absNotUnderSrc: false,
+    };
   }
-  // absolute path not under SRC — cannot be resolved against DEST; fall back to
-  // basename subtree but SIGNAL the case so it's counted separately, not as a miss.
-  return { path: join(destBase, basename(storagePath)), absNotUnderSrc: true };
+  // An absolute path outside SRC has no defensible destination mapping. Return no
+  // candidate rather than guessing by basename and potentially hash-validating the
+  // wrong file. The caller reports this separately and treats it as unverified.
+  return { path: null, absNotUnderSrc: true };
 };
diff --git a/ops/lib/snapshot-resolve.test.mjs b/ops/lib/snapshot-resolve.test.mjs
new file mode 100644
index 00000000..9031b7ab
--- /dev/null
+++ b/ops/lib/snapshot-resolve.test.mjs
@@ -0,0 +1,37 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { relTail, resolveAgainstDir, resolveAgainstDest } from "./snapshot-resolve.mjs";
+
+test("normalizes legacy relative prefixes under the active store", () => {
+  assert.equal(relTail("./var/snapshots/source/hash.html"), "source/hash.html");
+  assert.equal(
+    resolveAgainstDir("var/snapshots/source/hash.html", "/mnt/snapshots"),
+    "/mnt/snapshots/source/hash.html",
+  );
+});
+
+test("keeps legacy absolute paths unchanged for runtime reads", () => {
+  assert.equal(resolveAgainstDir("/legacy/source/hash.html", "/mnt/snapshots"), "/legacy/source/hash.html");
+});
+
+test("maps legacy absolute paths under src to the same tail under dest", () => {
+  assert.deepEqual(
+    resolveAgainstDest("/old/snapshots/source/hash.html", "/old/snapshots", "/new/snapshots"),
+    { path: "/new/snapshots/source/hash.html", absNotUnderSrc: false },
+  );
+});
+
+test("does not guess a destination for absolute paths outside src", () => {
+  assert.deepEqual(
+    resolveAgainstDest("/other/source/hash.html", "/old/snapshots", "/new/snapshots"),
+    { path: null, absNotUnderSrc: true },
+  );
+});
+
+test("rejects relative traversal outside the snapshot root", () => {
+  assert.throws(() => resolveAgainstDir("../../etc/passwd", "/mnt/snapshots"), /escapes snapshot root/);
+  assert.throws(
+    () => resolveAgainstDest("../outside", "/old/snapshots", "/new/snapshots"),
+    /escapes snapshot root/,
+  );
+});
diff --git a/ops/offload-snapshots.mjs b/ops/offload-snapshots.mjs
index baeac3f3..3da3a3cb 100644
--- a/ops/offload-snapshots.mjs
+++ b/ops/offload-snapshots.mjs
@@ -15,7 +15,7 @@
 import { execFileSync } from "node:child_process";
 import { readFileSync, statSync } from "node:fs";
 import { createHash } from "node:crypto";
-import { isAbsolute } from "node:path";
+import { isAbsolute, relative, resolve } from "node:path";
 import { resolveAgainstDest } from "./lib/snapshot-resolve.mjs";
 
 const argv = process.argv.slice(2);
@@ -39,8 +39,17 @@ if (isAbsolute(DEST) === false || isAbsolute(SRC) === false) {
   console.error("FATAL: --dest and --src must be absolute paths.");
   process.exit(2);
 }
-if (DEST === SRC) {
-  console.error("FATAL: --dest must differ from --src (refusing to copy onto itself).");
+if (!Number.isInteger(SAMPLE) || SAMPLE <= 0) {
+  console.error("FATAL: --sample must be a positive integer.");
+  process.exit(2);
+}
+const srcRoot = resolve(SRC);
+const destRoot = resolve(DEST);
+const srcToDest = relative(srcRoot, destRoot);
+const destToSrc = relative(destRoot, srcRoot);
+const nested = (rel) => rel !== "" && rel !== ".." && !rel.startsWith("../") && !isAbsolute(rel);
+if (srcRoot === destRoot || nested(srcToDest) || nested(destToSrc)) {
+  console.error("FATAL: --src and --dest must be disjoint paths (refusing an overlapping copy).");
   process.exit(2);
 }
 
@@ -113,12 +122,27 @@ console.log(
 let ok = 0;
 const misses = [];
 const mismatches = [];
+const invalid = [];
 let absNotUnderSrc = 0;
 for (const line of rows) {
   const [storagePath, contentHash] = line.split("\t");
-  if (!storagePath || !contentHash) continue;
-  const { path: dest, absNotUnderSrc: notUnder } = resolveAgainstDest(storagePath, SRC, DEST);
+  if (!storagePath || !contentHash) {
+    invalid.push({ storagePath: storagePath || "(empty)", reason: "malformed DB row" });
+    continue;
+  }
+  let resolved;
+  try {
+    resolved = resolveAgainstDest(storagePath, SRC, DEST);
+  } catch (error) {
+    invalid.push({ storagePath, reason: error.message });
+    continue;
+  }
+  const { path: dest, absNotUnderSrc: notUnder } = resolved;
   if (notUnder) absNotUnderSrc++;
+  if (!dest) {
+    invalid.push({ storagePath, reason: "absolute path is outside --src" });
+    continue;
+  }
   try {
     statSync(dest);
   } catch {
@@ -135,16 +159,23 @@ const coverage = ((ok / total) * 100).toFixed(2);
 console.log(`\n  coverage: ${coverage}%  (ok ${ok} / ${rows.length})`);
 console.log(`  missing : ${misses.length}`);
 console.log(`  mismatch: ${mismatches.length}`);
+console.log(`  invalid  : ${invalid.length}`);
 console.log(
-  `  abs-not-under-src: ${absNotUnderSrc} (these need --src to match the absolute paths in legacy rows — not necessarily a copy failure)`,
+  `  abs-not-under-src: ${absNotUnderSrc} (unverified: --src does not cover these legacy absolute paths)`,
 );
 for (const m of misses.slice(0, 20)) console.log(`    MISS ${m.storagePath} -> ${m.dest}`);
 if (misses.length > 20) console.log(`    … and ${misses.length - 20} more misses`);
 for (const m of mismatches.slice(0, 20))
   console.log(`    HASH ${m.storagePath}: expected ${m.expected.slice(0, 12)} got ${m.got.slice(0, 12)}`);
 if (mismatches.length > 20) console.log(`    … and ${mismatches.length - 20} more mismatches`);
+for (const m of invalid.slice(0, 20)) console.log(`    INVALID ${m.storagePath}: ${m.reason}`);
 
-const clean = misses.length === 0 && mismatches.length === 0;
+const rowCoverageComplete = rows.length === totalRows && totalRows > 0;
+const clean =
+  misses.length === 0 &&
+  mismatches.length === 0 &&
+  invalid.length === 0 &&
+  (!FULL || rowCoverageComplete);
 
 // Banner + exit-code logic (FIX 1). A SAMPLE run — even a clean one — is NOT proof
 // of a complete copy, so it must never present as a plain "VERIFY PASS" or exit 0
@@ -164,12 +195,14 @@ if (clean && FULL) {
 
 // Exit codes under --apply: FULL+clean = 0; any dirty = 1; SAMPLE+clean = 3
 // (distinct so downstream can't treat sample-only as a proven-clean copy).
-// In dry-run/no --apply, keep exit 0 (the SAMPLE-ONLY warning above still prints).
+// A dirty verification is non-zero even in planning mode: callers must never infer
+// that a printed rsync plan also proved the destination. SAMPLE+clean under --apply
+// remains a distinct exit 3 because the copy ran but completeness is unproven.
+if (!clean) {
+  console.error("FAIL: destination is not fully verified; DO NOT proceed to repoint or delete.");
+  process.exit(1);
+}
 if (APPLY) {
-  if (!clean) {
-    console.error("FAIL: verify coverage <100% under --apply; DO NOT proceed to repoint or delete.");
-    process.exit(1);
-  }
   if (!FULL) {
     console.error(
       "SAMPLE-ONLY under --apply: exiting 3 (not proven-complete). Re-run with --full before any gated delete.",
diff --git a/ops/offload-snapshots.test.mjs b/ops/offload-snapshots.test.mjs
new file mode 100644
index 00000000..f2b6f303
--- /dev/null
+++ b/ops/offload-snapshots.test.mjs
@@ -0,0 +1,73 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { mkdtempSync, mkdirSync, writeFileSync, chmodSync, unlinkSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { createHash } from "node:crypto";
+import { spawnSync } from "node:child_process";
+
+const script = join(import.meta.dirname, "offload-snapshots.mjs");
+
+function fixture() {
+  const root = mkdtempSync(join(tmpdir(), "snapshot-offload-test-"));
+  const src = join(root, "src");
+  const dest = join(root, "dest");
+  const bin = join(root, "bin");
+  mkdirSync(join(src, "builder"), { recursive: true });
+  mkdirSync(join(dest, "builder"), { recursive: true });
+  mkdirSync(bin);
+  const body = Buffer.from("preserved raw evidence\n");
+  const hash = createHash("sha256").update(body).digest("hex");
+  writeFileSync(join(src, "builder", `${hash}.html`), body);
+  writeFileSync(join(dest, "builder", `${hash}.html`), body);
+  const psql = join(bin, "psql");
+  writeFileSync(
+    psql,
+    `#!/bin/sh\ncase "$*" in\n  *'count(*)'*) printf '1\\n' ;;\n  *) printf 'builder/${hash}.html\\t${hash}\\n' ;;\nesac\n`,
+  );
+  chmodSync(psql, 0o755);
+  return { root, src, dest, bin, hash };
+}
+
+function run(args, bin) {
+  return spawnSync(process.execPath, [script, ...args], {
+    encoding: "utf8",
+    env: { ...process.env, PATH: `${bin}:${process.env.PATH}` },
+  });
+}
+
+test("full read-only verification proves every DB row by hash", () => {
+  const { src, dest, bin } = fixture();
+  const result = run(["--src", src, "--dest", dest, "--full"], bin);
+  assert.equal(result.status, 0, result.stderr || result.stdout);
+  assert.match(result.stdout, /VERIFY PASS \(FULL — 1 rows\)/);
+  assert.match(result.stdout, /COPY ONLY, no --delete/);
+});
+
+test("real copy-only rsync followed by full hash verification succeeds", () => {
+  const { src, dest, bin, hash } = fixture();
+  unlinkSync(join(dest, "builder", `${hash}.html`));
+  const result = run(["--src", src, "--dest", dest, "--apply", "--full"], bin);
+  assert.equal(result.status, 0, result.stderr || result.stdout);
+  assert.match(result.stdout, /rsync complete \(copy only\)/);
+  assert.match(result.stdout, /VERIFY PASS \(FULL — 1 rows\)/);
+});
+
+test("sample-only apply cannot masquerade as a complete verification", () => {
+  const { root, src, dest, bin } = fixture();
+  const rsync = join(bin, "rsync");
+  writeFileSync(rsync, "#!/bin/sh\nexit 0\n");
+  chmodSync(rsync, 0o755);
+  const result = run(["--src", src, "--dest", dest, "--apply", "--sample", "1"], bin);
+  assert.equal(result.status, 3, result.stderr || result.stdout);
+  assert.match(result.stdout, /SAMPLE ONLY/);
+  assert.match(result.stderr, /not proven-complete/);
+  assert.ok(root);
+});
+
+test("overlapping source and destination fail before copy or DB access", () => {
+  const { src, bin } = fixture();
+  const result = run(["--src", src, "--dest", join(src, "nested")], bin);
+  assert.equal(result.status, 2);
+  assert.match(result.stderr, /must be disjoint/);
+});
diff --git a/ops/verify-snapshots.mjs b/ops/verify-snapshots.mjs
index fc3be0ac..be929a3a 100644
--- a/ops/verify-snapshots.mjs
+++ b/ops/verify-snapshots.mjs
@@ -20,6 +20,10 @@ const opt = (name, def) => {
 
 const FULL = flag("full");
 const SAMPLE = Number(opt("sample", "500"));
+if (!Number.isInteger(SAMPLE) || SAMPLE <= 0) {
+  console.error("FATAL: --sample must be a positive integer.");
+  process.exit(2);
+}
 const SNAPSHOT_DIR = process.env.SNAPSHOT_DIR ?? join(import.meta.dirname, "../var/snapshots");
 const resolveSnapshot = (p) => resolveAgainstDir(p, SNAPSHOT_DIR);
 
@@ -33,47 +37,91 @@ const writeLatest = (obj) => {
   writeFileSync(join(dir, "latest.json"), JSON.stringify(obj, null, 2));
 };
 
-let rows;
+let rows, totalRows;
 try {
+  totalRows = Number(psql(`select count(*) from "RawSnapshot"`)) || 0;
   const orderClause = FULL ? "" : `order by random() limit ${SAMPLE}`;
   rows = psql(`select "storagePath" || E'\\t' || "contentHash" from "RawSnapshot" ${orderClause}`)
     .split("\n")
     .filter(Boolean);
 } catch (e) {
-  const rec = { verdict: "FAIL", status: "FAIL", checked: 0, ok: 0, missing: 0, mismatch: 0, error: e.message.split("\n")[0] };
+  const rec = {
+    verdict: "FAIL",
+    status: "FAIL",
+    checked: 0,
+    ok: 0,
+    missing: 0,
+    mismatch: 0,
+    error: e.message.split("\n")[0],
+  };
   writeLatest(rec);
   console.error(`FAIL: could not read RawSnapshot (${rec.error})`);
   process.exit(1);
 }
 
-let ok = 0, missing = 0, mismatch = 0;
-const misses = [], mismatches = [];
+let ok = 0,
+  missing = 0,
+  mismatch = 0,
+  invalid = 0;
+const misses = [],
+  mismatches = [],
+  invalids = [];
 for (const line of rows) {
   const [storagePath, contentHash] = line.split("\t");
-  if (!storagePath || !contentHash) continue;
-  const path = resolveSnapshot(storagePath);
+  if (!storagePath || !contentHash) {
+    invalid++;
+    invalids.push(storagePath || "(empty)");
+    continue;
+  }
+  let path;
+  try {
+    path = resolveSnapshot(storagePath);
+  } catch {
+    invalid++;
+    invalids.push(storagePath);
+    continue;
+  }
   try {
     statSync(path);
   } catch {
-    missing++; misses.push(storagePath); continue;
+    missing++;
+    misses.push(storagePath);
+    continue;
   }
   const digest = createHash("sha256").update(readFileSync(path)).digest("hex");
   if (digest === contentHash) ok++;
-  else { mismatch++; mismatches.push(storagePath); }
+  else {
+    mismatch++;
+    mismatches.push(storagePath);
+  }
 }
 
 const checked = rows.length;
+const expectedChecked = FULL ? totalRows : Math.min(SAMPLE, totalRows);
 // PASS = all sampled files present + hash-match. WARN = only missing files (a move
 // mid-flight or an un-offloaded row). FAIL = any hash mismatch (corruption).
 let verdict;
-if (mismatch > 0) verdict = "FAIL";
+if (mismatch > 0 || invalid > 0 || totalRows === 0 || checked !== expectedChecked) verdict = "FAIL";
 else if (missing > 0) verdict = "WARN";
 else verdict = "PASS";
 
-const rec = { verdict, status: verdict, checked, ok, missing, mismatch };
+const rec = {
+  verdict,
+  status: verdict,
+  full: FULL,
+  totalRows,
+  checked,
+  ok,
+  missing,
+  mismatch,
+  invalid,
+};
 writeLatest(rec);
 
-console.log(`snapshot integrity: ${verdict}  checked=${checked} ok=${ok} missing=${missing} mismatch=${mismatch}`);
+console.log(
+  `snapshot integrity: ${verdict}  checked=${checked}/${totalRows} ok=${ok} missing=${missing} mismatch=${mismatch} invalid=${invalid}`,
+);
 for (const p of misses.slice(0, 10)) console.log(`  MISS ${p}`);
 for (const p of mismatches.slice(0, 10)) console.log(`  HASH ${p}`);
+for (const p of invalids.slice(0, 10)) console.log(`  INVALID ${p}`);
 process.exit(verdict === "FAIL" ? 1 : 0);
diff --git a/verification/TK-10809-codex-e2e-proof.json b/verification/TK-10809-codex-e2e-proof.json
new file mode 100644
index 00000000..da77a899
--- /dev/null
+++ b/verification/TK-10809-codex-e2e-proof.json
@@ -0,0 +1,38 @@
+{
+  "ticket": "TK-10809",
+  "model": "codex",
+  "intent": "Prove that snapshot evidence can be copied to a disjoint store without deletion and that every selected DB row resolves and hash-matches at the destination.",
+  "riskTier": "R1",
+  "environment": "local macOS workspace; temporary snapshot stores and a deterministic fake read-only psql boundary; real rsync for the copy journey",
+  "baseline": {
+    "commit": "ca3c67b35c1eadf4b99cb1bdff831f86cd4ad41d",
+    "productionTouched": false,
+    "evidencePruned": false
+  },
+  "timestamp": "2026-08-30T16:37:04Z",
+  "commands": [
+    "node --test ops/lib/snapshot-resolve.test.mjs ops/offload-snapshots.test.mjs",
+    "apps/workers/node_modules/.bin/vitest run apps/workers/src/snapshot-path.test.ts",
+    "apps/workers/node_modules/.bin/tsc --noEmit -p apps/workers/tsconfig.json",
+    "node --check ops/offload-snapshots.mjs",
+    "node --check ops/verify-snapshots.mjs",
+    "node --check scripts/backfill-drh-geo-from-cache.mjs",
+    "git diff --check"
+  ],
+  "assertions": [
+    { "boundary": "path resolver", "verdict": "PASS", "evidence": "Relative and var/snapshots-prefixed rows resolve under the active root; traversal is rejected; legacy absolute rows remain readable." },
+    { "boundary": "copy", "verdict": "PASS", "evidence": "Real rsync -a copied a temporary raw-evidence file with no --delete and retained the source." },
+    { "boundary": "database-to-file", "verdict": "PASS", "evidence": "Full mode resolved 1/1 deterministic RawSnapshot row against the destination and matched SHA-256 contentHash." },
+    { "boundary": "incomplete verification", "verdict": "PASS", "evidence": "Apply plus sample-only verification exits 3 and explicitly states that completeness is not proven." },
+    { "boundary": "overlap guard", "verdict": "PASS", "evidence": "Destination nested under source exits 2 before DB or copy access." },
+    { "boundary": "worker compatibility", "verdict": "PASS", "evidence": "10 focused snapshot-path tests pass and worker TypeScript compilation is clean." }
+  ],
+  "negativeChecks": [
+    "Relative ../ traversal cannot escape SNAPSHOT_DIR.",
+    "Absolute legacy rows outside the declared source are not guessed by basename and remain unverified.",
+    "Invalid sample sizes fail closed.",
+    "Empty, malformed, missing, mismatched, or incomplete full verification cannot print a clean full-pass verdict."
+  ],
+  "cleanup": "All copy fixtures were created under the OS temporary directory. No production state, database rows, source snapshots, services, or approval-gated configuration were changed.",
+  "verdict": "PASS"
+}

← ca3c67b3 Add Mungo and Chafin CPG collectors  ·  back to Homesonspec  ·  Record TK-10809 post-commit proof ef253225 →