@openparachute/vault 0.7.9-rc.8 → 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.
@@ -0,0 +1,46 @@
1
+ import { test, expect } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { BunSqliteStore } from "./store.js";
4
+ import { initSchema } from "./schema.js";
5
+ import { runDoctorScan } from "./doctor.js";
6
+
7
+ test("no ledger rows are not evidence of a downgrade", async () => {
8
+ const db = new Database(":memory:");
9
+ try {
10
+ const store = new BunSqliteStore(db);
11
+ const note = await store.createNote("before");
12
+ await store.updateNote(note.id, { content: "after" });
13
+ db.exec("DELETE FROM schema_version; UPDATE history_compact_state SET refused=1");
14
+ initSchema(db);
15
+ expect(db.prepare("SELECT refused FROM history_compact_state").get()).toEqual({ refused: 1 });
16
+ db.exec("DROP TABLE history_compact_state; DELETE FROM schema_version");
17
+ initSchema(db);
18
+ expect(db.prepare("SELECT versions,refused FROM history_compact_state").get()).toEqual({ versions: 1, refused: 0 });
19
+ } finally { db.close(); }
20
+ });
21
+
22
+ for (const tied of [false, true]) test(`a downgrade ledger entry rebuilds existing stale hints (tied=${tied})`, async () => {
23
+ const db = new Database(":memory:");
24
+ try {
25
+ const store = new BunSqliteStore(db, { history: { compact_min_versions: 2, compact_ratio: 1 } });
26
+ const note = await store.createNote("old".repeat(1000));
27
+ await store.updateNote(note.id, { append: "first" });
28
+ const stale = db.prepare("SELECT * FROM history_compact_state WHERE note_id=?").get(note.id) as any;
29
+ await store.updateNote(note.id, { append: "second" });
30
+ await store.updateNote(note.id, { content: "x" });
31
+ // Reproduce the older writer: history/live rows advance but hints do not,
32
+ // and that binary records its own version on open.
33
+ db.prepare("UPDATE history_compact_state SET versions=?,stored=?,live=?,refused=1 WHERE note_id=?").run(stale.versions, stale.stored, stale.live, note.id);
34
+ db.prepare("UPDATE schema_version SET applied_at='2026-01-01T00:00:00.000Z'").run();
35
+ db.prepare("INSERT OR REPLACE INTO schema_version VALUES(31,?)").run(tied ? "2026-01-01T00:00:00.000Z" : "2026-01-02T00:00:00.000Z");
36
+ expect(runDoctorScan(db).findings.some(f=>f.type==="history_compact_state_drift")).toBe(true);
37
+ const rows = db.prepare("SELECT * FROM note_versions").all();
38
+ initSchema(db);
39
+ expect(runDoctorScan(db).findings.some(f=>f.type==="history_compact_state_drift")).toBe(false);
40
+ expect(db.prepare("SELECT * FROM note_versions").all()).toEqual(rows);
41
+ expect(store.compactHistory({ budgetMs: null, maxNotes: null }).notes_scanned).toBe(1);
42
+ db.prepare("UPDATE history_compact_state SET refused=1").run();
43
+ initSchema(db);
44
+ expect(db.prepare("SELECT refused FROM history_compact_state WHERE note_id=?").get(note.id)).toEqual({ refused: 1 });
45
+ } finally { db.close(); }
46
+ });
@@ -0,0 +1,129 @@
1
+ import { test, expect, beforeEach, afterEach } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { BunSqliteStore } from "./store.js";
4
+ import { initSchema } from "./schema.js";
5
+ import { runDoctorScan } from "./doctor.js";
6
+ import { pruneVersions, resolveHistoryPolicy, sweepDeletedHistory } from "./history.js";
7
+ import { readCompactState } from "./history-compact-state.js";
8
+ import { applyImportedNote, beginImportRun, importTargetDigest } from "./history-import.js";
9
+ let db: Database, store: BunSqliteStore;
10
+ beforeEach(() => { db = new Database(":memory:"); store = new BunSqliteStore(db, { history: { compact_min_versions: 2, compact_ratio: 1 } }); });
11
+ afterEach(() => db.close());
12
+ const state = (id: string) => db.query("SELECT versions,stored,live,refused FROM history_compact_state WHERE note_id=?").get(id) as { versions: number; stored: number; live: number; refused: number } | null;
13
+ test("live bytes refresh after mutation, empty histories are excluded and nonempty edits re-arm", async () => {
14
+ const n = await store.createNote("large".repeat(1000));
15
+ await store.updateNote(n.id, { append: "changed" });
16
+ await store.updateNote(n.id, { content: "" });
17
+ expect(state(n.id)?.live).toBe(0);
18
+ expect(store.compactHistory({ budgetMs: null, maxNotes: null }).notes_scanned).toBe(0);
19
+ await store.updateNote(n.id, { content: "x" });
20
+ expect(state(n.id)?.live).toBe(1);
21
+ expect(store.compactHistory({ budgetMs: null, maxNotes: null }).notes_scanned).toBe(1);
22
+ });
23
+ test("incompressible refusal disappears on pass two and any metadata capture re-arms", async () => {
24
+ const n = await store.createNote("a");
25
+ await store.updateNote(n.id, { content: "b" });
26
+ await store.updateNote(n.id, { content: "c" });
27
+ expect(store.compactHistory({ budgetMs: null, maxNotes: null }).notes_scanned).toBe(1);
28
+ expect(state(n.id)?.refused).toBe(1);
29
+ expect(store.compactHistory({ budgetMs: null, maxNotes: null }).notes_scanned).toBe(0);
30
+ await store.updateNote(n.id, { metadata: { status: 1 } });
31
+ expect(state(n.id)?.refused).toBe(0);
32
+ expect(store.compactHistory({ budgetMs: null, maxNotes: null }).notes_scanned).toBe(1);
33
+ });
34
+ test("shared blob rewrites refresh other notes without re-arming refusal", async () => {
35
+ const body = "shared history\n".repeat(1000);
36
+ const a = await store.createNote(body), b = await store.createNote(body);
37
+ await store.updateNote(a.id, { append: "a" });
38
+ await store.updateNote(a.id, { content: "small" });
39
+ await store.updateNote(b.id, { content: "small" });
40
+ db.query("UPDATE history_compact_state SET refused=1 WHERE note_id=?").run(b.id);
41
+ const before = state(b.id)!;
42
+ expect(store.compactNote(a.id).blobs_deltified).toBeGreaterThan(0);
43
+ expect(state(b.id)!.stored).toBeLessThan(before.stored);
44
+ expect(state(b.id)!.refused).toBe(1);
45
+ });
46
+ test("v31 migration backfills deleted and live history once and preserves bodies", async () => {
47
+ const n = await store.createNote("old");
48
+ await store.updateNote(n.id, { content: "new" });
49
+ await store.deleteNote(n.id);
50
+ const before = db.query("SELECT * FROM note_versions").all();
51
+ db.exec("DROP TABLE history_compact_state; UPDATE schema_version SET version=31");
52
+ initSchema(db);
53
+ expect(state(n.id)).toEqual({ versions: 2, stored: 6, live: 3, refused: 0 });
54
+ expect(db.query("PRAGMA foreign_key_list(history_compact_state)").all()).toEqual([]);
55
+ expect(db.query("SELECT * FROM note_versions").all()).toEqual(before);
56
+ expect((await store.getNoteVersion(n.id, 0))?.content).toBe("old");
57
+ db.query("UPDATE history_compact_state SET refused=1").run();
58
+ initSchema(db);
59
+ expect(state(n.id)?.refused).toBe(1);
60
+ await store.eraseNoteHistory(n.id);
61
+ expect(state(n.id)).toBeNull();
62
+ expect(db.query("PRAGMA foreign_key_check").all()).toEqual([]);
63
+ });
64
+ test("doctor reports bounded hint drift without repairing history", async () => {
65
+ const n = await store.createNote("before");
66
+ await store.updateNote(n.id, { content: "after" });
67
+ db.query("UPDATE history_compact_state SET stored=999 WHERE note_id=?").run(n.id);
68
+ const result = runDoctorScan(db);
69
+ expect(result.findings.some(f => f.type === ("history_compact_state_drift" as any) && f.severity === "warning")).toBe(true);
70
+ expect(state(n.id)?.stored).toBe(999);
71
+ expect(runDoctorScan(db, { allowedTags: ["restricted"] }).findings.some(f => f.type === "history_compact_state_drift")).toBe(false);
72
+ });
73
+ test("prune, imported observations, deleted compaction and sweep maintain exact counters", async () => {
74
+ const n = await store.createNote("body".repeat(1000));
75
+ const exact = () => {
76
+ const actual = readCompactState(db, n.id)!;
77
+ expect(state(n.id)).toMatchObject({ versions: actual.versions, stored: actual.stored, live: actual.live });
78
+ };
79
+ for (let i=0;i<4;i++) { await store.updateNote(n.id, { append: String(i) }); exact(); }
80
+ const policy = resolveHistoryPolicy({ min_versions: 1, max_versions: 2, compact_min_versions: 2, compact_ratio: 1 });
81
+ db.transaction(() => pruneVersions(db, n.id, policy))(); exact();
82
+ const run = { run_id: "state-test", source_fingerprint: "source", tip: "tip", options_digest: "options" };
83
+ beginImportRun(db, run);
84
+ applyImportedNote(db, { run, noteId: n.id, targetDigest: importTargetDigest(db, n.id), policy: resolveHistoryPolicy({ enabled: false }), now: Date.now(), observations: [{ content: "import", path: null, metadata: {}, extension: "md", created_at: null, observed_at: new Date().toISOString(), commit: "commit", blob: "blob" }] });
85
+ exact(); expect(state(n.id)?.refused).toBe(0);
86
+ await store.deleteNote(n.id); exact();
87
+ expect(store.compactNote(n.id).blobs_deltified).toBeGreaterThan(0); exact();
88
+ const remaining = await store.listNoteVersions(n.id);
89
+ for (const v of remaining) expect((await store.getNoteVersion(n.id, v.version_ix))?.content).not.toBeNull();
90
+ expect(db.query("SELECT 1 FROM note_blobs b JOIN note_blobs base ON base.hash=b.delta_of WHERE base.encoding IS NOT NULL").all()).toEqual([]);
91
+ expect(db.query("PRAGMA foreign_key_check").all()).toEqual([]);
92
+ sweepDeletedHistory(db, resolveHistoryPolicy({ deleted_retention_days: 1 }), new Date(Date.now()+3*86400000));
93
+ expect(state(n.id)).toBeNull();
94
+ });
95
+ test("explicit-ID recreation refreshes live bytes and bounded remaining is a total", async () => {
96
+ for(let i=0;i<10;i++) {
97
+ const n = await store.createNote("abc", { id: `candidate-${i}` });
98
+ await store.updateNote(n.id, { content: "def" });
99
+ await store.updateNote(n.id, { content: "x" });
100
+ }
101
+ const first = store.compactHistory({ maxNotes: 1, budgetMs: null });
102
+ expect(first.remaining_candidates).toBe(9);
103
+ await store.deleteNote("candidate-0");
104
+ await store.createNoteRaw("", { id: "candidate-0" });
105
+ expect(state("candidate-0")?.live).toBe(0);
106
+ });
107
+ test("failed enclosing write rolls back history and scheduling state together", async () => {
108
+ const n = await store.createNote("before");
109
+ await store.updateNote(n.id, { content: "after" });
110
+ const before = state(n.id);
111
+ db.exec("CREATE TRIGGER reject_hint BEFORE UPDATE ON history_compact_state BEGIN SELECT RAISE(ABORT,'injected refresh failure'); END");
112
+ await expect(store.updateNote(n.id, { content: "must roll back" })).rejects.toThrow("injected refresh failure");
113
+ expect((await store.getNote(n.id))?.content).toBe("after");
114
+ expect(state(n.id)).toEqual(before);
115
+ expect(await store.countNoteVersions(n.id)).toBe(1);
116
+ });
117
+ test("empty live content still obeys the history byte ceiling", async () => {
118
+ store = new BunSqliteStore(db, { history: { min_versions: 1, max_bytes_per_note: 65536, compact_min_versions: 2 } });
119
+ const body = () => Buffer.from(crypto.getRandomValues(new Uint8Array(15000))).toString("hex");
120
+ const n = await store.createNote(body());
121
+ for(let i=0;i<5;i++) await store.updateNote(n.id, { content: body() });
122
+ await store.updateNote(n.id, { content: "" });
123
+ expect(state(n.id)?.stored).toBe(180000);
124
+ const pass = store.compactHistory({ budgetMs: null, maxNotes: null });
125
+ expect(pass.notes_scanned).toBe(1);
126
+ expect(pass.versions_dropped).toBe(4);
127
+ expect(state(n.id)).toMatchObject({ stored: 60000, live: 0, refused: 0 });
128
+ expect(store.compactHistory({ budgetMs: null, maxNotes: null }).notes_scanned).toBe(0);
129
+ });
@@ -74,7 +74,7 @@ test("P6 triggers and off switch leave blobs unchanged; writes never compact", a
74
74
  const empty = await emptyStore.createNote("empty fixture".repeat(2000));
75
75
  await emptyStore.updateNote(empty.id, { append: "one" });
76
76
  await emptyStore.updateNote(empty.id, { content: "" });
77
- expect(emptyStore.compactHistory({ budgetMs: null, maxNotes: null }).notes_compacted).toBe(1);
77
+ expect(emptyStore.compactHistory({ budgetMs: null, maxNotes: null }).notes_scanned).toBe(0);
78
78
  } finally { emptyDb.close(); }
79
79
  });
80
80
  test("P7 third encode failure rolls back all changes", async () => {
@@ -115,7 +115,7 @@ test("P8 bounded passes resume and converge; unbounded visits all", async () =>
115
115
  expect(all.stopped_by).toBe("complete");
116
116
  expect(all.remaining_candidates).toBe(0);
117
117
  });
118
- test("P16/P8 incompressible candidates are refused on every pass", async () => {
118
+ test("P16/P8 explicit retries remain safe; automatic passes skip refused candidates", async () => {
119
119
  const bodies = Array.from({ length: 5 }, () => randomHex(20480));
120
120
  expect(codec).not.toBeNull();
121
121
  expect(codec!.encodeDelta(bodies[1]!, bodies[0]!).length).toBeGreaterThanOrEqual(20480 * .9);
@@ -129,7 +129,7 @@ test("P16/P8 incompressible candidates are refused on every pass", async () => {
129
129
  expect(result.blobs_deltified).toBe(0);
130
130
  expect(result.blobs_skipped_too_large).toBeGreaterThanOrEqual(1);
131
131
  expect(blobs()).toEqual(before);
132
- expect(h.compactVault(db, p, { budgetMs: null, maxNotes: null })).toMatchObject({ notes_scanned: 1, stopped_by: "complete" });
132
+ expect(h.compactVault(db, p, { budgetMs: null, maxNotes: null })).toMatchObject({ notes_scanned: 0, stopped_by: "complete" });
133
133
  }
134
134
  const twin = await seed();
135
135
  expect(store.compactNote(twin.id)).toMatchObject({ blobs_skipped_too_large: 0 });
@@ -48,11 +48,67 @@
48
48
  */
49
49
 
50
50
  import { historyTablesPresent, deletedHistoryStats, historyStorageStats, topNotesByHistoryBytes } from "./history.js";
51
+ import { readCompactState, type CompactState } from "./history-compact-state.js";
51
52
  import { Database } from "bun:sqlite";
52
53
  import { loadTagHierarchy, findHierarchyCycles } from "./tag-hierarchy.js";
53
54
  import { listIndexedFields } from "./indexed-fields.js";
54
55
  import { pruneOrphanedIndexedFields } from "./indexed-fields.js";
55
56
  import { loadSchemaConfig } from "./schema-defaults.js";
57
+ import { readBlobContent, hashContent, HistoryDeltaOrphanError } from "./history.js";
58
+
59
+ function scanCompactStateDrift(db: Database): DoctorFinding[] {
60
+ if (!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='history_compact_state'").get()) return [];
61
+ const sample = db.prepare("SELECT note_id,versions,stored,live FROM history_compact_state ORDER BY stored DESC LIMIT 200").all() as CompactState[];
62
+ const drift = sample.filter(row => {
63
+ const actual = readCompactState(db, row.note_id);
64
+ return !actual || actual.versions !== row.versions || actual.stored !== row.stored || actual.live !== row.live;
65
+ }).length;
66
+ return drift ? [{ type: "history_compact_state_drift", severity: "warning", subject: "history compaction scheduling hints",
67
+ detail: `${drift} of ${sample.length} sampled hint rows differ from history. Sample is bounded to the 200 largest hints; this is not a full audit.`,
68
+ remedy: "Investigate missed history-write refreshes. Self-hosted: history rebuild-state --vault <name>. Cloud: deploy a core-pin migration that rebuilds hints; no operator repair API. This scan does not repair them." }] : [];
69
+ }
70
+
71
+ export interface HistoryAuditPage {
72
+ checked: number;
73
+ corrupt: number;
74
+ examples: string[];
75
+ complete: boolean;
76
+ next_after: string | null;
77
+ }
78
+
79
+ /** Read-only keyset page. Budgets apply between blobs, not inside one decode.
80
+ * A sequence of pages is not a snapshot: use a consistent backup for a complete audit.
81
+ */
82
+ export function auditHistoryBlobs(db: Database, after = "", maxBlobs = 100, budgetMs = 250): HistoryAuditPage {
83
+ if (after !== "" && !/^[a-f0-9]{64}$/.test(after)) throw new Error("history_after must be a SHA-256 hash");
84
+ if (!Number.isInteger(maxBlobs) || maxBlobs < 1 || maxBlobs > 500) throw new Error("history_max_blobs must be an integer from 1 to 500");
85
+ if (!Number.isInteger(budgetMs) || budgetMs < 1 || budgetMs > 1000) throw new Error("history_budget_ms must be an integer from 1 to 1000");
86
+ const page: HistoryAuditPage = { checked: 0, corrupt: 0, examples: [], complete: true, next_after: null };
87
+ if (!historyTablesPresent(db)) return page;
88
+ const start = performance.now();
89
+ const rows = db.prepare("SELECT hash FROM note_blobs WHERE hash > ? ORDER BY hash LIMIT ?").all(after, maxBlobs + 1) as { hash: string }[];
90
+ let cursor = after;
91
+ for (const row of rows) {
92
+ if (page.checked >= maxBlobs || (page.checked > 0 && performance.now() - start >= budgetMs)) {
93
+ page.complete = false;
94
+ page.next_after = cursor;
95
+ break;
96
+ }
97
+ let damaged = false;
98
+ try {
99
+ const body = readBlobContent(db, row.hash);
100
+ damaged = body === null || hashContent(body) !== row.hash;
101
+ } catch (error) {
102
+ // Operational database failures must abort, not masquerade as corruption.
103
+ if (!(error instanceof HistoryDeltaOrphanError)) throw error;
104
+ damaged = true;
105
+ }
106
+ page.checked++;
107
+ cursor = row.hash;
108
+ if (damaged) { page.corrupt++; if (page.examples.length < 5) page.examples.push(row.hash); }
109
+ }
110
+ return page;
111
+ }
56
112
 
57
113
  export type DoctorFindingType =
58
114
  | "dangling_parent_name"
@@ -62,7 +118,9 @@ export type DoctorFindingType =
62
118
  | "dead_tag_metadata_reference"
63
119
  | "deleted_note_history"
64
120
  | "history_storage"
65
- | "history_delta_orphan";
121
+ | "history_delta_orphan"
122
+ | "history_content_audit"
123
+ | "history_compact_state_drift";
66
124
 
67
125
  export type DoctorSeverity = "error" | "warning" | "info";
68
126
 
@@ -86,6 +144,7 @@ export interface DoctorReport {
86
144
  findings: DoctorFinding[];
87
145
  summary: string;
88
146
  scanned_at: string;
147
+ history_audit?: HistoryAuditPage;
89
148
  }
90
149
 
91
150
  export interface DoctorScanOpts {
@@ -95,6 +154,10 @@ export interface DoctorScanOpts {
95
154
  * each finding type is scoped.
96
155
  */
97
156
  allowedTags?: Set<string> | null;
157
+ deep?: boolean;
158
+ history_after?: string;
159
+ history_max_blobs?: number;
160
+ history_budget_ms?: number;
98
161
  }
99
162
 
100
163
  /** Cap on exemplar note IDs embedded in a finding's `detail` (vault#552 — keep report bodies bounded). */
@@ -124,6 +187,7 @@ function makeNoteInScope(db: Database, allowedTags: Set<string> | null): (id: st
124
187
 
125
188
  export function runDoctorScan(db: Database, opts?: DoctorScanOpts): DoctorReport {
126
189
  const allowedTags = opts?.allowedTags ?? null;
190
+ if (opts?.deep && allowedTags !== null) throw new Error("deep history audit requires an unrestricted session");
127
191
  const findings: DoctorFinding[] = [
128
192
  ...scanDanglingParentNames(db, allowedTags),
129
193
  ...scanParentNamesCycles(db, allowedTags),
@@ -133,8 +197,15 @@ export function runDoctorScan(db: Database, opts?: DoctorScanOpts): DoctorReport
133
197
  ...(allowedTags === null ? scanDeletedNoteHistory(db) : []),
134
198
  ...(allowedTags === null ? scanHistoryStorage(db) : []),
135
199
  ...(allowedTags === null ? scanDeltaOrphans(db) : []),
200
+ ...(allowedTags === null ? scanCompactStateDrift(db) : []),
136
201
  ];
137
202
 
203
+ const audit = opts?.deep ? auditHistoryBlobs(db, opts.history_after, opts.history_max_blobs, opts.history_budget_ms) : undefined;
204
+ if (audit) findings.push({ type: "history_content_audit", severity: audit.corrupt ? "error" : audit.complete ? "info" : "warning",
205
+ subject: "history content audit page",
206
+ detail: `Checked ${audit.checked} blob(s); ${audit.corrupt} corrupt. ${audit.complete ? "End of scan reached." : "Incomplete: continue with next_after."} Examples: ${audit.examples.join(", ") || "none"}.`,
207
+ remedy: "Continue incomplete scans with history_after=next_after. For snapshot-level assurance, audit a consistent backup. Preserve backups and investigate corruption; this audit never repairs or erases history." });
208
+
138
209
  const errors = findings.filter((f) => f.severity === "error").length;
139
210
  const warnings = findings.filter((f) => f.severity === "warning").length;
140
211
  const infos = findings.filter((f) => f.severity === "info").length;
@@ -143,7 +214,8 @@ export function runDoctorScan(db: Database, opts?: DoctorScanOpts): DoctorReport
143
214
  ? "clean — no integrity findings"
144
215
  : `${findings.length} finding(s): ${errors} error(s), ${warnings} warning(s), ${infos} info(s)`;
145
216
 
146
- return { findings, summary, scanned_at: new Date().toISOString() };
217
+ return { findings, summary, scanned_at: new Date().toISOString(),
218
+ ...(audit ? { history_audit: audit } : {}) };
147
219
  }
148
220
 
149
221
  // ---------------------------------------------------------------------------
@@ -0,0 +1,51 @@
1
+ import { test, expect } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { SqliteStore } from "./store.js";
4
+ import { auditHistoryBlobs, runDoctorScan } from "./doctor.js";
5
+ import { hashContent } from "./history.js";
6
+ import { encodeDelta } from "./delta.js";
7
+
8
+ test("deep audit pages whole/delta blobs, detects content damage without writes", () => {
9
+ const db = new Database(":memory:"); new SqliteStore(db);
10
+ try {
11
+ const insert = db.prepare("INSERT INTO note_blobs(hash,content,byte_size,encoding,delta_of) VALUES(?,?,?,?,?)");
12
+ const base = "base text ".repeat(100), changed = base + "new tail";
13
+ const a = hashContent(base), b = hashContent(changed), c = hashContent("original");
14
+ insert.run(a, base, base.length, null, null);
15
+ const delta = encodeDelta(base, changed);
16
+ insert.run(b, delta, delta.length, "fossil-delta", a);
17
+ insert.run(c, "tampered", 8, null, null);
18
+ const before = db.prepare("SELECT * FROM note_blobs ORDER BY hash").all();
19
+ let after = "", checked = 0, corrupt = 0;
20
+ do {
21
+ const p = auditHistoryBlobs(db, after, 1);
22
+ checked += p.checked; corrupt += p.corrupt;
23
+ if (p.complete) { expect(p.next_after).toBeNull(); break; }
24
+ expect(p.next_after! > after).toBe(true); after = p.next_after!;
25
+ } while (true);
26
+ expect(checked).toBe(3); expect(corrupt).toBe(1);
27
+ const report = runDoctorScan(db, { deep: true });
28
+ expect(report.history_audit?.examples).toEqual([c]);
29
+ expect(report.findings.find(f => f.type === "history_content_audit")?.severity).toBe("error");
30
+ expect(db.prepare("SELECT * FROM note_blobs ORDER BY hash").all()).toEqual(before);
31
+ db.prepare("UPDATE note_blobs SET content='invalid delta' WHERE hash=?").run(b);
32
+ expect(auditHistoryBlobs(db).corrupt).toBe(2);
33
+ } finally { db.close(); }
34
+ });
35
+
36
+ test("deep audit is explicit, bounded, scope-safe, and reports empty completion", () => {
37
+ const db = new Database(":memory:"); new SqliteStore(db);
38
+ try {
39
+ expect(runDoctorScan(db).history_audit).toBeUndefined();
40
+ expect(auditHistoryBlobs(db)).toEqual({ checked: 0, corrupt: 0, examples: [], complete: true, next_after: null });
41
+ expect(() => runDoctorScan(db, { deep: true, allowedTags: new Set() })).toThrow("unrestricted");
42
+ for (const n of [0, -1, 501, NaN, 1.5]) expect(() => auditHistoryBlobs(db, "", n)).toThrow();
43
+ expect(() => auditHistoryBlobs(db, "bad cursor")).toThrow();
44
+ expect(() => auditHistoryBlobs(db, "", 100, 1001)).toThrow();
45
+ for (let i=0;i<7;i++) db.prepare("INSERT INTO note_blobs(hash,content,byte_size) VALUES(?,?,?)").run(hashContent(String(i)), "broken", 6);
46
+ const p = auditHistoryBlobs(db, "", 2);
47
+ expect(p.checked).toBe(2); expect(p.complete).toBe(false); expect(p.next_after).not.toBeNull();
48
+ expect(auditHistoryBlobs(db).examples).toHaveLength(5);
49
+ expect(runDoctorScan(db, { deep: true, history_max_blobs: 2 }).summary).toContain("error");
50
+ } finally { db.close(); }
51
+ });
@@ -0,0 +1,52 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import { transaction } from "./txn.js";
3
+
4
+ /** Rebuild derived hints only, atomically; authoritative history is untouched. */
5
+ export function rebuildCompactState(db: Database): number {
6
+ return transaction(db, () => {
7
+ if (db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='history_compact_state'").get()) {
8
+ db.exec("DROP TABLE history_compact_state");
9
+ }
10
+ db.exec(`CREATE TABLE history_compact_state (
11
+ note_id TEXT PRIMARY KEY, versions INTEGER NOT NULL, stored INTEGER NOT NULL,
12
+ live INTEGER NOT NULL, refused INTEGER NOT NULL DEFAULT 0, refreshed_at INTEGER NOT NULL
13
+ );
14
+ CREATE INDEX idx_history_compact_candidates ON history_compact_state(refused, stored DESC);
15
+ CREATE INDEX IF NOT EXISTS idx_note_versions_note_hash ON note_versions(note_id, content_hash);`);
16
+ db.prepare(`INSERT INTO history_compact_state(note_id,versions,stored,live,refused,refreshed_at)
17
+ SELECT note_id,versions,stored,live,0,? FROM (${COMPACT_STATE_AGGREGATE.replace("__FILTER__", "")})`).run(Date.now());
18
+ return (db.prepare("SELECT COUNT(*) AS n FROM history_compact_state").get() as { n: number }).n;
19
+ });
20
+ }
21
+
22
+ // Scheduling hints only. History remains authoritative, including for deleted notes.
23
+ export const COMPACT_STATE_AGGREGATE = `SELECT v.note_id,
24
+ (SELECT COUNT(*) FROM note_versions n WHERE n.note_id=v.note_id AND n.content_hash IS NOT NULL) AS versions,
25
+ SUM(b.byte_size) AS stored,
26
+ COALESCE(LENGTH(CAST(live_note.content AS BLOB)),
27
+ (SELECT content_len FROM note_versions newest WHERE newest.note_id=v.note_id ORDER BY version_ix DESC LIMIT 1),0) AS live
28
+ FROM (SELECT DISTINCT note_id,content_hash FROM note_versions WHERE content_hash IS NOT NULL __FILTER__) v
29
+ JOIN note_blobs b ON b.hash=v.content_hash LEFT JOIN notes live_note ON live_note.id=v.note_id
30
+ GROUP BY v.note_id`;
31
+
32
+ export interface CompactState { note_id: string; versions: number; stored: number; live: number }
33
+ export function readCompactState(db: Database, noteId: string): CompactState | null {
34
+ return db.prepare(COMPACT_STATE_AGGREGATE.replace("__FILTER__", "AND note_id=?")).get(noteId) as CompactState | null;
35
+ }
36
+
37
+ /** Caller owns the enclosing write transaction; run after the live-row mutation. */
38
+ export function refreshCompactState(db: Database, noteId: string, opts?: { refused?: boolean }): void {
39
+ // Keep the existing graceful behavior of writes before history tables exist.
40
+ const tables = db.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE type='table' AND name IN ('note_versions','note_blobs','history_compact_state')").get() as { n: number };
41
+ if (tables.n !== 3) return;
42
+ const state = readCompactState(db, noteId);
43
+ if (!state) {
44
+ db.prepare("DELETE FROM history_compact_state WHERE note_id=?").run(noteId);
45
+ return;
46
+ }
47
+ db.prepare(`INSERT INTO history_compact_state(note_id,versions,stored,live,refused,refreshed_at)
48
+ VALUES(?,?,?,?,?,?) ON CONFLICT(note_id) DO UPDATE SET
49
+ versions=excluded.versions,stored=excluded.stored,live=excluded.live,
50
+ refused=excluded.refused,refreshed_at=excluded.refreshed_at`)
51
+ .run(noteId, state.versions, state.stored, state.live, opts?.refused ? 1 : 0, Date.now());
52
+ }
@@ -1,6 +1,7 @@
1
1
  /** Git-independent import primitives. Callers own source parsing and offline access. */
2
2
  import type { Database } from "bun:sqlite";
3
3
  import { transaction } from "./txn.js";
4
+ import { refreshCompactState } from "./history-compact-state.js";
4
5
  import { HistoryUnrecoverableError, hashContent, byteLength, VERSION_MAX_BYTES, pruneVersions, compactNote, getVersion, type HistoryPolicy, type VersionRow } from "./history.js";
5
6
 
6
7
  export interface ImportedObservation {
@@ -112,7 +113,7 @@ export function applyImportedNote(db: Database, opts: {
112
113
  VALUES(?,?,?,?,?,?,?,NULL,'git-import','import',?,NULL,?)`).run(opts.noteId, storage, hash, row.path, canonicalJson(row.metadata), row.extension, row.observed_at, size, row.created_at);
113
114
  db.prepare("INSERT INTO history_import_refs VALUES(?,?,?,?)").run(opts.noteId, index, row.commit, row.blob);
114
115
  }
115
- if (opts.policy.enabled) pruneVersions(db, opts.noteId, opts.policy, opts.now);
116
+ if (opts.policy.enabled) pruneVersions(db, opts.noteId, opts.policy, opts.now, false);
116
117
  compactNote(db, opts.noteId, opts.policy);
117
118
  const after = new Set((db.prepare("SELECT version_ix FROM note_versions WHERE note_id=?").all(opts.noteId) as { version_ix: number }[]).map(r => r.version_ix));
118
119
  for (let i = 0; i < opts.observations.length; i++) {
@@ -125,6 +126,7 @@ export function applyImportedNote(db: Database, opts: {
125
126
  receipt.pruned_imported = receipt.imported_count - receipt.retained_count;
126
127
  receipt.pruned_native = nativeDrops.length;
127
128
  db.prepare("UPDATE history_import_receipts SET retained_count=?,pruned_imported=?,pruned_native=? WHERE note_id=?").run(receipt.retained_count, receipt.pruned_imported, receipt.pruned_native, opts.noteId);
129
+ refreshCompactState(db, opts.noteId);
128
130
  return { receipt, nativeDrops, skipped: false };
129
131
  });
130
132
  }
@@ -14,6 +14,7 @@ import type { Database } from "bun:sqlite";
14
14
  import { createHash } from "node:crypto";
15
15
  import { encodeDelta, decodeDelta } from "./delta.js";
16
16
  import { transaction } from "./txn.js";
17
+ import { refreshCompactState } from "./history-compact-state.js";
17
18
 
18
19
  export type HistoryOp =
19
20
  | "import"
@@ -212,7 +213,7 @@ export function captureVersion(
212
213
  size,
213
214
  prior.created_at,
214
215
  );
215
- pruneVersions(db, prior.id, opts.policy);
216
+ pruneVersions(db, prior.id, opts.policy, Date.now(), false);
216
217
  return;
217
218
  }
218
219
  const hash = hashContent(text);
@@ -237,7 +238,7 @@ export function captureVersion(
237
238
  size,
238
239
  prior.created_at,
239
240
  );
240
- pruneVersions(db, prior.id, opts.policy);
241
+ pruneVersions(db, prior.id, opts.policy, Date.now(), false);
241
242
  }
242
243
  export function appendRestoreMarker(
243
244
  db: Database,
@@ -261,13 +262,14 @@ export function appendRestoreMarker(
261
262
  noteId,
262
263
  tombstone.version_ix,
263
264
  );
264
- pruneVersions(db, noteId, policy);
265
+ pruneVersions(db, noteId, policy, Date.now(), false);
265
266
  }
266
267
  export function pruneVersions(
267
268
  db: Database,
268
269
  noteId: string,
269
270
  policy: HistoryPolicy,
270
271
  now = Date.now(),
272
+ refresh = true,
271
273
  ): { versionsDeleted: number; blobsDeleted: number } {
272
274
  const rows = db
273
275
  .prepare(
@@ -301,6 +303,7 @@ export function pruneVersions(
301
303
  )
302
304
  .run(hash, hash, hash).changes;
303
305
  }
306
+ if (refresh) refreshCompactState(db, noteId);
304
307
  return { versionsDeleted, blobsDeleted };
305
308
  }
306
309
  export function gcBlobs(db: Database): { blobsDeleted: number } {
@@ -331,10 +334,12 @@ export function sweepDeletedHistory(
331
334
  .all(cutoff) as { note_id: string }[];
332
335
  return transaction(db, () => {
333
336
  let versionsDeleted = 0;
334
- for (const v of victims)
337
+ for (const v of victims) {
335
338
  versionsDeleted += db
336
339
  .prepare("DELETE FROM note_versions WHERE note_id = ?")
337
340
  .run(v.note_id).changes;
341
+ db.prepare("DELETE FROM history_compact_state WHERE note_id=?").run(v.note_id);
342
+ }
338
343
  return { notesSwept: victims.length, versionsDeleted, ...gcBlobs(db) };
339
344
  });
340
345
  }
@@ -406,6 +411,7 @@ export function eraseHistory(
406
411
  const versionsDeleted = db
407
412
  .prepare("DELETE FROM note_versions WHERE note_id = ?")
408
413
  .run(noteId).changes;
414
+ db.prepare("DELETE FROM history_compact_state WHERE note_id=?").run(noteId);
409
415
  return { versionsDeleted, ...gcBlobs(db) };
410
416
  });
411
417
  }
@@ -553,6 +559,23 @@ export function compactNote(db: Database, noteId: string, policy: HistoryPolicy)
553
559
  const result: CompactResult = { blobs_deltified: 0, blobs_skipped_too_large: 0, versions_dropped: 0, bytes_before: 0, bytes_after: 0 };
554
560
  if (!policy.enabled || !policy.compact_enabled || !historyTablesPresent(db))
555
561
  return result;
562
+ return transaction(db, () => {
563
+ const changedHashes = new Set<string>();
564
+ const finish = () => {
565
+ // A blob can be referenced by several notes. Refresh each affected hint,
566
+ // without treating another note's compaction as a new history write.
567
+ const affected = new Set<string>();
568
+ for (const hash of changedHashes) {
569
+ for (const row of db.prepare("SELECT DISTINCT note_id FROM note_versions WHERE content_hash=?").all(hash) as { note_id: string }[]) affected.add(row.note_id);
570
+ }
571
+ for (const id of affected) {
572
+ if (id === noteId) continue;
573
+ const prior = db.prepare("SELECT refused FROM history_compact_state WHERE note_id=?").get(id) as { refused: number } | null;
574
+ refreshCompactState(db, id, { refused: prior?.refused === 1 });
575
+ }
576
+ refreshCompactState(db, noteId, { refused: result.blobs_deltified === 0 && result.versions_dropped === 0 });
577
+ return result;
578
+ };
556
579
  const rows = db.prepare("SELECT version_ix, content_hash, content_len FROM note_versions WHERE note_id = ? AND content_hash IS NOT NULL ORDER BY version_ix DESC").all(noteId) as {
557
580
  version_ix: number;
558
581
  content_hash: string;
@@ -569,10 +592,9 @@ export function compactNote(db: Database, noteId: string, policy: HistoryPolicy)
569
592
  const live = current?.content == null ? newest?.content_len ?? 0 : byteLength(current.content);
570
593
  const overBytes = policy.max_bytes_per_note !== null && stored > policy.max_bytes_per_note;
571
594
  if (!overBytes && rows.length < policy.compact_min_versions)
572
- return result;
595
+ return finish();
573
596
  if (!overBytes && stored <= policy.compact_ratio * Math.max(live, 1))
574
- return result;
575
- return transaction(db, () => {
597
+ return finish();
576
598
  for (let start = 0; start < rows.length; start += policy.compact_run_length) {
577
599
  const run = rows.slice(start, start + policy.compact_run_length);
578
600
  const baseRow = db.prepare("SELECT hash, encoding, delta_of FROM note_blobs WHERE hash = ?").get(run[0]!.content_hash) as {
@@ -614,13 +636,15 @@ export function compactNote(db: Database, noteId: string, policy: HistoryPolicy)
614
636
  result.blobs_skipped_too_large++;
615
637
  continue;
616
638
  }
617
- result.blobs_deltified += db.prepare("UPDATE note_blobs SET content = ?, byte_size = ?, encoding = 'fossil-delta', delta_of = ? WHERE hash = ? AND encoding IS NULL").run(payload, payload.length, baseHash, b.hash).changes;
639
+ const changed = db.prepare("UPDATE note_blobs SET content = ?, byte_size = ?, encoding = 'fossil-delta', delta_of = ? WHERE hash = ? AND encoding IS NULL").run(payload, payload.length, baseHash, b.hash).changes;
640
+ result.blobs_deltified += changed;
641
+ if (changed) changedHashes.add(b.hash);
618
642
  }
619
643
  }
620
644
  if (policy.max_bytes_per_note !== null)
621
645
  result.versions_dropped = enforceByteCeiling(db, noteId, policy);
622
646
  result.bytes_after = noteHistoryBytes(db, noteId);
623
- return result;
647
+ return finish();
624
648
  });
625
649
  }
626
650
  function enforceByteCeiling(db: Database, noteId: string, policy: HistoryPolicy): number {
@@ -650,15 +674,10 @@ export function compactVault(db: Database, policy: HistoryPolicy, opts?: {
650
674
  if (budget !== null && budget <= 0)
651
675
  return { ...result, stopped_by: "budget" };
652
676
  const started = performance.now();
653
- const candidates = opts?.noteId !== undefined ? [{ note_id: opts.noteId }] : db.prepare(`SELECT v.note_id, SUM(b.byte_size) AS stored,
654
- (SELECT COUNT(*) FROM note_versions n WHERE n.note_id = v.note_id AND n.content_hash IS NOT NULL) AS versions,
655
- COALESCE(LENGTH(CAST(live_note.content AS BLOB)),
656
- (SELECT content_len FROM note_versions newest WHERE newest.note_id = v.note_id ORDER BY version_ix DESC LIMIT 1),0) AS live
657
- FROM (SELECT DISTINCT note_id, content_hash FROM note_versions WHERE content_hash IS NOT NULL) v
658
- JOIN note_blobs b ON b.hash = v.content_hash LEFT JOIN notes live_note ON live_note.id = v.note_id
659
- GROUP BY v.note_id
660
- HAVING (versions >= ? AND stored > ? * MAX(live,1)) OR (? IS NOT NULL AND stored > ?)
661
- ORDER BY stored DESC`).all(policy.compact_min_versions, policy.compact_ratio, policy.max_bytes_per_note, policy.max_bytes_per_note) as {
677
+ const predicate = `refused=0 AND ((live>0 AND versions >= ? AND stored > ? * MAX(live,1)) OR (? IS NOT NULL AND stored > ?))`;
678
+ const args = [policy.compact_min_versions, policy.compact_ratio, policy.max_bytes_per_note, policy.max_bytes_per_note];
679
+ const total = opts?.noteId !== undefined ? 1 : (db.prepare(`SELECT COUNT(*) AS n FROM history_compact_state WHERE ${predicate}`).get(...args) as { n: number }).n;
680
+ const candidates = opts?.noteId !== undefined ? [{ note_id: opts.noteId }] : db.prepare(`SELECT note_id FROM history_compact_state WHERE ${predicate} ORDER BY stored DESC LIMIT ?`).all(...args, max === null ? -1 : max * 4) as {
662
681
  note_id: string;
663
682
  }[];
664
683
  for (const candidate of candidates) {
@@ -686,7 +705,7 @@ export function compactVault(db: Database, policy: HistoryPolicy, opts?: {
686
705
  console.warn("[history] compaction failed", candidate.note_id, err);
687
706
  }
688
707
  }
689
- result.remaining_candidates = candidates.length - result.notes_scanned;
708
+ result.remaining_candidates = total - result.notes_scanned;
690
709
  result.duration_ms = performance.now() - started;
691
710
  return result;
692
711
  }
@@ -705,7 +705,12 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
705
705
  requiredVerb: "read",
706
706
  description:
707
707
  "Read-only integrity scan across the tag/metadata taxonomy — run this after any bulk tag reorg (rename/merge/delete/subtree move) to confirm nothing leaked. Returns {findings, summary, scanned_at} — findings is an array, each entry {type, severity, subject, detail, remedy} — NEVER auto-fixes; apply the suggested remedy (usually rename-tag/merge-tags/update-tag/prune-schema) yourself. Finding types: dangling_parent_name (a parent_names entry naming a tag with no identity row), parent_names_cycle (a tag reaching itself through its ancestor chain — traversal tolerates this, but it's dishonest hierarchy state), mixed_type_indexed_field (a note's metadata value for an indexed field has a JSON type disagreeing with the field's declared storage type — the ordering/filtering-goes-silently-wrong precursor), orphaned_indexed_field_declarer (an indexed field naming a dead declarer tag — see prune-schema), and dead_tag_metadata_reference (HEURISTIC, always carries heuristic:true — a metadata value that looks like a stale reference to a renamed/merged/deleted tag, inferred from sibling notes using the same metadata key with values that ARE live tags; can never be certain since vault keeps no tag-rename history).",
708
- inputSchema: { type: "object", properties: {} },
708
+ inputSchema: { type: "object", properties: {
709
+ deep: { type: "boolean", description: "Opt-in, unrestricted sessions only: materialize and hash-check a bounded history blob page. Check history_audit.complete; incomplete is not a clean audit." },
710
+ history_after: { type: "string", description: "Resume after history_audit.next_after. Pages are not a snapshot; audit a consistent backup for snapshot assurance." },
711
+ history_max_blobs: { type: "integer", minimum: 1, maximum: 500, description: "Maximum blobs in a deep page; default 100." },
712
+ history_budget_ms: { type: "integer", minimum: 1, maximum: 1000, description: "Between-blob budget; default 250ms. One blob may exceed it." },
713
+ } },
709
714
  condition: "core",
710
715
  },
711
716
  {
package/core/src/mcp.ts CHANGED
@@ -2619,8 +2619,12 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
2619
2619
  // =====================================================================
2620
2620
  {
2621
2621
  name: "doctor",
2622
- execute: async () => {
2623
- return await store.doctor();
2622
+ execute: async (params) => {
2623
+ if (params.deep !== undefined && typeof params.deep !== "boolean") throw new Error("deep must be boolean");
2624
+ if (params.history_after !== undefined && typeof params.history_after !== "string") throw new Error("history_after must be a string");
2625
+ if (params.history_max_blobs !== undefined && typeof params.history_max_blobs !== "number") throw new Error("history_max_blobs must be a number");
2626
+ if (params.history_budget_ms !== undefined && typeof params.history_budget_ms !== "number") throw new Error("history_budget_ms must be a number");
2627
+ return await store.doctor({ deep: params.deep, history_after: params.history_after, history_max_blobs: params.history_max_blobs, history_budget_ms: params.history_budget_ms });
2624
2628
  },
2625
2629
  },
2626
2630
 
@@ -0,0 +1,9 @@
1
+ import { test, expect } from "bun:test";
2
+ import { isNoteIdShape } from "./ulid.js";
3
+
4
+ test("note ID shape accepts exact legacy strings without coercion", () => {
5
+ for (const id of ["a", "A", "legacy:abc123", "2020-01-02-03-04-05", "shortid", "a_b", "a".repeat(64), "01M2KT1VQBHNZ99V5KSGXP6VSE"])
6
+ expect(isNoteIdShape(id)).toBe(true);
7
+ for (const id of ["", "a b", "a/b", "a.b", "a".repeat(65), "-leading", "_leading", ":leading", "a\n", "a\r", "a\0", "é", 123, null, undefined])
8
+ expect(isNoteIdShape(id)).toBe(false);
9
+ });
package/core/src/notes.ts CHANGED
@@ -32,6 +32,7 @@ import {
32
32
  } from "./search-query.js";
33
33
  import { generateUlid } from "./ulid.js";
34
34
  import { captureVersion, readPriorNoteRow, DEFAULT_HISTORY_POLICY, type HistoryPolicy } from "./history.js";
35
+ import { refreshCompactState } from "./history-compact-state.js";
35
36
 
36
37
  /**
37
38
  * Write-attribution context (vault#298) — the two axes of provenance threaded
@@ -2594,6 +2595,7 @@ export function renameTag(db: Database, oldName: string, newName: string, attr?:
2594
2595
  const prior = readPriorNoteRow(db, row.id);
2595
2596
  if (prior) captureVersion(db, prior, { actor: attr?.actor ?? null, via: attr?.via ?? null, op: "tag-rename", policy });
2596
2597
  updateStmt.run(next, now, nowMs, row.id);
2598
+ refreshCompactState(db, row.id);
2597
2599
  notesRewritten++;
2598
2600
  }
2599
2601
  }
@@ -2618,6 +2620,7 @@ export function renameTag(db: Database, oldName: string, newName: string, attr?:
2618
2620
  const prior = readPriorNoteRow(db, row.id);
2619
2621
  if (prior) captureVersion(db, prior, { actor: attr?.actor ?? null, via: attr?.via ?? null, op: "tag-rename", policy });
2620
2622
  updateStmt.run(next, now, nowMs, row.id);
2623
+ refreshCompactState(db, row.id);
2621
2624
  pathsRenamed++;
2622
2625
  }
2623
2626
  }
@@ -19,7 +19,7 @@ it("P14a v28 upgrade creates both tables and three indexes without backfill", ()
19
19
  initSchema(db);
20
20
  expect(
21
21
  db.prepare("SELECT MAX(version) AS version FROM schema_version").get(),
22
- ).toEqual({ version: 31 });
22
+ ).toEqual({ version: 32 });
23
23
  const tables = db
24
24
  .prepare(
25
25
  "SELECT name FROM sqlite_master WHERE type='table' AND name IN ('note_versions','note_blobs') ORDER BY name",
@@ -26,7 +26,7 @@ test("P12 v29 opens losslessly, fresh and upgraded columns/indexes agree", async
26
26
  const snap = () => ({ blobs: db.prepare("SELECT hash,content,byte_size FROM note_blobs ORDER BY hash").all(), versions: db.prepare("SELECT note_id,version_ix,content_hash,path,metadata,extension,superseded_at,actor,via,op,content_len,encoding FROM note_versions ORDER BY note_id,version_ix").all() });
27
27
  const before = snap();
28
28
  expect(() => initSchema(db)).not.toThrow();
29
- expect(SCHEMA_VERSION).toBe(31);
29
+ expect(SCHEMA_VERSION).toBe(32);
30
30
  expect(snap()).toEqual(before);
31
31
  expect(db.prepare("SELECT hash FROM note_blobs WHERE delta_of IS NOT NULL OR encoding IS NOT NULL").all()).toEqual([]);
32
32
  expect(db.prepare("SELECT note_id FROM note_versions WHERE created_at IS NOT NULL").all()).toEqual([]);
@@ -13,7 +13,7 @@ test("v30 to v31 retains native history and matches fresh import tables; reopeni
13
13
  db.exec("UPDATE schema_version SET version=30");
14
14
  const before = db.query("SELECT * FROM note_versions").all();
15
15
  initSchema(db); initSchema(fresh); initSchema(db);
16
- expect(db.query("SELECT MAX(version) AS version FROM schema_version").get()).toEqual({ version: 31 });
16
+ expect(db.query("SELECT MAX(version) AS version FROM schema_version").get()).toEqual({ version: 32 });
17
17
  expect(db.query("SELECT * FROM note_versions").all()).toEqual(before);
18
18
  for (const table of tables) {
19
19
  expect(db.query(`PRAGMA table_info(${table})`).all()).toEqual(fresh.query(`PRAGMA table_info(${table})`).all());
@@ -5,8 +5,18 @@ import { findMixedTypeIndexedFieldNotes } from "./doctor.js";
5
5
  import { transaction } from "./txn.js";
6
6
  import { timestampToMs } from "./cursor.js";
7
7
  import { ensureRelationshipColumn } from "./wikilinks.js";
8
+ import { rebuildCompactState } from "./history-compact-state.js";
8
9
 
9
- export const SCHEMA_VERSION = 31;
10
+ export const SCHEMA_VERSION = 32;
11
+
12
+ function migrateToV32(db: Database): void {
13
+ const exists = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='history_compact_state'").get();
14
+ // Read before initSchema records this open. On a timestamp tie, prefer
15
+ // a harmless rebuild over missing an older writer's stale hints.
16
+ const previous = db.prepare("SELECT version FROM schema_version ORDER BY applied_at DESC, version ASC LIMIT 1").get() as { version: number } | null;
17
+ if (exists && (!previous || previous.version >= 32)) return;
18
+ rebuildCompactState(db);
19
+ }
10
20
 
11
21
  /**
12
22
  * Deterministic last-resort epoch for a note whose `updated_at` AND
@@ -742,6 +752,7 @@ export function initSchema(db: Database): void {
742
752
  // v30: depth-one history deltas and captured creation time, no backfill.
743
753
  migrateToV30(db);
744
754
  migrateToV31(db);
755
+ migrateToV32(db);
745
756
 
746
757
  // Rebuild any generated columns + indexes declared in indexed_fields.
747
758
  // No-op for a fresh vault; idempotent on existing vaults.
package/core/src/store.ts CHANGED
@@ -3,6 +3,7 @@ import { HistoryPreconditionRequiredError } from "./history.js";
3
3
  import type { Store, Note, Link, Attachment, QueryOpts, QueryNotesPage, AggregateRow, SemanticSearchResult } from "./types.js";
4
4
  import { compactNote, compactVault, countNoteVersions, type CompactResult, type CompactSummary, captureVersion, readPriorNoteRow, resolveHistoryPolicy, appendRestoreMarker, listVersions, getVersion, latestTombstone, eraseHistory, sweepDeletedHistory, deletedHistoryStats, HistoryNotFoundError, HistoryOverflowError, HistoryUnrecoverableError, DEFAULT_HISTORY_POLICY, VERSION_MAX_BYTES, type VersionRow, type HistoryPolicy, type HistoryOp } from "./history.js";
5
5
  import { initSchema } from "./schema.js";
6
+ import { refreshCompactState } from "./history-compact-state.js";
6
7
  import * as noteOps from "./notes.js";
7
8
  import * as linkOps from "./links.js";
8
9
  import * as tagSchemaOps from "./tag-schemas.js";
@@ -530,7 +531,12 @@ export class BunSqliteStore implements Store {
530
531
  if (normalized !== opts.metadata) opts = { ...opts, metadata: normalized };
531
532
  }
532
533
 
533
- const note = noteOps.createNote(this.db, content, opts);
534
+ const note = this.transaction(() => {
535
+ const created = noteOps.createNote(this.db, content, opts);
536
+ // Explicit IDs may recreate a deleted note with retained history.
537
+ refreshCompactState(this.db, created.id);
538
+ return created;
539
+ });
534
540
 
535
541
  if (content) {
536
542
  syncWikilinks(this.db, note.id, content);
@@ -692,6 +698,7 @@ export class BunSqliteStore implements Store {
692
698
  this.syncReferenceFieldLinks(note, priorMetadataForRefs);
693
699
  }
694
700
 
701
+ refreshCompactState(this.db, id);
695
702
  return note;
696
703
  });
697
704
 
@@ -818,6 +825,7 @@ export class BunSqliteStore implements Store {
818
825
  if (prior) captureVersion(this.db, prior, { ...attr, op: "cascade-rename", policy: this.historyPolicy });
819
826
  noteOps.updateNote(this.db, sourceId, { content: updated });
820
827
  syncWikilinks(this.db, sourceId, updated);
828
+ refreshCompactState(this.db, sourceId);
821
829
  }
822
830
  }
823
831
  }
@@ -858,6 +866,7 @@ export class BunSqliteStore implements Store {
858
866
  appendRestoreMarker(this.db, id, tomb, { actor: opts.actor ?? null, via: opts.via ?? null }, this.historyPolicy);
859
867
  if (note.content) syncWikilinks(this.db, id, note.content);
860
868
  if (note.path) resolveUnresolvedWikilinks(this.db, note.path, id);
869
+ refreshCompactState(this.db, id);
861
870
  return note;
862
871
  });
863
872
  }
@@ -910,6 +919,7 @@ export class BunSqliteStore implements Store {
910
919
  }
911
920
  noteOps.deleteNote(this.db, id);
912
921
  refreshAmbiguousLinks(this.db, ambiguityKeys);
922
+ refreshCompactState(this.db, id);
913
923
  });
914
924
  if (existing?.path) this.invalidateConfigCachesForPath(existing.path);
915
925
  // Dispatch even when `existing` was null — the caller asked for a
@@ -1603,7 +1613,11 @@ export class BunSqliteStore implements Store {
1603
1613
  const normalized = normalizeDateFields(this.getSchemaConfig(), { tags: opts.tags, metadata: opts.metadata });
1604
1614
  if (normalized !== opts.metadata) opts = { ...opts, metadata: normalized };
1605
1615
  }
1606
- return noteOps.createNote(this.db, content, opts);
1616
+ return this.transaction(() => {
1617
+ const created = noteOps.createNote(this.db, content, opts);
1618
+ refreshCompactState(this.db, created.id);
1619
+ return created;
1620
+ });
1607
1621
  }
1608
1622
 
1609
1623
  /**
package/core/src/ulid.ts CHANGED
@@ -114,3 +114,10 @@ export function generateUlid(): string {
114
114
 
115
115
  /** 26-char Crockford base32 (`0-9A-HJKMNP-TV-Z`, case-insensitive by spec — we always emit uppercase). */
116
116
  export const ULID_REGEX = /^[0-9A-HJKMNP-TV-Z]{26}$/;
117
+
118
+ const NOTE_ID_SHAPE = /^[A-Za-z0-9][A-Za-z0-9:_-]{0,63}$/;
119
+ /** Accepted history identity shape, including legacy IDs; never normalizes an ID. */
120
+ export function isNoteIdShape(value: unknown): value is string {
121
+ // Comparing the entire match also rejects a final newline (JS `$` permits one).
122
+ return typeof value === "string" && NOTE_ID_SHAPE.exec(value)?.[0] === value;
123
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.7.9-rc.8",
3
+ "version": "0.7.9-rc.9",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
package/src/cli.ts CHANGED
@@ -3432,6 +3432,8 @@ async function cmdExport(args: string[]) {
3432
3432
  * a past revision (`git show <sha>:<path>.md`).
3433
3433
  */
3434
3434
  async function cmdHistory(args: string[]) {
3435
+ const rebuild = args[0] === "rebuild-state";
3436
+ if (rebuild) args = args.slice(1);
3435
3437
  let vaultName = "default";
3436
3438
  let notePath: string | undefined;
3437
3439
  let limit: number | undefined;
@@ -3485,6 +3487,10 @@ async function cmdHistory(args: string[]) {
3485
3487
  }
3486
3488
  }
3487
3489
 
3490
+ if (rebuild && (showSha || notePath || limit !== undefined)) {
3491
+ console.error("rebuild-state accepts only --vault and --json.");
3492
+ process.exit(1);
3493
+ }
3488
3494
  if (showSha && !notePath) {
3489
3495
  console.error("--show <sha> requires --note <path> (which file to read at that revision).");
3490
3496
  process.exit(1);
@@ -3496,6 +3502,25 @@ async function cmdHistory(args: string[]) {
3496
3502
  process.exit(1);
3497
3503
  }
3498
3504
 
3505
+ if (rebuild) {
3506
+ const path = vaultDbPath(vaultName);
3507
+ if (!existsSync(path)) {
3508
+ console.error(`Vault database not found: ${path}`);
3509
+ process.exit(1);
3510
+ }
3511
+ const { Database } = await import("bun:sqlite");
3512
+ const { initSchema } = await import("../core/src/schema.js");
3513
+ const { rebuildCompactState } = await import("../core/src/history-compact-state.js");
3514
+ const db = new Database(path, { readwrite: true, create: false });
3515
+ try {
3516
+ initSchema(db);
3517
+ const rebuilt_notes = rebuildCompactState(db);
3518
+ console.log(asJson ? JSON.stringify({ vault: vaultName, rebuilt_notes }) :
3519
+ `Rebuilt compaction hints for ${rebuilt_notes} notes in vault "${vaultName}". History unchanged.`);
3520
+ } finally { db.close(); }
3521
+ return;
3522
+ }
3523
+
3499
3524
  // Resolve the mirror dir the same way the server does: per-vault mirror
3500
3525
  // config (or defaults) → resolveMirrorPath against the vault's data dir.
3501
3526
  const mirrorConfig = readMirrorConfigForVault(vaultName) ?? defaultMirrorConfig();
@@ -3558,6 +3583,10 @@ async function cmdHistory(args: string[]) {
3558
3583
  }
3559
3584
 
3560
3585
  function printHistoryUsage(): void {
3586
+ console.error("Usage: parachute-vault history rebuild-state [--vault <name>] [--json]");
3587
+ console.error("Rebuild compaction hints and reset refusals; does not compact or change history.");
3588
+ console.error("The current server may stay running: writes serialize with the rebuild transaction.");
3589
+ console.error("Stop the server optionally to avoid write contention; do not run older writers concurrently.\n");
3561
3590
  console.error(
3562
3591
  "Usage: parachute-vault history [--note <path>] [--limit N] [--vault <name>] [--json]\n" +
3563
3592
  " [--note <path> --show <sha>]",
@@ -11,6 +11,42 @@ import { getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
11
11
  import { getImportedVersion } from "../core/src/history-import.ts";
12
12
  import { historyMirrorStatePath, readHistoryMirrorPhase, mirrorConfigPath } from "./mirror-config.ts";
13
13
  const noteId = "01M2KT1VQBHNZ99V5KSGXP6VSE";
14
+ for (const id of ["legacy:abc123", "2020-01-02-03-04-05", "shortid"]) {
15
+ for (const sidecar of [false, true]) test(`legacy ID staging: ${id}, sidecar=${sidecar}`, () => {
16
+ const dir = mkdtempSync(join(tmpdir(), "legacy-stage-")), db = new Database(":memory:");
17
+ try {
18
+ const repo = repoAt(join(dir, "repo"));
19
+ if (sidecar) {
20
+ mkdirSync(join(repo, ".parachute/notes-meta"), { recursive: true });
21
+ writeFileSync(join(repo, ".parachute/notes-meta/entry.yaml"), `id: ${id}\npath: entry\nextension: json\n`);
22
+ writeFileSync(join(repo, "entry.json"), '{}');
23
+ git(repo, "add", "."); git(repo, "commit", "-m", "sidecar");
24
+ } else commit(repo, "legacy body", id);
25
+ stageArchive(repo, git(repo, "rev-parse", "HEAD"), db);
26
+ expect(db.query("SELECT note_id FROM observations").all()).toEqual([{ note_id: id }]);
27
+ expect(db.query("SELECT * FROM issues").all()).toEqual([]);
28
+ expect(db.query("SELECT * FROM quarantine").all()).toEqual([]);
29
+ } finally { db.close(); rmSync(dir, { recursive: true, force: true }); }
30
+ });
31
+ }
32
+ for (const id of ["a b", "a/b", "a.b", "a".repeat(65), "", "-leading"]) {
33
+ for (const sidecar of [false, true]) test(`invalid ID staging: ${JSON.stringify(id)}, sidecar=${sidecar}`, () => {
34
+ const dir = mkdtempSync(join(tmpdir(), "invalid-stage-")), db = new Database(":memory:");
35
+ try {
36
+ const repo = repoAt(join(dir, "repo"));
37
+ const meta = `id: ${JSON.stringify(id)}\npath: entry\n`;
38
+ if (sidecar) {
39
+ mkdirSync(join(repo, ".parachute/notes-meta"), { recursive: true });
40
+ writeFileSync(join(repo, ".parachute/notes-meta/entry.yaml"), meta + "extension: json\n");
41
+ writeFileSync(join(repo, "entry.json"), '{}');
42
+ } else writeFileSync(join(repo, "entry.md"), `---\n${meta}---\nbody`);
43
+ git(repo, "add", "."); git(repo, "commit", "-m", "invalid");
44
+ stageArchive(repo, git(repo, "rev-parse", "HEAD"), db);
45
+ expect(db.query("SELECT * FROM observations").all()).toEqual([]);
46
+ expect(db.query("SELECT reason FROM issues").all()).toContainEqual({ reason: sidecar ? "invalid_sidecar" : "invalid_id" });
47
+ } finally { db.close(); rmSync(dir, { recursive: true, force: true }); }
48
+ });
49
+ }
14
50
  function git(dir: string, ...args: string[]) {
15
51
  const p = spawnSync("git", ["-C", dir, ...args], { encoding: "utf8", env: { ...process.env, GIT_AUTHOR_NAME: "test", GIT_AUTHOR_EMAIL: "test@example.test", GIT_COMMITTER_NAME: "test", GIT_COMMITTER_EMAIL: "test@example.test" } });
16
52
  if (p.status !== 0) throw new Error(p.stderr); return p.stdout.trim();
@@ -35,6 +71,57 @@ test("git staging preserves exported CRLF bodies and A-B-A while quarantining du
35
71
  try { stageArchive(repo, duplicate, second); expect(second.query("SELECT reason FROM quarantine").all()).toEqual([{ reason: "duplicate_id" }]); expect(second.query("SELECT * FROM observations").all()).toEqual([]); } finally { second.close(); }
36
72
  } finally { db.close(); rmSync(dir, { recursive: true, force: true }); }
37
73
  });
74
+ test("legacy duplicate IDs still quarantine and selection entries preserve exact identity", () => {
75
+ const dir = mkdtempSync(join(tmpdir(), "legacy-duplicate-")), db = new Database(":memory:");
76
+ try {
77
+ const repo = repoAt(join(dir, "repo")), id = "legacy:duplicate";
78
+ commit(repo, "first", id); const tip = commit(repo, "second", id, "duplicate.md");
79
+ stageArchive(repo, tip, db);
80
+ expect(db.query("SELECT * FROM quarantine").all()).toEqual([{ note_id: id, reason: "duplicate_id" }]);
81
+ expect(db.query("SELECT * FROM observations").all()).toEqual([]);
82
+ const selections = normalizeHistorySelections({ format: 1, tip, source_fingerprint: "a".repeat(64), selections: [{
83
+ note_id: id, commit: tip, selected: { git_path: "entry.md", blob: git(repo, "rev-parse", `${tip}:entry.md`) },
84
+ rejected: [{ git_path: "duplicate.md", blob: git(repo, "rev-parse", `${tip}:duplicate.md`), reason: "explicit choice" }], reason: "explicit choice",
85
+ }] });
86
+ expect(selections.selections[0]!.note_id).toBe(id);
87
+ } finally { db.close(); rmSync(dir, { recursive: true, force: true }); }
88
+ });
89
+ test("legacy imports preserve exact IDs, receipts and idempotency; case mismatch never merges", async () => {
90
+ const dir = mkdtempSync(join(tmpdir(), "legacy-import-")), saved = process.env.PARACHUTE_HOME;
91
+ process.env.PARACHUTE_HOME = join(dir, "home");
92
+ try {
93
+ const probe = Bun.serve({ port: 0, fetch: () => new Response() }); const port = probe.port!; probe.stop(true);
94
+ writeGlobalConfig({ port });
95
+ writeVaultConfig({ name: "test", api_keys: [], created_at: new Date().toISOString() });
96
+ const ids = ["legacy:abc123", "2020-01-02-03-04-05", "shortid"];
97
+ const store = getVaultStore("test");
98
+ for (const id of [...ids, "CaseId"]) await store.createNote("native", { id });
99
+ clearVaultStoreCache();
100
+ const repo = repoAt(join(dir, "repo"));
101
+ for (const [i, id] of [...ids, "caseid"].entries()) commit(repo, "archive", id, `entry${i}.md`);
102
+ let tip = git(repo, "rev-parse", "HEAD");
103
+ const output = join(dir, "plan.json");
104
+ await runHistoryImport(["plan", "--vault", "test", "--source", repo, "--through", tip, "--output", output]);
105
+ const plan = JSON.parse(readFileSync(output, "utf8"));
106
+ expect(plan.skipped).toEqual(["caseid"]);
107
+ expect(plan.missing_at_tip).toEqual(["CaseId"]);
108
+ expect(plan.notes.map((n: any) => n.id).sort()).toEqual([...ids].sort());
109
+ // Resolve the fixture mismatch explicitly before prepare, never via coercion.
110
+ commit(repo, "archive", "CaseId", "entry3.md"); tip = git(repo, "rev-parse", "HEAD");
111
+ const manifest = join(dir, "final.json"), archive = join(dir, "archive.bundle");
112
+ await runHistoryImport(["prepare", "--vault", "test", "--source", repo, "--through", tip, "--archive", archive, "--output", manifest]);
113
+ await runHistoryImport(["apply", "--vault", "test", "--manifest", manifest, "--archive", archive]);
114
+ const db = new Database(vaultDbPath("test"));
115
+ try {
116
+ const before = db.query("SELECT * FROM history_import_receipts ORDER BY note_id").all();
117
+ expect(before).toHaveLength(4);
118
+ for (const id of ids) expect(getImportedVersion(db, id, 0)?.content).toBe("archive");
119
+ await runHistoryImport(["apply", "--vault", "test", "--manifest", manifest, "--archive", archive]);
120
+ expect(db.query("SELECT * FROM history_import_receipts ORDER BY note_id").all()).toEqual(before);
121
+ expect(db.query("SELECT id FROM notes ORDER BY id").all()).toEqual([...ids, "CaseId"].sort().map(id => ({ id })));
122
+ } finally { db.close(); }
123
+ } finally { clearVaultStoreCache(); if (saved === undefined) delete process.env.PARACHUTE_HOME; else process.env.PARACHUTE_HOME = saved; rmSync(dir, { recursive: true, force: true }); }
124
+ }, 30_000);
38
125
  test("offline prepare applies verified bundle, retries without resurrection, then retires", async () => {
39
126
  const dir = mkdtempSync(join(tmpdir(), "import-cutover-")), saved = process.env.PARACHUTE_HOME;
40
127
  process.env.PARACHUTE_HOME = join(dir, "home");
@@ -5,6 +5,7 @@ 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);
@@ -188,7 +188,7 @@ export function stageArchive(repo: string, tip: string, stage: Database, inputSe
188
188
  const message = error instanceof Error ? error.message : "invalid_observation";
189
189
  const reason = /^[a-z_]+$/.test(message) ? message : "invalid_metadata_or_utf8";
190
190
  if (error instanceof GitArchiveError || message === "Archive byte limit exceeded" || message === "Archive note limit exceeded") throw error;
191
- 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);
192
192
  else stage.prepare("INSERT INTO issues VALUES(?,?,?)").run(commit, entry.path, reason);
193
193
  };
194
194
  const format = files.get(".parachute/vault.yaml");
@@ -198,7 +198,7 @@ export function stageArchive(repo: string, tip: string, stage: Database, inputSe
198
198
  try {
199
199
  meta = yaml(read(entry));
200
200
  const extension = meta.extension;
201
- 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");
202
202
  const contentPath = `${meta.path ?? `_unpathed/${meta.id}`}.${extension}`;
203
203
  const body = files.get(contentPath);
204
204
  if (!body) throw new Error("missing_sidecar_body");
@@ -321,7 +321,7 @@ function project(vault: string, repo: string, tip: string, dir: string, opts: {
321
321
  const selections = opts.selections ?? null;
322
322
  const source = stageArchive(repo, tip, stage, selections), policy = resolveHistoryPolicy(config.history);
323
323
  const waivers = opts.waivers ?? {};
324
- 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");
325
325
  const options_digest = optionsDigest(policy, waivers, selections);
326
326
  const run = { run_id: "projection", source_fingerprint: source.source_fingerprint, tip, options_digest };
327
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
@@ -911,6 +911,7 @@ function applyTagScopeWrappers(
911
911
  wrapReadTool(tools, "doctor", async (orig, params) => {
912
912
  const allowed = await getAllowed();
913
913
  if (!allowed) return await orig(params);
914
+ if (params.deep) return forbidden("deep history audit requires an unrestricted session");
914
915
  return runDoctorScan(store.db, { allowedTags: allowed });
915
916
  });
916
917
  }
package/src/routes.ts CHANGED
@@ -14,7 +14,7 @@ import { getImportedVersion, importStorageIndex, parseHistorySelector, projectHi
14
14
 
15
15
  import { HistoryNotFoundError, latestTombstone } from "../core/src/history.js";
16
16
  import { projectHistoryProvenance } from "../core/src/history-visibility.js";
17
- import { ULID_REGEX } from "../core/src/ulid.js";
17
+ import { isNoteIdShape } from "../core/src/ulid.js";
18
18
  import type { Database } from "bun:sqlite";
19
19
  import type { Store, Note, QueryOpts, AggregateSpec } from "../core/src/types.ts";
20
20
  import { TAG_EXPAND_MODES, stripTagHash, suggestSimilarTag, type TagExpandMode } from "../core/src/tag-hierarchy.ts";
@@ -2932,7 +2932,7 @@ async function handleNotesInner(
2932
2932
  if (note && !noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
2933
2933
  return json({ error: "Not found", error_type: "not_found" }, 404);
2934
2934
  }
2935
- if (!note && (tagScope.raw !== null || !ULID_REGEX.test(idOrPath) ||
2935
+ if (!note && (tagScope.raw !== null || !isNoteIdShape(idOrPath) ||
2936
2936
  !(await store.listNoteVersions(idOrPath, { limit: 1 })).length)) {
2937
2937
  return json({ error: "Not found", error_type: "not_found" }, 404);
2938
2938
  }
@@ -4453,11 +4453,21 @@ export function handleUnresolvedWikilinks(
4453
4453
  * catalog.
4454
4454
  */
4455
4455
  export async function handleDoctor(
4456
- _req: Request,
4456
+ req: Request,
4457
4457
  store: Store,
4458
4458
  tagScope: TagScopeCtx = NO_TAG_SCOPE,
4459
4459
  ): Promise<Response> {
4460
- 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 });
4461
4471
  return json(report);
4462
4472
  }
4463
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");
@@ -369,6 +369,21 @@ test("P19 overflow deletion remains possible but its tombstone cannot restore",
369
369
  expect(r.status).toBe(409);
370
370
  expect(r.body.error_type).toBe("history_unrecoverable");
371
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
+ });
372
387
  test("P20 REST recreates untagged at the tombstone path and guards conflicts", async () => {
373
388
  const n = await fixture();
374
389
  await store.deleteNote(n.id);