@openparachute/vault 0.7.0-rc.7 → 0.7.0-rc.8

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.
package/src/tag-scope.ts CHANGED
@@ -83,6 +83,66 @@ export function filterNotesByTagScope<T extends Note>(
83
83
  return notes.filter((n) => noteWithinTagScope(n, allowed, rawRoots));
84
84
  }
85
85
 
86
+ /**
87
+ * Is a SINGLE tag name visible to this token? The per-tag core of
88
+ * `noteWithinTagScope` — exact allowlist membership OR string-form root
89
+ * match — pulled out so the `validation_status` scrub below (which reasons
90
+ * about a warning's `schema`/`loser_schema` tag NAMES, not a note's whole
91
+ * tag set) uses the identical visibility rule. `rawRoots === null` (unscoped)
92
+ * → always visible.
93
+ */
94
+ function tagVisibleInScope(
95
+ tag: string,
96
+ allowed: Set<string> | null,
97
+ rawRoots: string[] | null,
98
+ ): boolean {
99
+ if (rawRoots === null) return true;
100
+ if (allowed && allowed.has(tag)) return true;
101
+ const root = tag.split("/")[0];
102
+ return !!root && rawRoots.includes(root);
103
+ }
104
+
105
+ /**
106
+ * Scrub a note's `validation_status` so a tag-scoped caller can't learn the
107
+ * SCHEMA SHAPE (field name, type, enum values) of an OUT-OF-SCOPE tag that
108
+ * happens to co-tag a note it can otherwise see (vault#555 auth review;
109
+ * the #560 leak class). Repro: a scoped caller reading a note tagged both
110
+ * `mine` (in scope) and `project-manhattan` (out of scope) received
111
+ * `validation_status.warnings: [{ schema: "project-manhattan", message:
112
+ * "'codeword' must be one of [fizzbuzz] ...", ... }]` and
113
+ * `schemas: ["project-manhattan"]` — leaking that tag's field/enum.
114
+ *
115
+ * Core produces the FULL status (scope-unaware by architecture, same as
116
+ * every other core surface); this server-layer scrub — mirroring
117
+ * `scrubTagFieldViolationsByScope` — drops any warning whose declaring
118
+ * tag (`schema`, and for a `schema_conflict` the overridden `loser_schema`)
119
+ * is out of scope, and filters the `schemas` array to visible tags. When
120
+ * nothing in-scope remains (the note's only schema-declaring tag was
121
+ * out-of-scope), returns `undefined` so the caller omits `validation_status`
122
+ * entirely — byte-identical to a note with no applicable schema. Unscoped
123
+ * callers (`rawRoots === null`) get the status untouched.
124
+ *
125
+ * Applied at every point a scoped caller receives `validation_status`: the
126
+ * MCP `query-notes` wrapper and the REST `GET /notes[/{id}]` read paths.
127
+ */
128
+ export function scrubValidationStatusByScope<
129
+ S extends { schemas: string[]; warnings: Array<{ schema: string; loser_schema?: string }> },
130
+ >(
131
+ status: S | null | undefined,
132
+ allowed: Set<string> | null,
133
+ rawRoots: string[] | null,
134
+ ): S | undefined {
135
+ if (!status || rawRoots === null) return status ?? undefined;
136
+ const schemas = status.schemas.filter((s) => tagVisibleInScope(s, allowed, rawRoots));
137
+ const warnings = status.warnings.filter(
138
+ (w) =>
139
+ tagVisibleInScope(w.schema, allowed, rawRoots) &&
140
+ (w.loser_schema === undefined || tagVisibleInScope(w.loser_schema, allowed, rawRoots)),
141
+ );
142
+ if (schemas.length === 0 && warnings.length === 0) return undefined;
143
+ return { ...status, schemas, warnings };
144
+ }
145
+
86
146
  /**
87
147
  * For write paths: a note being created/updated must end up carrying at
88
148
  * least one tag inside the allowlist. `tags` is the post-write tag set
package/src/vault.test.ts CHANGED
@@ -1554,7 +1554,16 @@ describe("scoped MCP wrapper", async () => {
1554
1554
  closeAllStores();
1555
1555
  });
1556
1556
 
1557
- test("update-note tags.add auto-populate does not bump updatedAt", async () => {
1557
+ // vault#555 fix 2 REVISED this test's contract. Pre-fix-2 a tags-only
1558
+ // `update-note` (here, adding a tag that ALSO triggers a schema-default
1559
+ // backfill) did NOT bump `updated_at` — this test asserted that. Fix 2
1560
+ // makes ANY tag mutation bump `updated_at` (so cursor/sync loops see it);
1561
+ // the separate defaults backfill still rides `skipUpdatedAt: true` and
1562
+ // doesn't itself bump. Net: the tag add bumps updatedAt exactly once, and
1563
+ // the default still lands. (The old assertion passed only by
1564
+ // create+update landing in the same millisecond — a latent flake fix 2
1565
+ // turned into a real contradiction; caught on a review re-run.)
1566
+ test("update-note tags.add bumps updatedAt AND still applies the schema default (vault#555 fix 2)", async () => {
1558
1567
  const { generateScopedMcpTools } = await import("./mcp-tools.ts");
1559
1568
  const { writeVaultConfig } = await import("./config.ts");
1560
1569
  const { getVaultStore, closeAllStores: close } = await import("./vault-store.ts");
@@ -1568,8 +1577,7 @@ describe("scoped MCP wrapper", async () => {
1568
1577
 
1569
1578
  const vaultStore = getVaultStore(vaultName);
1570
1579
  // vault#553 Decision B: backfill is explicit-`default`-only — declare
1571
- // one so this test still exercises an actual defaults-write (and can
1572
- // assert it lands without bumping updatedAt).
1580
+ // one so this still exercises an actual defaults-write.
1573
1581
  await vaultStore.upsertTagSchema("person", {
1574
1582
  description: "A person",
1575
1583
  fields: { name: { type: "string", default: "unknown" } },
@@ -1582,9 +1590,13 @@ describe("scoped MCP wrapper", async () => {
1582
1590
 
1583
1591
  const note = await createNote.execute({ content: "Test" }) as any;
1584
1592
  const originalUpdatedAt = note.updatedAt;
1593
+ await new Promise((r) => setTimeout(r, 5)); // deterministic ms gap
1585
1594
  await updateNote.execute({ id: note.id, tags: { add: ["person"] }, force: true });
1586
1595
  const after = await queryNotes.execute({ id: note.id }) as any;
1587
- expect(after.updatedAt).toBe(originalUpdatedAt);
1596
+ // Fix 2: the tag mutation bumped updatedAt.
1597
+ expect(after.updatedAt).not.toBe(originalUpdatedAt);
1598
+ expect(new Date(after.updatedAt) > new Date(originalUpdatedAt)).toBe(true);
1599
+ // The schema default still backfilled.
1588
1600
  expect(after.metadata.name).toBe("unknown");
1589
1601
 
1590
1602
  close();
@@ -1726,6 +1738,83 @@ describe("scoped MCP wrapper", async () => {
1726
1738
 
1727
1739
  closeAllStores();
1728
1740
  });
1741
+
1742
+ // vault#555 auth review — validation_status must not leak an out-of-scope
1743
+ // co-tag's schema shape (field name / type / enum) to a scoped caller
1744
+ // (the #560 leak class). A note the caller CAN see (in-scope tag) may also
1745
+ // carry an out-of-scope tag whose schema it violates.
1746
+ test("MCP query-notes scrubs out-of-scope tag schema from validation_status; only the in-scope tag's warning survives", async () => {
1747
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
1748
+ const { writeVaultConfig } = await import("./config.ts");
1749
+ const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
1750
+
1751
+ const vaultName = `tagscope-vs-${Date.now()}`;
1752
+ writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
1753
+ const store = getVaultStore(vaultName);
1754
+ // In-scope tag "work" declares a field the note violates; out-of-scope
1755
+ // "project-manhattan" declares a SECRET field/enum the note also violates.
1756
+ await store.upsertTagSchema("work", { fields: { priority: { type: "string", enum: ["hi", "lo"] } } });
1757
+ await store.upsertTagSchema("project-manhattan", { fields: { codeword: { type: "string", enum: ["fizzbuzz"] } } });
1758
+ const note = await store.createNote("co-tagged", {
1759
+ path: "CoTagged",
1760
+ tags: ["work", "project-manhattan"],
1761
+ metadata: { priority: "URGENT", codeword: "leaked" },
1762
+ });
1763
+
1764
+ const tools = generateScopedMcpTools(vaultName, authForTags(["work"]) as any);
1765
+ const query = tools.find((t) => t.name === "query-notes")!;
1766
+ const result = await query.execute({ id: note.id }) as any;
1767
+
1768
+ // The out-of-scope tag's SCHEMA SHAPE — the leak fix 3 introduced and
1769
+ // this scrub closes — must appear nowhere in the whole response.
1770
+ // `fizzbuzz` (its enum value) exists ONLY in project-manhattan's schema,
1771
+ // so its total absence proves no schema-shape leak. (`codeword` is also
1772
+ // a key in the note's OWN metadata, visible to anyone who can read the
1773
+ // note, so it's not a schema-shape indicator.)
1774
+ const serialized = JSON.stringify(result);
1775
+ expect(serialized).not.toContain("fizzbuzz");
1776
+ // The tag NAME must be gone from validation_status specifically. NOTE:
1777
+ // it still appears in the note's `.tags` array — that's PRE-EXISTING
1778
+ // scoped-read behavior (noteWithinTagScope has never scrubbed a returned
1779
+ // note's tag set), independent of fix 3, and out of scope for this fix.
1780
+ expect(JSON.stringify(result.validation_status)).not.toContain("project-manhattan");
1781
+
1782
+ // The in-scope tag's own warning DOES survive.
1783
+ const vs = result.validation_status;
1784
+ expect(vs).toBeTruthy();
1785
+ expect(vs.schemas).toEqual(["work"]);
1786
+ expect(vs.warnings).toHaveLength(1);
1787
+ expect(vs.warnings[0].schema).toBe("work");
1788
+ expect(vs.warnings[0].field).toBe("priority");
1789
+
1790
+ closeAllStores();
1791
+ });
1792
+
1793
+ test("UNSCOPED query-notes still surfaces BOTH co-tags' validation_status (regression)", async () => {
1794
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
1795
+ const { writeVaultConfig } = await import("./config.ts");
1796
+ const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
1797
+
1798
+ const vaultName = `tagscope-vs-unscoped-${Date.now()}`;
1799
+ writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
1800
+ const store = getVaultStore(vaultName);
1801
+ await store.upsertTagSchema("work", { fields: { priority: { type: "string", enum: ["hi", "lo"] } } });
1802
+ await store.upsertTagSchema("project-manhattan", { fields: { codeword: { type: "string", enum: ["fizzbuzz"] } } });
1803
+ const note = await store.createNote("co-tagged", {
1804
+ path: "CoTagged",
1805
+ tags: ["work", "project-manhattan"],
1806
+ metadata: { priority: "URGENT", codeword: "leaked" },
1807
+ });
1808
+
1809
+ const tools = generateScopedMcpTools(vaultName); // unscoped
1810
+ const query = tools.find((t) => t.name === "query-notes")!;
1811
+ const result = await query.execute({ id: note.id }) as any;
1812
+ const vs = result.validation_status;
1813
+ expect(vs.schemas.sort()).toEqual(["project-manhattan", "work"]);
1814
+ expect(vs.warnings).toHaveLength(2);
1815
+
1816
+ closeAllStores();
1817
+ });
1729
1818
  });
1730
1819
 
1731
1820
  describe("auth permissions", () => {
@@ -4185,6 +4274,34 @@ describe("HTTP PATCH /notes/:idOrPath (update)", async () => {
4185
4274
  expect(body.tags).not.toContain("old");
4186
4275
  });
4187
4276
 
4277
+ // vault#555 fix 2 — a tags-only (or links-only) PATCH with `force: true`
4278
+ // (no `if_updated_at`) used to leave `updated_at` frozen: `updates` had no
4279
+ // core fields, so `store.updateNote` was never called at all.
4280
+ test("PATCH tags-only/links-only with force:true bumps updated_at", async () => {
4281
+ await store.createNote("x", { id: "x", tags: ["old"] });
4282
+ await store.createNote("y", { id: "y" });
4283
+ const before = (await store.getNote("x"))!.updatedAt;
4284
+ await new Promise((r) => setTimeout(r, 5));
4285
+
4286
+ const tagsRes = await handleNotes(
4287
+ mkReq("PATCH", "/notes/x", { tags: { add: ["new"] }, force: true }),
4288
+ store,
4289
+ "/x",
4290
+ );
4291
+ const tagsBody = await tagsRes.json() as any;
4292
+ expect(tagsBody.updatedAt).not.toBe(before);
4293
+ expect(new Date(tagsBody.updatedAt) > new Date(before)).toBe(true);
4294
+
4295
+ await new Promise((r) => setTimeout(r, 5));
4296
+ const linksRes = await handleNotes(
4297
+ mkReq("PATCH", "/notes/x", { links: { add: [{ target: "y", relationship: "mentions" }] }, force: true }),
4298
+ store,
4299
+ "/x",
4300
+ );
4301
+ const linksBody = await linksRes.json() as any;
4302
+ expect(new Date(linksBody.updatedAt) > new Date(tagsBody.updatedAt)).toBe(true);
4303
+ });
4304
+
4188
4305
  test("PATCH adds/removes links", async () => {
4189
4306
  await store.createNote("a", { id: "a" });
4190
4307
  await store.createNote("b", { id: "b" });
@@ -4248,6 +4365,59 @@ describe("HTTP PATCH /notes/:idOrPath (update)", async () => {
4248
4365
  expect(body.links[0].targetId).toBe("c");
4249
4366
  });
4250
4367
 
4368
+ // vault#555 — REST PATCH links.add gets the same basename/title
4369
+ // resolution + lazy forward-ref queueing as the MCP update-note tool
4370
+ // (both surfaces share core/wikilinks.ts's resolveOrQueueLink).
4371
+ test("PATCH links.add resolves by BASENAME and warns+queues when unresolvable", async () => {
4372
+ await store.createNote("a", { id: "a" });
4373
+ await store.createNote("Bob's note", { path: "People/Bob" });
4374
+ const byTitle = await handleNotes(
4375
+ mkReq("PATCH", "/notes/a", { links: { add: [{ target: "Bob", relationship: "knows" }] }, force: true }),
4376
+ store,
4377
+ "/a",
4378
+ );
4379
+ expect(byTitle.status).toBe(200);
4380
+ const byTitleBody = await byTitle.json() as any;
4381
+ expect(byTitleBody.warnings).toBeUndefined();
4382
+ expect(await store.getLinks("a", { direction: "outbound" })).toHaveLength(1);
4383
+
4384
+ const unresolved = await handleNotes(
4385
+ mkReq("PATCH", "/notes/a", { links: { add: [{ target: "Not Yet Real", relationship: "wants" }] }, force: true }),
4386
+ store,
4387
+ "/a",
4388
+ );
4389
+ const unresolvedBody = await unresolved.json() as any;
4390
+ expect(unresolvedBody.warnings).toBeDefined();
4391
+ expect(unresolvedBody.warnings[0].code).toBe("unresolved_link");
4392
+ expect(unresolvedBody.warnings[0].target).toBe("Not Yet Real");
4393
+
4394
+ const target = await store.createNote("arrived", { path: "Not Yet Real" });
4395
+ const links = await store.getLinks("a", { direction: "outbound" });
4396
+ expect(links.some((l) => l.targetId === target.id && l.relationship === "wants")).toBe(true);
4397
+ });
4398
+
4399
+ // vault#555 — POST /notes batch: a link to a note created LATER in the
4400
+ // same batch must resolve (forward-ref), matching MCP create-note.
4401
+ test("POST /notes batch: a link to a note created LATER in the same batch resolves (forward-ref)", async () => {
4402
+ const res = await handleNotes(
4403
+ mkReq("POST", "/notes", {
4404
+ notes: [
4405
+ { path: "RestA", content: "links to RestB", links: [{ target: "RestB", relationship: "knows" }] },
4406
+ { path: "RestB", content: "the target" },
4407
+ ],
4408
+ }),
4409
+ store,
4410
+ "",
4411
+ );
4412
+ expect(res.status).toBe(201);
4413
+ const body = await res.json() as any[];
4414
+ const a = body.find((n: any) => n.path === "RestA");
4415
+ expect(a.warnings).toBeUndefined();
4416
+ const links = await store.getLinks(a.id, { direction: "outbound" });
4417
+ expect(links).toHaveLength(1);
4418
+ expect(links[0]!.relationship).toBe("knows");
4419
+ });
4420
+
4251
4421
  test("PATCH without a link mutation or flag does NOT include links", async () => {
4252
4422
  await store.createNote("a", { id: "a" });
4253
4423
  await store.createNote("b", { id: "b" });
@@ -4436,6 +4606,42 @@ describe("HTTP PATCH /notes/:idOrPath (update)", async () => {
4436
4606
  expect((await store.getNote("x"))!.content).toBe("hi hi");
4437
4607
  });
4438
4608
 
4609
+ // vault#555 fix 7 (INVESTIGATE) — a tester reported "old_text not found"
4610
+ // failing 7/8 for batch/parallel content_edit calls sharing the same
4611
+ // old_text. Not reproducible against DIFFERENT notes (see
4612
+ // core/src/core.test.ts's matching investigation for the full writeup
4613
+ // and the "SAME note" control that DOES reproduce "1 succeeds, 7 fail" —
4614
+ // correct behavior, likely what the original report actually hit). REST
4615
+ // parity check here: N parallel PATCH requests, same old_text, different
4616
+ // notes, all succeed.
4617
+ test("PATCH parallel: N concurrent content_edit calls with the SAME old_text on DIFFERENT notes all succeed", async () => {
4618
+ const N = 8;
4619
+ const notes = [];
4620
+ for (let i = 0; i < N; i++) {
4621
+ notes.push(await store.createNote(`prefix TARGET_TEXT suffix-${i}`, { path: `rest-ce-${i}` }));
4622
+ }
4623
+
4624
+ const results = await Promise.all(
4625
+ notes.map((n) =>
4626
+ handleNotes(
4627
+ mkReq("PATCH", `/notes/${n.id}`, {
4628
+ content_edit: { old_text: "TARGET_TEXT", new_text: "REPLACED" },
4629
+ force: true,
4630
+ }),
4631
+ store,
4632
+ `/${n.id}`,
4633
+ ),
4634
+ ),
4635
+ );
4636
+ for (const res of results) {
4637
+ expect(res.status).toBe(200);
4638
+ }
4639
+ for (let i = 0; i < N; i++) {
4640
+ const fresh = await store.getNote(notes[i]!.id);
4641
+ expect(fresh!.content).toBe(`prefix REPLACED suffix-${i}`);
4642
+ }
4643
+ });
4644
+
4439
4645
  test("PATCH rejects content + append combination with 400", async () => {
4440
4646
  await store.createNote("seed", { id: "x" });
4441
4647
 
@@ -4618,6 +4824,87 @@ describe("HTTP PATCH /notes/:idOrPath (update)", async () => {
4618
4824
  });
4619
4825
  });
4620
4826
 
4827
+ // vault#555 fix 3 — validation_status used to be visible ONLY on the
4828
+ // one-time create/update WRITE response; a caller reading the note back
4829
+ // (GET single note, or the structured-query list) saw nothing at all,
4830
+ // contradicting "advisory violations surface as warnings" for an
4831
+ // enum_mismatch on a non-strict (even indexed) field.
4832
+ describe("HTTP GET /notes — validation_status on reads (vault#555)", async () => {
4833
+ test("GET /notes/:id surfaces validation_status (enum_mismatch on an indexed field)", async () => {
4834
+ const res0 = await handleTags(
4835
+ mkReq("PUT", "/tags/widget555", { fields: { status: { type: "string", enum: ["a", "b"], indexed: true } } }),
4836
+ store,
4837
+ "/widget555",
4838
+ );
4839
+ expect(res0.status).toBe(200);
4840
+ const created = await store.createNote("x", { tags: ["widget555"], metadata: { status: "bogus" } });
4841
+
4842
+ const res = await handleNotes(mkReq("GET", `/notes/${created.id}`), store, `/${created.id}`);
4843
+ expect(res.status).toBe(200);
4844
+ const body = await res.json() as any;
4845
+ expect(body.validation_status?.warnings?.[0]?.reason).toBe("enum_mismatch");
4846
+ expect(body.validation_status.warnings[0].field).toBe("status");
4847
+ });
4848
+
4849
+ test("GET /notes (structured query list) surfaces validation_status per note", async () => {
4850
+ await store.upsertTagSchema("task555list", {
4851
+ fields: { priority: { type: "string", enum: ["high", "low"] } },
4852
+ });
4853
+ await store.createNote("x", { tags: ["task555list"], metadata: { priority: "ULTRA" } });
4854
+
4855
+ const res = await handleNotes(mkReq("GET", "/notes?tag=task555list"), store, "");
4856
+ expect(res.status).toBe(200);
4857
+ const body = await res.json() as any[];
4858
+ expect(body).toHaveLength(1);
4859
+ expect(body[0].validation_status?.warnings?.[0]?.reason).toBe("enum_mismatch");
4860
+ });
4861
+
4862
+ test("GET /notes/:id and list both omit validation_status when no tag declares fields", async () => {
4863
+ const note = await store.createNote("plain", { tags: ["plain555"] });
4864
+ const single = await handleNotes(mkReq("GET", `/notes/${note.id}`), store, `/${note.id}`);
4865
+ const singleBody = await single.json() as any;
4866
+ expect(singleBody.validation_status).toBeUndefined();
4867
+
4868
+ const list = await handleNotes(mkReq("GET", "/notes?tag=plain555"), store, "");
4869
+ const listBody = await list.json() as any[];
4870
+ expect(listBody[0].validation_status).toBeUndefined();
4871
+ });
4872
+
4873
+ // vault#555 auth review — REST parity for the validation_status scope
4874
+ // scrub (the #560 leak class). A scoped caller reading a note co-tagged
4875
+ // with an out-of-scope tag must not learn that tag's schema shape.
4876
+ test("GET /notes/:id and list scrub out-of-scope co-tag schema from validation_status for a scoped caller", async () => {
4877
+ await store.upsertTagSchema("workrs", { fields: { priority: { type: "string", enum: ["hi", "lo"] } } });
4878
+ await store.upsertTagSchema("manhattanrs", { fields: { codeword: { type: "string", enum: ["fizzbuzz"] } } });
4879
+ const note = await store.createNote("co-tagged", {
4880
+ path: "CoTaggedRS",
4881
+ tags: ["workrs", "manhattanrs"],
4882
+ metadata: { priority: "URGENT", codeword: "leaked" },
4883
+ });
4884
+ const scope = { allowed: new Set(["workrs"]), raw: ["workrs"] };
4885
+
4886
+ // Single GET — `fizzbuzz` (the out-of-scope enum value) exists only in
4887
+ // manhattanrs's schema, so its absence proves no schema-shape leak. The
4888
+ // tag name is scrubbed from validation_status (still in `.tags` —
4889
+ // pre-existing, out of scope; see the MCP counterpart test).
4890
+ const single = await handleNotes(mkReq("GET", `/notes/${note.id}`), store, `/${note.id}`, undefined, scope);
4891
+ const singleBody = await single.json() as any;
4892
+ expect(JSON.stringify(singleBody)).not.toContain("fizzbuzz");
4893
+ expect(JSON.stringify(singleBody.validation_status)).not.toContain("manhattanrs");
4894
+ expect(singleBody.validation_status.schemas).toEqual(["workrs"]);
4895
+ expect(singleBody.validation_status.warnings).toHaveLength(1);
4896
+ expect(singleBody.validation_status.warnings[0].field).toBe("priority");
4897
+
4898
+ // List GET
4899
+ const list = await handleNotes(mkReq("GET", "/notes?tag=workrs&include_content=true"), store, "", undefined, scope);
4900
+ const listBody = await list.json() as any[];
4901
+ expect(JSON.stringify(listBody)).not.toContain("fizzbuzz");
4902
+ const target = listBody.find((n) => n.id === note.id);
4903
+ expect(JSON.stringify(target.validation_status)).not.toContain("manhattanrs");
4904
+ expect(target.validation_status.schemas).toEqual(["workrs"]);
4905
+ });
4906
+ });
4907
+
4621
4908
  describe("HTTP POST /notes — validation_status attachment (vault#287)", async () => {
4622
4909
  // Mirror of the PATCH cases for create. The MCP create-note path
4623
4910
  // attaches validation_status; HTTP POST must match (vault#287).