@openparachute/vault 0.7.2-rc.3 → 0.7.2-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.
@@ -214,6 +214,29 @@ describe("aggregateNotes — errors", () => {
214
214
  }
215
215
  });
216
216
 
217
+ // LB — a `type: "number"` (float) field can NEVER be `indexed: true`
218
+ // (only integer/boolean are indexable numeric shapes), so it legitimately
219
+ // never appears in `indexed_fields`. Before the fix, the FIELD_NOT_INDEXED
220
+ // thrown here carried the SAME generic "declare it via update-tag with
221
+ // indexed: true" hint every other unindexed field gets — advice that's
222
+ // impossible to satisfy for `number` specifically, dead-ending an agent
223
+ // that tries it and hits the SEPARATE "unsupported type ... for indexing"
224
+ // error. The hint must instead name the real fix: store as an integer.
225
+ it("FIELD_NOT_INDEXED on a type: \"number\" sum field carries an actionable hint, not the impossible generic \"declare indexed: true\" advice", async () => {
226
+ await store.upsertTagRecord("expense", { fields: { amount: { type: "number" } } });
227
+ await store.createNote("a", { tags: ["expense"], metadata: { amount: 9.99 } });
228
+ try {
229
+ aggregateNotes(db, { aggregate: { group_by: "tag", op: "sum", field: "amount" } });
230
+ throw new Error("expected throw");
231
+ } catch (e: any) {
232
+ expect(e.code).toBe("FIELD_NOT_INDEXED");
233
+ expect(e.message).not.toContain("declare it via update-tag with indexed: true");
234
+ const guidance = `${e.message} ${e.hint ?? ""}`.toLowerCase();
235
+ expect(guidance).toContain("integer");
236
+ expect(guidance).toMatch(/float|number|decimal/);
237
+ }
238
+ });
239
+
217
240
  it("throws INVALID_QUERY when the sum field is indexed but not numeric (TEXT-backed)", async () => {
218
241
  await store.upsertTagRecord("task", {
219
242
  fields: {
@@ -295,6 +295,28 @@ describe("contract: taxonomy — #552 (flipped from test.todo)", () => {
295
295
  expect(cleanReport.summary).toContain("clean");
296
296
  });
297
297
 
298
+ // LB — a DIRECT self-parent (`parent_names: [tag]` on tag itself) is a
299
+ // degenerate one-node cycle: `getTagDescendants` starts its traversal
300
+ // result at `{tag}`, and the moment it walks the tag's own self-edge it
301
+ // finds `tag` already `result.has(child)` and skips re-expanding it — so
302
+ // `descendants` never grows past size 1. `findHierarchyCycles`'s original
303
+ // gate (`descendants.has(tag) && descendants.size > 1`) requires size>1,
304
+ // so this exact case reported clean despite being a real cycle. Only
305
+ // reachable via guard-bypassing data (direct DB write / import) since
306
+ // `upsertTagRecord` rejects a self-parent at write time (see the "bare
307
+ // self-parent: also caught" assertion above, which exercises the WRITE
308
+ // guard — this test exercises the READ-side doctor scan on data that
309
+ // skipped that guard).
310
+ it("doctor flags a bare self-parent cycle (X declares parent_names: [X]), which the size>1 gate alone misses", async () => {
311
+ await store.upsertTagRecord("self-cyc", {});
312
+ db.prepare("UPDATE tags SET parent_names = ? WHERE name = ?").run(JSON.stringify(["self-cyc"]), "self-cyc");
313
+
314
+ const report = await store.doctor();
315
+ const finding = report.findings.find((f) => f.type === "parent_names_cycle" && f.subject === "self-cyc");
316
+ expect(finding).toBeDefined();
317
+ expect(finding?.severity).toBe("error");
318
+ });
319
+
298
320
  it("dead_tag_metadata_reference does not false-positive on a schema-declared enum field whose values coincide with an unrelated live tag (vault#570)", async () => {
299
321
  // "archived" is a real, unrelated live tag — coincidentally sharing a
300
322
  // name with one of "status"'s declared enum values. Pre-fix, this ONE
@@ -4572,6 +4572,390 @@ describe("MCP tools", async () => {
4572
4572
  expect(afterLink!.targetId).toBe(alice.id);
4573
4573
  expect(afterLink!.createdAt).toBe(beforeLink!.createdAt);
4574
4574
  });
4575
+
4576
+ it("regression guard: cardinality:'one' (scalar) reference field still creates exactly one edge", async () => {
4577
+ const alice = await store.createNote("Alice", { id: "alice" });
4578
+ await store.upsertTagSchema("task", { fields: { assignee: { type: "reference", cardinality: "one" } } });
4579
+ const tools = generateMcpTools(store);
4580
+ const createNote = tools.find((t) => t.name === "create-note")!;
4581
+ const note = await createNote.execute({
4582
+ content: "Ship it",
4583
+ tags: ["task"],
4584
+ metadata: { assignee: "alice" },
4585
+ }) as any;
4586
+
4587
+ const links = (await store.getLinks(note.id, { direction: "outbound" }))
4588
+ .filter((l) => l.relationship === "assignee");
4589
+ expect(links).toHaveLength(1);
4590
+ expect(links[0]!.targetId).toBe(alice.id);
4591
+ });
4592
+
4593
+ // ---- Gap #2: cardinality:"many" (array) reference fields (vault#typed-reference-field) ----
4594
+
4595
+ it("cardinality:'many' reference field creates one link per array element, deduped, with no spurious type_mismatch warning", async () => {
4596
+ const carol = await store.createNote("Carol", { path: "People/Carol" });
4597
+ const dave = await store.createNote("Dave", { path: "People/Dave" });
4598
+ await store.upsertTagSchema("task", {
4599
+ fields: { collaborators: { type: "reference", cardinality: "many" } },
4600
+ });
4601
+ const tools = generateMcpTools(store);
4602
+ const createNote = tools.find((t) => t.name === "create-note")!;
4603
+ const result = await createNote.execute({
4604
+ content: "Ship it",
4605
+ tags: ["task"],
4606
+ // "People/Carol" appears twice — duplicate elements must dedupe to one edge.
4607
+ metadata: { collaborators: ["People/Carol", "People/Dave", "People/Carol"] },
4608
+ }) as any;
4609
+
4610
+ // No spurious type_mismatch — an array is exactly what cardinality:"many" asks
4611
+ // for; pre-fix, valueMatchesType's "reference" case only accepted strings.
4612
+ expect(result.validation_status.warnings).toEqual([]);
4613
+
4614
+ const links = (await store.getLinks(result.id, { direction: "outbound" }))
4615
+ .filter((l) => l.relationship === "collaborators");
4616
+ expect(links).toHaveLength(2);
4617
+ expect(new Set(links.map((l) => l.targetId))).toEqual(new Set([carol.id, dave.id]));
4618
+
4619
+ // Traversable via find-path, same as a scalar reference link.
4620
+ const findPath = tools.find((t) => t.name === "find-path")!;
4621
+ const path = await findPath.execute({ source: result.id, target: carol.id }) as any;
4622
+ expect(path?.path).toEqual([result.id, carol.id]);
4623
+ });
4624
+
4625
+ it("update-note changing a cardinality:'many' reference array adds the new link, drops the removed one, and leaves the unchanged one alone", async () => {
4626
+ const carol = await store.createNote("Carol", { path: "People/Carol" });
4627
+ const dave = await store.createNote("Dave", { path: "People/Dave" });
4628
+ const erin = await store.createNote("Erin", { path: "People/Erin" });
4629
+ await store.upsertTagSchema("task", {
4630
+ fields: { collaborators: { type: "reference", cardinality: "many" } },
4631
+ });
4632
+ const tools = generateMcpTools(store);
4633
+ const createNote = tools.find((t) => t.name === "create-note")!;
4634
+ const note = await createNote.execute({
4635
+ content: "Ship it",
4636
+ tags: ["task"],
4637
+ metadata: { collaborators: ["People/Carol", "People/Dave"] },
4638
+ }) as any;
4639
+
4640
+ let links = (await store.getLinks(note.id, { direction: "outbound" }))
4641
+ .filter((l) => l.relationship === "collaborators");
4642
+ expect(links).toHaveLength(2);
4643
+ const carolLinkBefore = links.find((l) => l.targetId === carol.id)!;
4644
+
4645
+ const updateNote = tools.find((t) => t.name === "update-note")!;
4646
+ // Drop Dave, add Erin, keep Carol.
4647
+ await updateNote.execute({
4648
+ id: note.id,
4649
+ metadata: { collaborators: ["People/Carol", "People/Erin"] },
4650
+ force: true,
4651
+ });
4652
+
4653
+ links = (await store.getLinks(note.id, { direction: "outbound" }))
4654
+ .filter((l) => l.relationship === "collaborators");
4655
+ expect(links).toHaveLength(2);
4656
+ expect(new Set(links.map((l) => l.targetId))).toEqual(new Set([carol.id, erin.id]));
4657
+ // Dropped — Dave's link is gone.
4658
+ expect(links.some((l) => l.targetId === dave.id)).toBe(false);
4659
+ // Unchanged — Carol's edge is the SAME row (createdAt preserved), proving a
4660
+ // real diff (not a blanket clear-and-recreate under the relationship).
4661
+ const carolLinkAfter = links.find((l) => l.targetId === carol.id)!;
4662
+ expect(carolLinkAfter.createdAt).toBe(carolLinkBefore.createdAt);
4663
+ });
4664
+
4665
+ it("an unresolved array element is queued and backfills when its target note is created later, without disturbing already-resolved siblings", async () => {
4666
+ const carol = await store.createNote("Carol", { path: "People/Carol" });
4667
+ await store.upsertTagSchema("task", {
4668
+ fields: { collaborators: { type: "reference", cardinality: "many" } },
4669
+ });
4670
+ const tools = generateMcpTools(store);
4671
+ const createNote = tools.find((t) => t.name === "create-note")!;
4672
+ const note = await createNote.execute({
4673
+ content: "Ship it",
4674
+ tags: ["task"],
4675
+ metadata: { collaborators: ["People/Carol", "People/Not Yet Created"] },
4676
+ }) as any;
4677
+
4678
+ let links = (await store.getLinks(note.id, { direction: "outbound" }))
4679
+ .filter((l) => l.relationship === "collaborators");
4680
+ expect(links).toHaveLength(1); // only Carol resolves so far
4681
+ expect(links[0]!.targetId).toBe(carol.id);
4682
+
4683
+ const queryNotes = tools.find((t) => t.name === "query-notes")!;
4684
+ const broken = await queryNotes.execute({ has_broken_links: true }) as any[];
4685
+ expect(broken.map((n: any) => n.id)).toContain(note.id);
4686
+
4687
+ const target = await store.createNote("here now", { path: "People/Not Yet Created" });
4688
+
4689
+ links = (await store.getLinks(note.id, { direction: "outbound" }))
4690
+ .filter((l) => l.relationship === "collaborators");
4691
+ expect(links).toHaveLength(2);
4692
+ expect(new Set(links.map((l) => l.targetId))).toEqual(new Set([carol.id, target.id]));
4693
+ });
4694
+
4695
+ it("an array element resolving to the note itself creates a self-loop link, matching scalar self-reference behavior", async () => {
4696
+ await store.upsertTagSchema("task", {
4697
+ fields: { collaborators: { type: "reference", cardinality: "many" } },
4698
+ });
4699
+ const tools = generateMcpTools(store);
4700
+ const createNote = tools.find((t) => t.name === "create-note")!;
4701
+ const note = await createNote.execute({
4702
+ content: "Ship it",
4703
+ tags: ["task"],
4704
+ path: "Tasks/Self",
4705
+ metadata: { collaborators: ["Tasks/Self"] },
4706
+ }) as any;
4707
+
4708
+ const links = (await store.getLinks(note.id, { direction: "outbound" }))
4709
+ .filter((l) => l.relationship === "collaborators");
4710
+ expect(links).toHaveLength(1);
4711
+ expect(links[0]!.targetId).toBe(note.id);
4712
+ });
4713
+
4714
+ it("an ambiguous array element (matches ≥2 notes) is neither linked nor queued, mirroring the scalar ambiguous contract", async () => {
4715
+ await store.createNote("Shared Title", { path: "A/Shared Title" });
4716
+ await store.createNote("Shared Title", { path: "B/Shared Title" });
4717
+ const target = await store.createNote("Target", { path: "People/Target" });
4718
+ await store.upsertTagSchema("task", {
4719
+ fields: { collaborators: { type: "reference", cardinality: "many" } },
4720
+ });
4721
+ const tools = generateMcpTools(store);
4722
+ const createNote = tools.find((t) => t.name === "create-note")!;
4723
+ const note = await createNote.execute({
4724
+ content: "Ship it",
4725
+ tags: ["task"],
4726
+ metadata: { collaborators: ["Shared Title", "People/Target"] },
4727
+ }) as any;
4728
+
4729
+ const links = (await store.getLinks(note.id, { direction: "outbound" }))
4730
+ .filter((l) => l.relationship === "collaborators");
4731
+ // Only the unambiguous element links; "Shared Title" is neither linked nor queued.
4732
+ expect(links).toHaveLength(1);
4733
+ expect(links[0]!.targetId).toBe(target.id);
4734
+
4735
+ const queryNotes = tools.find((t) => t.name === "query-notes")!;
4736
+ const broken = await queryNotes.execute({ has_broken_links: true }) as any[];
4737
+ expect(broken.map((n: any) => n.id)).not.toContain(note.id);
4738
+ });
4739
+
4740
+ // ---- Gap #3: schema-after-data backfill (vault#typed-reference-field) ----
4741
+
4742
+ it("declaring type:'reference' via update-tag after notes already carry the value backfills links for existing notes (scalar and array)", async () => {
4743
+ const alice = await store.createNote("Alice", { path: "People/Alice" });
4744
+ const bob = await store.createNote("Bob", { path: "People/Bob" });
4745
+ const carol = await store.createNote("Carol", { path: "People/Carol" });
4746
+ const tools = generateMcpTools(store);
4747
+ const createNote = tools.find((t) => t.name === "create-note")!;
4748
+
4749
+ // Written BEFORE the tag declares these fields as `reference` — plain
4750
+ // untyped metadata, no schema applies at write time, so no link is created.
4751
+ const scalarNote = await createNote.execute({
4752
+ content: "Scalar task",
4753
+ tags: ["task"],
4754
+ metadata: { manager: "People/Alice" },
4755
+ }) as any;
4756
+ const arrayNote = await createNote.execute({
4757
+ content: "Array task",
4758
+ tags: ["task"],
4759
+ metadata: { collaborators: ["People/Bob", "People/Carol"] },
4760
+ }) as any;
4761
+
4762
+ expect((await store.getLinks(scalarNote.id, { direction: "outbound" })).length).toBe(0);
4763
+ expect((await store.getLinks(arrayNote.id, { direction: "outbound" })).length).toBe(0);
4764
+
4765
+ const updateTag = tools.find((t) => t.name === "update-tag")!;
4766
+ await updateTag.execute({
4767
+ tag: "task",
4768
+ fields: {
4769
+ manager: { type: "reference" },
4770
+ collaborators: { type: "reference", cardinality: "many" },
4771
+ },
4772
+ });
4773
+
4774
+ const scalarLinks = (await store.getLinks(scalarNote.id, { direction: "outbound" }))
4775
+ .filter((l) => l.relationship === "manager");
4776
+ expect(scalarLinks).toHaveLength(1);
4777
+ expect(scalarLinks[0]!.targetId).toBe(alice.id);
4778
+
4779
+ const arrayLinks = (await store.getLinks(arrayNote.id, { direction: "outbound" }))
4780
+ .filter((l) => l.relationship === "collaborators");
4781
+ expect(arrayLinks).toHaveLength(2);
4782
+ expect(new Set(arrayLinks.map((l) => l.targetId))).toEqual(new Set([bob.id, carol.id]));
4783
+
4784
+ // Idempotency: a LATER update-tag call that declares a NEW reference
4785
+ // field re-triggers the tag's whole backfill walk (see
4786
+ // `backfillReferenceFieldLinks` — it re-syncs every reference field on
4787
+ // each note, not just the newly-added one). Already-synced links must
4788
+ // not duplicate.
4789
+ await updateTag.execute({
4790
+ tag: "task",
4791
+ fields: { reviewer: { type: "reference" } },
4792
+ });
4793
+
4794
+ const scalarLinksAfter = (await store.getLinks(scalarNote.id, { direction: "outbound" }))
4795
+ .filter((l) => l.relationship === "manager");
4796
+ expect(scalarLinksAfter).toHaveLength(1);
4797
+ const arrayLinksAfter = (await store.getLinks(arrayNote.id, { direction: "outbound" }))
4798
+ .filter((l) => l.relationship === "collaborators");
4799
+ expect(arrayLinksAfter).toHaveLength(2);
4800
+ });
4801
+
4802
+ it("declaring type:'reference' on a PARENT tag backfills links for notes carrying a descendant (child) tag", async () => {
4803
+ const alice = await store.createNote("Alice", { path: "People/Alice" });
4804
+ const tools = generateMcpTools(store);
4805
+ const updateTag = tools.find((t) => t.name === "update-tag")!;
4806
+ // `subtask` is a child of `task` via parent_names — schema inheritance
4807
+ // (vault#270) means `subtask` notes are in `task`'s effective walk.
4808
+ await updateTag.execute({ tag: "subtask", parent_names: ["task"] });
4809
+
4810
+ const createNote = tools.find((t) => t.name === "create-note")!;
4811
+ const note = await createNote.execute({
4812
+ content: "Child task",
4813
+ tags: ["subtask"],
4814
+ metadata: { manager: "People/Alice" },
4815
+ }) as any;
4816
+
4817
+ expect((await store.getLinks(note.id, { direction: "outbound" })).length).toBe(0);
4818
+
4819
+ await updateTag.execute({ tag: "task", fields: { manager: { type: "reference" } } });
4820
+
4821
+ const links = (await store.getLinks(note.id, { direction: "outbound" }))
4822
+ .filter((l) => l.relationship === "manager");
4823
+ expect(links).toHaveLength(1);
4824
+ expect(links[0]!.targetId).toBe(alice.id);
4825
+ });
4826
+
4827
+ // ---- Round-4 review fixes (2026-07-10) ----
4828
+
4829
+ it("BLOCKER 1: the schema-declaration backfill walks ALL notes, not the first 100", async () => {
4830
+ // One shared target every note references.
4831
+ const target = await store.createNote("Target", { path: "People/Target" });
4832
+ const tools = generateMcpTools(store);
4833
+ const createNote = tools.find((t) => t.name === "create-note")!;
4834
+
4835
+ // 120 notes each carrying a `manager` value BEFORE the field is a
4836
+ // reference — exceeds queryNotes' default LIMIT 100.
4837
+ const noteIds: string[] = [];
4838
+ for (let i = 0; i < 120; i++) {
4839
+ const n = await createNote.execute({
4840
+ content: `Task ${i}`,
4841
+ tags: ["task"],
4842
+ metadata: { manager: "People/Target" },
4843
+ }) as any;
4844
+ noteIds.push(n.id);
4845
+ }
4846
+
4847
+ const updateTag = tools.find((t) => t.name === "update-tag")!;
4848
+ await updateTag.execute({ tag: "task", fields: { manager: { type: "reference" } } });
4849
+
4850
+ // EVERY one of the 120 notes must have a manager edge — a 100-cap would
4851
+ // leave 20 unlinked (the silent-half-graph this feature prevents).
4852
+ let linked = 0;
4853
+ for (const id of noteIds) {
4854
+ const links = (await store.getLinks(id, { direction: "outbound" }))
4855
+ .filter((l) => l.relationship === "manager" && l.targetId === target.id);
4856
+ if (links.length === 1) linked++;
4857
+ }
4858
+ expect(linked).toBe(120);
4859
+ });
4860
+
4861
+ it("BLOCKER 2: re-declaring an ALREADY-reference field via update-tag heals notes whose links were never built", async () => {
4862
+ const alice = await store.createNote("Alice", { path: "People/Alice" });
4863
+ const tools = generateMcpTools(store);
4864
+ const updateTag = tools.find((t) => t.name === "update-tag")!;
4865
+ const createNote = tools.find((t) => t.name === "create-note")!;
4866
+
4867
+ // Field is declared reference from the start, and a note is written.
4868
+ await updateTag.execute({ tag: "task", fields: { manager: { type: "reference" } } });
4869
+ const note = await createNote.execute({
4870
+ content: "Ship it",
4871
+ tags: ["task"],
4872
+ metadata: { manager: "People/Alice" },
4873
+ }) as any;
4874
+ expect((await store.getLinks(note.id, { direction: "outbound" }))
4875
+ .filter((l) => l.relationship === "manager")).toHaveLength(1);
4876
+
4877
+ // Simulate a pre-fix vault: the edge was never built. Delete it directly,
4878
+ // leaving the metadata value in place but the graph edge missing.
4879
+ await store.deleteLink(note.id, alice.id, "manager");
4880
+ expect((await store.getLinks(note.id, { direction: "outbound" }))
4881
+ .filter((l) => l.relationship === "manager")).toHaveLength(0);
4882
+
4883
+ // Re-declare the SAME already-reference field — the documented heal path.
4884
+ // A transition-keyed trigger would no-op here; the fix fires the backfill
4885
+ // for any reference field in the declared schema.
4886
+ await updateTag.execute({ tag: "task", fields: { manager: { type: "reference" } } });
4887
+
4888
+ const healed = (await store.getLinks(note.id, { direction: "outbound" }))
4889
+ .filter((l) => l.relationship === "manager");
4890
+ expect(healed).toHaveLength(1);
4891
+ expect(healed[0]!.targetId).toBe(alice.id);
4892
+ });
4893
+
4894
+ it("NIT 3: dropping an array element reconciles against RESOLVED targets — a since-renamed target's stale edge is dropped, and an aliased-same-target edge survives", async () => {
4895
+ const dave = await store.createNote("Dave", { path: "People/Dave" });
4896
+ const erin = await store.createNote("Erin", { path: "People/Erin" });
4897
+ await store.upsertTagSchema("task", {
4898
+ fields: { collaborators: { type: "reference", cardinality: "many" } },
4899
+ });
4900
+ const tools = generateMcpTools(store);
4901
+ const createNote = tools.find((t) => t.name === "create-note")!;
4902
+ const updateNote = tools.find((t) => t.name === "update-note")!;
4903
+
4904
+ // --- Corner (a): a still-present element whose target is renamed after
4905
+ // linking; then a DIFFERENT element is dropped. The renamed target's
4906
+ // stale edge must be reconciled away (the old re-resolve-removed-value
4907
+ // approach left it forever).
4908
+ const note = await createNote.execute({
4909
+ content: "Ship it",
4910
+ tags: ["task"],
4911
+ metadata: { collaborators: ["People/Dave", "People/Erin"] },
4912
+ }) as any;
4913
+ expect((await store.getLinks(note.id, { direction: "outbound" }))
4914
+ .filter((l) => l.relationship === "collaborators")).toHaveLength(2);
4915
+
4916
+ // Rename Dave's target note — the raw value "People/Dave" no longer resolves.
4917
+ await updateNote.execute({ id: dave.id, path: "People/David", force: true });
4918
+
4919
+ // Drop Erin (a different element); keep the now-stale "People/Dave".
4920
+ await updateNote.execute({
4921
+ id: note.id,
4922
+ metadata: { collaborators: ["People/Dave"] },
4923
+ force: true,
4924
+ });
4925
+
4926
+ const afterA = (await store.getLinks(note.id, { direction: "outbound" }))
4927
+ .filter((l) => l.relationship === "collaborators");
4928
+ // Erin dropped; Dave's stale edge (target renamed, value no longer
4929
+ // resolves) reconciled away → zero edges, not a lingering stale one.
4930
+ expect(afterA.some((l) => l.targetId === erin.id)).toBe(false);
4931
+ expect(afterA.some((l) => l.targetId === dave.id)).toBe(false);
4932
+
4933
+ // --- Corner (c): two elements ALIAS the same target (path + H1 title).
4934
+ // Dropping one alias must keep the shared edge (still referenced by the
4935
+ // survivor), not delete it.
4936
+ const carol = await store.createNote("# Carol\n\nbody", { path: "People/Carol" });
4937
+ const aliasNote = await createNote.execute({
4938
+ content: "Alias task",
4939
+ tags: ["task"],
4940
+ // "People/Carol" (path) and "Carol" (H1 title) both resolve to carol.
4941
+ metadata: { collaborators: ["People/Carol", "Carol"] },
4942
+ }) as any;
4943
+ let aliasLinks = (await store.getLinks(aliasNote.id, { direction: "outbound" }))
4944
+ .filter((l) => l.relationship === "collaborators");
4945
+ expect(aliasLinks).toHaveLength(1);
4946
+ expect(aliasLinks[0]!.targetId).toBe(carol.id);
4947
+
4948
+ // Drop the title alias, keep the path alias → the shared edge survives.
4949
+ await updateNote.execute({
4950
+ id: aliasNote.id,
4951
+ metadata: { collaborators: ["People/Carol"] },
4952
+ force: true,
4953
+ });
4954
+ aliasLinks = (await store.getLinks(aliasNote.id, { direction: "outbound" }))
4955
+ .filter((l) => l.relationship === "collaborators");
4956
+ expect(aliasLinks).toHaveLength(1);
4957
+ expect(aliasLinks[0]!.targetId).toBe(carol.id);
4958
+ });
4575
4959
  });
4576
4960
 
4577
4961
  it("find-path works with ID/path resolution", async () => {
@@ -101,13 +101,69 @@ function toBinding(field: string, op: string, value: unknown): SQLQueryBindings
101
101
  );
102
102
  }
103
103
 
104
+ /**
105
+ * Best-effort lookup of a metadata field's declared `type` across every tag
106
+ * schema — used ONLY to sharpen the {@link requireIndexedField} error hint
107
+ * below when the field was never indexed. Scans `tags.fields` directly
108
+ * rather than going through `indexed_fields` (which by construction has no
109
+ * row for a type that can never BE indexed, e.g. `"number"`) or a
110
+ * per-note schema resolution (scoped to one note's tags, not "any tag
111
+ * anywhere"). Only reached on the FIELD_NOT_INDEXED error path — a full
112
+ * table scan here is fine since it runs once per rejected call, never on a
113
+ * hot path. Returns the type from the FIRST tag found declaring `field`
114
+ * (a cross-tag type conflict on the same field name is its own separate
115
+ * validation elsewhere, not this function's job); `undefined` when no tag
116
+ * declares this field name, or its `fields` JSON doesn't parse.
117
+ */
118
+ function findDeclaredFieldType(db: Database, field: string): string | undefined {
119
+ const rows = db.prepare(
120
+ `SELECT fields FROM tags WHERE fields IS NOT NULL`,
121
+ ).all() as { fields: string | null }[];
122
+ for (const row of rows) {
123
+ if (!row.fields) continue;
124
+ try {
125
+ const parsed: unknown = JSON.parse(row.fields);
126
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) continue;
127
+ const spec = (parsed as Record<string, unknown>)[field];
128
+ if (spec === null || typeof spec !== "object" || Array.isArray(spec)) continue;
129
+ const type = (spec as Record<string, unknown>).type;
130
+ if (typeof type === "string") return type;
131
+ } catch {
132
+ // Malformed `fields` JSON on some other tag — not this lookup's job
133
+ // to validate; skip and keep scanning.
134
+ }
135
+ }
136
+ return undefined;
137
+ }
138
+
104
139
  /**
105
140
  * Look up `field` in `indexed_fields` or throw a loud error suggesting the
106
141
  * caller declare it via `update-tag` with `indexed: true`.
142
+ *
143
+ * That generic advice is IMPOSSIBLE to satisfy for a field whose schema
144
+ * declares `type: "number"` (a float) — only `integer`/`boolean` are
145
+ * indexable numeric shapes (see `indexed-fields.ts`'s `TYPE_MAP`), so
146
+ * `update-tag` with `indexed: true` on a `number` field itself throws
147
+ * "unsupported type ... for indexing". An agent that only sees the generic
148
+ * hint has no escape from that loop (LB — round-4 bug hunt). When the
149
+ * declared type is `"number"`, swap in a hint that names the actual fix:
150
+ * store the value as an integer (e.g. cents instead of dollars) and index
151
+ * THAT field instead.
107
152
  */
108
153
  export function requireIndexedField(db: Database, field: string): IndexedField {
109
154
  const row = getIndexedField(db, field);
110
155
  if (!row) {
156
+ if (findDeclaredFieldType(db, field) === "number") {
157
+ throw new QueryError(
158
+ `metadata field "${field}" is not indexed, and can never be: it's declared type: "number" (a float), and only integer/boolean fields are indexable numeric types. "declare indexed: true" will not work on this field.`,
159
+ "FIELD_NOT_INDEXED",
160
+ {
161
+ error_type: "field_not_indexed",
162
+ field,
163
+ hint: `"${field}" is a float ("number") field — float fields can't be server-side indexed, filtered with operators, used in order_by, or summed via aggregate. Store the value as an integer instead (e.g. cents rather than dollars) and declare indexed: true on THAT field to enable those.`,
164
+ },
165
+ );
166
+ }
111
167
  throw new QueryError(
112
168
  `metadata field "${field}" is not indexed. To use operator queries or order_by on this field, declare it via update-tag with indexed: true.`,
113
169
  "FIELD_NOT_INDEXED",
@@ -348,7 +348,7 @@ function jsonTypeOf(value: unknown): string {
348
348
  return typeof value; // "string" | "number" | "boolean" | "object" | ...
349
349
  }
350
350
 
351
- function valueMatchesType(value: unknown, type: SchemaField["type"]): boolean {
351
+ function valueMatchesType(value: unknown, type: SchemaField["type"], cardinality?: SchemaField["cardinality"]): boolean {
352
352
  if (type === undefined) return true;
353
353
  switch (type) {
354
354
  case "string":
@@ -373,7 +373,22 @@ function valueMatchesType(value: unknown, type: SchemaField["type"]): boolean {
373
373
  // the write path (core/src/store.ts) separately resolves this value to
374
374
  // a note and maintains a graph link; see tag-schemas.ts's
375
375
  // `VALID_FIELD_TYPES` doc comment for the full contract.
376
+ //
377
+ // `cardinality: "many"` (vault#typed-reference-field gap #2) is a
378
+ // one-to-MANY reference field — the value is an ARRAY of reference
379
+ // strings, one per linked note, not a single string. Validate the
380
+ // shape a "many" reference actually takes: an array whose elements are
381
+ // ALL non-empty-typed strings (per-item type check — the ARRAY shape
382
+ // itself is separately covered by the `cardinality_mismatch` check
383
+ // below in `validateNote`, so this only needs to judge element types).
384
+ // Without this branch, EVERY valid `cardinality:"many"` reference write
385
+ // fired a self-contradictory `type_mismatch` ("should be reference, got
386
+ // array") — an array is exactly what "many" asks for; only a
387
+ // non-string ELEMENT should ever fail this check.
376
388
  case "reference":
389
+ if (cardinality === "many") {
390
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
391
+ }
377
392
  return typeof value === "string";
378
393
  }
379
394
  }
@@ -435,7 +450,7 @@ export function validateNote(
435
450
 
436
451
  if (absent) continue;
437
452
 
438
- if (spec.type && !valueMatchesType(value, spec.type)) {
453
+ if (spec.type && !valueMatchesType(value, spec.type, spec.cardinality)) {
439
454
  // Decision A (vault#553): an INDEXED field's type is a query
440
455
  // contract — a type-mismatched write poisons range-query ordering
441
456
  // (SQLite's TEXT-sorts-above-INTEGER affinity) regardless of whether