@openparachute/vault 0.7.0-rc.3 → 0.7.0-rc.5

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,330 @@
1
+ /**
2
+ * `vault doctor` — read-only taxonomy/metadata integrity scan (vault#552).
3
+ *
4
+ * NOT to be confused with `parachute-vault doctor` (the CLI's install/daemon
5
+ * diagnostic in `src/cli.ts` — port reachability, config sanity, launchd/
6
+ * systemd registration). This module answers a different question: "did my
7
+ * tag reorg (rename/merge/delete) leak?" It never auto-fixes — every finding
8
+ * carries a suggested `remedy` the caller applies deliberately (usually via
9
+ * `rename-tag`/`merge-tags`/`prune-schema`/another `update-tag` call).
10
+ *
11
+ * Scans:
12
+ * - `dangling_parent_name` — a `parent_names` entry naming a tag with no
13
+ * identity row.
14
+ * - `parent_names_cycle` — a tag that reaches itself through its
15
+ * declared ancestor chain (traversal is cycle-safe; this just SURFACES
16
+ * data that predates the vault#552 write-time cycle guard, or was
17
+ * written by a path that bypasses it).
18
+ * - `mixed_type_indexed_field` — a note whose `metadata.<field>` JSON type
19
+ * disagrees with the field's declared indexed sqlite type. This is the
20
+ * WS4 typed-index "poison precursor": inconsistent column affinity
21
+ * coercion silently breaks ordering/comparison across rows. The finding
22
+ * shape here is intentionally reusable as WS4's migration pre-flight.
23
+ * - `orphaned_indexed_field_declarer` — an `indexed_fields` row naming a
24
+ * declarer tag with no `tags` row (overlaps `prune-schema`, which is the
25
+ * suggested remedy).
26
+ * - `dead_tag_metadata_reference` — HEURISTIC, always labeled `heuristic:
27
+ * true`. A metadata key is used elsewhere in the vault with values that
28
+ * ARE live tag names (establishing "this key holds tag-shaped values");
29
+ * a value under the same key that does NOT match any live tag is
30
+ * flagged as a possible stale reference to a renamed/merged/deleted tag
31
+ * — the PM's `metadata.epic` drift class from the #552 write-up. Vault
32
+ * has no history of past tag names, so this can never be certain —
33
+ * hence heuristic.
34
+ *
35
+ * Tag-scope (vault#552): pass `allowedTags` (the caller's EXPANDED
36
+ * allowlist, e.g. from `expandTokenTagScope`) to restrict every finding to
37
+ * in-scope tags/fields/notes. `null` (or omitted) — the default — scans the
38
+ * whole vault. The scan is re-run with the allowlist rather than filtering
39
+ * an unscoped result afterward, so the `summary` counts never leak
40
+ * out-of-scope activity.
41
+ */
42
+
43
+ import { Database } from "bun:sqlite";
44
+ import { loadTagHierarchy, findHierarchyCycles } from "./tag-hierarchy.js";
45
+ import { listIndexedFields } from "./indexed-fields.js";
46
+ import { pruneOrphanedIndexedFields } from "./indexed-fields.js";
47
+
48
+ export type DoctorFindingType =
49
+ | "dangling_parent_name"
50
+ | "parent_names_cycle"
51
+ | "mixed_type_indexed_field"
52
+ | "orphaned_indexed_field_declarer"
53
+ | "dead_tag_metadata_reference";
54
+
55
+ export type DoctorSeverity = "error" | "warning" | "info";
56
+
57
+ export interface DoctorFinding {
58
+ type: DoctorFindingType;
59
+ severity: DoctorSeverity;
60
+ /** The tag / field / metadata-key this finding is about. */
61
+ subject: string;
62
+ detail: string;
63
+ remedy: string;
64
+ /**
65
+ * True ONLY for `dead_tag_metadata_reference` — flags the finding as a
66
+ * best-effort inference (no tag-rename history exists to confirm it),
67
+ * never present (not even `false`) on the other, structurally-certain
68
+ * finding types.
69
+ */
70
+ heuristic?: true;
71
+ }
72
+
73
+ export interface DoctorReport {
74
+ findings: DoctorFinding[];
75
+ summary: string;
76
+ scanned_at: string;
77
+ }
78
+
79
+ export interface DoctorScanOpts {
80
+ /**
81
+ * The caller's expanded tag allowlist (root ∪ descendants). `null`
82
+ * (default) scans the whole vault. See the module doc comment for how
83
+ * each finding type is scoped.
84
+ */
85
+ allowedTags?: Set<string> | null;
86
+ }
87
+
88
+ /** Cap on exemplar note IDs embedded in a finding's `detail` (vault#552 — keep report bodies bounded). */
89
+ const MAX_EXEMPLARS = 5;
90
+
91
+ /**
92
+ * Build a per-note in-scope predicate for a tag-scoped doctor run (vault#552
93
+ * auth fold). A note is in-scope iff at least one of its tags is in
94
+ * `allowedTags`; an unscoped run (`allowedTags === null`) treats every note
95
+ * as in-scope. Caches each note's tag lookup so repeated checks across scans
96
+ * cost one query per note. Shared by the mixed-type-indexed-field and
97
+ * dead-tag-metadata scans, both of which query notes vault-wide and must NOT
98
+ * surface an out-of-scope note id (or count, or exemplar) to a scoped caller.
99
+ */
100
+ function makeNoteInScope(db: Database, allowedTags: Set<string> | null): (id: string) => boolean {
101
+ if (!allowedTags) return () => true;
102
+ const cache = new Map<string, boolean>();
103
+ return (id: string): boolean => {
104
+ const cached = cache.get(id);
105
+ if (cached !== undefined) return cached;
106
+ const tags = (db.prepare("SELECT tag_name FROM note_tags WHERE note_id = ?").all(id) as { tag_name: string }[]).map((r) => r.tag_name);
107
+ const inScope = tags.some((t) => allowedTags.has(t));
108
+ cache.set(id, inScope);
109
+ return inScope;
110
+ };
111
+ }
112
+
113
+ export function runDoctorScan(db: Database, opts?: DoctorScanOpts): DoctorReport {
114
+ const allowedTags = opts?.allowedTags ?? null;
115
+ const findings: DoctorFinding[] = [
116
+ ...scanDanglingParentNames(db, allowedTags),
117
+ ...scanParentNamesCycles(db, allowedTags),
118
+ ...scanMixedTypeIndexedFields(db, allowedTags),
119
+ ...scanOrphanedIndexedFieldDeclarers(db, allowedTags),
120
+ ...scanDeadTagMetadataReferences(db, allowedTags),
121
+ ];
122
+
123
+ const errors = findings.filter((f) => f.severity === "error").length;
124
+ const warnings = findings.filter((f) => f.severity === "warning").length;
125
+ const infos = findings.filter((f) => f.severity === "info").length;
126
+ const summary =
127
+ findings.length === 0
128
+ ? "clean — no integrity findings"
129
+ : `${findings.length} finding(s): ${errors} error(s), ${warnings} warning(s), ${infos} info(s)`;
130
+
131
+ return { findings, summary, scanned_at: new Date().toISOString() };
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // A. dangling parent_names
136
+ // ---------------------------------------------------------------------------
137
+
138
+ function scanDanglingParentNames(db: Database, allowedTags: Set<string> | null): DoctorFinding[] {
139
+ const rows = db
140
+ .prepare("SELECT name, parent_names FROM tags WHERE parent_names IS NOT NULL")
141
+ .all() as { name: string; parent_names: string }[];
142
+ const allTags = new Set(
143
+ (db.prepare("SELECT name FROM tags").all() as { name: string }[]).map((r) => r.name),
144
+ );
145
+
146
+ const findings: DoctorFinding[] = [];
147
+ for (const row of rows) {
148
+ if (allowedTags && !allowedTags.has(row.name)) continue;
149
+ let parsed: unknown;
150
+ try { parsed = JSON.parse(row.parent_names); } catch { continue; }
151
+ if (!Array.isArray(parsed)) continue;
152
+ for (const parent of parsed) {
153
+ if (typeof parent !== "string" || parent.length === 0) continue;
154
+ if (allTags.has(parent)) continue;
155
+ findings.push({
156
+ type: "dangling_parent_name",
157
+ severity: "warning",
158
+ subject: row.name,
159
+ detail: `tag "${row.name}" declares parent "${parent}", which has no identity row — a typo, a tag that was never created, or a rename/delete that didn't update this reference.`,
160
+ remedy: `remove "${parent}" from "${row.name}"'s parent_names (update-tag), or create the missing "${parent}" tag if it's meant to exist`,
161
+ });
162
+ }
163
+ }
164
+ return findings;
165
+ }
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // B. parent_names cycles (pre-existing data — the write-time guard blocks
169
+ // NEW ones; this surfaces anything already on disk)
170
+ // ---------------------------------------------------------------------------
171
+
172
+ function scanParentNamesCycles(db: Database, allowedTags: Set<string> | null): DoctorFinding[] {
173
+ const hierarchy = loadTagHierarchy(db);
174
+ const cycles = findHierarchyCycles(hierarchy);
175
+ const findings: DoctorFinding[] = [];
176
+ for (const tag of cycles) {
177
+ if (allowedTags && !allowedTags.has(tag)) continue;
178
+ findings.push({
179
+ type: "parent_names_cycle",
180
+ severity: "error",
181
+ subject: tag,
182
+ detail: `tag "${tag}" reaches itself through its declared parent_names ancestor chain. Query expansion tolerates this (a visited-set stops it looping), but it's confusing hierarchy state.`,
183
+ remedy: `clear or correct parent_names on one of the tags in "${tag}"'s ancestor chain (update-tag) to break the cycle`,
184
+ });
185
+ }
186
+ return findings;
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // C. mixed-type values in an indexed column (WS4 poison precursor)
191
+ // ---------------------------------------------------------------------------
192
+
193
+ /** `json_type()` results consistent with each declared indexed sqlite storage class. */
194
+ const EXPECTED_JSON_TYPES: Record<string, Set<string>> = {
195
+ INTEGER: new Set(["integer", "real", "true", "false"]),
196
+ TEXT: new Set(["text"]),
197
+ };
198
+
199
+ function scanMixedTypeIndexedFields(db: Database, allowedTags: Set<string> | null): DoctorFinding[] {
200
+ const findings: DoctorFinding[] = [];
201
+ const noteInScope = makeNoteInScope(db, allowedTags);
202
+ for (const f of listIndexedFields(db)) {
203
+ if (allowedTags && !f.declarerTags.some((t) => allowedTags.has(t))) continue;
204
+ const expected = EXPECTED_JSON_TYPES[f.sqliteType];
205
+ if (!expected) continue; // unknown sqlite_type — nothing to compare against
206
+
207
+ // `json_type(metadata, ?)` returns NULL when the key is absent — those
208
+ // notes simply don't declare the field and aren't a mismatch. Only rows
209
+ // where the key IS present get compared.
210
+ const path = `$."${f.field}"`;
211
+ const rows = db
212
+ .prepare(
213
+ `SELECT id, json_type(metadata, ?) as jt FROM notes WHERE metadata IS NOT NULL AND metadata != '' AND json_valid(metadata)`,
214
+ )
215
+ .all(path) as { id: string; jt: string | null }[];
216
+ // Tag-scope (vault#552 auth fold): the note query above is vault-wide —
217
+ // filter mismatches to IN-SCOPE notes so a scoped caller never sees an
218
+ // out-of-scope note id (nor a count/exemplar reflecting one). If every
219
+ // mismatch is on an out-of-scope note, the finding is dropped entirely.
220
+ const mismatches = rows.filter((r) => r.jt !== null && !expected.has(r.jt) && noteInScope(r.id));
221
+ if (mismatches.length === 0) continue;
222
+
223
+ // Also generalize the "Declared by" list to in-scope declarers only —
224
+ // the field can be co-declared by an out-of-scope tag whose name must
225
+ // not leak (the caller sees this field only via its in-scope declarer).
226
+ const visibleDeclarers = allowedTags
227
+ ? f.declarerTags.filter((t) => allowedTags.has(t))
228
+ : f.declarerTags;
229
+ const exemplars = mismatches.slice(0, MAX_EXEMPLARS).map((r) => `${r.id} (${r.jt})`);
230
+ findings.push({
231
+ type: "mixed_type_indexed_field",
232
+ severity: "error",
233
+ subject: f.field,
234
+ detail: `field "${f.field}" is indexed as ${f.sqliteType} but ${mismatches.length} note(s) carry a metadata."${f.field}" value of a disagreeing JSON type — the generated column's affinity coercion makes ordering/filtering across these rows inconsistent. Declared by: ${visibleDeclarers.join(", ") || "(no live declarer)"}. Example note(s): ${exemplars.join(", ")}${mismatches.length > exemplars.length ? ", …" : ""}`,
235
+ remedy: `backfill each listed note's metadata."${f.field}" to a value matching the declared type, or relax the field's declared type (update-tag) if the mixed values are intentional`,
236
+ });
237
+ }
238
+ return findings;
239
+ }
240
+
241
+ // ---------------------------------------------------------------------------
242
+ // D. orphaned indexed_fields declarers (overlaps prune-schema)
243
+ // ---------------------------------------------------------------------------
244
+
245
+ function scanOrphanedIndexedFieldDeclarers(db: Database, allowedTags: Set<string> | null): DoctorFinding[] {
246
+ const plan = pruneOrphanedIndexedFields(db, { dryRun: true });
247
+ const findings: DoctorFinding[] = [];
248
+ for (const p of plan) {
249
+ if (allowedTags && !p.deadDeclarers.some((t) => allowedTags.has(t))) {
250
+ // Dead declarer names never survive `allowedTags` (it's derived from
251
+ // LIVE tags only) — this branch exists for completeness; in practice
252
+ // a scoped caller only sees this finding when the report is unscoped.
253
+ continue;
254
+ }
255
+ findings.push({
256
+ type: "orphaned_indexed_field_declarer",
257
+ severity: p.dropped ? "warning" : "info",
258
+ subject: p.field,
259
+ detail: p.dropped
260
+ ? `field "${p.field}" has NO surviving live declarer (dead: ${p.deadDeclarers.join(", ")}) — the generated column + index are stale and will be dropped on next prune (metadata values are never lost, only the index).`
261
+ : `field "${p.field}" has dead declarer tag(s) [${p.deadDeclarers.join(", ")}] still listed alongside live ones — cosmetic, no functional impact.`,
262
+ remedy: `run the prune-schema tool (apply: true) to drop the dead declarer(s)${p.dropped ? " and the now-unreferenced column/index" : ""}`,
263
+ });
264
+ }
265
+ return findings;
266
+ }
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // E. metadata values equal to a tag name that no longer exists (heuristic)
270
+ // ---------------------------------------------------------------------------
271
+
272
+ function scanDeadTagMetadataReferences(db: Database, allowedTags: Set<string> | null): DoctorFinding[] {
273
+ const liveTags = new Set(
274
+ (db.prepare("SELECT name FROM tags").all() as { name: string }[]).map((r) => r.name),
275
+ );
276
+
277
+ const rows = db
278
+ .prepare("SELECT id, metadata FROM notes WHERE metadata IS NOT NULL AND metadata != ''")
279
+ .all() as { id: string; metadata: string }[];
280
+
281
+ // key -> value -> note ids carrying that (key, value) pair.
282
+ const byKey = new Map<string, Map<string, string[]>>();
283
+ for (const row of rows) {
284
+ let parsed: unknown;
285
+ try { parsed = JSON.parse(row.metadata); } catch { continue; }
286
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
287
+ for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
288
+ if (typeof value !== "string" || value.length === 0) continue;
289
+ let valueMap = byKey.get(key);
290
+ if (!valueMap) { valueMap = new Map(); byKey.set(key, valueMap); }
291
+ let ids = valueMap.get(value);
292
+ if (!ids) { ids = []; valueMap.set(value, ids); }
293
+ ids.push(row.id);
294
+ }
295
+ }
296
+
297
+ // Tag-scope (vault#552 auth fold): a note is in-scope iff at least one of
298
+ // its tags is in `allowedTags` (unscoped → every note in-scope).
299
+ const noteInScope = makeNoteInScope(db, allowedTags);
300
+
301
+ const findings: DoctorFinding[] = [];
302
+ for (const [key, valueMap] of byKey) {
303
+ // Signal: does this metadata key hold tag-shaped values on IN-SCOPE
304
+ // notes? Computed over in-scope notes ONLY (not vault-wide) so the
305
+ // example live value quoted in `detail` can never leak an out-of-scope
306
+ // tag name — a scoped caller must not learn a tag exists just because a
307
+ // note it can't see uses it as a metadata value. Only flag mismatches
308
+ // under a key that demonstrably holds tag-shaped values in-scope.
309
+ const liveValuesInScope = [...valueMap.entries()]
310
+ .filter(([v, ids]) => liveTags.has(v) && ids.some(noteInScope))
311
+ .map(([v]) => v);
312
+ if (liveValuesInScope.length === 0) continue;
313
+
314
+ for (const [value, noteIds] of valueMap) {
315
+ if (liveTags.has(value)) continue;
316
+ const inScopeIds = noteIds.filter(noteInScope);
317
+ if (inScopeIds.length === 0) continue;
318
+ const exemplars = inScopeIds.slice(0, MAX_EXEMPLARS);
319
+ findings.push({
320
+ type: "dead_tag_metadata_reference",
321
+ severity: "info",
322
+ subject: `metadata.${key}`,
323
+ detail: `${inScopeIds.length} note(s) carry metadata.${key} = "${value}", which matches no current tag — other notes' metadata.${key} values ARE live tags (e.g. "${liveValuesInScope[0]}"), suggesting "${value}" may be a stale reference to a tag that was renamed, merged, or deleted. Example note(s): ${exemplars.join(", ")}${inScopeIds.length > exemplars.length ? ", …" : ""}`,
324
+ remedy: `if "${value}" was renamed/merged/deleted, update these notes' metadata.${key} to the current tag name; if "${value}" was never a tag, ignore this finding`,
325
+ heuristic: true,
326
+ });
327
+ }
328
+ }
329
+ return findings;
330
+ }
@@ -14,6 +14,7 @@ import {
14
14
  validateFieldName,
15
15
  } from "./indexed-fields.js";
16
16
  import { buildVaultProjection } from "./vault-projection.js";
17
+ import { TagFieldConflictError } from "./tag-schemas.js";
17
18
 
18
19
  let db: Database;
19
20
  let store: SqliteStore;
@@ -186,9 +187,17 @@ describe("update-tag: indexed flag", () => {
186
187
  it("type conflict across declarers throws and names the other tag", async () => {
187
188
  const t = findTool("update-tag");
188
189
  await t.execute({ tag: "project", fields: { status: { type: "string", indexed: true } } });
190
+ // vault#554 wire review: a BOTH-indexed cross-tag type conflict is
191
+ // deliberately excluded from the update-tag pre-check (see
192
+ // `collectCrossTagFieldViolations` doc-comment exclusion 2) so it keeps
193
+ // its pre-existing declareField → IndexedFieldError path — REST maps
194
+ // that to the established 400 invalid_indexed_field. The declarer is
195
+ // still named (declareField's message shape), which is this test's
196
+ // intent; the pre-#554 inline-loop wording (`tag "project" declares
197
+ // "string"`) is gone along with the loop.
189
198
  expect(() =>
190
199
  t.execute({ tag: "ticket", fields: { status: { type: "integer", indexed: true } } }),
191
- ).toThrow(/tag "project".*"string"/);
200
+ ).toThrow(/declared by tag\(s\) \[project\]/);
192
201
  });
193
202
 
194
203
  it("indexed-flag conflict across declarers throws", async () => {
@@ -209,12 +218,28 @@ describe("update-tag: indexed flag", () => {
209
218
  });
210
219
 
211
220
  it("invalid field name for indexing throws", async () => {
212
- expect(() =>
213
- findTool("update-tag").execute({
221
+ // vault#553/#554: update-tag's cross-tag field validation now collects
222
+ // EVERY violation into one `TagFieldConflictError` (carrying a
223
+ // `violations` array) instead of throwing the first `IndexedFieldError`
224
+ // it hits — see core/src/tag-schemas.ts `collectTagFieldViolations`.
225
+ // The underlying reason ("invalid_field_name") and message text are
226
+ // unchanged; only the thrown class + the "collect everything" framing
227
+ // are new.
228
+ let caught: unknown;
229
+ try {
230
+ await findTool("update-tag").execute({
214
231
  tag: "project",
215
232
  fields: { "bad-name": { type: "string", indexed: true } },
216
- }),
217
- ).toThrow(IndexedFieldError);
233
+ });
234
+ } catch (e) {
235
+ caught = e;
236
+ }
237
+ expect(caught).toBeInstanceOf(TagFieldConflictError);
238
+ const err = caught as TagFieldConflictError;
239
+ expect(err.violations).toHaveLength(1);
240
+ expect(err.violations[0]!.field).toBe("bad-name");
241
+ expect(err.violations[0]!.reason).toBe("invalid_field_name");
242
+ expect(err.message).toContain("no changes were applied");
218
243
  });
219
244
 
220
245
  it("rejects non-atomic indexed-flag change while other declarers hold it true", async () => {
@@ -45,6 +45,22 @@ interface IndexedFieldRow {
45
45
 
46
46
  export class IndexedFieldError extends Error {
47
47
  override name = "IndexedFieldError";
48
+ // Stable error_type (vault#554) — additive; matches the string REST has
49
+ // hardcoded in its json response since vault#478. Lets the generic MCP
50
+ // domain-error mapping (src/mcp-http.ts) pick this class up.
51
+ error_type = "invalid_indexed_field" as const;
52
+ /**
53
+ * Structured context for the CROSS-DECLARER sqlite-type conflict thrown
54
+ * by `declareField` (vault#554 auth-and-scope fold): the message names
55
+ * the other declarer tag(s), which a tag-scoped caller must not learn
56
+ * when they're outside its allowlist. `declarer_tags` lets the server
57
+ * layer (`scrubIndexedFieldConflictError` in src/tag-scope.ts) detect
58
+ * that case and generalize the message. Absent on the solo own-field
59
+ * throws (invalid name, unsupported type), whose messages name no other
60
+ * tag.
61
+ */
62
+ field?: string;
63
+ declarer_tags?: string[];
48
64
  }
49
65
 
50
66
  // Restrict field names to safe SQL identifiers. This also bounds the
@@ -172,8 +188,14 @@ export function declareField(
172
188
  if (existing.sqliteType !== sqliteType) {
173
189
  const others = existing.declarerTags.filter((t) => t !== tag);
174
190
  if (others.length > 0) {
175
- throw new IndexedFieldError(
176
- `field "${field}" is declared by tag(s) [${others.join(", ")}] with sqlite type ${existing.sqliteType}; tag "${tag}" requested ${sqliteType}`,
191
+ // `field` + `declarer_tags` stamped so the server's tag-scope layer
192
+ // can generalize this message when a declarer is outside a scoped
193
+ // caller's allowlist (see IndexedFieldError's doc comment).
194
+ throw Object.assign(
195
+ new IndexedFieldError(
196
+ `field "${field}" is declared by tag(s) [${others.join(", ")}] with sqlite type ${existing.sqliteType}; tag "${tag}" requested ${sqliteType}`,
197
+ ),
198
+ { field, declarer_tags: others },
177
199
  );
178
200
  }
179
201
  dropColumnAndIndex(db, field);