@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
@@ -20,7 +20,7 @@ import { mkdirSync, writeFileSync, existsSync, readFileSync, statSync } from "fs
20
20
  import { join, normalize } from "path";
21
21
  import type { Store } from "../core/src/types.ts";
22
22
  import type { AttachmentTicket, AttachmentTicketProvider } from "../core/src/attachment/tickets.ts";
23
- import { sanitizeAttachmentExtension } from "../core/src/attachment/policy.ts";
23
+ import { sanitizeAttachmentExtension, contentTypeForAttachmentPath } from "../core/src/attachment/policy.ts";
24
24
  import { assetsDir, readVaultConfig } from "./config.ts";
25
25
  import {
26
26
  NO_PROVIDER_ERROR,
@@ -370,7 +370,7 @@ async function handleDownloadSpend(
370
370
  return new Response(fileBuffer, {
371
371
  status: 200,
372
372
  headers: {
373
- "Content-Type": attachment.mimeType || "application/octet-stream",
373
+ "Content-Type": contentTypeForAttachmentPath(attachment.path),
374
374
  "Content-Length": String(stat.size),
375
375
  // Same defense-in-depth as the existing GET /storage/<path> byte-serve
376
376
  // (src/routes.ts) — never let a browser MIME-sniff a stored asset into
package/src/cli.ts CHANGED
@@ -176,7 +176,7 @@ import {
176
176
  TRANSCRIBE_CPP_SOURCE_REF,
177
177
  type CliBuildResult,
178
178
  } from "./transcription/build.ts";
179
- import { downloadTo } from "./transcription/download.ts";
179
+ import { downloadTo, ensureDownloaded } from "./transcription/download.ts";
180
180
  import {
181
181
  describeWhisperPlan,
182
182
  planWhisperInstall,
@@ -3983,11 +3983,31 @@ async function runTranscribeCppInstall(opts: {
3983
3983
 
3984
3984
  // 2) Model GGUF.
3985
3985
  const modelDest = join(paths.modelsDir, plan.model!.file);
3986
- if (existsSync(modelDest) && !force) {
3987
- console.log(`✓ model already present (${modelDest}) skipping (use --force to re-download).`);
3988
- } else {
3989
- console.log(`Downloading model ${plan.model!.file} (~${plan.model!.approxSizeMb}MB) …`);
3990
- await downloadTo(plan.model!.url, modelDest);
3986
+ // `ensureDownloaded` (vault#531) replaces a bare existsSync skip: when the
3987
+ // host publishes a digest (HuggingFace does for every GGUF here) an
3988
+ // already-present file is VERIFIED before it's trusted, so a model left
3989
+ // corrupt by an interrupted run gets repaired instead of being skipped
3990
+ // forever by every future non---force re-run.
3991
+ const modelResult = await ensureDownloaded(plan.model!.url, modelDest, {
3992
+ force,
3993
+ onBeforeDownload: (reason) => {
3994
+ if (reason === "corrupt") {
3995
+ console.log(
3996
+ `⚠ model at ${modelDest} failed its checksum — re-downloading ${plan.model!.file} …`,
3997
+ );
3998
+ } else {
3999
+ console.log(`Downloading model ${plan.model!.file} (~${plan.model!.approxSizeMb}MB) …`);
4000
+ }
4001
+ },
4002
+ });
4003
+ if (modelResult.outcome === "reused") {
4004
+ console.log(
4005
+ modelResult.verified
4006
+ ? `✓ model already present and checksum-verified (${modelDest}) — skipping.`
4007
+ : `✓ model already present (${modelDest}) — skipping (use --force to re-download).`,
4008
+ );
4009
+ } else if (modelResult.verified) {
4010
+ console.log(`✓ model checksum verified.`);
3991
4011
  }
3992
4012
 
3993
4013
  // 3) Build transcribe-cli from source against the extracted dylibs.
@@ -4283,10 +4303,18 @@ async function cmdTranscriptionStatus(): Promise<void> {
4283
4303
  legacy.push(` transcribe-cpp ${probe.ok ? "runnable" : `not runnable (${probe.reason})`}`);
4284
4304
  }
4285
4305
  if (active === "parakeet-mlx" || parakeetMlxInstalled()) {
4286
- legacy.push(` parakeet-mlx ${parakeetMlxInstalled() ? `runnable (${resolveParakeetMlxBin()})` : "not installed"}`);
4306
+ const bin = resolveParakeetMlxBin();
4307
+ const probe = bin ? await probeTranscribeCliRunnable(bin) : { ok: false, reason: "not installed" };
4308
+ legacy.push(
4309
+ ` parakeet-mlx ${probe.ok ? `runnable (${bin})` : `not runnable (${probe.reason})`}`,
4310
+ );
4287
4311
  }
4288
4312
  if (active === "onnx-asr" || onnxAsrInstalled()) {
4289
- legacy.push(` onnx-asr ${onnxAsrInstalled() ? `runnable (${resolveOnnxAsrBin()})` : "not installed"}`);
4313
+ const bin = resolveOnnxAsrBin();
4314
+ const probe = bin ? await probeTranscribeCliRunnable(bin) : { ok: false, reason: "not installed" };
4315
+ legacy.push(
4316
+ ` onnx-asr ${probe.ok ? `runnable (${bin})` : `not runnable (${probe.reason})`}`,
4317
+ );
4290
4318
  }
4291
4319
  if (legacy.length > 0) {
4292
4320
  console.log("\nLegacy providers (superseded by whisper-cpp):");
package/src/config.ts CHANGED
@@ -61,7 +61,40 @@ import {
61
61
  // ---------------------------------------------------------------------------
62
62
 
63
63
  function configDirPath(): string {
64
- return process.env.PARACHUTE_HOME ?? join(homedir(), ".parachute");
64
+ const fromEnv = process.env.PARACHUTE_HOME;
65
+ if (fromEnv) return fromEnv;
66
+ // Second layer of the test-isolation defense (`core/src/test-preload.ts` is
67
+ // the first). The preload is loaded by `bunfig.toml`, which Bun reads
68
+ // relative to the *cwd* — run `bun test path/to/parachute-vault/src/...`
69
+ // from a parent directory and the preload silently never loads, leaving
70
+ // every unset-PARACHUTE_HOME test writing real vaults into the developer's
71
+ // live install. `bun test` sets NODE_ENV=test, so the ambiguity is
72
+ // detectable here, at the one function that resolves the root. Deliberately
73
+ // narrow: only the *unset* case throws. Tests legitimately point
74
+ // PARACHUTE_HOME at a sandbox whose HOME is that same sandbox (init.test.ts
75
+ // does exactly this), so an equality check against `~/.parachute` would fire
76
+ // on correct code.
77
+ //
78
+ // This is reachable in SHIPPED code, not just in this repo's suite: the
79
+ // package ships source (`bin` points at `src/cli.ts`, no bundler) and
80
+ // `CONFIG_DIR` below is evaluated at module load, so an end user whose
81
+ // vitest/jest integration test shells out to the CLI hits it — those runners
82
+ // set NODE_ENV=test too. The message has to serve that reader as well as a
83
+ // contributor. The escape hatch needs no new env var: the guard only fires
84
+ // when PARACHUTE_HOME is UNSET, so setting it — to a sandbox, or to the real
85
+ // `~/.parachute` if that is genuinely what the test wants — is both the fix
86
+ // and the statement of intent.
87
+ if (process.env.NODE_ENV === "test") {
88
+ throw new Error(
89
+ "[parachute-vault] NODE_ENV=test and PARACHUTE_HOME is unset — refusing to default " +
90
+ "to the live install at ~/.parachute, because a test run that writes there creates " +
91
+ "real vaults in a real install. Set PARACHUTE_HOME explicitly for this process: a " +
92
+ "temp dir to keep the test hermetic, or ~/.parachute if you truly mean the live " +
93
+ "install. (Contributors to parachute-vault: running `bun test` from the repo root " +
94
+ "sets it for you via bunfig.toml's preload.)",
95
+ );
96
+ }
97
+ return join(homedir(), ".parachute");
65
98
  }
66
99
 
67
100
  function vaultHomePath(): string {
@@ -286,7 +286,39 @@ describe("contract: truncation-honesty warning (V1.3)", () => {
286
286
  const query = tools.find((t) => t.name === "query-notes")!;
287
287
  const result = (await query.execute({ limit: 2 })) as any;
288
288
  expect(result.warnings).toBeDefined();
289
- expect(result.warnings.some((w: any) => w.code === "truncated" && w.limit === 2)).toBe(true);
289
+ const truncated = result.warnings.find((w: any) => w.code === "truncated" && w.limit === 2);
290
+ expect(truncated).toBeDefined();
291
+ expect(truncated.message).toContain('cursor: ""');
292
+ expect(truncated.message).not.toContain("?cursor=");
293
+ });
294
+
295
+ it("an explicit-offset page does not carry a truncated warning (vault#601)", async () => {
296
+ for (let i = 0; i < 3; i++) await store.createNote(`offset note ${i}`);
297
+ const res = await getNotes("limit=2&offset=1");
298
+ expect(res.status).toBe(200);
299
+ const body: any[] = await res.json();
300
+ expect(body.length).toBe(2);
301
+ const warnings = decodeWarningsHeader(res);
302
+ expect(warnings?.some((w) => w.code === "truncated") ?? false).toBe(false);
303
+ });
304
+
305
+ it("MCP query-notes with explicit offset does not warn (vault#601)", async () => {
306
+ for (let i = 0; i < 3; i++) await store.createNote(`mcp offset ${i}`);
307
+ const tools = generateMcpTools(store);
308
+ const query = tools.find((t) => t.name === "query-notes")!;
309
+ const result = (await query.execute({ limit: 2, offset: 1 })) as any;
310
+ const notes = Array.isArray(result) ? result : result.notes;
311
+ expect(notes).toHaveLength(2);
312
+ expect(result.warnings?.some((w: any) => w.code === "truncated") ?? false).toBe(false);
313
+ });
314
+
315
+ it("REST truncated wording still names ?cursor= (vault#601)", async () => {
316
+ for (let i = 0; i < 3; i++) await store.createNote(`rest wording ${i}`);
317
+ const res = await getNotes("limit=2");
318
+ const warnings = decodeWarningsHeader(res);
319
+ const truncated = warnings!.find((w) => w.code === "truncated");
320
+ expect(truncated.message).toContain("?cursor=");
321
+ expect(truncated.message).not.toContain('cursor: ""');
290
322
  });
291
323
  });
292
324
 
@@ -133,6 +133,53 @@ describe("contract: search — passing (lock in current behavior)", () => {
133
133
  });
134
134
  });
135
135
 
136
+ describe("contract: search composes with structured filters (vault#647)", () => {
137
+ it("REST ?search= + exclude_tag drops notes that carry the excluded tag", async () => {
138
+ await store.createNote("unique-647-term keep", { tags: ["keep-tag"] });
139
+ await store.createNote("unique-647-term drop", { tags: ["keep-tag", "drop-tag"] });
140
+ const res = await search("search=unique-647-term&tag=keep-tag&exclude_tag=drop-tag&include_content=true");
141
+ expect(res.status).toBe(200);
142
+ const body = await bodyOf(res);
143
+ const contents = body.map((n: any) => n.content);
144
+ expect(contents).toContain("unique-647-term keep");
145
+ expect(contents).not.toContain("unique-647-term drop");
146
+ });
147
+
148
+ it("REST ?search= + meta[created_at][gte] drops notes below the date floor", async () => {
149
+ // REST removed the flat date_from param at 0.6.4; the live date grammar
150
+ // is bracket-style. Same composition hole as MCP date_from.
151
+ await store.createNote("unique-647-date recent", {
152
+ tags: ["keep-tag"],
153
+ created_at: "2026-07-01T00:00:00.000Z",
154
+ });
155
+ await store.createNote("unique-647-date old", {
156
+ tags: ["keep-tag"],
157
+ created_at: "2023-06-01T00:00:00.000Z",
158
+ });
159
+ const res = await search(
160
+ "search=unique-647-date&tag=keep-tag&meta[created_at][gte]=2026-06-01&include_content=true",
161
+ );
162
+ expect(res.status).toBe(200);
163
+ const body = await bodyOf(res);
164
+ const contents = body.map((n: any) => n.content);
165
+ expect(contents).toContain("unique-647-date recent");
166
+ expect(contents).not.toContain("unique-647-date old");
167
+ });
168
+
169
+ it("REST ?search= + exclude_path_prefix drops matching paths (vault#628)", async () => {
170
+ await store.createNote("unique-628-search-term keep", { path: "Projects/a" });
171
+ await store.createNote("unique-628-search-term drop", { path: ".parachute/notes/settings" });
172
+ const res = await search(
173
+ "search=unique-628-search-term&exclude_path_prefix=.parachute/&include_content=true",
174
+ );
175
+ expect(res.status).toBe(200);
176
+ const body = await bodyOf(res);
177
+ const contents = body.map((n: any) => n.content);
178
+ expect(contents).toContain("unique-628-search-term keep");
179
+ expect(contents).not.toContain("unique-628-search-term drop");
180
+ });
181
+ });
182
+
136
183
  describe('contract: search — literal-by-default (#551, flipped from todo)', () => {
137
184
  it(`unquoted search "didn't" finds the contraction content (literal-by-default — the bare apostrophe used to split into two AND'd tokens and return [])`, async () => {
138
185
  const res = await search(`search=${encodeURIComponent("didn't")}&include_content=true`);
@@ -14,8 +14,11 @@
14
14
  * bearer token, when the endpoint needs one.
15
15
  * - **Bundled floor** (`onnx-transformers.ts`): no env at all → the
16
16
  * zero-config default, `bge-small-en-v1.5` (q8 ONNX) running
17
- * in-process. This is what makes semantic search work on a fresh
18
- * install with no operator action.
17
+ * in-process. This is what makes semantic search work with no
18
+ * provider configuration but note it is still gated by the opt-in
19
+ * switch below, so "no env at all" now means NO provider, not the
20
+ * bundled one. Pre-0.7.3 this tier made the feature work on a fresh
21
+ * install with no operator action at all; that is no longer true.
19
22
  *
20
23
  * **Opt-in gate (0.7.3, Aaron-ratified):** semantic search is OFF by
21
24
  * default. `buildEmbeddingProvider` returns a provider ONLY when the
@@ -25,7 +28,17 @@
25
28
  *
26
29
  * 1. **`EMBEDDINGS_ENABLED` env var** — the low-level override. `true`/`1`
27
30
  * forces ON, `false`/`0` forces OFF, anything else (incl. unset)
28
- * defers to the persisted setting. Mirrors the cloud wrangler var.
31
+ * defers to the persisted setting.
32
+ *
33
+ * NOTE: this is **tri-state** (on / off / defer), not the plain
34
+ * off-switch it used to be — before 0.7.3 the feature was on unless
35
+ * this var turned it off, so an UNSET var meant ON; now an unset var
36
+ * means "defer", and the persisted setting defaults OFF. Anything
37
+ * that consumes this selector and relies on the old
38
+ * unset-means-enabled reading will silently come up with semantic
39
+ * search DISABLED. If the cloud worker shares this selector, set its
40
+ * embeddings default EXPLICITLY rather than leaving it to this
41
+ * default-off (vault#623).
29
42
  * 2. **Persisted `embeddings_enabled`** (config.yaml, wired in by the
30
43
  * caller — see `getSharedEmbeddingProvider`) — the self-host settings
31
44
  * toggle, so an operator can turn semantic search on without editing
@@ -122,6 +122,14 @@ describe("live-match — predicate parity with the query engine", () => {
122
122
  expect(ids.size).toBe(2);
123
123
  });
124
124
 
125
+ it("excludePathPrefix (vault#628)", async () => {
126
+ await store.createNote("user", { path: "Projects/a" });
127
+ await store.createNote("sys", { path: ".parachute/notes/settings" });
128
+ await store.createNote("bare");
129
+ const ids = await assertParity({ excludePathPrefix: [".parachute/"] });
130
+ expect(ids.size).toBe(2);
131
+ });
132
+
125
133
  it("hasTags true/false (M1 — presence parity)", async () => {
126
134
  await store.createNote("tagged", { tags: ["x"] });
127
135
  await store.createNote("bare", {});
package/src/live-match.ts CHANGED
@@ -21,6 +21,8 @@
21
21
  * - `excludeTags` — raw exact-name match (engine does NOT expand excludes).
22
22
  * - `path` — case-insensitive exact (engine: `n.path = ? COLLATE NOCASE`).
23
23
  * - `pathPrefix` — prefix (engine: `n.path LIKE prefix || '%'`).
24
+ * - `excludePathPrefix` — NOT those prefixes (engine: `n.path IS NULL OR
25
+ * n.path NOT LIKE prefix || '%'`). Repeatable. vault#628.
24
26
  * - `extension` — lower-cased, default "md" (engine: `LOWER(n.extension)`),
25
27
  * a note with no extension is treated as "md".
26
28
  * - `metadata` operator objects (eq/ne/gt/gte/lt/lte/in/not_in/exists) +
@@ -283,6 +285,19 @@ function matchAgainst(
283
285
  if (!note.path || !note.path.toLowerCase().startsWith(opts.pathPrefix.toLowerCase())) return false;
284
286
  }
285
287
 
288
+ // ---- excludePathPrefix (vault#628) — same CI prefix match as pathPrefix;
289
+ // a note with no path is not under the prefix, so it stays.
290
+ if (opts.excludePathPrefix && opts.excludePathPrefix.length > 0) {
291
+ const p = note.path;
292
+ if (p) {
293
+ const lower = p.toLowerCase();
294
+ for (const prefix of opts.excludePathPrefix) {
295
+ if (typeof prefix !== "string" || prefix.length === 0) continue;
296
+ if (lower.startsWith(prefix.toLowerCase())) return false;
297
+ }
298
+ }
299
+ }
300
+
286
301
  // ---- extension (lower-cased; default "md") ----
287
302
  if (opts.extension !== undefined) {
288
303
  const exts = Array.isArray(opts.extension) ? opts.extension : [opts.extension];
@@ -128,6 +128,18 @@ describe("handleMcp JSON-RPC error mapping — end to end (vault#555 fix 6)", ()
128
128
  expect(body.error.data.field).toBe("limit");
129
129
  });
130
130
 
131
+ test("invalid_query forwards how_to (vault#617 bun/cloud symmetry)", async () => {
132
+ const body = await callBoom(() => {
133
+ throw Object.assign(new Error("size_bytes must be a positive number"), {
134
+ error_type: "invalid_query",
135
+ field: "size_bytes",
136
+ how_to: "pass the exact byte length of the file you're about to upload",
137
+ });
138
+ });
139
+ expect(body.error.data.error_type).toBe("invalid_query");
140
+ expect(body.error.data.how_to).toBe("pass the exact byte length of the file you're about to upload");
141
+ });
142
+
131
143
  test("a truly unknown error (no error_type anywhere) falls through to the unstructured isError text, not a thrown McpError", async () => {
132
144
  const body = await callBoom(() => {
133
145
  throw new Error("plain unstructured failure");
package/src/mcp-http.ts CHANGED
@@ -258,6 +258,7 @@ export async function handleMcp(
258
258
  field: e.field,
259
259
  got: e.got,
260
260
  hint: e.hint,
261
+ ...(e.how_to !== undefined ? { how_to: e.how_to } : {}),
261
262
  });
262
263
  }
263
264
  // Advanced-mode full-text search syntax error (vault#551) — a
package/src/mcp-tools.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  filterHydratedLinksByTagScope,
24
24
  noteWithinTagScope,
25
25
  scrubIndexedFieldConflictError,
26
+ scrubNoteTagsByScope,
26
27
  scrubParentCycleError,
27
28
  scrubReferencingTagsByScope,
28
29
  scrubTagFieldViolationsByScope,
@@ -398,33 +399,57 @@ function applyTagScopeWrappers(
398
399
  };
399
400
  const rawTags = auth.scoped_tags;
400
401
 
401
- // Scrub a returned note's hydrated `links` array (present when the caller
402
- // set `include_links`) so out-of-scope NEIGHBOR summaries (id/path/tags)
403
- // don't leak symmetric with the REST `include_links` fix. Mutates in
404
- // place and returns the note for chaining. No-op when `links` is absent.
402
+ // Apply every scope scrub a returned note needs, in one place:
403
+ //
404
+ // 1. hydrated `links` (present when the caller set `include_links`) drop
405
+ // out-of-scope NEIGHBOR summaries (id/path/tags) and scrub the
406
+ // surviving summaries' own `.tags`; symmetric with REST.
407
+ // 2. `validation_status` (vault#555 auth review) — a note the caller can
408
+ // see may ALSO carry an out-of-scope co-tag whose schema would
409
+ // otherwise leak (field name / type / enum values, the #560 class).
410
+ // 3. the note's OWN `.tags` array (vault#568) — being admitted via one
411
+ // in-scope tag must not disclose the NAMES of its out-of-scope
412
+ // co-tags. This is the same leak class as (2) through a plainer field.
413
+ //
414
+ // (1) and (2) mutate in place; (3) is non-mutating and may return a NEW
415
+ // object, so callers must use the RETURN VALUE, never rely on the mutation.
405
416
  //
406
417
  // Ordering invariant: reading `allowedHolder.value` here is safe ONLY
407
- // because every wrapper that calls scrubNoteLinks first does
418
+ // because every wrapper that calls scrubNoteForScope first does
408
419
  // `await getAllowed()` (which populates the holder) before `orig(params)`
409
420
  // and before this scrub runs. So by the time we read `holder.value` it is
410
421
  // the resolved allowlist, never the initial `null`. The `?? null` fallback
411
422
  // is the unscoped/holder-absent path; `filterHydratedLinksByTagScope` then
412
423
  // keys off `rawTags` (non-null here) for the actual scope check.
413
- const scrubNoteLinks = (n: any): any => {
424
+ const scrubNoteForScope = (n: any): any => {
414
425
  if (n && Array.isArray(n.links)) {
415
426
  n.links = filterHydratedLinksByTagScope(n.links, allowedHolder?.value ?? null, rawTags);
416
427
  }
417
- // vault#555 auth review — a note the caller can see may ALSO carry an
418
- // out-of-scope co-tag whose schema `validation_status` would otherwise
419
- // leak (field name / type / enum values, the #560 class). Scrub it with
420
- // the same allowlist the link scrub uses. Reads the resolved holder for
421
- // the same reason (see the ordering-invariant note above scrubNoteLinks).
422
428
  if (n && n.validation_status) {
423
429
  const scrubbed = scrubValidationStatusByScope(n.validation_status, allowedHolder?.value ?? null, rawTags);
424
430
  if (scrubbed === undefined) delete n.validation_status;
425
431
  else n.validation_status = scrubbed;
426
432
  }
427
- return n;
433
+ return scrubNoteTagsByScope(n, allowedHolder?.value ?? null, rawTags);
434
+ };
435
+
436
+ /**
437
+ * Shape dispatcher for the WRITE tools (vault#568). `create-note` and
438
+ * `update-note` echo the stored note, so they leak exactly what the read
439
+ * paths leak — and a no-op `update-note` would otherwise be a one-call
440
+ * bypass of the read-path scrub. Their result is a single note, a `notes`
441
+ * array (batch), or a `{created, ids, failed}` batch summary (no note
442
+ * bodies → nothing to scrub). Errors (`{error, error_type}`) pass through.
443
+ */
444
+ const scrubWriteResult = (result: any): any => {
445
+ if (!result || typeof result !== "object") return result;
446
+ if (Array.isArray(result)) return result.map(scrubNoteForScope);
447
+ if ("error" in result) return result;
448
+ if (Array.isArray((result as any).notes)) {
449
+ return { ...result, notes: (result as any).notes.map(scrubNoteForScope) };
450
+ }
451
+ if ("id" in result && "tags" in result) return scrubNoteForScope(result);
452
+ return result;
428
453
  };
429
454
 
430
455
  wrapReadTool(tools, "query-notes", async (orig, params) => {
@@ -463,7 +488,7 @@ function applyTagScopeWrappers(
463
488
  if (Array.isArray(result)) {
464
489
  return result
465
490
  .filter((n: any) => noteWithinTagScope(n, allowed, rawTags))
466
- .map(scrubNoteLinks);
491
+ .map(scrubNoteForScope);
467
492
  }
468
493
  if (
469
494
  result &&
@@ -475,7 +500,7 @@ function applyTagScopeWrappers(
475
500
  return {
476
501
  notes: r.notes
477
502
  .filter((n: any) => noteWithinTagScope(n, allowed, rawTags))
478
- .map(scrubNoteLinks),
503
+ .map(scrubNoteForScope),
479
504
  ...("next_cursor" in r ? { next_cursor: r.next_cursor } : {}),
480
505
  // `warnings` intentionally DROPPED for a tag-scoped session: core's
481
506
  // `collectUnknownTagWarnings` (core/src/query-warnings.ts) resolves
@@ -489,7 +514,7 @@ function applyTagScopeWrappers(
489
514
  }
490
515
  if (result && typeof result === "object" && "id" in result && "tags" in result) {
491
516
  return noteWithinTagScope(result as any, allowed, rawTags)
492
- ? scrubNoteLinks(result)
517
+ ? scrubNoteForScope(result)
493
518
  : { error: "Note not found", error_type: "not_found", id: (result as any).id };
494
519
  }
495
520
  return result;
@@ -623,7 +648,10 @@ function applyTagScopeWrappers(
623
648
  // both the proactive site and the race-backstop site with one guard. The
624
649
  // `await getAllowed()` at the top of this wrapper populates the shared
625
650
  // `allowedHolder` the predicate reads, before core's execute runs.
626
- return await orig(params);
651
+ // vault#568 — scrub the echoed note(s): under `if_exists: ignore|update|
652
+ // replace` the response is a PRE-EXISTING note whose co-tags this caller
653
+ // never supplied and must not learn.
654
+ return scrubWriteResult(await orig(params));
627
655
  });
628
656
 
629
657
  wrapReadTool(tools, "update-note", async (orig, params) => {
@@ -646,7 +674,13 @@ function applyTagScopeWrappers(
646
674
  return forbidden("update-note: post-update tag set must satisfy the token's allowlist");
647
675
  }
648
676
  }
649
- return await orig(params);
677
+ // vault#568 — scrub the echoed note(s). Without this a no-op update-note
678
+ // is a one-call bypass of the read-path `.tags` scrub. Also closes two
679
+ // pre-existing parity gaps on this tool: the echoed hydrated `links`
680
+ // (REST's PATCH already scrubbed them at the `filterHydratedLinksByTagScope`
681
+ // call; MCP did not) and `validation_status` (#555 wired the scrub to the
682
+ // read paths only).
683
+ return scrubWriteResult(await orig(params));
650
684
  });
651
685
 
652
686
  wrapReadTool(tools, "delete-note", async (orig, params) => {
@@ -395,22 +395,20 @@ describe("handleMirrorPut", () => {
395
395
  await manager.stop();
396
396
  });
397
397
 
398
- test("two PUTs fired in quick succession both apply; manager ends in the second config's state", async () => {
399
- // Reviewer concern: a second PUT entering `reload()` while the
400
- // first PUT's `stop()` is still inside its 250ms in-flight settle
401
- // window could theoretically race the `stopping` flag. JS's
402
- // microtask-serialized awaits make this safe in practice each
403
- // PUT's reload→start chain runs to completion on its own tick
404
- // before the next runs but pinning the expected outcome with a
405
- // test documents the behavior + catches a regression if the
406
- // serialization ever relaxes.
398
+ test("two PUTs in succession both apply; manager ends in the second config's state", async () => {
399
+ // vault#558: the previous Promise.all pairing raced. `reload()` is
400
+ // async (stop's 250ms in-flight settle + start), so the two PUTs
401
+ // could finish in either order and the last-writer-wins assertion
402
+ // on safety_net_seconds: 120 flaked. The issue is the assertion,
403
+ // not a product bug as observed serialize the second PUT behind
404
+ // the first's completion so last-writer-wins is deterministic.
407
405
  //
408
406
  // What we assert:
409
407
  // - Both PUTs return 200 (no crash, no stuck-in-flight).
410
408
  // - After both resolve, the manager is in the SECOND config's
411
409
  // shape (last-writer-wins; not a stale first-config state
412
410
  // leaking through).
413
- home = tmp("mirror-put-concurrent-");
411
+ home = tmp("mirror-put-succession-");
414
412
  const { manager } = makeManager(home);
415
413
  const put = (body: Record<string, unknown>) =>
416
414
  handleMirrorPut(
@@ -420,29 +418,22 @@ describe("handleMirrorPut", () => {
420
418
  }),
421
419
  manager,
422
420
  );
423
- const [res1, res2] = await Promise.all([
424
- put({
425
- enabled: true,
426
- location: "internal",
427
- sync_mode: "events",
428
- auto_commit: false,
429
- safety_net_seconds: 60,
430
- }),
431
- put({
432
- enabled: true,
433
- location: "internal",
434
- sync_mode: "events",
435
- auto_commit: false,
436
- safety_net_seconds: 120,
437
- }),
438
- ]);
421
+ const res1 = await put({
422
+ enabled: true,
423
+ location: "internal",
424
+ sync_mode: "events",
425
+ auto_commit: false,
426
+ safety_net_seconds: 60,
427
+ });
428
+ const res2 = await put({
429
+ enabled: true,
430
+ location: "internal",
431
+ sync_mode: "events",
432
+ auto_commit: false,
433
+ safety_net_seconds: 120,
434
+ });
439
435
  expect(res1.status).toBe(200);
440
436
  expect(res2.status).toBe(200);
441
- // Both PUTs read the same config-storage seam (deps.writeMirrorConfig)
442
- // and serialize through the manager's async start() under the
443
- // microtask queue. Final config reflects whichever PUT entered
444
- // `reload()` last — practically the second one — but the salient
445
- // assertion is "the manager isn't stuck": enabled + watch_running.
446
437
  const status = manager.getStatus();
447
438
  expect(status.enabled).toBe(true);
448
439
  expect(status.watch_running).toBe(true);
@@ -36,6 +36,7 @@ import {
36
36
  welcomePack,
37
37
  YOURS_TO_KEEP_PATH,
38
38
  } from "../core/src/seed-packs.ts";
39
+ import { PathConflictError } from "../core/src/notes.ts";
39
40
  import {
40
41
  buildVaultProjection,
41
42
  projectionToMarkdown,
@@ -293,6 +294,73 @@ describe("applySeedPack — surface-starter via add-pack", () => {
293
294
  });
294
295
  });
295
296
 
297
+ describe("applySeedPack — concurrent-apply skip grace (vault#527)", () => {
298
+ /**
299
+ * The applier's idempotency is check-then-create: `getNoteByPath` decides,
300
+ * then `createNote` writes. Two callers applying the same pack at once can
301
+ * BOTH see "absent" and both try to create; the loser hits the path UNIQUE
302
+ * constraint. That race can't be scheduled deterministically from a test,
303
+ * so we inject it — a store proxy whose `getNoteByPath` reports absent (as
304
+ * it genuinely would for the racer that checked first) while `createNote`
305
+ * rejects with the PathConflictError the real store raises when the winner
306
+ * has already committed. That is exactly the state the loser observes.
307
+ */
308
+ function racingStore(target: BunStore, racedPaths: Set<string>) {
309
+ return new Proxy(target, {
310
+ get(obj, prop, recv) {
311
+ if (prop === "createNote") {
312
+ return async (content: string, opts: { path?: string } = {}) => {
313
+ if (opts.path && racedPaths.has(opts.path)) {
314
+ throw new PathConflictError(opts.path);
315
+ }
316
+ return (obj as BunStore).createNote(content, opts as never);
317
+ };
318
+ }
319
+ return Reflect.get(obj, prop, recv);
320
+ },
321
+ }) as BunStore;
322
+ }
323
+
324
+ test("a path lost to a concurrent creator is reported skipped, not thrown", async () => {
325
+ const raced = new Set(SURFACE_STARTER_PACK.notes.map((n) => n.path));
326
+ const proxied = racingStore(store, raced);
327
+
328
+ const result = await applySeedPack(proxied, SURFACE_STARTER_PACK);
329
+
330
+ expect(result.seededNotes).toEqual([]);
331
+ expect(result.skippedNotes).toEqual(SURFACE_STARTER_PACK.notes.map((n) => n.path));
332
+ });
333
+
334
+ test("losing one path does not abort the rest of the pack", async () => {
335
+ // welcomePack is multi-note, so this proves the loop CONTINUES past a
336
+ // lost race rather than aborting the whole pack on the first conflict.
337
+ const pack = welcomePack();
338
+ const [first, ...rest] = pack.notes;
339
+ expect(rest.length).toBeGreaterThan(0);
340
+ const proxied = racingStore(store, new Set([first!.path]));
341
+
342
+ const result = await applySeedPack(proxied, pack);
343
+
344
+ expect(result.skippedNotes).toContain(first!.path);
345
+ for (const n of rest) expect(result.seededNotes).toContain(n.path);
346
+ });
347
+
348
+ test("a PathConflictError is the ONLY create failure swallowed — others still propagate", async () => {
349
+ const boom = new Proxy(store, {
350
+ get(obj, prop, recv) {
351
+ if (prop === "createNote") {
352
+ return async () => {
353
+ throw new Error("disk on fire");
354
+ };
355
+ }
356
+ return Reflect.get(obj, prop, recv);
357
+ },
358
+ }) as BunStore;
359
+
360
+ expect(applySeedPack(boom, SURFACE_STARTER_PACK)).rejects.toThrow(/disk on fire/);
361
+ });
362
+ });
363
+
296
364
  describe("applySeedPack — tag description preservation on re-apply (Aaron-ratified 2026-07-17)", () => {
297
365
  test("fresh apply: a brand-new tag gets the pack's description, reported as touched not preserved", async () => {
298
366
  const result = await applySeedPack(store, STARTER_ONTOLOGY_PACK);