@openparachute/vault 0.7.6 → 0.7.7

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.
Files changed (54) hide show
  1. package/README.md +16 -16
  2. package/core/src/attachment/policy.test.ts +7 -0
  3. package/core/src/attachment/policy.ts +9 -0
  4. package/core/src/attachment-tickets-tool.test.ts +15 -0
  5. package/core/src/conformance.test.ts +78 -0
  6. package/core/src/conformance.ts +34 -5
  7. package/core/src/connection-pragmas.test.ts +27 -1
  8. package/core/src/core.test.ts +88 -3
  9. package/core/src/cursor.ts +2 -0
  10. package/core/src/do-param-cap.test.ts +167 -0
  11. package/core/src/lede.test.ts +60 -0
  12. package/core/src/mcp-manifest.ts +15 -1
  13. package/core/src/mcp.ts +37 -5
  14. package/core/src/notes.ts +120 -48
  15. package/core/src/query-operators.ts +87 -5
  16. package/core/src/query-warnings.ts +11 -3
  17. package/core/src/schema.ts +32 -0
  18. package/core/src/seed-packs.ts +74 -5
  19. package/core/src/sql-in.ts +32 -2
  20. package/core/src/store.ts +16 -49
  21. package/core/src/test-preload.ts +48 -3
  22. package/core/src/types.ts +13 -1
  23. package/core/src/wikilinks.test.ts +57 -0
  24. package/core/src/wikilinks.ts +63 -29
  25. package/package.json +1 -1
  26. package/src/attachment-tickets.test.ts +62 -0
  27. package/src/attachment-tickets.ts +2 -2
  28. package/src/cli.ts +36 -8
  29. package/src/config.ts +34 -1
  30. package/src/contract-honest-queries.test.ts +33 -1
  31. package/src/contract-search.test.ts +47 -0
  32. package/src/embedding/select.ts +16 -3
  33. package/src/live-match.test.ts +8 -0
  34. package/src/live-match.ts +15 -0
  35. package/src/mcp-http.test.ts +12 -0
  36. package/src/mcp-http.ts +1 -0
  37. package/src/mcp-tools.ts +51 -17
  38. package/src/mirror-routes.test.ts +22 -31
  39. package/src/onboarding-seed.test.ts +68 -0
  40. package/src/routes.ts +88 -25
  41. package/src/routing.test.ts +24 -0
  42. package/src/routing.ts +2 -0
  43. package/src/subscriptions.ts +18 -2
  44. package/src/tag-scope-note-tags.test.ts +476 -0
  45. package/src/tag-scope.ts +73 -5
  46. package/src/test-home-isolation.test.ts +137 -0
  47. package/src/test-support/spawn.ts +12 -0
  48. package/src/transcription/download.test.ts +187 -1
  49. package/src/transcription/download.ts +149 -2
  50. package/src/transcription/install-python.test.ts +23 -2
  51. package/src/transcription/install-python.ts +13 -4
  52. package/src/vault.test.ts +39 -4
  53. package/src/version.test.ts +8 -0
  54. package/src/ws-server.ts +12 -2
@@ -388,6 +388,9 @@ CREATE INDEX IF NOT EXISTS idx_mcp_mint_ledger_session ON mcp_mint_ledger(parent
388
388
  * on every open. Migrations occasionally disable it transiently (see
389
389
  * migrateToV14's BEGIN IMMEDIATE block); the boot path re-enables.
390
390
  *
391
+ * `busy_timeout` is likewise per-connection. See {@link BUSY_TIMEOUT_MS} for
392
+ * why a zero default is wrong for a daemon-plus-CLI deployment.
393
+ *
391
394
  * WAL requires a filesystem that supports memory-mapped shared-memory
392
395
  * (the `-shm` sidecar). NFS, some FUSE mounts, and a few Docker volume
393
396
  * drivers don't qualify and silently fall back to the prior journal mode
@@ -397,6 +400,30 @@ CREATE INDEX IF NOT EXISTS idx_mcp_mint_ledger_session ON mcp_mint_ledger(parent
397
400
  */
398
401
  const APPLY_PRAGMAS_LOGGED = new WeakSet<Database>();
399
402
 
403
+ /**
404
+ * How long SQLite parks on a locked database before giving up with
405
+ * SQLITE_BUSY (vault#527).
406
+ *
407
+ * The default is 0 — a contended write fails INSTANTLY. That's the wrong
408
+ * default for this product's actual deployment shape: the daemon holds a live
409
+ * WAL connection on :1940 while the operator runs a CLI command (`add-pack`,
410
+ * `schema migrate-field`, an import) against the same file from another
411
+ * process. Under WAL, readers never block, but two WRITERS still serialize —
412
+ * and with no timeout the CLI's write loses the instant it overlaps the
413
+ * daemon's, surfacing as an error whose only remedy is "please re-run".
414
+ *
415
+ * Five seconds is far longer than any single vault write (these are
416
+ * millisecond-scale row inserts) so it costs nothing on an uncontended box,
417
+ * while comfortably covering an overlapping daemon transaction. It is not a
418
+ * substitute for retry logic on a genuinely long-held lock — it's the
419
+ * difference between "wait your turn" and "fail immediately", which is the
420
+ * behaviour a single-file embedded database should have had from the start.
421
+ *
422
+ * Per-connection (not persistent), so it must be re-applied on every open —
423
+ * which is why it lives here rather than in a migration.
424
+ */
425
+ export const BUSY_TIMEOUT_MS = 5000;
426
+
400
427
  export interface ConnectionPragmaResult {
401
428
  /** True when the connection ended up in WAL mode. False means the FS doesn't support WAL. */
402
429
  wal: boolean;
@@ -457,6 +484,11 @@ export function applyConnectionPragmas(db: Database): ConnectionPragmaResult {
457
484
 
458
485
  try { db.exec("PRAGMA foreign_keys = ON"); } catch {}
459
486
 
487
+ // Deliberately NOT gated on the WAL branch: SQLITE_BUSY is not WAL-specific,
488
+ // and a rollback-journal DB (the NFS/FUSE fallback above) serializes harder,
489
+ // so it needs the grace more, not less.
490
+ try { db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`); } catch {}
491
+
460
492
  return { wal, journalMode };
461
493
  }
462
494
 
@@ -780,6 +780,32 @@ matter to the operator; surface the indexed fields they filter on. If the vault
780
780
  doesn't yet have the structure a surface wants, that's a signal to design tags +
781
781
  schemas first.
782
782
 
783
+ ## Deploying your surface
784
+
785
+ A surface is a **static build** — there's no server to run. \`bun run build\`
786
+ produces a \`dist/\` of plain HTML/JS/CSS that any static host serves: GitHub
787
+ Pages, Cloudflare Pages, Netlify, an S3 bucket.
788
+
789
+ For **GitHub Pages**, push the BUILT FILES to a \`gh-pages\` branch (or a
790
+ \`docs/\` folder on your default branch) and point Settings → Pages at it.
791
+ Deliberately: **don't reach for an Actions workflow.** Some agent GitHub
792
+ integrations can't write \`.github/workflows/*\` — the push comes back 403 —
793
+ and a static build doesn't need CI at all. Build locally, publish the output.
794
+
795
+ Two things must line up or the deployed surface will load and then fail to
796
+ sign in:
797
+
798
+ - the **hub origin** your surface points at has to be reachable from wherever
799
+ the browser is (a \`localhost\` hub won't answer a page served from GitHub
800
+ Pages);
801
+ - that surface's **origin must be trusted by the hub** for CORS + as an OAuth
802
+ redirect origin.
803
+
804
+ Both trace back to the same place the \`createVaultSurface\` config above does:
805
+ \`vault-info\`'s **"This vault's coordinates"** block reports this vault's hub
806
+ origin and name. If sign-in fails from the deployed URL but works from
807
+ \`localhost\`, suspect the origin allowlist before the code.
808
+
783
809
  ## Adapt this note
784
810
 
785
811
  When you build a surface for this vault, record it here: what it's for, the
@@ -1045,6 +1071,12 @@ export interface ApplySeedPackResult {
1045
1071
  * rows — so a re-run can never duplicate, and never clobbers a note the
1046
1072
  * operator/AI has since edited or recreated.
1047
1073
  *
1074
+ * The per-note check is check-then-create, so two appliers running at once
1075
+ * can both see "absent" and both try to create. The loser's path-UNIQUE
1076
+ * rejection is caught and reported as a SKIP (vault#527) — same outcome the
1077
+ * non-racing branch reports, because the note it would have written is there.
1078
+ * Only a path conflict is absorbed; see `isPathConflict`.
1079
+ *
1048
1080
  * **Tag description preservation** (Aaron-ratified 2026-07-17): a pack writes
1049
1081
  * a tag's `description` only when (a) the tag has no prior description (new
1050
1082
  * tag, or an existing bare row nothing ever described), or (b) the prior
@@ -1112,13 +1144,50 @@ export async function applySeedPack(
1112
1144
  result.skippedNotes.push(note.path);
1113
1145
  continue;
1114
1146
  }
1115
- await store.createNote(note.content, {
1116
- path: note.path,
1117
- tags: note.tags,
1118
- metadata: note.metadata,
1119
- });
1147
+ try {
1148
+ await store.createNote(note.content, {
1149
+ path: note.path,
1150
+ tags: note.tags,
1151
+ metadata: note.metadata,
1152
+ });
1153
+ } catch (err) {
1154
+ // Lost a check-then-create race (vault#527). The `getNoteByPath` above
1155
+ // said absent, but another applier committed that path before our
1156
+ // insert landed, and the path UNIQUE index rejected us. The OUTCOME is
1157
+ // the one this applier already promises — a note exists at that path
1158
+ // and we didn't clobber it — so report it the same way the non-racing
1159
+ // branch does, as a skip, rather than aborting the pack mid-way with a
1160
+ // raw constraint error the operator can only answer by re-running.
1161
+ //
1162
+ // Narrow by design: ONLY a path conflict is absorbed. Any other create
1163
+ // failure still propagates, per this function's "errors propagate"
1164
+ // contract above.
1165
+ if (!isPathConflict(err)) throw err;
1166
+ result.skippedNotes.push(note.path);
1167
+ continue;
1168
+ }
1120
1169
  result.seededNotes.push(note.path);
1121
1170
  }
1122
1171
 
1123
1172
  return result;
1124
1173
  }
1174
+
1175
+ /**
1176
+ * Is this the store's "a note already uses that path" rejection?
1177
+ *
1178
+ * Duck-typed on the STABLE `error_type` / `code` contract (vault#554) rather
1179
+ * than `instanceof PathConflictError`, because this module is deliberately
1180
+ * import-type-only — it carries no runtime dependency on `notes.ts`/`bun:sqlite`
1181
+ * so the cloud Durable Object can share it. Both fields are pinned public API
1182
+ * on the self-host class and on the REST/MCP error mapping, so matching them
1183
+ * is not a guess about internals.
1184
+ */
1185
+ function isPathConflict(err: unknown): boolean {
1186
+ if (typeof err !== "object" || err === null) return false;
1187
+ const e = err as { error_type?: unknown; code?: unknown; name?: unknown };
1188
+ return (
1189
+ e.error_type === "path_conflict" ||
1190
+ e.code === "PATH_CONFLICT" ||
1191
+ e.name === "PathConflictError"
1192
+ );
1193
+ }
@@ -36,6 +36,8 @@
36
36
  * and is the conservative floor everywhere.
37
37
  */
38
38
 
39
+ import type { SqliteType } from "./indexed-fields.js";
40
+
39
41
  /**
40
42
  * Chunk size for standalone `IN (?, ?, …)` id-lists. 90 leaves headroom
41
43
  * under the DO 100-param cap for statements whose only bound params are the
@@ -70,7 +72,35 @@ export function chunkForInClause<T>(items: readonly T[], size = IN_PARAM_CHUNK):
70
72
  */
71
73
  export const IN_VIA_JSON_EACH = "(SELECT value FROM json_each(?))";
72
74
 
73
- /** Serialize an id-set for the single {@link IN_VIA_JSON_EACH} bound param. */
74
- export function jsonEachParam(values: readonly (string | number)[]): string {
75
+ /**
76
+ * Like {@link IN_VIA_JSON_EACH}, but CASTs each `json_each` value to the
77
+ * target column's declared SQLite storage type before the `IN` comparison.
78
+ *
79
+ * A placeholder list `IN (?, ?, …)` applied the LEFT column's type affinity
80
+ * to each bound value for free — a bound `5` compared against a TEXT-affinity
81
+ * column matched the stored `'5'`. Values arriving out of a `json_each`
82
+ * subquery do NOT get that conversion, so a numeric `5` from `{ in: [5] }`
83
+ * silently stopped matching a TEXT-affinity indexed column (vault#676). The
84
+ * explicit CAST restores the old cross-type match in BOTH directions
85
+ * (TEXT column / numeric value AND INTEGER column / string value), mirroring
86
+ * exactly what the placeholder list got implicitly. `sqliteType` is a fixed
87
+ * enum ("TEXT" | "INTEGER") from `indexed_fields`, never user input, so
88
+ * interpolating it into the SQL is safe.
89
+ */
90
+ export function inViaJsonEachCast(sqliteType: SqliteType): string {
91
+ return `(SELECT CAST(value AS ${sqliteType}) FROM json_each(?))`;
92
+ }
93
+
94
+ /**
95
+ * Serialize a value-set for the single {@link IN_VIA_JSON_EACH} bound param.
96
+ *
97
+ * Accepts `boolean`/`null` as well as ids because the metadata `in`/`not_in`
98
+ * operators route through this too (vault#536), and those take any primitive
99
+ * the caller can put in a metadata field. `bigint` is deliberately NOT
100
+ * accepted — `JSON.stringify` throws on it — so callers must narrow first.
101
+ */
102
+ export function jsonEachParam(
103
+ values: readonly (string | number | boolean | null)[],
104
+ ): string {
75
105
  return JSON.stringify(values);
76
106
  }
package/core/src/store.ts CHANGED
@@ -832,55 +832,22 @@ export class BunSqliteStore implements Store {
832
832
  return { ...opts, _tagsExpanded: expanded } as QueryOpts;
833
833
  }
834
834
 
835
- async searchNotes(query: string, opts?: { tags?: string[]; limit?: number; expand?: TagExpandMode; mode?: SearchMode; sort?: "asc" | "desc" }): Promise<Note[]> {
836
- // Canonical-bare-tag guard (vault#XXX): strip leading `#` from search tag
837
- // filters before expansion, so `#manual` and `manual` resolve identically.
838
- if (opts?.tags && opts.tags.length > 0) {
839
- opts = { ...opts, tags: opts.tags.map(stripTagHash).filter((t) => t !== "") };
840
- }
841
- // Same tag-expansion treatment as queryNotes, along the SAME `expand` axis
842
- // (vault tag `expand` axis) — searching `#manual` should match notes
843
- // tagged with any descendant under "subtypes", any `manual/*` under
844
- // "namespace", etc. The underlying FTS path already uses `IN (...)` for
845
- // tags, so we flatten the per-input expansions into a single union (search
846
- // semantics are "any tag matches").
847
- //
848
- // `_default` collapse is a SUBTYPES-axis concept (the universal *parent*):
849
- // when `_default` is among the requested tags and a `_default` row exists,
850
- // the OR collapses to "every note" — drop the tag filter entirely so the
851
- // search hits the full corpus and untagged notes are reachable. It fires
852
- // only on the subtypes/both axes (mirrors `expandQueryTags`).
853
- if (opts?.tags && opts.tags.length > 0) {
854
- const mode: TagExpandMode = opts.expand ?? DEFAULT_TAG_EXPAND_MODE;
855
- const subtypeAxis = mode === "subtypes" || mode === "both";
856
- const hierarchy = this.getTagHierarchy();
857
- if (subtypeAxis && hierarchy.allTags.has(DEFAULT_TAG_NAME) && opts.tags.includes(DEFAULT_TAG_NAME)) {
858
- const { tags: _drop, expand: _e, ..._rest } = opts;
859
- return noteOps.searchNotes(this.db, query, _rest);
860
- }
861
- // Subtypes fast-path: with no declared hierarchy there are no
862
- // descendants, so the tags pass through unchanged (byte-identical to
863
- // pre-axis behavior). `exact` likewise needs no expansion.
864
- // Namespace/both must still run (lexical expansion is independent of
865
- // `parent_names`).
866
- const skipExpansion =
867
- mode === "exact" || (mode === "subtypes" && hierarchy.childrenOf.size === 0);
868
- if (!skipExpansion) {
869
- const expanded = new Set<string>();
870
- for (const t of opts.tags) {
871
- for (const x of getTagExpansion(hierarchy, t, mode)) expanded.add(x);
872
- }
873
- const { expand: _e, ..._rest } = opts;
874
- return noteOps.searchNotes(this.db, query, { ..._rest, tags: Array.from(expanded) });
875
- }
876
- }
877
- // Strip the internal `expand` before passing to noteOps (it has no field
878
- // for it; harmless but keep the boundary clean).
879
- if (opts && "expand" in opts) {
880
- const { expand: _e, ..._rest } = opts;
881
- return noteOps.searchNotes(this.db, query, _rest);
882
- }
883
- return noteOps.searchNotes(this.db, query, opts);
835
+ async searchNotes(query: string, opts?: QueryOpts & { mode?: SearchMode }): Promise<Note[]> {
836
+ // Same bare-tag strip + hierarchy expansion queryNotes uses, so
837
+ // `search` + `tag: "manual"` still matches declared descendants
838
+ // (vault#227) and `#tag` / `tag` resolve identically. Default
839
+ // `tagMatch` to `"any"` when the caller didn't set it — historical FTS
840
+ // tag semantics are a single IN (...) ("any tag matches"), which also
841
+ // makes `_default` collapse drop the whole tag filter (OR + universal
842
+ // parent = every note).
843
+ const incoming = opts ?? {};
844
+ const withTagMatch: QueryOpts & { mode?: SearchMode } = {
845
+ ...incoming,
846
+ tagMatch: incoming.tagMatch ?? (incoming.tags && incoming.tags.length > 0 ? "any" : undefined),
847
+ };
848
+ const expanded = this.expandQueryTags(this.normalizeQueryTags(withTagMatch));
849
+ const { expand: _e, ...rest } = expanded as QueryOpts & { mode?: SearchMode };
850
+ return noteOps.searchNotes(this.db, query, rest);
884
851
  }
885
852
 
886
853
  /**
@@ -1,8 +1,53 @@
1
1
  // Isolate PARACHUTE_HOME so tests never touch the real ~/.parachute directory.
2
2
  // This must run before any `./config.ts` import resolves CONFIG_DIR.
3
+ //
4
+ // The override is UNCONDITIONAL, and that is the entire point. This file used
5
+ // to read `if (!process.env.PARACHUTE_HOME)` — polite, and exactly backwards:
6
+ // the dangerous case is an *inherited* PARACHUTE_HOME, because on a developer
7
+ // box the inherited value is the live install. A machine whose shell profile
8
+ // carries `export PARACHUTE_HOME="$HOME/.parachute"` (a normal thing to have —
9
+ // it is also how parachute-hub launches the daemon) handed `bun test` a
10
+ // pointer straight at the real vault data dir, and this guard stepped politely
11
+ // aside. On 2026-08-22 two runs wrote ~158 real vault directories into a live
12
+ // install — `tagscope-*`, `mint-*`, `retier-*`, `ledger-*`, plus a `solo`
13
+ // vault from the mirror-routes tests.
14
+ //
15
+ // Nothing legitimate depends on the ambient value surviving. `config.ts`
16
+ // re-reads `process.env.PARACHUTE_HOME` on every call (see the "Historical
17
+ // note" above `configDirPath`), and every test that needs a particular home
18
+ // either assigns it at run time or passes it explicitly to the child it
19
+ // spawns. Docker and CI set PARACHUTE_HOME for the *server*, never for
20
+ // `bun test`.
3
21
  import { mkdtempSync } from "fs";
4
- import { tmpdir } from "os";
22
+ import { homedir, tmpdir } from "os";
5
23
  import { join } from "path";
6
- if (!process.env.PARACHUTE_HOME) {
7
- process.env.PARACHUTE_HOME = mkdtempSync(join(tmpdir(), "parachute-test-home-"));
24
+
25
+ const TEST_HOME = mkdtempSync(join(tmpdir(), "parachute-test-home-"));
26
+ process.env.PARACHUTE_HOME = TEST_HOME;
27
+
28
+ // Tripwire, and a live one: `mkdtempSync` builds on `tmpdir()`, which honors
29
+ // TMPDIR. An operator or CI that points TMPDIR inside the parachute home
30
+ // (`TMPDIR=~/.parachute/tmp` is not exotic) would have this file dutifully
31
+ // create the suite's "isolated" home *inside the live install*, which is the
32
+ // failure this preload exists to prevent, arrived at by another road. Assert
33
+ // the temp home is neither the real home nor nested under it.
34
+ //
35
+ // The check on `process.env.PARACHUTE_HOME` itself is deliberately NOT here:
36
+ // it was just assigned two lines up, so any such comparison is dead code that
37
+ // reads as live defense. `configDirPath()` in `src/config.ts` carries the real
38
+ // runtime tripwire, at the one function that resolves the root.
39
+ const REAL_HOME = join(homedir(), ".parachute");
40
+ if (TEST_HOME === REAL_HOME || TEST_HOME.startsWith(REAL_HOME + "/")) {
41
+ throw new Error(
42
+ `[test-preload] refusing to run the test suite: the temp home ${TEST_HOME} is inside ` +
43
+ `the live install at ${REAL_HOME} — check TMPDIR. Tests would create real vaults ` +
44
+ `there; that happened on 2026-08-22.`,
45
+ );
8
46
  }
47
+
48
+ // No cleanup handler on purpose. `bun test` dispatches neither `exit` nor
49
+ // `beforeExit` (verified on Bun 1.3.14 — a handler registered here never runs,
50
+ // even on a clean green exit), so a cleanup hook would be a comment promising
51
+ // something that does not happen. Each run therefore leaves one empty-ish
52
+ // `parachute-test-home-*` under tmpdir(); the OS reaps them, and a dropping in
53
+ // tmpdir is a rounding error next to a dropping in someone's real vault.
package/core/src/types.ts CHANGED
@@ -188,6 +188,13 @@ export interface QueryOpts {
188
188
  hasBrokenLinks?: boolean;
189
189
  path?: string; // exact path match (case-insensitive)
190
190
  pathPrefix?: string; // e.g., "Projects/Parachute" matches "Projects/Parachute/README"
191
+ /**
192
+ * Exclude notes whose path matches any of these prefixes. Same matching
193
+ * as `pathPrefix` (`n.path LIKE prefix || '%'`, ASCII case-insensitive).
194
+ * Repeatable. A note with no path is not excluded (it isn't under the
195
+ * prefix). vault#628 — `.parachute/` system-space is the first client.
196
+ */
197
+ excludePathPrefix?: string[];
191
198
  /**
192
199
  * Filter by file extension. Pass a single extension (e.g. `"csv"`) or
193
200
  * an array (e.g. `["csv", "yaml", "json"]`). Extension is compared
@@ -504,8 +511,13 @@ export interface Store {
504
511
  * rather than silently returning `[]`. `sort` (vault#551): omitted stays
505
512
  * FTS5 relevance ranking (default); an explicit "asc"/"desc" switches to
506
513
  * `created_at` ordering. See `core/src/search-query.ts`.
514
+ *
515
+ * Every other `QueryOpts` filter (excludeTags, dateFrom/dateFilter, path,
516
+ * metadata, …) composes the same way `queryNotes` / `semanticSearch` do
517
+ * (vault#647). Unspecified `tagMatch` defaults to `"any"` so historical
518
+ * FTS tag semantics (a single IN (...)) stay put.
507
519
  */
508
- searchNotes(query: string, opts?: { tags?: string[]; limit?: number; expand?: TagExpandMode; mode?: SearchMode; sort?: "asc" | "desc" }): Promise<Note[]>;
520
+ searchNotes(query: string, opts?: QueryOpts & { mode?: SearchMode }): Promise<Note[]>;
509
521
  /**
510
522
  * Semantic search (EXPERIMENTAL — see `QueryOpts.nearText`/`semantic`).
511
523
  * The one invocation point for the store's `EmbeddingProvider`: embeds
@@ -7,6 +7,7 @@ import {
7
7
  resolveWikilink,
8
8
  resolveWikilinkDetailed,
9
9
  resolveUnresolvedWikilinks,
10
+ queueUnresolvedLink,
10
11
  listUnresolvedWikilinks,
11
12
  getContentWikilinkWarnings,
12
13
  resolveOrQueueLink,
@@ -630,6 +631,18 @@ describe("delete → recreate re-resolves inbound wikilinks (LB6)", () => {
630
631
  expect(getUnresolvedLinksForNote(db, b.id)).toHaveLength(0);
631
632
  });
632
633
 
634
+ it("re-heals a non-ASCII title that differs only in case (vault#589 COLLATE NOCASE is ASCII-only)", async () => {
635
+ const source = await store.createNote("see [[CAFÉ]]", { path: "A" });
636
+ expect(getUnresolvedLinksForNote(db, source.id).map((r) => r.target_path ?? r.target)).toEqual(["CAFÉ"]);
637
+ expect(await store.getLinks(source.id, { direction: "outbound" })).toHaveLength(0);
638
+
639
+ const target = await store.createNote("# café\n\nbody", { path: "people/cafe" });
640
+ const links = await store.getLinks(source.id, { direction: "outbound" });
641
+ expect(links).toHaveLength(1);
642
+ expect(links[0]!.targetId).toBe(target.id);
643
+ expect(getUnresolvedLinksForNote(db, source.id)).toHaveLength(0);
644
+ });
645
+
633
646
  // The completed sweep must NOT mis-resolve an AMBIGUOUS target — matching
634
647
  // write-time's "don't guess" contract. A pending [[John Doe]] is swept when
635
648
  // a same-titled note is created; if TWO notes already share that H1 by the
@@ -766,3 +779,47 @@ describe("ensureRelationshipColumn — crash-safe rebuild", () => {
766
779
  expect(links.some((l) => l.targetId === targetA!.id && l.relationship === "wikilink")).toBe(true);
767
780
  });
768
781
  });
782
+
783
+ describe("deferred resolution — ID leg (vault#591)", () => {
784
+ it("heals an ID-valued pending row when the target note is later created", async () => {
785
+ const source = await store.createNote("src", { path: "Src" });
786
+ const futureId = "tgt-id-591";
787
+ queueUnresolvedLink(db, source.id, futureId, "reference");
788
+ expect(await store.getLinks(source.id, { direction: "outbound" })).toHaveLength(0);
789
+
790
+ const target = await store.createNote("tgt", { id: futureId, path: "Tgt" });
791
+ const links = await store.getLinks(source.id, { direction: "outbound" });
792
+ expect(links).toHaveLength(1);
793
+ expect(links[0]!.targetId).toBe(target.id);
794
+ expect(links[0]!.relationship).toBe("reference");
795
+ expect(getUnresolvedLinksForNote(db, source.id)).toHaveLength(0);
796
+ });
797
+
798
+ // Watch-fail: a shared ID-first verify lets a decoy whose id equals the
799
+ // pending bracket text short-circuit resolveLinkTargetDetailed. The
800
+ // titled note's sweep then sees detail.note_id !== noteId and leaves the
801
+ // row queued forever. Write-time resolveWikilinkDetailed has no ID leg,
802
+ // so a fresh save would heal to the titled note.
803
+ it("does not let an ID-named decoy steal a pending wikilink from a later titled note", async () => {
804
+ await store.createNote("decoy body", { id: "shadow", path: "people/decoy" });
805
+ const source = await store.createNote("see [[shadow]]", { path: "Src" });
806
+ expect(getUnresolvedLinksForNote(db, source.id).map((l) => l.target)).toEqual(["shadow"]);
807
+ expect(await store.getLinks(source.id, { direction: "outbound" })).toHaveLength(0);
808
+
809
+ const titled = await store.createNote("# shadow\n\nreal.", { path: "people/shadow" });
810
+ const links = await store.getLinks(source.id, { direction: "outbound" });
811
+ expect(links).toHaveLength(1);
812
+ expect(links[0]!.targetId).toBe(titled.id);
813
+ expect(links[0]!.relationship).toBe("wikilink");
814
+ expect(getUnresolvedLinksForNote(db, source.id)).toHaveLength(0);
815
+ });
816
+
817
+ it("does not heal a pending wikilink against a later note whose id equals the bracket text", async () => {
818
+ const source = await store.createNote("see [[foo591]]", { path: "Src" });
819
+ expect(getUnresolvedLinksForNote(db, source.id).map((l) => l.target)).toEqual(["foo591"]);
820
+
821
+ await store.createNote("unrelated", { id: "foo591", path: "elsewhere" });
822
+ expect(await store.getLinks(source.id, { direction: "outbound" })).toHaveLength(0);
823
+ expect(getUnresolvedLinksForNote(db, source.id).map((l) => l.target)).toEqual(["foo591"]);
824
+ });
825
+ });
@@ -624,26 +624,27 @@ function syncUnresolvedWikilinks(
624
624
  * (a structured link queued via {@link queueUnresolvedLink} backfills with
625
625
  * the caller's original relationship, not "wikilink").
626
626
  *
627
- * Deferred resolution now covers all four legs of
628
- * {@link resolveWikilinkDetailed}: exact path, basename, H1 title, and the
629
- * explicit `path.ext` form. (Caveat: the candidate pre-filter below matches the
630
- * title/ext legs via SQL `COLLATE NOCASE`, which case-folds ASCII only — a
631
- * non-ASCII title differing from the target only in letter case is missed at
632
- * the candidate stage and re-heals on the source's next save instead. Rare;
633
- * tracked as a follow-up.) Before this, the sweep matched a pending row to
634
- * the new note by PATH TEXT ONLY (`target_path = path OR path LIKE
635
- * '%/'||target_path`) so a `[[John Doe]]` that resolved at write time via
636
- * the H1-title fallback (its note's displayed title differs from its path,
637
- * e.g. `people/jdoe`), or a `[[Foo.csv]]` that resolved via the extension
638
- * leg, silently never re-healed on a delete→recreate (the exact LB6 gap) and
627
+ * Deferred resolution covers all four legs of
628
+ * {@link resolveWikilinkDetailed} (exact path, basename, H1 title, explicit
629
+ * `path.ext`) plus, for structured-link pending rows, the ID leg of
630
+ * {@link resolveLinkTargetDetailed} (vault#591). Verify picks the resolver
631
+ * by `row.relationship` so a wikilink row cannot heal via ID. (Caveat: the
632
+ * candidate pre-filter below matches the title/ext legs via SQL
633
+ * `COLLATE NOCASE`, which case-folds ASCII only a non-ASCII title
634
+ * differing from the target only in letter case is missed at the candidate
635
+ * stage and re-heals on the source's next save instead. Rare; tracked as a
636
+ * follow-up.) Before this, the sweep matched a pending row to the new note
637
+ * by PATH TEXT ONLY (`target_path = path OR path LIKE '%/'||target_path`)
638
+ * so a `[[John Doe]]` that resolved at write time via the H1-title
639
+ * fallback (its note's displayed title differs from its path, e.g.
640
+ * `people/jdoe`), or a `[[Foo.csv]]` that resolved via the extension leg,
641
+ * silently never re-healed on a delete→recreate (the exact LB6 gap) and
639
642
  * more broadly never backfilled when the target note was created AFTER the
640
- * referencing note. Each candidate pending row is now VERIFIED through
641
- * `resolveWikilinkDetailed` against the current DB a row is healed only
642
- * when its target string actually resolves to THIS note. An AMBIGUOUS target
643
- * (≥2 notes now share the path/title) resolves to neither and stays queued,
644
- * identical to write-time's "don't guess" contract — this also closes the
645
- * pre-existing asymmetry where the path-only sweep would link an ambiguous
646
- * `[[Foo]]` to whichever colliding note happened to be created.
643
+ * referencing note. An AMBIGUOUS target (≥2 notes now share the
644
+ * path/title) resolves to neither and stays queued, identical to
645
+ * write-time's "don't guess" contract this also closes the pre-existing
646
+ * asymmetry where the path-only sweep would link an ambiguous `[[Foo]]` to
647
+ * whichever colliding note happened to be created.
647
648
  *
648
649
  * Returns the number of links resolved.
649
650
  */
@@ -666,19 +667,45 @@ export function resolveUnresolvedWikilinks(
666
667
  let rows: { source_id: string; target_path: string; relationship: string }[];
667
668
  try {
668
669
  // Candidate pre-filter: every pending row whose `target_path` COULD
669
- // resolve to this note under any resolveWikilinkDetailed leg — exact
670
- // path, basename (target is the last path segment), H1 title, or the
671
- // `path.ext` form. A `null` bind (no H1 heading / no extension) makes its
672
- // clause never match (`target_path = NULL` is NULL, i.e. falsy in SQL).
673
- // The verify step below is what enforces correctness; this clause only
674
- // BOUNDS how many rows reach the (title-fallback-scanning) resolver.
670
+ // resolve to this note under any resolveLinkTargetDetailed leg — exact
671
+ // path, basename (target is the last path segment), H1 title, the
672
+ // `path.ext` form, or a raw note ID (vault#591: typed `reference` fields
673
+ // and ID-form structured links). A `null` bind (no H1 heading / no
674
+ // extension) makes its clause never match (`target_path = NULL` is NULL,
675
+ // i.e. falsy in SQL). The verify step below is what enforces correctness;
676
+ // this clause only BOUNDS how many rows reach the resolver.
675
677
  rows = db.prepare(`
676
678
  SELECT source_id, target_path, relationship FROM unresolved_wikilinks
677
679
  WHERE target_path = ? COLLATE NOCASE
678
680
  OR ? LIKE '%/' || target_path
679
681
  OR target_path = ? COLLATE NOCASE
680
682
  OR target_path = ? COLLATE NOCASE
681
- `).all(notePath, notePath, h1Title, pathDotExt) as typeof rows;
683
+ OR target_path = ?
684
+ `).all(notePath, notePath, h1Title, pathDotExt, noteId) as typeof rows;
685
+
686
+ // vault#589: SQLite COLLATE NOCASE is ASCII-only. `[[CAFÉ]]` vs H1
687
+ // `café` is excluded by the SQL pre-filter even though
688
+ // findNotesByTitle folds with JS toLowerCase. Union remaining pending
689
+ // rows whose target Unicode-folds equal to the H1; verify still
690
+ // decides. The path.ext fold is omitted: write-time's extension leg
691
+ // also uses COLLATE NOCASE on path, so a unicode-only path.ext miss
692
+ // would fail verify too. (`[[FOO.CSV]]` vs `foo.csv` is ASCII and
693
+ // already caught by the SQL clause.)
694
+ if (h1Title) {
695
+ const all = db.prepare(
696
+ "SELECT source_id, target_path, relationship FROM unresolved_wikilinks",
697
+ ).all() as typeof rows;
698
+ const seen = new Set(rows.map((r) => `${r.source_id}\0${r.target_path}\0${r.relationship}`));
699
+ const h1 = h1Title.toLowerCase();
700
+ for (const row of all) {
701
+ const key = `${row.source_id}\0${row.target_path}\0${row.relationship}`;
702
+ if (seen.has(key)) continue;
703
+ if (row.target_path.toLowerCase() === h1) {
704
+ rows.push(row);
705
+ seen.add(key);
706
+ }
707
+ }
708
+ }
682
709
  } catch {
683
710
  return 0; // Table doesn't exist
684
711
  }
@@ -694,10 +721,17 @@ export function resolveUnresolvedWikilinks(
694
721
  // target string actually resolves to THIS note now. A miss or an
695
722
  // ambiguous result leaves the row queued (surfaced as a visible broken
696
723
  // link, and re-tried on the next matching note create).
697
- const detail = resolveWikilinkDetailed(db, row.target_path);
698
- if (!detail.resolved || detail.note_id !== noteId) continue;
699
-
724
+ // Resolver is picked by relationship (vault#591): wikilink rows go
725
+ // through resolveWikilinkDetailed (write-time has no ID leg).
726
+ // Structured-link rows go through resolveLinkTargetDetailed
727
+ // (ID-then-path). A shared ID-first resolver over-heals `[[foo591]]`
728
+ // against a later note with that id, and lets a decoy whose id equals
729
+ // the bracket text steal a pending wikilink from a later titled note.
700
730
  const relationship = row.relationship || WIKILINK_REL;
731
+ const detail = relationship === WIKILINK_REL
732
+ ? resolveWikilinkDetailed(db, row.target_path)
733
+ : resolveLinkTargetDetailed(db, row.target_path);
734
+ if (!detail.resolved || detail.note_id !== noteId) continue;
701
735
  linkOps.createLink(db, row.source_id, noteId, relationship);
702
736
  resolved++;
703
737
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.7.6",
3
+ "version": "0.7.7",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
@@ -431,6 +431,68 @@ describe("attachment tickets — download lifecycle", () => {
431
431
  expect(secondRes.status).toBe(404);
432
432
  });
433
433
 
434
+ test("spend Content-Type is extension-derived, not the caller-asserted row mime (vault#617)", async () => {
435
+ const vaultName = freshVault("tickets-download-mime");
436
+ const store = getVaultStore(vaultName);
437
+ const note = await store.createNote("# T\n", { path: "t" });
438
+
439
+ const uploadMint = await callTool(vaultName, "request-attachment-upload", {
440
+ note: note.id,
441
+ filename: "photo.png",
442
+ size_bytes: 4,
443
+ mime_type: "text/html",
444
+ });
445
+ const uploadRes = await routeReq(
446
+ new Request(uploadMint.url, {
447
+ method: "PUT",
448
+ headers: { "content-type": "text/html" },
449
+ body: new Uint8Array([137, 80, 78, 71]),
450
+ }),
451
+ );
452
+ const attachment = (await uploadRes.json()) as any;
453
+ expect(attachment.mimeType).toBe("text/html");
454
+
455
+ const downloadMint = await callTool(vaultName, "request-attachment-download", {
456
+ attachment_id: attachment.id,
457
+ });
458
+ const downloadRes = await routeReq(new Request(downloadMint.url, { method: "GET" }));
459
+ expect(downloadRes.status).toBe(200);
460
+ expect(downloadRes.headers.get("content-type")).toBe("image/png");
461
+ expect(downloadRes.headers.get("x-content-type-options")).toBe("nosniff");
462
+ expect(downloadMint.mime_type).toBe("image/png");
463
+ });
464
+
465
+ test("uncurated extension with caller-asserted text/html serves octet-stream (vault#617)", async () => {
466
+ const vaultName = freshVault("tickets-download-octet");
467
+ const store = getVaultStore(vaultName);
468
+ const note = await store.createNote("# T\n", { path: "t" });
469
+
470
+ const uploadMint = await callTool(vaultName, "request-attachment-upload", {
471
+ note: note.id,
472
+ filename: "payload.bin",
473
+ size_bytes: 4,
474
+ mime_type: "text/html",
475
+ });
476
+ const uploadRes = await routeReq(
477
+ new Request(uploadMint.url, {
478
+ method: "PUT",
479
+ headers: { "content-type": "text/html" },
480
+ body: new Uint8Array([10, 20, 30, 40]),
481
+ }),
482
+ );
483
+ expect(uploadRes.status).toBe(201);
484
+ const attachment = (await uploadRes.json()) as any;
485
+
486
+ const downloadMint = await callTool(vaultName, "request-attachment-download", {
487
+ attachment_id: attachment.id,
488
+ });
489
+ const downloadRes = await routeReq(new Request(downloadMint.url, { method: "GET" }));
490
+ expect(downloadRes.status).toBe(200);
491
+ expect(downloadRes.headers.get("content-type")).toBe("application/octet-stream");
492
+ expect(downloadRes.headers.get("x-content-type-options")).toBe("nosniff");
493
+ expect(downloadMint.mime_type).toBe("application/octet-stream");
494
+ });
495
+
434
496
  test("path confinement: an attachment row pointing outside assetsDir can't be walked to via a download ticket", async () => {
435
497
  const vaultName = freshVault("tickets-confinement");
436
498
  const store = getVaultStore(vaultName);