@openparachute/vault 0.7.9-rc.7 → 0.7.9-rc.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,11 @@
1
1
  /** Offline git-history import. Git objects are data; no working tree is checked out. */
2
- import { Database } from "bun:sqlite";
2
+ import { Database, SQLiteError } from "bun:sqlite";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { createHash } from "node:crypto";
5
5
  import { chmodSync, closeSync, copyFileSync, existsSync, fsyncSync, mkdirSync, mkdtempSync, openSync, readSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
6
6
  import { dirname, join, resolve } from "node:path";
7
7
  import { tmpdir } from "node:os";
8
+ import { isNoteIdShape } from "../core/src/ulid.ts";
8
9
  import { applyImportedNote, beginImportRun, canonicalJson, importObservationDigest, importTargetDigest, readImportReceipt, type ImportedObservation, type ImportRun } from "../core/src/history-import.ts";
9
10
  import { hashContent, resolveHistoryPolicy, VERSION_MAX_BYTES, type HistoryPolicy } from "../core/src/history.ts";
10
11
  import { applyConnectionPragmas, initSchema } from "../core/src/schema.ts";
@@ -16,7 +17,6 @@ const MAX_OUTPUT = 32 * 1024 * 1024;
16
17
  const MAX_COMMITS = 100_000;
17
18
  const MAX_OBJECTS = 10_000_000;
18
19
  const MAX_BYTES = 2 * 1024 * 1024 * 1024;
19
- const ID = /^[0-9A-HJKMNP-TV-Z]{26}$/;
20
20
  const decoder = new TextDecoder("utf-8", { fatal: true });
21
21
  class GitArchiveError extends Error {}
22
22
  function git(repo: string, args: string[]): Buffer {
@@ -47,7 +47,7 @@ function inline(raw: string): { meta: Record<string, unknown>; content: string }
47
47
  return { meta: yaml(match[1]!), content: raw.slice(match[0].length) };
48
48
  }
49
49
  function observation(meta: Record<string, unknown>, content: string, commit: string, blob: string, time: string): { id: string; row: ImportedObservation } {
50
- if (typeof meta.id !== "string" || !ID.test(meta.id)) throw new Error("invalid_id");
50
+ if (!isNoteIdShape(meta.id)) throw new Error("invalid_id");
51
51
  if (Buffer.byteLength(content) > VERSION_MAX_BYTES) throw new Error("oversized_body");
52
52
  const path = meta.path ?? null, extension = meta.extension ?? "md", created = meta.created_at ?? null;
53
53
  if (path !== null && (typeof path !== "string" || path.includes("\0") || path.split("/").includes(".."))) throw new Error("invalid_path");
@@ -88,7 +88,7 @@ export function normalizeHistorySelections(input: unknown): HistorySelections {
88
88
  const seen = new Set<string>();
89
89
  const selections = value.selections.map(raw => {
90
90
  const entry = selectionObject(raw, ["note_id", "commit", "selected", "rejected", "reason"]);
91
- if (typeof entry.note_id !== "string" || !ID.test(entry.note_id) || typeof entry.commit !== "string" || !OBJECT_ID.test(entry.commit) || !Array.isArray(entry.rejected) || !entry.rejected.length) throw new Error("Invalid selection entry");
91
+ if (!isNoteIdShape(entry.note_id) || typeof entry.commit !== "string" || !OBJECT_ID.test(entry.commit) || !Array.isArray(entry.rejected) || !entry.rejected.length) throw new Error("Invalid selection entry");
92
92
  const key = `${entry.note_id}:${entry.commit}`;
93
93
  if (seen.has(key)) throw new Error("Duplicate selection note/commit");
94
94
  seen.add(key);
@@ -182,10 +182,13 @@ export function stageArchive(repo: string, tip: string, stage: Database, inputSe
182
182
  collect(id, state, () => JSON.parse((parsedBody.get(cacheKey) as { row_json: string }).row_json), { git_path: entry.path, blob: entry.hash, sidecar });
183
183
  };
184
184
  const failure = (entry: typeof entries[number], error: unknown, id?: unknown) => {
185
+ // Staging/cache failures are operational, not evidence of malformed source.
186
+ // Abort so no diagnostic manifest can bless an incomplete archive walk.
187
+ if (error instanceof SQLiteError) throw error;
185
188
  const message = error instanceof Error ? error.message : "invalid_observation";
186
189
  const reason = /^[a-z_]+$/.test(message) ? message : "invalid_metadata_or_utf8";
187
190
  if (error instanceof GitArchiveError || message === "Archive byte limit exceeded" || message === "Archive note limit exceeded") throw error;
188
- if (typeof id === "string" && ID.test(id)) stage.prepare("INSERT OR REPLACE INTO quarantine VALUES(?,?)").run(id, reason);
191
+ if (isNoteIdShape(id)) stage.prepare("INSERT OR REPLACE INTO quarantine VALUES(?,?)").run(id, reason);
189
192
  else stage.prepare("INSERT INTO issues VALUES(?,?,?)").run(commit, entry.path, reason);
190
193
  };
191
194
  const format = files.get(".parachute/vault.yaml");
@@ -195,7 +198,7 @@ export function stageArchive(repo: string, tip: string, stage: Database, inputSe
195
198
  try {
196
199
  meta = yaml(read(entry));
197
200
  const extension = meta.extension;
198
- if (typeof meta.id !== "string" || !ID.test(meta.id) || typeof extension !== "string") throw new Error("invalid_sidecar");
201
+ if (!isNoteIdShape(meta.id) || typeof extension !== "string") throw new Error("invalid_sidecar");
199
202
  const contentPath = `${meta.path ?? `_unpathed/${meta.id}`}.${extension}`;
200
203
  const body = files.get(contentPath);
201
204
  if (!body) throw new Error("missing_sidecar_body");
@@ -318,7 +321,7 @@ function project(vault: string, repo: string, tip: string, dir: string, opts: {
318
321
  const selections = opts.selections ?? null;
319
322
  const source = stageArchive(repo, tip, stage, selections), policy = resolveHistoryPolicy(config.history);
320
323
  const waivers = opts.waivers ?? {};
321
- for (const [id, reason] of Object.entries(waivers)) if (!ID.test(id) || typeof reason !== "string" || !reason.trim()) throw new Error("Waivers require note IDs and reasons");
324
+ for (const [id, reason] of Object.entries(waivers)) if (!isNoteIdShape(id) || typeof reason !== "string" || !reason.trim()) throw new Error("Waivers require note IDs and reasons");
322
325
  const options_digest = optionsDigest(policy, waivers, selections);
323
326
  const run = { run_id: "projection", source_fingerprint: source.source_fingerprint, tip, options_digest };
324
327
  beginImportRun(db, run);
@@ -0,0 +1,38 @@
1
+ import { test, expect } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { writeVaultConfig, vaultDbPath } from "./config.ts";
7
+ import { BunSqliteStore } from "../core/src/store.ts";
8
+ import { runDoctorScan } from "../core/src/doctor.ts";
9
+
10
+ test("history rebuild-state repairs hints only, without requiring a Git mirror", async () => {
11
+ const taskDir = mkdtempSync(join(tmpdir(), "history-rebuild-"));
12
+ const saved = process.env.PARACHUTE_HOME;
13
+ process.env.PARACHUTE_HOME = taskDir;
14
+ let db: Database | undefined;
15
+ try {
16
+ writeVaultConfig({ name: "repair", api_keys: [], created_at: new Date().toISOString() });
17
+ db = new Database(vaultDbPath("repair"), { create: true });
18
+ const store = new BunSqliteStore(db);
19
+ const n = await store.createNote("before");
20
+ await store.updateNote(n.id, { content: "after" });
21
+ db.exec("UPDATE history_compact_state SET stored=999,refused=1");
22
+ expect(runDoctorScan(db).findings.some(f=>f.type==="history_compact_state_drift")).toBe(true);
23
+ const notes = db.query("SELECT * FROM notes").all(), versions = db.query("SELECT * FROM note_versions").all(), blobs = db.query("SELECT * FROM note_blobs").all();
24
+ db.close(); db=undefined;
25
+ const proc = Bun.spawnSync({ cmd: [process.execPath, join(import.meta.dir,"cli.ts"),"history","rebuild-state","--vault","repair","--json"], env: {...process.env, PARACHUTE_HOME: taskDir}, stdout:"pipe",stderr:"pipe" });
26
+ expect(proc.exitCode, new TextDecoder().decode(proc.stderr)).toBe(0);
27
+ expect(JSON.parse(new TextDecoder().decode(proc.stdout))).toEqual({ vault: "repair", rebuilt_notes: 1 });
28
+ db = new Database(vaultDbPath("repair"));
29
+ expect(runDoctorScan(db).findings.some(f=>f.type==="history_compact_state_drift")).toBe(false);
30
+ expect(db.query("SELECT * FROM notes").all()).toEqual(notes);
31
+ expect(db.query("SELECT * FROM note_versions").all()).toEqual(versions);
32
+ expect(db.query("SELECT * FROM note_blobs").all()).toEqual(blobs);
33
+ } finally {
34
+ db?.close();
35
+ if(saved===undefined)delete process.env.PARACHUTE_HOME;else process.env.PARACHUTE_HOME=saved;
36
+ rmSync(taskDir,{recursive:true,force:true});
37
+ }
38
+ });
package/src/mcp-tools.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { generateMcpTools, resolveNote } from "../core/src/mcp.ts";
9
+ import { projectHistoryProvenance } from "../core/src/history-visibility.js";
9
10
  import type { McpToolDef, GenerateMcpToolsOpts } from "../core/src/mcp.ts";
10
11
  import { getNote, getNoteTags, getVaultMap } from "../core/src/notes.ts";
11
12
  import { narrowLinkWarningsForVisibility } from "../core/src/wikilinks.ts";
@@ -579,8 +580,13 @@ function applyTagScopeWrappers(
579
580
  )) {
580
581
  const id = (params as any).versions.note_id;
581
582
  const note = resolveNote(store.db, id);
582
- return note && noteWithinTagScope(note, allowed, rawTags)
583
- ? result : { error: "Note not found", error_type: "not_found", id };
583
+ if (!note || !noteWithinTagScope(note, allowed, rawTags)) {
584
+ return { error: "Note not found", error_type: "not_found", id };
585
+ }
586
+ if ("versions" in result && Array.isArray(result.versions)) {
587
+ return { ...result, versions: result.versions.map(row => projectHistoryProvenance(row, true)) };
588
+ }
589
+ return projectHistoryProvenance(result, true);
584
590
  }
585
591
  // Possible response shapes (vault#550 added the `warnings` variants):
586
592
  // - Array (legacy list, no cursor, no warnings)
@@ -905,6 +911,7 @@ function applyTagScopeWrappers(
905
911
  wrapReadTool(tools, "doctor", async (orig, params) => {
906
912
  const allowed = await getAllowed();
907
913
  if (!allowed) return await orig(params);
914
+ if (params.deep) return forbidden("deep history audit requires an unrestricted session");
908
915
  return runDoctorScan(store.db, { allowedTags: allowed });
909
916
  });
910
917
  }
package/src/routes.ts CHANGED
@@ -13,7 +13,8 @@ import { getImportedVersion, importStorageIndex, parseHistorySelector, projectHi
13
13
  */
14
14
 
15
15
  import { HistoryNotFoundError, latestTombstone } from "../core/src/history.js";
16
- import { ULID_REGEX } from "../core/src/ulid.js";
16
+ import { projectHistoryProvenance } from "../core/src/history-visibility.js";
17
+ import { isNoteIdShape } from "../core/src/ulid.js";
17
18
  import type { Database } from "bun:sqlite";
18
19
  import type { Store, Note, QueryOpts, AggregateSpec } from "../core/src/types.ts";
19
20
  import { TAG_EXPAND_MODES, stripTagHash, suggestSimilarTag, type TagExpandMode } from "../core/src/tag-hierarchy.ts";
@@ -268,6 +269,7 @@ function conflictResponse(e: any): Response | null {
268
269
  400,
269
270
  );
270
271
  }
272
+ if (e && e.code === "PRECONDITION_REQUIRED") return json({ error_type: "precondition_required", note_id: e.note_id, message: e.message }, 428);
271
273
  if (e && e.code === "HISTORY_NOT_FOUND") return json({ error: e.message, error_type: "not_found" }, 404);
272
274
  if (e && e.code === "HISTORY_OVERFLOW") return json({
273
275
  error_type: "history_overflow", note_id: e.note_id, byte_size: e.byte_size, limit: e.limit, message: e.message,
@@ -2930,7 +2932,7 @@ async function handleNotesInner(
2930
2932
  if (note && !noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
2931
2933
  return json({ error: "Not found", error_type: "not_found" }, 404);
2932
2934
  }
2933
- if (!note && (tagScope.raw !== null || !ULID_REGEX.test(idOrPath) ||
2935
+ if (!note && (tagScope.raw !== null || !isNoteIdShape(idOrPath) ||
2934
2936
  !(await store.listNoteVersions(idOrPath, { limit: 1 })).length)) {
2935
2937
  return json({ error: "Not found", error_type: "not_found" }, 404);
2936
2938
  }
@@ -2942,7 +2944,7 @@ async function handleNotesInner(
2942
2944
  const offset = Math.max(0, Number.parseInt(url.searchParams.get("offset") ?? "0", 10) || 0);
2943
2945
  const versions = await store.listNoteVersions(id, { limit, offset });
2944
2946
  const total = await store.countNoteVersions(id);
2945
- return json({ versions: versions.map(projectHistoryRow), total });
2947
+ return json({ versions: versions.map(row => projectHistoryProvenance(projectHistoryRow(row), tagScope.allowed !== null)), total });
2946
2948
  }
2947
2949
  if (method === "DELETE") {
2948
2950
  const result = await store.eraseNoteHistory(id);
@@ -2953,7 +2955,7 @@ async function handleNotesInner(
2953
2955
  if (!/^\d+$/.test(importMatch[1]!) || !Number.isSafeInteger(index) || index < 0) return json({ error: "Invalid import_ix", error_type: "invalid_request" }, 400);
2954
2956
  try {
2955
2957
  const version = getImportedVersion(db, id, index);
2956
- return version ? json(projectHistoryRow(version)) : json({ error: "Not found", error_type: "not_found", origin: "git-import", import_ix: index }, 404);
2958
+ return version ? json(projectHistoryProvenance(projectHistoryRow(version), tagScope.allowed !== null)) : json({ error: "Not found", error_type: "not_found", origin: "git-import", import_ix: index }, 404);
2957
2959
  } catch (e) {
2958
2960
  const response = conflictResponse(e);
2959
2961
  if (response) return response;
@@ -2964,7 +2966,7 @@ async function handleNotesInner(
2964
2966
  if (!/^\d+$/.test(verMatch[1]!) || !Number.isInteger(ix) || ix < 0) return json({ error: "Invalid version_ix", error_type: "invalid_request" }, 400);
2965
2967
  try {
2966
2968
  const version = await store.getNoteVersion(id, ix);
2967
- return version ? json(version) : json({ error: "Not found", error_type: "not_found" }, 404);
2969
+ return version ? json(projectHistoryProvenance(version, tagScope.allowed !== null)) : json({ error: "Not found", error_type: "not_found" }, 404);
2968
2970
  } catch (e) {
2969
2971
  const response = conflictResponse(e);
2970
2972
  if (response) return response;
@@ -4451,11 +4453,21 @@ export function handleUnresolvedWikilinks(
4451
4453
  * catalog.
4452
4454
  */
4453
4455
  export async function handleDoctor(
4454
- _req: Request,
4456
+ req: Request,
4455
4457
  store: Store,
4456
4458
  tagScope: TagScopeCtx = NO_TAG_SCOPE,
4457
4459
  ): Promise<Response> {
4458
- const report = await store.doctor({ allowedTags: tagScope.allowed });
4460
+ const q = new URL(req.url).searchParams;
4461
+ if (q.has("deep") && !["true", "false"].includes(q.get("deep")!)) return json({ error: "deep must be true or false" }, 400);
4462
+ const deep = q.get("deep") === "true";
4463
+ if (deep && tagScope.allowed !== null) return json({ error: "deep history audit requires an unrestricted session" }, 403);
4464
+ const after = q.get("history_after") ?? undefined;
4465
+ const maxBlobs = q.has("history_max_blobs") ? Number(q.get("history_max_blobs")) : undefined;
4466
+ const budgetMs = q.has("history_budget_ms") ? Number(q.get("history_budget_ms")) : undefined;
4467
+ if ((after !== undefined && !/^[a-f0-9]{64}$/.test(after)) ||
4468
+ (maxBlobs !== undefined && (!Number.isInteger(maxBlobs) || maxBlobs < 1 || maxBlobs > 500)) ||
4469
+ (budgetMs !== undefined && (!Number.isInteger(budgetMs) || budgetMs < 1 || budgetMs > 1000))) return json({ error: "invalid history audit bounds or cursor" }, 400);
4470
+ const report = await store.doctor({ allowedTags: tagScope.allowed, deep, history_after: after, history_max_blobs: maxBlobs, history_budget_ms: budgetMs });
4459
4471
  return json(report);
4460
4472
  }
4461
4473
 
@@ -65,6 +65,24 @@ async function toolsFor(vaultName: string, scopedTags: string[] | null, verb: "r
65
65
  return generateScopedMcpTools(vaultName, authFor(vaultName, scopedTags, verb));
66
66
  }
67
67
 
68
+ test("deep doctor REST/MCP parity and scoped refusal", async () => {
69
+ seedVault("journal");
70
+ const store = getVaultStore("journal");
71
+ await store.upsertTagRecord("mine", {});
72
+ const doctor = (await toolsFor("journal", null, "read")).find(t => t.name === "doctor")!;
73
+ const mcp: any = await doctor.execute({ deep: true, history_max_blobs: 1 });
74
+ const rest = await handleDoctor(new Request("http://localhost/api/doctor?deep=true&history_max_blobs=1"), store);
75
+ expect(rest.status).toBe(200);
76
+ expect((await rest.json() as any).history_audit).toEqual(mcp.history_audit);
77
+ expect(mcp.history_audit.complete).toBe(true);
78
+ const scoped = (await toolsFor("journal", ["mine"], "admin")).find(t => t.name === "doctor")!;
79
+ expect((await scoped.execute({ deep: true }) as any).error_type).toBe("tag_scope_violation");
80
+ expect((await handleDoctor(new Request("http://localhost/api/doctor?deep=true"), store, { allowed: new Set(["mine"]), raw: ["mine"] })).status).toBe(403);
81
+ for (const q of ["deep=1", "deep=true&history_after=oops", "deep=true&history_max_blobs=501", "deep=true&history_budget_ms=0"]) {
82
+ expect((await handleDoctor(new Request("http://localhost/api/doctor?" + q), store)).status).toBe(400);
83
+ }
84
+ });
85
+
68
86
  describe("rename-tag / merge-tags — tag-scope gating (vault#552)", () => {
69
87
  test("rename-tag: old_name or new_name outside the allowlist is refused before reaching core", async () => {
70
88
  seedVault("journal");
@@ -64,7 +64,7 @@ test("P9/P18 compacted REST and MCP versions roundtrip with exact keys", async (
64
64
  expect(mcp).toEqual(get.body);
65
65
  const ml = await query()({ versions: { note_id: n.id, limit: 2 } }) as any;
66
66
  expect(ml.total).toBe(list.body.total);
67
- const restore = await call(`/notes/${n.id}/restore`, "POST", { version_ix: 0 });
67
+ const restore = await call(`/notes/${n.id}/restore`, "POST", { version_ix: 0, if_updated_at: (await store.getNote(n.id))!.updatedAt });
68
68
  expect(restore.status).toBe(200);
69
69
  expect((await store.getNote(n.id))!.content).toBe(before.content);
70
70
  });
@@ -11,6 +11,8 @@ import {
11
11
  import { getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
12
12
  import { generateScopedMcpTools } from "./mcp-tools.ts";
13
13
  import { runDoctorScan } from "../core/src/doctor.ts";
14
+ import { applyImportedNote, beginImportRun, importTargetDigest } from "../core/src/history-import.ts";
15
+ import { resolveHistoryPolicy } from "../core/src/history.ts";
14
16
  let taskDir: string,
15
17
  savedHome: string | undefined,
16
18
  store: ReturnType<typeof getVaultStore>;
@@ -78,9 +80,56 @@ async function fixture() {
78
80
  await store.updateNote(n.id, { content: "two" });
79
81
  return n;
80
82
  }
83
+ test("tag-scoped REST and MCP history omit provenance without changing stored rows", async () => {
84
+ const n = await store.createNote("original", { path: "history-visible", tags: ["journal"] });
85
+ await store.updateNote(n.id, { content: "current", actor: "PRIVATE_EDITOR", via: "PRIVATE_INTERFACE" });
86
+ const run = { run_id: "visibility", source_fingerprint: "source", tip: "tip", options_digest: "options" };
87
+ beginImportRun(store.db, run);
88
+ applyImportedNote(store.db, { run, noteId: n.id, targetDigest: importTargetDigest(store.db, n.id),
89
+ policy: resolveHistoryPolicy({ min_versions: 20 }), now: Date.now(),
90
+ observations: [{ content: "archive", path: "old/path", metadata: {}, extension: "md", created_at: null,
91
+ observed_at: new Date().toISOString(), commit: "commit", blob: "blob" }] });
92
+ const before = await store.listNoteVersions(n.id);
93
+ const full = (await call(n.id, "/versions")).body;
94
+ expect(full.versions[0]).toMatchObject({ actor: "PRIVATE_EDITOR", via: "PRIVATE_INTERFACE" });
95
+ for (const scoped of [false, true]) {
96
+ const rest = (await call(n.id, "/versions", "GET", undefined, scoped)).body;
97
+ const mcp = await query(scoped ? ["journal"] : null)({ versions: { note_id: n.id } });
98
+ expect(mcp).toEqual(rest);
99
+ expect(rest.total).toBe(2);
100
+ expect(rest.versions[0].version_ix).toBe(0);
101
+ expect(rest.versions[1]).toMatchObject({ origin: "git-import", import_ix: 0 });
102
+ for (const row of rest.versions) {
103
+ expect(Object.hasOwn(row, "actor")).toBe(!scoped);
104
+ expect(Object.hasOwn(row, "via")).toBe(!scoped);
105
+ }
106
+ for (const [path, selector, content] of [
107
+ ["/versions/0", { version_ix: 0 }, "original"],
108
+ ["/imports/0", { origin: "git-import", import_ix: 0 }, "archive"],
109
+ ] as const) {
110
+ const one = await call(n.id, path, "GET", undefined, scoped);
111
+ expect(one.status).toBe(200);
112
+ expect(one.body.content).toBe(content);
113
+ expect(await query(scoped ? ["journal"] : null)({ versions: { note_id: n.id, ...selector } })).toEqual(one.body);
114
+ expect(Object.hasOwn(one.body, "actor")).toBe(!scoped);
115
+ expect(Object.hasOwn(one.body, "via")).toBe(!scoped);
116
+ }
117
+ }
118
+ expect(await store.listNoteVersions(n.id)).toEqual(before);
119
+ expect((await store.getNote(n.id))!.content).toBe("current");
120
+ // Empty tag lists mean unrestricted in the existing authorization contract.
121
+ expect(await query([])({ versions: { note_id: n.id } })).toEqual(full);
122
+ const denied = await query(["unrelated"])({ versions: { note_id: n.id } });
123
+ expect(denied).toMatchObject({ error_type: "not_found" });
124
+ });
81
125
  test("P5 REST restore rejects stale optimistic tokens without capture", async () => {
82
126
  const n = await fixture();
83
127
  const before = await store.listNoteVersions(n.id);
128
+ for (const extra of [{}, { force: true }]) {
129
+ const missing = await call(n.id, "/restore", "POST", { version_ix: 0, ...extra });
130
+ expect(missing.status).toBe(428);
131
+ expect(missing.body.error_type).toBe("precondition_required");
132
+ }
84
133
  const r = await call(n.id, "/restore", "POST", {
85
134
  version_ix: 0,
86
135
  if_updated_at: "1999-01-01T00:00:00.000Z",
@@ -320,6 +369,21 @@ test("P19 overflow deletion remains possible but its tombstone cannot restore",
320
369
  expect(r.status).toBe(409);
321
370
  expect(r.body.error_type).toBe("history_unrecoverable");
322
371
  });
372
+ test("legacy deleted IDs resolve exact history and restore without exposing scoped tombstones", async () => {
373
+ for (const id of ["2020-01-02-03-04-05", "legacy:abc123", "shortid"]) {
374
+ await store.createNote("original", { id, path: `legacy/${id}` });
375
+ await store.updateNote(id, { content: "current" });
376
+ await store.deleteNote(id);
377
+ expect((await call(id, "/versions", "GET", undefined, true)).status).toBe(404);
378
+ expect((await call(id, "/restore", "POST", { version_ix: 0 }, true)).status).toBe(404);
379
+ expect((await call(id, "/versions")).status).toBe(200);
380
+ const restored = await call(id, "/restore", "POST", { version_ix: 0 });
381
+ expect(restored.status).toBe(200);
382
+ expect(restored.body).toMatchObject({ id, content: "original", recreated: true });
383
+ }
384
+ expect((await call("not-a-tombstone", "/versions")).status).toBe(404);
385
+ expect((await call("not-a-tombstone", "/restore", "POST", { version_ix: 0 })).status).toBe(404);
386
+ });
323
387
  test("P20 REST recreates untagged at the tombstone path and guards conflicts", async () => {
324
388
  const n = await fixture();
325
389
  await store.deleteNote(n.id);
@@ -393,7 +457,7 @@ test("P24 migration bypass restores, captures once, and emits the bypass log", a
393
457
  const n = await strictFixture();
394
458
  const log = spyOn(console, "warn").mockImplementation(() => {});
395
459
  try {
396
- const r = await call(n.id, "/restore", "POST", { version_ix: 0 }, false, {
460
+ const r = await call(n.id, "/restore", "POST", { version_ix: 0, if_updated_at: (await store.getNote(n.id))!.updatedAt }, false, {
397
461
  actor: "migrator",
398
462
  via: "api",
399
463
  bypassStrict: true,