@openparachute/vault 0.7.0-rc.4 → 0.7.0-rc.6

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.
@@ -17,6 +17,7 @@
17
17
 
18
18
  import { Database } from "bun:sqlite";
19
19
  import { mapFieldType, validateFieldName } from "./indexed-fields.js";
20
+ import { loadTagHierarchy, findParentCycle } from "./tag-hierarchy.js";
20
21
 
21
22
  // ---------------------------------------------------------------------------
22
23
  // Types
@@ -43,6 +44,25 @@ export interface TagFieldSchema {
43
44
  // "one" (scalar, default) or "many" (array). Advisory unless `strict:true`.
44
45
  // Distinct from relationship cardinality. vault#299.
45
46
  cardinality?: "one" | "many";
47
+ /**
48
+ * Explicit backfill value (vault#553 Decision B). When a note gains this
49
+ * tag and doesn't set the field, `applySchemaDefaults` (core/src/mcp.ts)
50
+ * writes THIS value onto the note. Undeclared (the default) means the
51
+ * field stays ABSENT on notes that don't set it — no more implicit
52
+ * first-enum-value / type-zero-value backfill. This is what makes
53
+ * `exists:false` trustworthy: "never set" and "explicitly set to the
54
+ * default" are now distinguishable states.
55
+ *
56
+ * Must conform to this field's own `type` (and `enum`, when declared) —
57
+ * `upsertTagRecord` (core/src/store.ts, the single chokepoint both REST
58
+ * and MCP funnel through) validates the default BEFORE persisting via
59
+ * {@link validateFieldDefault}; a non-conforming default is a tag-schema
60
+ * error (`invalid_field_default` / `TagFieldViolation` reason
61
+ * `"invalid_default"`), not a silent typo. `undefined` is JSON-serialized
62
+ * away by `JSON.stringify` — an omitted `default` key round-trips as "not
63
+ * declared", never as a stored `null`.
64
+ */
65
+ default?: unknown;
46
66
  }
47
67
 
48
68
  /**
@@ -132,10 +152,128 @@ export function getTagRecord(db: Database, tag: string): TagRecord | null {
132
152
  return row ? rowToRecord(row) : null;
133
153
  }
134
154
 
155
+ /**
156
+ * Thrown by `upsertTagRecord` (and therefore by both `PUT /api/tags/:name`
157
+ * and the MCP `update-tag` tool — this is the single chokepoint both share,
158
+ * see `core/src/store.ts:upsertTagRecord`) when the incoming `parent_names`
159
+ * would create a cycle in the hierarchy (vault#552). Traversal elsewhere
160
+ * (`getTagDescendants`) is already cycle-safe — a visited-set stops it from
161
+ * looping forever — but the WRITE itself was dishonest about creating one:
162
+ * an adversarial or accidental `A→B` then `B→A` (or a bare self-parent)
163
+ * silently succeeded pre-#552. `cycle` is the offending path, e.g.
164
+ * `["A", "B", "A"]` for a direct A↔B cycle, or the longer chain for a
165
+ * transitive one; the SERVER layer scope-scrubs out-of-scope names in it
166
+ * for a tag-scoped caller (`scrubParentCycleError` in src/tag-scope.ts),
167
+ * same posture as `TagFieldConflictError`.
168
+ */
169
+ /**
170
+ * Thrown by `upsertTagRecord` (core/src/store.ts's chokepoint — REST
171
+ * `PUT /api/tags/:name` and MCP `update-tag` both funnel through it) when a
172
+ * field's declared `default` (vault#553 Decision B) doesn't conform to that
173
+ * SAME field's own `type` (or `enum`, when declared). A bad default is a
174
+ * tag-schema authoring error, not silently-accepted junk that would poison
175
+ * every note gaining the tag — fail closed, same posture as
176
+ * `IndexedFieldError` and `ParentCycleError`. `error_type` is stamped
177
+ * directly (own-field validation leaf, one caller-facing shape) so the
178
+ * generic MCP domain-error mapping (`src/mcp-http.ts`) and REST's dedicated
179
+ * catch branch both surface it without a bespoke class-specific mapping.
180
+ */
181
+ export class InvalidFieldDefaultError extends Error {
182
+ code = "INVALID_FIELD_DEFAULT" as const;
183
+ error_type = "invalid_field_default" as const;
184
+ field: string;
185
+
186
+ constructor(field: string, message: string) {
187
+ super(message);
188
+ this.name = "InvalidFieldDefaultError";
189
+ this.field = field;
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Validate that a field's declared `default` (vault#553 Decision B) conforms
195
+ * to its own `type` and (when declared) `enum`. Returns `null` when the
196
+ * field has no `default` (the common case) or the default is valid;
197
+ * otherwise a {@link TagFieldViolation} with `reason: "invalid_default"`.
198
+ * Pure — never throws; the two callers (the store chokepoint's fail-fast
199
+ * pre-validate, and {@link collectTagFieldViolations}'s bundled MCP report)
200
+ * each decide how to surface it.
201
+ *
202
+ * Deliberately independent of `schema-defaults.ts`'s `valueMatchesType` (this
203
+ * module doesn't import that one, and vice versa — no cycle either way) so
204
+ * the two modules' type vocabularies can drift without cross-coupling; the
205
+ * rules are kept in lockstep by hand (string/number/integer/boolean/array/
206
+ * object, the SAME six as `TagFieldSchema.type`'s documented vocabulary).
207
+ */
208
+ export function validateFieldDefault(field: string, spec: TagFieldSchema): TagFieldViolation | null {
209
+ if (spec.default === undefined) return null;
210
+ const value = spec.default;
211
+ const typeOk = defaultMatchesType(value, spec.type);
212
+ if (!typeOk) {
213
+ return {
214
+ field,
215
+ reason: "invalid_default",
216
+ message: `field "${field}" declares default ${JSON.stringify(value)}, which does not match its own type "${spec.type}"`,
217
+ };
218
+ }
219
+ if (spec.enum && spec.enum.length > 0 && typeof value === "string" && !spec.enum.includes(value)) {
220
+ return {
221
+ field,
222
+ reason: "invalid_default",
223
+ message: `field "${field}" declares default "${value}", which is not one of its own enum values [${spec.enum.join(", ")}]`,
224
+ };
225
+ }
226
+ return null;
227
+ }
228
+
229
+ /** Same six-type vocabulary as `TagFieldSchema.type`'s doc comment. Unknown/unset types pass (nothing to check against). */
230
+ function defaultMatchesType(value: unknown, type: string): boolean {
231
+ switch (type) {
232
+ case "string":
233
+ return typeof value === "string";
234
+ case "number":
235
+ return typeof value === "number" && Number.isFinite(value);
236
+ case "integer":
237
+ return typeof value === "number" && Number.isInteger(value);
238
+ case "boolean":
239
+ return typeof value === "boolean";
240
+ case "array":
241
+ return Array.isArray(value);
242
+ case "object":
243
+ return !!value && typeof value === "object" && !Array.isArray(value);
244
+ default:
245
+ return true;
246
+ }
247
+ }
248
+
249
+ export class ParentCycleError extends Error {
250
+ code = "PARENT_CYCLE" as const;
251
+ error_type = "parent_cycle" as const;
252
+ tag: string;
253
+ cycle: string[];
254
+
255
+ constructor(tag: string, cycle: string[]) {
256
+ super(
257
+ `parent_cycle: setting "${tag}"'s parent_names would create a cycle: ${cycle.join(" → ")}`,
258
+ );
259
+ this.name = "ParentCycleError";
260
+ this.tag = tag;
261
+ this.cycle = cycle;
262
+ }
263
+ }
264
+
135
265
  /**
136
266
  * Upsert a tag record — partial update. Any field left `undefined` is
137
267
  * preserved. Pass `null` explicitly to clear a column. Always touches
138
268
  * `updated_at`; sets `created_at` on first insert.
269
+ *
270
+ * Cycle guard (vault#552): when `patch.parent_names` is a non-empty array,
271
+ * validate it against the CURRENT hierarchy (loaded fresh — this function is
272
+ * the shared chokepoint for both REST and MCP, so it can't rely on a
273
+ * possibly-stale caller cache) before touching any row. A conflicting parent
274
+ * throws {@link ParentCycleError} and nothing is persisted — same
275
+ * fail-closed-before-any-write posture as the cross-tag field-conflict
276
+ * checks in this module.
139
277
  */
140
278
  export function upsertTagRecord(
141
279
  db: Database,
@@ -147,6 +285,12 @@ export function upsertTagRecord(
147
285
  parent_names?: string[] | null;
148
286
  },
149
287
  ): TagRecord {
288
+ if (patch.parent_names != null && patch.parent_names.length > 0) {
289
+ const hierarchy = loadTagHierarchy(db);
290
+ const cycle = findParentCycle(hierarchy, tag, patch.parent_names);
291
+ if (cycle) throw new ParentCycleError(tag, cycle);
292
+ }
293
+
150
294
  const now = new Date().toISOString();
151
295
  db.prepare(
152
296
  "INSERT OR IGNORE INTO tags (name, created_at, updated_at) VALUES (?, ?, ?)",
@@ -358,7 +502,12 @@ function jsonOrNull(value: unknown): string | null {
358
502
  */
359
503
  export interface TagFieldViolation {
360
504
  field: string;
361
- reason: "type_conflict" | "indexed_flag_conflict" | "unsupported_indexed_type" | "invalid_field_name";
505
+ reason:
506
+ | "type_conflict"
507
+ | "indexed_flag_conflict"
508
+ | "unsupported_indexed_type"
509
+ | "invalid_field_name"
510
+ | "invalid_default";
362
511
  message: string;
363
512
  /**
364
513
  * The conflicting declarer tag — present on the cross-tag reasons
@@ -477,12 +626,17 @@ export function collectCrossTagFieldViolations(
477
626
  /**
478
627
  * Full field-violation collection: {@link collectCrossTagFieldViolations}
479
628
  * PLUS the own-field checks (unsupported type for indexing, invalid
480
- * identifier). Used by the MCP `update-tag` tool, which unlike REST — had
481
- * no prior single-violation status-code contract to preserve for those two
482
- * checks (its old inline loop threw an unstructured `Error` for them, same
483
- * as everything else pre-#554); collecting everything here is a strict
629
+ * identifier, and vault#553 Decision Ba non-conforming `default`).
630
+ * Used by the MCP `update-tag` tool, which unlike REST — had no prior
631
+ * single-violation status-code contract to preserve for those checks (its
632
+ * old inline loop threw an unstructured `Error` for them, same as
633
+ * everything else pre-#554); collecting everything here is a strict
484
634
  * improvement. See {@link collectCrossTagFieldViolations}'s doc comment for
485
- * why REST calls that narrower function directly instead of this one.
635
+ * why REST calls that narrower function directly instead of this one — REST
636
+ * gets the SAME `invalid_default` coverage via `store.upsertTagRecord`'s
637
+ * fail-fast pre-validate (mirrors its indexed-type/name checks), just as a
638
+ * single-violation `InvalidFieldDefaultError` → 400 rather than a bundled
639
+ * `TagFieldConflictError` → 422.
486
640
  */
487
641
  export function collectTagFieldViolations(
488
642
  db: Database,
@@ -492,6 +646,12 @@ export function collectTagFieldViolations(
492
646
  const violations = collectCrossTagFieldViolations(db, tag, incomingFields);
493
647
 
494
648
  for (const [fieldName, spec] of Object.entries(incomingFields)) {
649
+ // Own-field default-conformance check (vault#553 Decision B) — applies
650
+ // to EVERY field (not just indexed ones); a bad default is a tag-schema
651
+ // error regardless of whether the field is queryable.
652
+ const defaultViolation = validateFieldDefault(fieldName, spec);
653
+ if (defaultViolation) violations.push(defaultViolation);
654
+
495
655
  if (spec.indexed === true) {
496
656
  const mapped = mapFieldType(spec.type);
497
657
  if (!mapped) {
package/core/src/types.ts CHANGED
@@ -6,6 +6,7 @@ import type { SearchMode } from "./search-query.js";
6
6
  import type { ValidationStatus } from "./schema-defaults.js";
7
7
  import type { ConformanceReport } from "./conformance.js";
8
8
  import type { FindPathResult } from "./links.js";
9
+ import type { DoctorReport, DoctorScanOpts } from "./doctor.js";
9
10
 
10
11
  // ---- Re-exports ----
11
12
 
@@ -14,6 +15,7 @@ export type { PrunedField } from "./indexed-fields.js";
14
15
  export type { TagExpandMode, TagHierarchy } from "./tag-hierarchy.js";
15
16
  export type { ConformanceReport } from "./conformance.js";
16
17
  export type { FindPathResult } from "./links.js";
18
+ export type { DoctorReport, DoctorFinding, DoctorFindingType, DoctorSeverity, DoctorScanOpts } from "./doctor.js";
17
19
 
18
20
  // ---- Note ----
19
21
 
@@ -364,7 +366,20 @@ export interface Store {
364
366
  * alongside the literal `count`. See `core/src/tag-hierarchy.ts:computeExpandedTagCounts`.
365
367
  */
366
368
  listTags(): Promise<{ name: string; count: number; expanded_count: number }[]>;
367
- deleteTag(name: string): Promise<{ deleted: boolean; notes_untagged: number }>;
369
+ /**
370
+ * Delete a tag. Refused (vault#552) when another tag's `parent_names`
371
+ * still references it — pass `cascade` or `detach` (synonyms; either
372
+ * strips the stale reference from the referencing tags' `parent_names`
373
+ * in the same transaction) to proceed anyway. Notes are never deleted,
374
+ * only untagged.
375
+ */
376
+ deleteTag(
377
+ name: string,
378
+ opts?: { cascade?: boolean; detach?: boolean },
379
+ ): Promise<
380
+ | { deleted: boolean; notes_untagged: number; parent_refs_detached?: number }
381
+ | { error: "tag_referenced_as_parent"; referencing_tags: string[] }
382
+ >;
368
383
  renameTag(
369
384
  oldName: string,
370
385
  newName: string,
@@ -431,6 +446,13 @@ export interface Store {
431
446
  */
432
447
  reconcileDeclaredIndexes(): Promise<number>;
433
448
 
449
+ /**
450
+ * Read-only taxonomy/metadata integrity scan (vault#552). Never mutates —
451
+ * every finding carries a suggested `remedy` the caller applies
452
+ * deliberately. See `core/src/doctor.ts` for the finding-type catalog.
453
+ */
454
+ doctor(opts?: DoctorScanOpts): Promise<DoctorReport>;
455
+
434
456
  // Tag records — full v14 identity row (description + fields + typed
435
457
  // relationships + parent_names + timestamps). See
436
458
  // docs/contracts/tag-data-model.md.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.7.0-rc.4",
3
+ "version": "0.7.0-rc.6",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
package/src/mcp-http.ts CHANGED
@@ -170,6 +170,8 @@ async function handleMcp(
170
170
  reason?: string;
171
171
  limit?: number;
172
172
  tag?: string;
173
+ cycle?: unknown;
174
+ referencing_tags?: unknown;
173
175
  };
174
176
  // Honest-queries validation errors (vault#550) — `limit`/`offset`/date
175
177
  // values that are structurally invalid rather than merely "no
@@ -313,6 +315,17 @@ async function handleMcp(
313
315
  violations: e.violations ?? [],
314
316
  });
315
317
  }
318
+ // parent_names cycle guard (vault#552) — `update-tag`'s write would
319
+ // close a cycle. Mirrors REST's 409 `parent_cycle` shape; the tool
320
+ // wrapper (src/mcp-tools.ts) scope-scrubs `cycle` before this throw
321
+ // for a tag-scoped caller, same as TAG_FIELD_CONFLICT above.
322
+ if (e?.code === "PARENT_CYCLE") {
323
+ throw new McpError(ErrorCode.InvalidRequest, message, {
324
+ error_type: "parent_cycle",
325
+ tag: e.tag,
326
+ cycle: e.cycle ?? [],
327
+ });
328
+ }
316
329
  // Generic catch-all (vault#554): any remaining error that carries a
317
330
  // stable `error_type` — either a domain error class that stamps it as
318
331
  // an instance property (IndexedFieldError, and the classes handled by
package/src/mcp-tools.ts CHANGED
@@ -23,11 +23,14 @@ import {
23
23
  filterHydratedLinksByTagScope,
24
24
  noteWithinTagScope,
25
25
  scrubIndexedFieldConflictError,
26
+ scrubParentCycleError,
27
+ scrubReferencingTagsByScope,
26
28
  scrubTagFieldViolationsByScope,
27
29
  tagsWithinScope,
28
30
  } from "./tag-scope.ts";
29
- import { TagFieldConflictError } from "../core/src/tag-schemas.ts";
31
+ import { TagFieldConflictError, ParentCycleError } from "../core/src/tag-schemas.ts";
30
32
  import { IndexedFieldError } from "../core/src/indexed-fields.ts";
33
+ import { runDoctorScan } from "../core/src/doctor.ts";
31
34
  import {
32
35
  findTokensReferencingTag,
33
36
  recordMcpMintLedger,
@@ -219,11 +222,12 @@ export function generateScopedMcpTools(
219
222
  }
220
223
 
221
224
  /**
222
- * Tag-delete and (future) tag-merge always check for tag-scoped tokens
223
- * referencing the doomed tag — regardless of whether the *deleter* is
224
- * itself tag-scoped. A successful delete that orphans an allowlist would
225
+ * Tag-delete and tag-merge always check for tag-scoped tokens referencing
226
+ * the doomed tag(s) — regardless of whether the CALLER is itself
227
+ * tag-scoped. A successful delete/merge that orphans an allowlist would
225
228
  * silently widen surface area downstream. Mirrors the REST 409
226
- * `tag_in_use_by_tokens` envelope.
229
+ * `tag_in_use_by_tokens` envelope (routes.ts's DELETE /tags/:name and
230
+ * POST /tags/merge run the identical check).
227
231
  */
228
232
  function applyTagDependencyGuards(tools: McpToolDef[], vaultName: string): void {
229
233
  const store = getVaultStore(vaultName);
@@ -243,6 +247,29 @@ function applyTagDependencyGuards(tools: McpToolDef[], vaultName: string): void
243
247
  }
244
248
  return await orig(params);
245
249
  });
250
+ // vault#552: merging consumes every source tag (same identity-drop as
251
+ // delete), so a source referenced by a tag-scoped token would orphan that
252
+ // token's allowlist exactly like a bare delete would. Aggregate matches
253
+ // across all sources into one 409-equivalent envelope, same shape REST's
254
+ // POST /tags/merge returns.
255
+ wrapReadTool(tools, "merge-tags", async (orig, params) => {
256
+ const sources = Array.isArray((params as any).sources) ? ((params as any).sources as unknown[]) : [];
257
+ const referenced: { source: string; tokens: { id: string; label: string }[] }[] = [];
258
+ for (const src of sources) {
259
+ if (typeof src !== "string") continue;
260
+ const tokens = findTokensReferencingTag(store.db, src);
261
+ if (tokens.length > 0) referenced.push({ source: src, tokens });
262
+ }
263
+ if (referenced.length > 0) {
264
+ return {
265
+ error: "TagInUseByTokens",
266
+ error_type: "tag_in_use_by_tokens",
267
+ message: `Cannot merge: ${referenced.length} source tag(s) referenced by tag-scoped token(s); revoke or re-mint them first.`,
268
+ referenced_by: referenced,
269
+ };
270
+ }
271
+ return await orig(params);
272
+ });
246
273
  }
247
274
 
248
275
  /**
@@ -535,6 +562,14 @@ function applyTagScopeWrappers(
535
562
  if (err instanceof IndexedFieldError) {
536
563
  throw scrubIndexedFieldConflictError(err, allowed);
537
564
  }
565
+ // parent_names cycle guard (vault#552) — the hierarchy `upsertTagRecord`
566
+ // validates against is vault-wide (scope-unaware by architecture), so
567
+ // the cycle path can name an out-of-scope tag. Same "write stays
568
+ // rejected, path gets generalized" posture as the field-conflict scrub
569
+ // above.
570
+ if (err instanceof ParentCycleError) {
571
+ throw scrubParentCycleError(err, allowed);
572
+ }
538
573
  throw err;
539
574
  }
540
575
  });
@@ -546,9 +581,59 @@ function applyTagScopeWrappers(
546
581
  if (typeof tag === "string" && !allowed.has(tag)) {
547
582
  return forbidden(`delete-tag: tag "${tag}" is outside the token's allowlist`);
548
583
  }
584
+ const result = await orig(params);
585
+ // Referential-integrity refusal (vault#552) — scrub out-of-scope
586
+ // referencing tag names before returning. The delete stays refused
587
+ // either way; only the response's tag-name visibility changes.
588
+ if (result && typeof result === "object" && (result as any).error_type === "tag_referenced_as_parent") {
589
+ return {
590
+ ...(result as Record<string, unknown>),
591
+ referencing_tags: scrubReferencingTagsByScope((result as any).referencing_tags ?? [], allowed),
592
+ };
593
+ }
594
+ return result;
595
+ });
596
+
597
+ // rename-tag / merge-tags (vault#552 — MCP parity with the pre-existing
598
+ // REST engine). Same tag-scope posture as update-tag/delete-tag: every
599
+ // tag NAMED in the request (source(s) + target, or old/new name) must be
600
+ // inside the caller's allowlist — a rename/merge that pulls a tag out of
601
+ // scope (or in) is a privilege-boundary move, refuse the whole op before
602
+ // it reaches core.
603
+ wrapReadTool(tools, "rename-tag", async (orig, params) => {
604
+ const allowed = await getAllowed();
605
+ if (!allowed) return await orig(params);
606
+ const oldName = (params as any).old_name ?? (params as any).from ?? (params as any).tag;
607
+ const newName = (params as any).new_name ?? (params as any).to;
608
+ for (const t of [oldName, newName]) {
609
+ if (typeof t === "string" && !allowed.has(t)) {
610
+ return forbidden(`rename-tag: tag "${t}" is outside the token's allowlist`);
611
+ }
612
+ }
613
+ return await orig(params);
614
+ });
615
+
616
+ wrapReadTool(tools, "merge-tags", async (orig, params) => {
617
+ const allowed = await getAllowed();
618
+ if (!allowed) return await orig(params);
619
+ const sources = Array.isArray((params as any).sources) ? ((params as any).sources as unknown[]) : [];
620
+ const target = (params as any).target;
621
+ for (const t of [...sources, target]) {
622
+ if (typeof t === "string" && !allowed.has(t)) {
623
+ return forbidden(`merge-tags: tag "${t}" is outside the token's allowlist`);
624
+ }
625
+ }
549
626
  return await orig(params);
550
627
  });
551
628
 
629
+ // doctor (vault#552) — the scan itself is re-run with the caller's
630
+ // expanded allowlist rather than post-filtering the unscoped result, so
631
+ // aggregate counts (findings summary) never reflect out-of-scope data.
632
+ wrapReadTool(tools, "doctor", async (orig, params) => {
633
+ const allowed = await getAllowed();
634
+ if (!allowed) return await orig(params);
635
+ return runDoctorScan(store.db, { allowedTags: allowed });
636
+ });
552
637
  }
553
638
 
554
639
  function wrapReadTool(
@@ -518,6 +518,11 @@ function importResultFromStats(
518
518
  `Orphaned sidecar ${ss.sidecar_id} (path=${ss.expected_path ?? "—"}): ${ss.reason}`,
519
519
  );
520
520
  }
521
+ for (const sp of stats.skipped_schema_parents) {
522
+ warnings.push(
523
+ `Dropped parent_names on tag "${sp.tag}": ${sp.reason}`,
524
+ );
525
+ }
521
526
  const result: ImportResult = {
522
527
  notes_imported: stats.notes_created + stats.notes_updated,
523
528
  tags_imported: stats.schemas_restored,
@@ -166,8 +166,11 @@ describe("seedOnboardingNotes — default packs (welcome + getting-started)", ()
166
166
  expect(gs!.content).toContain("## Write gotchas");
167
167
  expect(gs!.content).toContain("if_updated_at"); // optimistic concurrency
168
168
  expect(gs!.content).toContain("force: true");
169
- // schema-default back-fill: enum→first value, integer→0.
170
- expect(gs!.content).toContain("first listed value");
169
+ // schema-default back-fill is explicit-`default`-only (vault#553 Decision B).
170
+ expect(gs!.content).toContain("explicit `default`");
171
+ expect(gs!.content).toContain("exists: false");
172
+ // indexed⇒strict type enforcement (vault#553 Decision A).
173
+ expect(gs!.content).toContain("field's TYPE is always enforced");
171
174
  });
172
175
 
173
176
  test("A3 (reshaped): the welcome ring resolves to real link edges; Getting Started has no dangling Surface Starter link", async () => {
package/src/routes.ts CHANGED
@@ -24,7 +24,7 @@ import {
24
24
  contentRangeRequiresContent,
25
25
  type ContentRange,
26
26
  } from "../core/src/content-range.ts";
27
- import { attachValidationStatus, enforceStrictWrite } from "../core/src/mcp.ts";
27
+ import { attachValidationStatus, enforceStrictWrite, applySchemaDefaults } from "../core/src/mcp.ts";
28
28
  import type { ValidationWarning } from "../core/src/schema-defaults.ts";
29
29
  import { logStrictBypass } from "./scopes.ts";
30
30
  import * as linkOps from "../core/src/links.ts";
@@ -42,10 +42,13 @@ import {
42
42
  filterNotesByTagScope,
43
43
  noteWithinTagScope,
44
44
  scrubIndexedFieldConflictError,
45
+ scrubParentCycleError,
46
+ scrubReferencingTagsByScope,
45
47
  scrubTagFieldViolationsByScope,
46
48
  tagScopeForbidden,
47
49
  tagsWithinScope,
48
50
  } from "./tag-scope.ts";
51
+ import { ParentCycleError, InvalidFieldDefaultError } from "../core/src/tag-schemas.ts";
49
52
  import { findTokensReferencingTag } from "./token-store.ts";
50
53
 
51
54
  /**
@@ -2665,6 +2668,41 @@ export async function handleTags(
2665
2668
  400,
2666
2669
  );
2667
2670
  }
2671
+ if (err instanceof InvalidFieldDefaultError) {
2672
+ // vault#553 Decision B — a declared `default` doesn't conform to its
2673
+ // own field's type/enum. Own-field error (no cross-tag data), so no
2674
+ // scrub needed. Mirrors IndexedFieldError's 400 shape/posture — see
2675
+ // store.upsertTagRecord's pre-validate comment for why REST hits
2676
+ // this (fail-fast, single violation) while MCP's update-tag usually
2677
+ // reports it bundled via `TagFieldConflictError` instead.
2678
+ return json(
2679
+ { error: err.message, error_type: "invalid_field_default", field: err.field },
2680
+ 400,
2681
+ );
2682
+ }
2683
+ if (err instanceof ParentCycleError) {
2684
+ // vault#552: parent_names would close a cycle. Nothing was
2685
+ // persisted. Scope-scrub the cycle path for a tag-scoped caller —
2686
+ // the write stays rejected either way. `error` is a SHORT code and
2687
+ // `message` the human sentence — the same split every sibling 409 in
2688
+ // this file uses (target_exists, tag_in_use_by_tokens,
2689
+ // tag_referenced_as_parent). parachute-surface's shared VaultClient
2690
+ // 409 handler reads `body.message` (falling back to a hardcoded
2691
+ // "Note was edited elsewhere"), so the message key is load-bearing:
2692
+ // without it the Tags parent_names editor would show the wrong
2693
+ // conflict text.
2694
+ const scrubbed = scrubParentCycleError(err, tagScope.allowed);
2695
+ return json(
2696
+ {
2697
+ error: "ParentCycle",
2698
+ error_type: "parent_cycle",
2699
+ tag: scrubbed.tag,
2700
+ cycle: scrubbed.cycle,
2701
+ message: scrubbed.message,
2702
+ },
2703
+ 409,
2704
+ );
2705
+ }
2668
2706
  throw err;
2669
2707
  }
2670
2708
  return json(result);
@@ -2692,7 +2730,33 @@ export async function handleTags(
2692
2730
  409,
2693
2731
  );
2694
2732
  }
2695
- return json(await store.deleteTag(tagName));
2733
+ // `?cascade=true` / `?detach=true` (synonyms — see DeleteTagOpts):
2734
+ // required to delete a tag another tag's parent_names still references
2735
+ // (vault#552). Query params, not a DELETE body, so the flag is visible
2736
+ // in access logs and works with any HTTP client without body support.
2737
+ const cascade = parseBool(parseQuery(url, "cascade"), false);
2738
+ const detach = parseBool(parseQuery(url, "detach"), false);
2739
+ const result = await store.deleteTag(tagName, { cascade, detach });
2740
+ if ("error" in result) {
2741
+ // Referential-integrity refusal (vault#552). Scope-scrub the
2742
+ // referencing tag names for a tag-scoped caller — the delete is
2743
+ // still refused (integrity is scope-independent), but an
2744
+ // out-of-scope referrer's name must not leak. Same posture as
2745
+ // `tag_field_conflict`'s scrub.
2746
+ const referencing_tags = scrubReferencingTagsByScope(result.referencing_tags, tagScope.allowed);
2747
+ return json(
2748
+ {
2749
+ error: "TagReferencedAsParent",
2750
+ error_type: "tag_referenced_as_parent",
2751
+ tag: tagName,
2752
+ referencing_tags,
2753
+ message: `Tag "${tagName}" is referenced by ${referencing_tags.length} other tag(s)' parent_names; pass ?cascade=true or ?detach=true to also remove the reference, or update the referencing tag(s) first.`,
2754
+ hint: "pass ?cascade=true or ?detach=true, or fix the referencing tag(s)' parent_names first",
2755
+ },
2756
+ 409,
2757
+ );
2758
+ }
2759
+ return json(result);
2696
2760
  }
2697
2761
 
2698
2762
  return json({ error: "Method not allowed", error_type: "method_not_allowed" }, 405);
@@ -2924,6 +2988,27 @@ export function handleUnresolvedWikilinks(
2924
2988
  return Response.json({ unresolved: filtered, count: filtered.length });
2925
2989
  }
2926
2990
 
2991
+ // ---------------------------------------------------------------------------
2992
+ // Doctor — GET /api/doctor (vault#552, admin-gated in routing.ts)
2993
+ // ---------------------------------------------------------------------------
2994
+
2995
+ /**
2996
+ * `GET /vault/{name}/api/doctor` — read-only taxonomy/metadata integrity
2997
+ * scan. Method + `vault:admin` scope are already enforced by the caller
2998
+ * (`src/routing.ts`, dispatched before the generic read/write gate — same
2999
+ * shape as `/api/triggers`); this handler just runs the scan with the
3000
+ * caller's tag-scope allowlist. See `core/src/doctor.ts` for the finding
3001
+ * catalog.
3002
+ */
3003
+ export async function handleDoctor(
3004
+ _req: Request,
3005
+ store: Store,
3006
+ tagScope: TagScopeCtx = NO_TAG_SCOPE,
3007
+ ): Promise<Response> {
3008
+ const report = await store.doctor({ allowedTags: tagScope.allowed });
3009
+ return json(report);
3010
+ }
3011
+
2927
3012
  // ---------------------------------------------------------------------------
2928
3013
  // Published notes — public, no-auth HTML rendering
2929
3014
  // ---------------------------------------------------------------------------
@@ -3662,56 +3747,10 @@ export async function handleStorage(
3662
3747
  return json({ error: "Not found", error_type: "not_found" }, 404);
3663
3748
  }
3664
3749
 
3665
- // ---------------------------------------------------------------------------
3666
- // Tag schema defaults same logic as core/src/mcp.ts applySchemaDefaults
3667
- // ---------------------------------------------------------------------------
3668
-
3669
- // Returns the IDs of notes whose metadata was actually default-filled, so
3670
- // the caller can re-read ONLY the mutated notes (and skip the re-read when
3671
- // nothing changed). Mirrors the core/src/mcp.ts contract.
3672
- async function applySchemaDefaults(store: Store, db: any, noteIds: string[], tags: string[]): Promise<string[]> {
3673
- const schemas = tagSchemaOps.getTagSchemaMap(db);
3674
- if (Object.keys(schemas).length === 0) return [];
3675
-
3676
- const defaults: Record<string, unknown> = {};
3677
- for (const tag of tags) {
3678
- const schema = schemas[tag];
3679
- if (!schema?.fields) continue;
3680
- for (const [field, fieldSchema] of Object.entries(schema.fields)) {
3681
- if (!(field in defaults)) {
3682
- defaults[field] = defaultForField(fieldSchema);
3683
- }
3684
- }
3685
- }
3686
- if (Object.keys(defaults).length === 0) return [];
3687
-
3688
- const mutated: string[] = [];
3689
- for (const noteId of noteIds) {
3690
- const note = await store.getNote(noteId);
3691
- if (!note) continue;
3692
- const existing = (note.metadata as Record<string, unknown>) ?? {};
3693
- const missing: Record<string, unknown> = {};
3694
- for (const [field, value] of Object.entries(defaults)) {
3695
- if (!(field in existing)) missing[field] = value;
3696
- }
3697
- if (Object.keys(missing).length === 0) continue;
3698
- await store.updateNote(noteId, {
3699
- metadata: { ...existing, ...missing },
3700
- skipUpdatedAt: true,
3701
- });
3702
- mutated.push(noteId);
3703
- }
3704
- return mutated;
3705
- }
3706
-
3707
- function defaultForField(field: { type: string; enum?: string[] }): unknown {
3708
- if (field.enum && field.enum.length > 0) return field.enum[0];
3709
- switch (field.type) {
3710
- case "boolean": return false;
3711
- case "integer": return 0;
3712
- default: return "";
3713
- }
3714
- }
3750
+ // applySchemaDefaults lives in core/src/mcp.ts (vault#553) — REST used to
3751
+ // carry a byte-identical duplicate; importing it means REST and MCP can
3752
+ // never drift on this behavior again, and the cloud runtime (which imports
3753
+ // core directly) inherits it without any handler-side code.
3715
3754
 
3716
3755
  function removeWikilinkBrackets(content: string, targetPath: string): string {
3717
3756
  const escaped = targetPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");