@openparachute/vault 0.6.4-rc.10 → 0.6.4-rc.12

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,112 @@
1
+ /**
2
+ * Conformance check (vault#283) — counting existing notes that would violate
3
+ * a proposed tightening of a tag's field spec. Backs the Schema editor's
4
+ * "N existing notes violate this" warning.
5
+ */
6
+ import { describe, it, expect, beforeEach } from "bun:test";
7
+ import { Database } from "bun:sqlite";
8
+ import { SqliteStore } from "./store.js";
9
+ import { countConformanceViolations } from "./conformance.js";
10
+
11
+ let db: Database;
12
+ let store: SqliteStore;
13
+
14
+ beforeEach(() => {
15
+ db = new Database(":memory:");
16
+ store = new SqliteStore(db);
17
+ });
18
+
19
+ describe("countConformanceViolations", () => {
20
+ it("returns zero violations when proposed fields is empty", async () => {
21
+ await store.createNote("n", { tags: ["task"], metadata: { status: "open" } });
22
+ const r = countConformanceViolations(db, "task", {});
23
+ expect(r.total_notes).toBe(1);
24
+ expect(r.violating_notes).toBe(0);
25
+ expect(r.checked_fields).toEqual([]);
26
+ });
27
+
28
+ it("counts notes missing a newly-required field", async () => {
29
+ await store.createNote("has", { tags: ["task"], metadata: { due: "2026-01-01" } });
30
+ await store.createNote("missing-1", { tags: ["task"], metadata: {} });
31
+ await store.createNote("missing-2", { tags: ["task"] });
32
+
33
+ const r = countConformanceViolations(db, "task", {
34
+ due: { type: "string", required: true },
35
+ });
36
+ expect(r.total_notes).toBe(3);
37
+ expect(r.violating_notes).toBe(2);
38
+ expect(r.checked_fields).toEqual(["due"]);
39
+ // missing_required reasons surface in the sample.
40
+ expect(r.sample.every((s) => s.fields.some((f) => f.reason === "missing_required"))).toBe(true);
41
+ });
42
+
43
+ it("counts notes whose value falls outside a narrowed enum", async () => {
44
+ await store.createNote("ok", { tags: ["task"], metadata: { status: "open" } });
45
+ await store.createNote("bad", { tags: ["task"], metadata: { status: "archived" } });
46
+
47
+ const r = countConformanceViolations(db, "task", {
48
+ status: { type: "string", enum: ["open", "done"] },
49
+ });
50
+ expect(r.violating_notes).toBe(1);
51
+ expect(r.sample[0]?.fields[0]?.reason).toBe("enum_mismatch");
52
+ });
53
+
54
+ it("counts notes whose value contradicts a changed type", async () => {
55
+ await store.createNote("num", { tags: ["task"], metadata: { count: 3 } });
56
+ await store.createNote("str", { tags: ["task"], metadata: { count: "three" } });
57
+
58
+ const r = countConformanceViolations(db, "task", {
59
+ count: { type: "integer" },
60
+ });
61
+ expect(r.violating_notes).toBe(1);
62
+ expect(r.sample[0]?.fields[0]?.reason).toBe("type_mismatch");
63
+ });
64
+
65
+ it("includes descendant-tag notes via inheritance", async () => {
66
+ // dev/log declares dev as a parent → a strict field on dev applies to
67
+ // dev/log notes too.
68
+ await store.upsertTagRecord("dev/log", { parent_names: ["dev"] });
69
+ await store.createNote("parent-note", { tags: ["dev"], metadata: {} });
70
+ await store.createNote("child-note", { tags: ["dev/log"], metadata: {} });
71
+
72
+ const r = countConformanceViolations(db, "dev", {
73
+ kind: { type: "string", required: true },
74
+ });
75
+ expect(r.total_notes).toBe(2);
76
+ expect(r.violating_notes).toBe(2);
77
+ });
78
+
79
+ it("ignores fields the proposal does not touch", async () => {
80
+ await store.createNote("n", { tags: ["task"], metadata: { other: 123 } });
81
+ // We only propose `status`; `other` being weird is irrelevant.
82
+ const r = countConformanceViolations(db, "task", {
83
+ status: { type: "string" },
84
+ });
85
+ // `status` absent + not required → no violation.
86
+ expect(r.violating_notes).toBe(0);
87
+ });
88
+
89
+ it("caps the sample at sampleLimit but counts all violations", async () => {
90
+ for (let i = 0; i < 5; i++) {
91
+ await store.createNote(`n${i}`, { tags: ["task"], metadata: {} });
92
+ }
93
+ const r = countConformanceViolations(
94
+ db,
95
+ "task",
96
+ { req: { type: "string", required: true } },
97
+ { sampleLimit: 2 },
98
+ );
99
+ expect(r.violating_notes).toBe(5);
100
+ expect(r.sample.length).toBe(2);
101
+ });
102
+ });
103
+
104
+ describe("store.countTagConformance", () => {
105
+ it("proxies to the core helper", async () => {
106
+ await store.createNote("n", { tags: ["task"], metadata: {} });
107
+ const r = await store.countTagConformance("task", {
108
+ due: { type: "string", required: true },
109
+ });
110
+ expect(r.violating_notes).toBe(1);
111
+ });
112
+ });
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Schema conformance check — count how many EXISTING notes would violate a
3
+ * PROPOSED field spec for a tag, before the operator commits a tightening
4
+ * (vault#283, the MW2/#314 tie-in).
5
+ *
6
+ * Motivation: marking a field `strict`/`required`, narrowing an `enum`, or
7
+ * changing a `type` is a backwards-incompatible move — notes written under
8
+ * the old contract may now reject on their next write
9
+ * (`enforceStrictSchema` → `SchemaValidationError`). The Schema editor in
10
+ * the admin SPA calls this BEFORE save so the operator sees "N existing
11
+ * notes violate this — they'll reject on next write" and is pointed at the
12
+ * `parachute-vault schema migrate-field` CLI (#314) rather than silently
13
+ * shipping a contract their own data breaks.
14
+ *
15
+ * What "violate" means here: we overlay the PROPOSED `fields` for `tag` on
16
+ * top of the live schema config, treating every proposed field as if it
17
+ * were `strict:true` for the purpose of the count (so the count is "notes
18
+ * that would hard-reject IF this field were strict" — the worst case the
19
+ * operator is being warned about). We then walk every note carrying `tag`
20
+ * (descendants included, per the hierarchy) and run the SAME `validateNote`
21
+ * resolver the write path uses, counting notes with ≥1 violation on a field
22
+ * the proposal touches.
23
+ *
24
+ * This is a READ — no mutation. It's deliberately scoped to the proposed
25
+ * tag's own fields (the columns the operator is editing); inherited fields
26
+ * from ancestors are already enforced and aren't what the operator is
27
+ * tightening in this edit.
28
+ */
29
+
30
+ import { Database } from "bun:sqlite";
31
+ import {
32
+ loadSchemaConfig,
33
+ validateNote,
34
+ type SchemaField,
35
+ type ResolvedSchemas,
36
+ } from "./schema-defaults.ts";
37
+ import type { TagFieldSchema } from "./tag-schemas.ts";
38
+ import { getTagDescendants, loadTagHierarchy } from "./tag-hierarchy.ts";
39
+ import { queryNotes, getNoteTagsForNotes } from "./notes.ts";
40
+
41
+ export interface ConformanceViolation {
42
+ /** Note id of a non-conforming note. */
43
+ id: string;
44
+ /** Note path, when set (helps the operator find it). */
45
+ path?: string | null;
46
+ /** The proposed-field violations on this note (reasons + messages). */
47
+ fields: { field: string; reason: string; message: string }[];
48
+ }
49
+
50
+ export interface ConformanceReport {
51
+ tag: string;
52
+ /** Total notes carrying the tag (descendants included). */
53
+ total_notes: number;
54
+ /** Count of notes that would violate the proposed spec. */
55
+ violating_notes: number;
56
+ /** The field names the proposal touches (the ones checked). */
57
+ checked_fields: string[];
58
+ /**
59
+ * A bounded sample of violating notes (id + path + which fields), so the
60
+ * SPA can show examples without paging the whole vault. Capped at
61
+ * `sampleLimit` (default 20).
62
+ */
63
+ sample: ConformanceViolation[];
64
+ }
65
+
66
+ /**
67
+ * Convert the on-disk `TagFieldSchema` shape to the resolver's `SchemaField`
68
+ * shape, FORCING `strict:true` so `validateNote` flags every constraint as a
69
+ * (strict) violation we can count. `indexed` is dropped — it's not a
70
+ * note-data constraint. `type` is narrowed to the resolver's union; an
71
+ * unknown type string is dropped (no type check) rather than throwing.
72
+ */
73
+ function toStrictSchemaField(spec: TagFieldSchema): SchemaField {
74
+ const out: SchemaField = { strict: true };
75
+ if (
76
+ spec.type === "string" ||
77
+ spec.type === "number" ||
78
+ spec.type === "integer" ||
79
+ spec.type === "boolean" ||
80
+ spec.type === "array" ||
81
+ spec.type === "object"
82
+ ) {
83
+ out.type = spec.type;
84
+ }
85
+ if (Array.isArray(spec.enum)) out.enum = spec.enum;
86
+ if (spec.required === true) out.required = true;
87
+ if (spec.cardinality === "one" || spec.cardinality === "many") {
88
+ out.cardinality = spec.cardinality;
89
+ }
90
+ if (typeof spec.description === "string") out.description = spec.description;
91
+ return out;
92
+ }
93
+
94
+ /**
95
+ * Build a `ResolvedSchemas` snapshot with the proposed fields overlaid on
96
+ * `tag`'s OWN field map. We start from the live config (so inheritance +
97
+ * other tags are intact) and replace just `tag`'s field declarations with
98
+ * the strict-forced proposed set.
99
+ */
100
+ function overlayProposedSchema(
101
+ base: ResolvedSchemas,
102
+ tag: string,
103
+ proposedFields: Record<string, TagFieldSchema>,
104
+ ): ResolvedSchemas {
105
+ const tagToFields = new Map(base.tagToFields);
106
+ const forced: Record<string, SchemaField> = {};
107
+ for (const [name, spec] of Object.entries(proposedFields)) {
108
+ forced[name] = toStrictSchemaField(spec);
109
+ }
110
+ if (Object.keys(forced).length > 0) {
111
+ tagToFields.set(tag, forced);
112
+ } else {
113
+ tagToFields.delete(tag);
114
+ }
115
+ return {
116
+ allTags: new Set(base.allTags).add(tag),
117
+ tagToFields,
118
+ tagToParents: base.tagToParents,
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Count existing notes that would violate the PROPOSED field spec for `tag`.
124
+ *
125
+ * `proposedFields` is the full merged field map the operator intends to save
126
+ * (the same object the PUT body carries). Only the fields in this map are
127
+ * checked — the violation count answers "if I tighten THESE fields, how many
128
+ * notes break?" The check runs against every note carrying `tag` or any of
129
+ * its descendants.
130
+ *
131
+ * Pure read; no mutation. Returns 0 violating notes when `proposedFields` is
132
+ * empty (nothing to enforce).
133
+ */
134
+ export function countConformanceViolations(
135
+ db: Database,
136
+ tag: string,
137
+ proposedFields: Record<string, TagFieldSchema>,
138
+ opts: { sampleLimit?: number } = {},
139
+ ): ConformanceReport {
140
+ const sampleLimit = opts.sampleLimit ?? 20;
141
+ const checkedFields = Object.keys(proposedFields);
142
+
143
+ const empty: ConformanceReport = {
144
+ tag,
145
+ total_notes: 0,
146
+ violating_notes: 0,
147
+ checked_fields: checkedFields,
148
+ sample: [],
149
+ };
150
+
151
+ // Resolve the tag's descendant set so we count notes on subtype tags too
152
+ // (a strict field on `#dev` applies to `#dev/log` notes via inheritance).
153
+ const hierarchy = loadTagHierarchy(db);
154
+ const tagSet = Array.from(getTagDescendants(hierarchy, tag));
155
+ if (tagSet.length === 0) return empty;
156
+
157
+ // All notes carrying the tag (or a descendant). `tagMatch: "any"` over the
158
+ // expanded set — a note on ANY of these tags is in scope. We hydrate tags
159
+ // ourselves (batched) so the resolver sees the full ancestor set per note.
160
+ const notes = queryNotes(db, { tags: tagSet, tagMatch: "any" });
161
+ if (notes.length === 0) return empty;
162
+
163
+ if (checkedFields.length === 0) {
164
+ return { ...empty, total_notes: notes.length };
165
+ }
166
+
167
+ const base = loadSchemaConfig(db);
168
+ const resolved = overlayProposedSchema(base, tag, proposedFields);
169
+ const checkedSet = new Set(checkedFields);
170
+
171
+ const tagsById = getNoteTagsForNotes(
172
+ db,
173
+ notes.map((n) => n.id),
174
+ );
175
+
176
+ let violating = 0;
177
+ const sample: ConformanceViolation[] = [];
178
+
179
+ for (const note of notes) {
180
+ const tags = tagsById.get(note.id) ?? note.tags ?? [];
181
+ const status = validateNote(resolved, {
182
+ path: note.path ?? null,
183
+ tags,
184
+ metadata: note.metadata ?? {},
185
+ });
186
+ if (!status) continue;
187
+ // Keep only violations on a field the PROPOSAL touches (ignore conflicts
188
+ // + violations on inherited/other fields the operator isn't editing).
189
+ const relevant = status.warnings.filter(
190
+ (w) => w.reason !== "schema_conflict" && checkedSet.has(w.field),
191
+ );
192
+ if (relevant.length === 0) continue;
193
+ violating++;
194
+ if (sample.length < sampleLimit) {
195
+ sample.push({
196
+ id: note.id,
197
+ path: note.path ?? null,
198
+ fields: relevant.map((w) => ({
199
+ field: w.field,
200
+ reason: w.reason,
201
+ message: w.message,
202
+ })),
203
+ });
204
+ }
205
+ }
206
+
207
+ return {
208
+ tag,
209
+ total_notes: notes.length,
210
+ violating_notes: violating,
211
+ checked_fields: checkedFields,
212
+ sample,
213
+ };
214
+ }
package/core/src/store.ts CHANGED
@@ -28,6 +28,10 @@ import {
28
28
  type ResolvedSchemas,
29
29
  type ValidationStatus,
30
30
  } from "./schema-defaults.js";
31
+ import {
32
+ countConformanceViolations,
33
+ type ConformanceReport,
34
+ } from "./conformance.js";
31
35
 
32
36
  /**
33
37
  * bun:sqlite-backed Store implementation. Internally everything is
@@ -716,6 +720,21 @@ export class BunSqliteStore implements Store {
716
720
  return result;
717
721
  }
718
722
 
723
+ /**
724
+ * Conformance check (vault#283) — count how many EXISTING notes carrying
725
+ * `tag` (descendants included) would violate the PROPOSED field spec, so a
726
+ * tightening edit (strict / required / narrowed enum / changed type) can
727
+ * warn the operator BEFORE save. Pure read — no mutation. See
728
+ * core/src/conformance.ts.
729
+ */
730
+ async countTagConformance(
731
+ tag: string,
732
+ proposedFields: Record<string, tagSchemaOps.TagFieldSchema>,
733
+ opts?: { sampleLimit?: number },
734
+ ): Promise<ConformanceReport> {
735
+ return countConformanceViolations(this.db, tag, proposedFields, opts);
736
+ }
737
+
719
738
  // ---- Batch Wikilink Sync ----
720
739
 
721
740
  /**
package/core/src/types.ts CHANGED
@@ -3,12 +3,14 @@ import type { TagFieldSchema, TagRelationship, TagRelationshipMap, TagRecord } f
3
3
  import type { PrunedField } from "./indexed-fields.js";
4
4
  import type { TagExpandMode } from "./tag-hierarchy.js";
5
5
  import type { ValidationStatus } from "./schema-defaults.js";
6
+ import type { ConformanceReport } from "./conformance.js";
6
7
 
7
8
  // ---- Re-exports ----
8
9
 
9
10
  export type { TagFieldSchema, TagRelationship, TagRelationshipMap, TagRecord } from "./tag-schemas.js";
10
11
  export type { PrunedField } from "./indexed-fields.js";
11
12
  export type { TagExpandMode } from "./tag-hierarchy.js";
13
+ export type { ConformanceReport } from "./conformance.js";
12
14
 
13
15
  // ---- Note ----
14
16
 
@@ -413,6 +415,19 @@ export interface Store {
413
415
  },
414
416
  ): Promise<TagRecord>;
415
417
 
418
+ /**
419
+ * Conformance check (vault#283) — count existing notes carrying `tag`
420
+ * (descendants included) that would violate the PROPOSED field spec, so a
421
+ * tightening edit (strict / required / narrowed enum / changed type) can
422
+ * warn before save. Pure read. `proposedFields` is the full merged field
423
+ * map the operator intends to save; only those fields are checked.
424
+ */
425
+ countTagConformance(
426
+ tag: string,
427
+ proposedFields: Record<string, TagFieldSchema>,
428
+ opts?: { sampleLimit?: number },
429
+ ): Promise<ConformanceReport>;
430
+
416
431
  // Schema validation (post-v17: backed by `tags.fields` only — the
417
432
  // standalone note_schemas + schema_mappings subsystem retired in v17, see
418
433
  // vault#267). Post vault#270 the resolver walks `parent_names` so a note's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.6.4-rc.10",
3
+ "version": "0.6.4-rc.12",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
package/src/routes.ts CHANGED
@@ -27,6 +27,8 @@ import { logStrictBypass } from "./scopes.ts";
27
27
  import * as linkOps from "../core/src/links.ts";
28
28
  import * as tagSchemaOps from "../core/src/tag-schemas.ts";
29
29
  import { IndexedFieldError } from "../core/src/indexed-fields.ts";
30
+ import { buildVaultProjection, resolveTagInheritance } from "../core/src/vault-projection.ts";
31
+ import { loadSchemaConfig } from "../core/src/schema-defaults.ts";
30
32
  import {
31
33
  buildExpandVisibility,
32
34
  filterHydratedLinksByTagScope,
@@ -1997,6 +1999,64 @@ export async function handleTags(
1997
1999
  return json(result);
1998
2000
  }
1999
2001
 
2002
+ // POST /tags/:name/conformance — count existing notes that would VIOLATE a
2003
+ // proposed field spec for the tag (vault#283 tightening warning). Read-only
2004
+ // (POST because it carries a proposed `fields` body). Must precede the
2005
+ // /:name matcher so "conformance" isn't read as a tag name.
2006
+ const conformanceMatch = subpath.match(/^\/([^/]+)\/conformance$/);
2007
+ if (conformanceMatch) {
2008
+ if (req.method !== "POST") return json({ error: "Method not allowed" }, 405);
2009
+ const cTag = decodeURIComponent(conformanceMatch[1]!);
2010
+ if (tagScope.allowed && !tagScope.allowed.has(cTag)) {
2011
+ return json({ error: "Tag not found", tag: cTag }, 404);
2012
+ }
2013
+ const body = (await req.json().catch(() => null)) as
2014
+ | { fields?: Record<string, unknown> | null }
2015
+ | null;
2016
+ if (!body) return json({ error: "Invalid JSON body" }, 400);
2017
+ // The proposed fields the operator intends to save. Sanitized through the
2018
+ // same parse the resolver uses (drop non-object specs). Empty/absent →
2019
+ // nothing to enforce → zero violations.
2020
+ const proposed: Record<string, tagSchemaOps.TagFieldSchema> = {};
2021
+ if (body.fields && typeof body.fields === "object" && !Array.isArray(body.fields)) {
2022
+ for (const [k, v] of Object.entries(body.fields)) {
2023
+ if (v && typeof v === "object" && !Array.isArray(v)) {
2024
+ proposed[k] = v as tagSchemaOps.TagFieldSchema;
2025
+ }
2026
+ }
2027
+ }
2028
+ const report = await store.countTagConformance(cTag, proposed);
2029
+ return json(report);
2030
+ }
2031
+
2032
+ // GET /tags/:name/effective — the tag's effective (own ∪ inherited) fields +
2033
+ // direct/effective parents + schema-conflict info, drawn from the same
2034
+ // projection vault-info exposes. Read-only inheritance preview for the
2035
+ // Schema editor (vault#283). Must precede the /:name matcher.
2036
+ const effectiveMatch = subpath.match(/^\/([^/]+)\/effective$/);
2037
+ if (effectiveMatch) {
2038
+ if (req.method !== "GET") return json({ error: "Method not allowed" }, 405);
2039
+ const eTag = decodeURIComponent(effectiveMatch[1]!);
2040
+ if (tagScope.allowed && !tagScope.allowed.has(eTag)) {
2041
+ return json({ error: "Tag not found", tag: eTag }, 404);
2042
+ }
2043
+ const projection = buildVaultProjection(store.db);
2044
+ const record = await store.getTagRecord(eTag);
2045
+ // Resolve inheritance directly (not via projection.tags, which omits
2046
+ // hierarchy-only tags carrying no own schema) so the editor's preview
2047
+ // works even for a tag the operator is just starting to give fields.
2048
+ const resolved = loadSchemaConfig(store.db);
2049
+ const { effective_parents, effective_fields } = resolveTagInheritance(resolved, eTag);
2050
+ return json({
2051
+ name: eTag,
2052
+ parents: record?.parent_names ?? [],
2053
+ effective_parents,
2054
+ fields: record?.fields ?? null,
2055
+ effective_fields,
2056
+ indexed_fields: projection.indexed_fields,
2057
+ });
2058
+ }
2059
+
2000
2060
  // Routes with tag name
2001
2061
  const nameMatch = subpath.match(/^\/([^/]+)$/);
2002
2062
  if (!nameMatch) return json({ error: "Not found" }, 404);
@@ -2034,7 +2094,18 @@ export async function handleTags(
2034
2094
  fields?: Record<string, unknown> | null;
2035
2095
  relationships?: Record<string, unknown> | null;
2036
2096
  parent_names?: unknown;
2097
+ /**
2098
+ * When true, `fields` is treated as the FULL intended field map for the
2099
+ * tag — fields absent from the payload are DROPPED (a replace, not a
2100
+ * merge). Default false preserves the historical partial-update merge
2101
+ * the MCP `update-tag` tool relies on (omitted keys preserved). The
2102
+ * Schema editor (vault#283) sends the full map + `replace_fields: true`
2103
+ * so removing a field row actually deletes the field. See
2104
+ * patterns/tag-data-model.md.
2105
+ */
2106
+ replace_fields?: unknown;
2037
2107
  };
2108
+ const replaceFields = body.replace_fields === true;
2038
2109
 
2039
2110
  // Validate the relationships payload up front so a bad payload returns
2040
2111
  // 400, not a thrown 500. `relationships` is an opaque vocabulary map
@@ -2071,7 +2142,10 @@ export async function handleTags(
2071
2142
  }
2072
2143
 
2073
2144
  // Field merge mirrors MCP update-tag — preserves prior keys when the
2074
- // payload only declares new ones.
2145
+ // payload only declares new ones. UNLESS `replace_fields: true`, in which
2146
+ // case `fields` is the full intended map and absent keys are dropped (the
2147
+ // Schema editor's full-replacement save — vault#283; without this a
2148
+ // removed field row is silently resurrected by the merge).
2075
2149
  let fieldsPatch:
2076
2150
  | Record<string, tagSchemaOps.TagFieldSchema>
2077
2151
  | null
@@ -2079,12 +2153,17 @@ export async function handleTags(
2079
2153
  if (body.fields === null) {
2080
2154
  fieldsPatch = null;
2081
2155
  } else if (body.fields !== undefined) {
2082
- const existing = await store.getTagSchema(tagName);
2083
- const merged: Record<string, tagSchemaOps.TagFieldSchema> = {
2084
- ...(existing?.fields ?? {}),
2085
- ...(body.fields as Record<string, tagSchemaOps.TagFieldSchema>),
2086
- };
2087
- fieldsPatch = Object.keys(merged).length > 0 ? merged : null;
2156
+ if (replaceFields) {
2157
+ const full = body.fields as Record<string, tagSchemaOps.TagFieldSchema>;
2158
+ fieldsPatch = Object.keys(full).length > 0 ? full : null;
2159
+ } else {
2160
+ const existing = await store.getTagSchema(tagName);
2161
+ const merged: Record<string, tagSchemaOps.TagFieldSchema> = {
2162
+ ...(existing?.fields ?? {}),
2163
+ ...(body.fields as Record<string, tagSchemaOps.TagFieldSchema>),
2164
+ };
2165
+ fieldsPatch = Object.keys(merged).length > 0 ? merged : null;
2166
+ }
2088
2167
  }
2089
2168
 
2090
2169
  // A bad indexed-field name (or an unindexable type, or a cross-tag type
@@ -1956,6 +1956,131 @@ describe("scope enforcement on /api/*", () => {
1956
1956
  expect(res.status).toBe(200);
1957
1957
  });
1958
1958
 
1959
+ // ----- vault#283: Schema editor read endpoints -------------------------
1960
+
1961
+ test("POST /api/tags/:name/conformance → counts notes violating a proposed spec", async () => {
1962
+ createVault("journal");
1963
+ const store = getVaultStore("journal");
1964
+ await store.createNote("ok", { tags: ["task"], metadata: { status: "open" } });
1965
+ await store.createNote("missing", { tags: ["task"], metadata: {} });
1966
+ const admin = await createAdminToken("journal");
1967
+
1968
+ const path = "/vault/journal/api/tags/task/conformance";
1969
+ const res = await route(
1970
+ new Request(`http://localhost:1940${path}`, {
1971
+ method: "POST",
1972
+ headers: { authorization: `Bearer ${admin}`, "content-type": "application/json" },
1973
+ body: JSON.stringify({ fields: { status: { type: "string", required: true } } }),
1974
+ }),
1975
+ path,
1976
+ );
1977
+ expect(res.status).toBe(200);
1978
+ const body = (await res.json()) as {
1979
+ tag: string;
1980
+ total_notes: number;
1981
+ violating_notes: number;
1982
+ checked_fields: string[];
1983
+ };
1984
+ expect(body.tag).toBe("task");
1985
+ expect(body.total_notes).toBe(2);
1986
+ expect(body.violating_notes).toBe(1);
1987
+ expect(body.checked_fields).toEqual(["status"]);
1988
+ });
1989
+
1990
+ test("POST /api/tags/:name/conformance is read-only — a read token may run it", async () => {
1991
+ createVault("journal");
1992
+ const store = getVaultStore("journal");
1993
+ await store.createNote("missing", { tags: ["task"], metadata: {} });
1994
+ const readToken = await mintToken("journal", {
1995
+ permission: "read",
1996
+ scopes: ["vault:read"],
1997
+ });
1998
+
1999
+ const path = "/vault/journal/api/tags/task/conformance";
2000
+ const res = await route(
2001
+ new Request(`http://localhost:1940${path}`, {
2002
+ method: "POST",
2003
+ headers: { authorization: `Bearer ${readToken}`, "content-type": "application/json" },
2004
+ body: JSON.stringify({ fields: { status: { type: "string", required: true } } }),
2005
+ }),
2006
+ path,
2007
+ );
2008
+ expect(res.status).toBe(200);
2009
+ const body = (await res.json()) as { violating_notes: number };
2010
+ expect(body.violating_notes).toBe(1);
2011
+ });
2012
+
2013
+ test("PUT /api/tags/:name with replace_fields drops omitted fields (no merge resurrection)", async () => {
2014
+ createVault("journal");
2015
+ const store = getVaultStore("journal");
2016
+ await store.upsertTagRecord("task", {
2017
+ fields: { status: { type: "string" }, due: { type: "string" } },
2018
+ });
2019
+ const admin = await createAdminToken("journal");
2020
+
2021
+ const path = "/vault/journal/api/tags/task";
2022
+ // Send only `status` WITH replace_fields → `due` must be dropped.
2023
+ const res = await route(
2024
+ new Request(`http://localhost:1940${path}`, {
2025
+ method: "PUT",
2026
+ headers: { authorization: `Bearer ${admin}`, "content-type": "application/json" },
2027
+ body: JSON.stringify({ fields: { status: { type: "string" } }, replace_fields: true }),
2028
+ }),
2029
+ path,
2030
+ );
2031
+ expect(res.status).toBe(200);
2032
+ const rec = await store.getTagRecord("task");
2033
+ expect(Object.keys(rec?.fields ?? {})).toEqual(["status"]);
2034
+ expect(rec?.fields?.due).toBeUndefined();
2035
+ });
2036
+
2037
+ test("PUT /api/tags/:name WITHOUT replace_fields preserves omitted fields (merge — MCP contract)", async () => {
2038
+ createVault("journal");
2039
+ const store = getVaultStore("journal");
2040
+ await store.upsertTagRecord("task", {
2041
+ fields: { status: { type: "string" }, due: { type: "string" } },
2042
+ });
2043
+ const admin = await createAdminToken("journal");
2044
+
2045
+ const path = "/vault/journal/api/tags/task";
2046
+ // Send only `status` WITHOUT the flag → `due` preserved (partial update).
2047
+ const res = await route(
2048
+ new Request(`http://localhost:1940${path}`, {
2049
+ method: "PUT",
2050
+ headers: { authorization: `Bearer ${admin}`, "content-type": "application/json" },
2051
+ body: JSON.stringify({ fields: { status: { type: "string", enum: ["a"] } } }),
2052
+ }),
2053
+ path,
2054
+ );
2055
+ expect(res.status).toBe(200);
2056
+ const rec = await store.getTagRecord("task");
2057
+ expect(Object.keys(rec?.fields ?? {}).sort()).toEqual(["due", "status"]);
2058
+ });
2059
+
2060
+ test("GET /api/tags/:name/effective → surfaces inherited fields + parents", async () => {
2061
+ createVault("journal");
2062
+ const store = getVaultStore("journal");
2063
+ // dev declares a field; dev/log inherits it via parent_names.
2064
+ await store.upsertTagRecord("dev", {
2065
+ fields: { area: { type: "string" } },
2066
+ });
2067
+ await store.upsertTagRecord("dev/log", { parent_names: ["dev"] });
2068
+ const admin = await createAdminToken("journal");
2069
+
2070
+ const path = "/vault/journal/api/tags/dev%2Flog/effective";
2071
+ const res = await route(authed(admin, "GET", path), path);
2072
+ expect(res.status).toBe(200);
2073
+ const body = (await res.json()) as {
2074
+ name: string;
2075
+ parents: string[];
2076
+ effective_parents: string[];
2077
+ effective_fields: Record<string, { type?: string }>;
2078
+ };
2079
+ expect(body.parents).toEqual(["dev"]);
2080
+ expect(body.effective_parents).toContain("dev");
2081
+ expect(body.effective_fields.area?.type).toBe("string");
2082
+ });
2083
+
1959
2084
  test("POST /api/tags/:name/rename → 200 cascades token allowlists (vault#240)", async () => {
1960
2085
  createVault("journal");
1961
2086
  const store = getVaultStore("journal");