@openparachute/vault 0.6.4-rc.4 → 0.6.4-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.
@@ -5,6 +5,7 @@ import { generateMcpTools } from "./mcp.js";
5
5
  import { initSchema } from "./schema.js";
6
6
  import { decodeCursor } from "./cursor.js";
7
7
  import { traverseLinks } from "./links.js";
8
+ import * as indexedFieldOps from "./indexed-fields.js";
8
9
 
9
10
  let store: SqliteStore;
10
11
  let db: Database;
@@ -2169,6 +2170,50 @@ describe("MCP tools", async () => {
2169
2170
  expect(result.extension).toBe("csv");
2170
2171
  });
2171
2172
 
2173
+ // ---- Metadata key-deletion via null tombstone (vault#478 / #479) ---------
2174
+
2175
+ it("update-note deletes a metadata key when its value is null (RFC 7386)", async () => {
2176
+ const note = await store.createNote("m", {
2177
+ metadata: { keep: "yes", drop: "old", other: 1 },
2178
+ });
2179
+ const tools = generateMcpTools(store);
2180
+ const updateNote = tools.find((t) => t.name === "update-note")!;
2181
+ await updateNote.execute({ id: note.id, metadata: { drop: null }, force: true });
2182
+
2183
+ const fresh = await store.getNote(note.id);
2184
+ // The deleted key is GONE — not a literal JSON null.
2185
+ expect(fresh!.metadata).not.toHaveProperty("drop");
2186
+ // Other keys are untouched.
2187
+ expect(fresh!.metadata).toMatchObject({ keep: "yes", other: 1 });
2188
+ });
2189
+
2190
+ it("update-note metadata key-rename in one call (new key set, old key deleted)", async () => {
2191
+ const note = await store.createNote("m", {
2192
+ metadata: { "old-key": "v", stable: true },
2193
+ });
2194
+ const tools = generateMcpTools(store);
2195
+ const updateNote = tools.find((t) => t.name === "update-note")!;
2196
+ await updateNote.execute({
2197
+ id: note.id,
2198
+ metadata: { new_key: "v", "old-key": null },
2199
+ force: true,
2200
+ });
2201
+
2202
+ const fresh = await store.getNote(note.id);
2203
+ expect(fresh!.metadata).not.toHaveProperty("old-key");
2204
+ expect(fresh!.metadata).toMatchObject({ new_key: "v", stable: true });
2205
+ });
2206
+
2207
+ it("deleting an absent metadata key is a no-op (round-trips cleanly)", async () => {
2208
+ const note = await store.createNote("m", { metadata: { a: 1 } });
2209
+ const tools = generateMcpTools(store);
2210
+ const updateNote = tools.find((t) => t.name === "update-note")!;
2211
+ await updateNote.execute({ id: note.id, metadata: { ghost: null }, force: true });
2212
+ const fresh = await store.getNote(note.id);
2213
+ expect(fresh!.metadata).toEqual({ a: 1 });
2214
+ expect(fresh!.metadata).not.toHaveProperty("ghost");
2215
+ });
2216
+
2172
2217
  it("query-notes filters by extension (vault#328)", async () => {
2173
2218
  await store.createNote("md note", { path: "a" });
2174
2219
  await store.createNote("csv note", { path: "b", extension: "csv" });
@@ -5301,6 +5346,87 @@ describe("tag record API (patterns/tag-data-model.md)", async () => {
5301
5346
  expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(0);
5302
5347
  expect(await store.getTagRecord("voice")).toBeNull();
5303
5348
  });
5349
+
5350
+ // ---- Indexed-field atomicity (vault#478) ----------------------------------
5351
+
5352
+ it("upsertTagRecord with a bad indexed-field name throws IndexedFieldError and leaves the schema unchanged", async () => {
5353
+ // kebab-case field name violates [A-Za-z0-9_] / no-leading-digit. The
5354
+ // declaration must NOT persist, and no orphan/lying index may be created.
5355
+ await expect(
5356
+ store.upsertTagRecord("meeting", {
5357
+ description: "meetings",
5358
+ fields: { "meeting-type": { type: "string", indexed: true } },
5359
+ }),
5360
+ ).rejects.toThrow(indexedFieldOps.IndexedFieldError);
5361
+
5362
+ // Schema is untouched — the tag row may not even exist, and definitely
5363
+ // doesn't claim the poisoned field.
5364
+ const record = await store.getTagRecord("meeting");
5365
+ expect(record?.fields?.["meeting-type"]).toBeUndefined();
5366
+
5367
+ // No backing index row was created for the bad field.
5368
+ expect(indexedFieldOps.getIndexedField(db, "meeting-type")).toBeNull();
5369
+ // And the generated column doesn't exist either.
5370
+ const cols = (db.prepare("PRAGMA table_xinfo(notes)").all() as { name: string }[]).map(
5371
+ (c) => c.name,
5372
+ );
5373
+ expect(cols).not.toContain("meta_meeting-type");
5374
+ });
5375
+
5376
+ it("upsertTagRecord with an unindexable type throws and leaves the schema unchanged", async () => {
5377
+ await expect(
5378
+ store.upsertTagRecord("meeting", {
5379
+ fields: { attendees: { type: "json", indexed: true } },
5380
+ }),
5381
+ ).rejects.toThrow(indexedFieldOps.IndexedFieldError);
5382
+ const record = await store.getTagRecord("meeting");
5383
+ expect(record?.fields?.attendees).toBeUndefined();
5384
+ expect(indexedFieldOps.getIndexedField(db, "attendees")).toBeNull();
5385
+ });
5386
+
5387
+ it("a bad indexed field does NOT corrupt a prior valid declaration on the same tag", async () => {
5388
+ // First, a clean valid indexed field.
5389
+ await store.upsertTagRecord("meeting", {
5390
+ fields: { held_on: { type: "string", indexed: true } },
5391
+ });
5392
+ expect(indexedFieldOps.getIndexedField(db, "held_on")?.declarerTags).toContain("meeting");
5393
+
5394
+ // Now an update that adds a poisoned field. The whole write must roll
5395
+ // back — the prior valid field survives untouched.
5396
+ await expect(
5397
+ store.upsertTagRecord("meeting", {
5398
+ fields: {
5399
+ held_on: { type: "string", indexed: true },
5400
+ "bad-field": { type: "string", indexed: true },
5401
+ },
5402
+ }),
5403
+ ).rejects.toThrow(indexedFieldOps.IndexedFieldError);
5404
+
5405
+ const record = await store.getTagRecord("meeting");
5406
+ expect(record?.fields?.held_on?.indexed).toBe(true);
5407
+ expect(record?.fields?.["bad-field"]).toBeUndefined();
5408
+ expect(indexedFieldOps.getIndexedField(db, "held_on")?.declarerTags).toContain("meeting");
5409
+ expect(indexedFieldOps.getIndexedField(db, "bad-field")).toBeNull();
5410
+ });
5411
+
5412
+ it("upsertTagRecord with fields:null releases indexed fields (transaction commits the release path)", async () => {
5413
+ // Declare an indexed field, then clear all fields — the release path runs
5414
+ // inside the same transaction as the persist. It must commit cleanly:
5415
+ // the row, generated column, and index all drop.
5416
+ await store.upsertTagRecord("meeting", {
5417
+ fields: { held_on: { type: "string", indexed: true } },
5418
+ });
5419
+ expect(indexedFieldOps.getIndexedField(db, "held_on")?.declarerTags).toContain("meeting");
5420
+
5421
+ await store.upsertTagRecord("meeting", { fields: null });
5422
+ const record = await store.getTagRecord("meeting");
5423
+ expect(record?.fields).toBeUndefined();
5424
+ expect(indexedFieldOps.getIndexedField(db, "held_on")).toBeNull();
5425
+ const cols = (db.prepare("PRAGMA table_xinfo(notes)").all() as { name: string }[]).map(
5426
+ (c) => c.name,
5427
+ );
5428
+ expect(cols).not.toContain("meta_held_on");
5429
+ });
5304
5430
  });
5305
5431
 
5306
5432
  // ---------------------------------------------------------------------------
package/core/src/mcp.ts CHANGED
@@ -830,7 +830,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
830
830
  },
831
831
  path: { type: "string", description: "New path" },
832
832
  extension: { type: "string", description: "Change the note's file extension (vault#328). Allowed but caller-owned — you're responsible for content validity if you switch a non-empty note's extension. Lowercase alphanumeric, 1–16 chars; \"parachute\" prefix reserved." },
833
- metadata: { type: "object", description: "Metadata to merge (keys are merged, not replaced wholesale)" },
833
+ metadata: { type: "object", description: "Metadata to merge (keys are merged, not replaced wholesale). A value of `null` deletes that key (RFC 7386 merge-patch) — e.g. `{\"new_key\": \"v\", \"old_key\": null}` renames in one call. Omitting a key preserves its existing value." },
834
834
  created_at: { type: "string", description: "New created_at timestamp" },
835
835
  if_updated_at: { type: "string", description: "Optimistic concurrency check: the updated_at value you last read. Rejects with a conflict error if the note has been modified since. Required unless `force: true` is set or the call is `append`/`prepend`-only." },
836
836
  force: { type: "boolean", description: "Waive the *requirement to supply* `if_updated_at` and run the update unconditionally. Use only for bulk migrations or scripted writes where concurrency is known-safe. Note: this does not override an `if_updated_at` you actually pass — if you supply both, the precondition still applies and a mismatch returns a conflict error." },
@@ -1194,9 +1194,13 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1194
1194
  updates.extension = validateExtension(item.extension);
1195
1195
  }
1196
1196
  if (item.metadata !== undefined) {
1197
- // Merge metadata (don't replace wholesale)
1198
- const existing = (note.metadata as Record<string, unknown>) ?? {};
1199
- updates.metadata = { ...existing, ...(item.metadata as Record<string, unknown>) };
1197
+ // Merge metadata (RFC 7386: keys are merged, incoming `null`
1198
+ // removes the key rather than persisting a literal null —
1199
+ // vault#478/#479). Mirrors the REST PATCH path.
1200
+ updates.metadata = noteOps.mergeMetadata(
1201
+ note.metadata as Record<string, unknown> | null | undefined,
1202
+ item.metadata as Record<string, unknown>,
1203
+ );
1200
1204
  }
1201
1205
  if (item.created_at !== undefined) updates.created_at = item.created_at;
1202
1206
  if (item.if_updated_at !== undefined) updates.if_updated_at = item.if_updated_at as string;
package/core/src/notes.ts CHANGED
@@ -1831,6 +1831,43 @@ export function filterMetadata(obj: any, includeMetadata: boolean | string[] | u
1831
1831
  return { ...obj, metadata: Object.keys(filtered).length > 0 ? filtered : undefined };
1832
1832
  }
1833
1833
 
1834
+ /**
1835
+ * Shallow-merge an incoming metadata patch onto a note's existing metadata,
1836
+ * with RFC 7386 (JSON Merge Patch) null-as-delete semantics — the canonical
1837
+ * merge for every metadata write surface (REST `PATCH /api/notes`, MCP
1838
+ * `update-note`, batch).
1839
+ *
1840
+ * An incoming value of `null` is a DELETE tombstone: the key is removed from
1841
+ * the result rather than persisted as a literal JSON `null`. This is the only
1842
+ * way to remove a metadata key through the API — under a plain
1843
+ * `{ ...existing, ...incoming }` merge, omission can't delete (it preserves the
1844
+ * prior value) and `null` used to persist literally, leaving no removal path.
1845
+ * Now key renames are pure-API: `{ new_key: "v", "old-key": null }` adds the
1846
+ * new key and drops the old one in one PATCH. See vault#478 / #479.
1847
+ *
1848
+ * Shallow by design — top-level keys only, matching the existing wholesale
1849
+ * top-level merge. A nested object value replaces its key wholesale (we do not
1850
+ * recurse), so an incoming `null` removes the whole key regardless of depth.
1851
+ * Compat: storing a literal null is no longer possible via this path; the
1852
+ * boulder migration confirmed zero callers relied on that (vault#478). A caller
1853
+ * that genuinely needs an absent-vs-null distinction should model it with a
1854
+ * sentinel string, not a JSON null.
1855
+ */
1856
+ export function mergeMetadata(
1857
+ existing: Record<string, unknown> | null | undefined,
1858
+ incoming: Record<string, unknown>,
1859
+ ): Record<string, unknown> {
1860
+ const result: Record<string, unknown> = { ...(existing ?? {}) };
1861
+ for (const [key, value] of Object.entries(incoming)) {
1862
+ if (value === null) {
1863
+ delete result[key];
1864
+ } else {
1865
+ result[key] = value;
1866
+ }
1867
+ }
1868
+ return result;
1869
+ }
1870
+
1834
1871
  // ---- Vault stats (aggregate situational awareness) ----
1835
1872
 
1836
1873
  /**
package/core/src/store.ts CHANGED
@@ -631,36 +631,65 @@ export class BunSqliteStore implements Store {
631
631
  const priorRecord =
632
632
  patch.fields !== undefined ? tagSchemaOps.getTagRecord(this.db, tag) : null;
633
633
 
634
- const result = tagSchemaOps.upsertTagRecord(this.db, tag, patch);
635
-
634
+ const indexedSet = (fields: Record<string, tagSchemaOps.TagFieldSchema> | null | undefined) =>
635
+ new Set(
636
+ Object.entries(fields ?? {})
637
+ .filter(([, v]) => v.indexed === true)
638
+ .map(([k]) => k),
639
+ );
640
+ const nextFields = patch.fields; // object | null | undefined
641
+ const priorIndexed = indexedSet(priorRecord?.fields);
642
+ const nextIndexed = indexedSet(nextFields);
643
+
644
+ // PRE-VALIDATE every newly-indexed field BEFORE any persistence. A bad
645
+ // field name (or unmappable type) must fail closed — the schema record
646
+ // must NOT be written when the backing index can't be created. Pre-checking
647
+ // here, before the transaction even opens, turns the failure into a clean
648
+ // caller error (IndexedFieldError → 400) and leaves the schema untouched.
649
+ // Without this, the prior code persisted the field declaration, THEN threw
650
+ // on declareField — a 500 plus a tag claiming an index the engine can't
651
+ // build (the "lying schema" loop). See vault#478.
636
652
  if (patch.fields !== undefined) {
637
- const indexedSet = (fields: Record<string, tagSchemaOps.TagFieldSchema> | null | undefined) =>
638
- new Set(
639
- Object.entries(fields ?? {})
640
- .filter(([, v]) => v.indexed === true)
641
- .map(([k]) => k),
642
- );
643
- const nextFields = patch.fields; // object | null
644
- const priorIndexed = indexedSet(priorRecord?.fields);
645
- const nextIndexed = indexedSet(nextFields);
646
653
  for (const fieldName of nextIndexed) {
647
654
  const spec = nextFields![fieldName]!;
648
655
  const mapped = indexedFieldOps.mapFieldType(spec.type);
649
- // Unmappable type for indexing is a caller error; surface it rather
650
- // than silently skipping. MCP/REST validate up-front for a cleaner
651
- // message, but this is the backstop at the chokepoint.
652
656
  if (!mapped) {
653
657
  throw new indexedFieldOps.IndexedFieldError(
654
658
  `field "${fieldName}" has unsupported type "${spec.type}" for indexing (supported: string, integer, boolean)`,
655
659
  );
656
660
  }
657
- indexedFieldOps.declareField(this.db, fieldName, mapped, tag);
661
+ // Throws IndexedFieldError on an invalid identifier (e.g. kebab-case).
662
+ indexedFieldOps.validateFieldName(fieldName);
658
663
  }
659
- for (const fieldName of priorIndexed) {
660
- if (!nextIndexed.has(fieldName)) {
661
- indexedFieldOps.releaseField(this.db, fieldName, tag);
664
+ }
665
+
666
+ // Persist the record + reconcile the indexed-field lifecycle atomically.
667
+ // If declareField throws inside the transaction (e.g. a cross-tag type
668
+ // mismatch only detectable once the existing declarer set is consulted),
669
+ // the whole write rolls back — the schema never ends up claiming an index
670
+ // that doesn't exist. vault#478 transactional fix.
671
+ let result: tagSchemaOps.TagRecord;
672
+ this.db.exec("BEGIN IMMEDIATE");
673
+ try {
674
+ result = tagSchemaOps.upsertTagRecord(this.db, tag, patch);
675
+
676
+ if (patch.fields !== undefined) {
677
+ for (const fieldName of nextIndexed) {
678
+ const spec = nextFields![fieldName]!;
679
+ // Type already validated above; non-null assertion is safe here.
680
+ const mapped = indexedFieldOps.mapFieldType(spec.type)!;
681
+ indexedFieldOps.declareField(this.db, fieldName, mapped, tag);
682
+ }
683
+ for (const fieldName of priorIndexed) {
684
+ if (!nextIndexed.has(fieldName)) {
685
+ indexedFieldOps.releaseField(this.db, fieldName, tag);
686
+ }
662
687
  }
663
688
  }
689
+ this.db.exec("COMMIT");
690
+ } catch (err) {
691
+ this.db.exec("ROLLBACK");
692
+ throw err;
664
693
  }
665
694
 
666
695
  if (patch.parent_names !== undefined) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.6.4-rc.4",
3
+ "version": "0.6.4-rc.5",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
@@ -14,31 +14,26 @@
14
14
  *
15
15
  * PUT /.parachute/config is Phase 3 — not implemented here.
16
16
  *
17
+ * Scope boundary (vault#478): `GET /.parachute/config` is gated by
18
+ * `vault:<name>:admin` — admin over *your* vault only — so it describes and
19
+ * returns ONLY per-vault config, never daemon-GLOBAL settings.
20
+ *
17
21
  * Fields currently described:
18
22
  * - audio_retention: per-vault enum, backed by VaultConfig.audio_retention.
19
- * - port: GlobalConfig.port, exposed read-only.
20
- * - autoTranscribe.*: vault↔scribe handoff (vault#353, design 2026-05-21
21
- * Part 2). Three nested fields per design Q4:
22
- * - enabled: boolean toggle, default true when scribe is
23
- * reachable (persisted in
24
- * GlobalConfig.auto_transcribe.enabled). Default
25
- * flipped from off → on so installing scribe is
26
- * the only opt-in signal needed.
27
- * - scribeUrl: readOnly resolved per-process from
28
- * `~/.parachute/services.json` via
29
- * `scribe-discovery.ts`. Operators can't point at an
30
- * arbitrary scribe; the discovery layer is the gate.
31
- * - scribeBearer: writeOnly — sourced from SCRIBE_AUTH_TOKEN env var.
32
- * Hub install generates one at first boot
33
- * (see scribe-env.ts:ensureScribeBearer); manual
34
- * rotation is via `parachute-vault config set`.
35
- * - scribe_url / scribe_token (deprecated): kept under their legacy names
36
- * through one release for the hub admin SPA's prior
37
- * render path; new code should read autoTranscribe.*.
23
+ * - autoTranscribe.enabled: per-vault toggle (vault#353). Reports the value
24
+ * THIS vault will use, resolving per-vault override
25
+ * (VaultConfig.auto_transcribe.enabled) server
26
+ * default (GlobalConfig.auto_transcribe.enabled)
27
+ * true. The inherited fallback is the per-vault
28
+ * truth, not a leak of the global setting.
29
+ *
30
+ * Deliberately NOT exposed here (daemon-global operator-only surface):
31
+ * - port: GlobalConfig.port (deployment-wide listen port).
32
+ * - autoTranscribe.scribeUrl / scribe_url: discovery-resolved per-process.
33
+ * - autoTranscribe.scribeBearer / scribe_token: shared SCRIBE_AUTH_TOKEN.
38
34
  */
39
35
 
40
36
  import type { VaultConfig, GlobalConfig } from "./config.ts";
41
- import { resolveScribeUrl } from "./scribe-discovery.ts";
42
37
 
43
38
  export interface ModuleConfigSchema {
44
39
  $schema: string;
@@ -75,83 +70,54 @@ export function buildConfigSchema(): ModuleConfigSchema {
75
70
  default: true,
76
71
  title: "Enable auto-transcription",
77
72
  description:
78
- "Master toggle. Default on — audio uploads transcribe automatically when scribe is reachable. Set to false to disable. Global persisted in `GlobalConfig.auto_transcribe.enabled` and applies to every vault on this server. Per-vault control is a future enhancement when multi-vault deployments need it.",
79
- },
80
- scribeUrl: {
81
- type: "string",
82
- format: "uri",
83
- readOnly: true,
84
- title: "Scribe URL",
85
- description:
86
- "URL of the scribe service. Auto-populated from `~/.parachute/services.json` at vault startup (or from the SCRIBE_URL env var when set). Read-only — operators can't point at an arbitrary scribe.",
87
- },
88
- scribeBearer: {
89
- type: "string",
90
- writeOnly: true,
91
- title: "Scribe auth bearer",
92
- description:
93
- "Shared bearer for the vault→scribe loopback contract. Hub install generates one at first boot. Write-only — never returned by GET.",
73
+ "Per-vault toggle. Default on — audio uploads transcribe automatically when scribe is reachable. Set to false to disable for THIS vault. Resolves per-vault override server default on, so leaving it unset inherits the deployment default.",
94
74
  },
95
75
  },
96
76
  },
97
- // Legacy aliases kept for back-compat with callers that read the
98
- // pre-vault#353 shape. New consumers should read `autoTranscribe.*`.
99
- scribe_url: {
100
- type: "string",
101
- format: "uri",
102
- title: "Scribe URL (deprecated alias)",
103
- description:
104
- "Legacy alias for `autoTranscribe.scribeUrl`. Will be removed in a future release.",
105
- readOnly: true,
106
- deprecated: true,
107
- },
108
- scribe_token: {
109
- type: "string",
110
- title: "Scribe auth token (deprecated alias)",
111
- description:
112
- "Legacy alias for `autoTranscribe.scribeBearer`. Will be removed in a future release.",
113
- writeOnly: true,
114
- deprecated: true,
115
- },
116
- port: {
117
- type: "integer",
118
- minimum: 1,
119
- maximum: 65535,
120
- title: "HTTP port",
121
- description: "Port the vault server listens on. Set at init time; changing requires a restart.",
122
- readOnly: true,
123
- },
77
+ // NOTE (vault#478): daemon-GLOBAL fields (the listen `port`, the
78
+ // discovery-resolved scribe URL, the shared scribe bearer) are
79
+ // deliberately NOT described here. This endpoint is gated by
80
+ // `vault:<name>:admin` — admin over *your* vault only — so it neither
81
+ // describes nor returns deployment-wide settings. Those live behind the
82
+ // operator-only surface (CLI / global config).
124
83
  },
125
84
  };
126
85
  }
127
86
 
128
87
  /**
129
- * Effective config values, with `writeOnly` fields stripped. `scribeBearer`
130
- * (and its legacy alias `scribe_token`) are declared `writeOnly` and never
131
- * returned, even when set in the environment.
88
+ * Effective config values for ONE vault. The shared scribe bearer is
89
+ * daemon-global and never returned (see the scope boundary below).
90
+ *
91
+ * Scope boundary (vault#478): the `GET /vault/<name>/.parachute/config`
92
+ * endpoint is gated by `vault:<name>:admin` — "admin over *your* vault only".
93
+ * It must therefore return ONLY per-vault config, never daemon-GLOBAL settings
94
+ * (the listen `port`, the discovery-resolved scribe URL, the server-wide
95
+ * auto-transcribe default). Those describe the whole deployment, not this
96
+ * vault, and leaking them across the per-vault admin boundary matters once a
97
+ * shared multi-vault daemon hands admin-on-one-vault to a beta signup. They
98
+ * live behind the operator-only surface (the CLI / global config), not here.
99
+ *
100
+ * `autoTranscribe.enabled` IS per-vault: it reports the value THIS vault will
101
+ * actually use, resolving per-vault override → global default → true (mirrors
102
+ * `shouldAutoTranscribe`). That's a per-vault effective value, not the raw
103
+ * daemon-global toggle — reporting it doesn't leak the global setting (an
104
+ * unset vault simply inherits, which is the per-vault truth).
132
105
  */
133
106
  export function buildConfigValues(
134
107
  vaultConfig: VaultConfig,
135
108
  globalConfig: GlobalConfig,
136
- env: { SCRIBE_URL?: string | undefined } = process.env as { SCRIBE_URL?: string },
137
109
  ): Record<string, unknown> {
138
- // Resolve scribe URL through the discovery layer so the GET shape reflects
139
- // what the worker will actually use (services.json > SCRIBE_URL > unset).
140
- // Pass env through so the test harness's override is honored.
141
- const scribeUrl = resolveScribeUrl(env as NodeJS.ProcessEnv) ?? "";
142
110
  return {
143
111
  audio_retention: vaultConfig.audio_retention ?? "keep",
144
112
  autoTranscribe: {
145
- // Match shouldAutoTranscribe's `?? true` so the admin SPA displays
146
- // the same value runtime uses. An unset config row shows `true`
147
- // because that's what vault will actually do on the next audio upload.
148
- enabled: globalConfig.auto_transcribe?.enabled ?? true,
149
- scribeUrl,
113
+ // Per-vault effective value: this vault's own override wins; otherwise it
114
+ // inherits the server default; otherwise true. Same ladder as
115
+ // shouldAutoTranscribe, so the admin SPA shows what this vault will do.
116
+ enabled:
117
+ vaultConfig.auto_transcribe?.enabled
118
+ ?? globalConfig.auto_transcribe?.enabled
119
+ ?? true,
150
120
  },
151
- // Legacy alias mirrors `autoTranscribe.scribeUrl` so hubs reading the
152
- // pre-vault#353 shape don't regress.
153
- scribe_url: scribeUrl,
154
- port: globalConfig.port,
155
121
  };
156
122
  }
157
123
 
package/src/routes.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  import type { Store, Note, QueryOpts } from "../core/src/types.ts";
15
15
  import { TAG_EXPAND_MODES, type TagExpandMode } from "../core/src/tag-hierarchy.ts";
16
16
  import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
17
- import { getNote, getNotes, getNoteTags, toNoteIndex, filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
17
+ import { getNote, getNotes, getNoteTags, toNoteIndex, filterMetadata, mergeMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
18
18
  import {
19
19
  parseContentRange,
20
20
  applyContentRange,
@@ -26,6 +26,7 @@ import type { ValidationWarning } from "../core/src/schema-defaults.ts";
26
26
  import { logStrictBypass } from "./scopes.ts";
27
27
  import * as linkOps from "../core/src/links.ts";
28
28
  import * as tagSchemaOps from "../core/src/tag-schemas.ts";
29
+ import { IndexedFieldError } from "../core/src/indexed-fields.ts";
29
30
  import {
30
31
  buildExpandVisibility,
31
32
  filterHydratedLinksByTagScope,
@@ -1652,8 +1653,11 @@ async function handleNotesInner(
1652
1653
  updates.extension = validateExtension(body.extension);
1653
1654
  }
1654
1655
  if (body.metadata !== undefined) {
1655
- const existing = (note.metadata as Record<string, unknown>) ?? {};
1656
- updates.metadata = { ...existing, ...body.metadata };
1656
+ // RFC 7386 merge: incoming `null` removes the key (vault#478/#479).
1657
+ updates.metadata = mergeMetadata(
1658
+ note.metadata as Record<string, unknown> | null | undefined,
1659
+ body.metadata as Record<string, unknown>,
1660
+ );
1657
1661
  }
1658
1662
  if (body.created_at !== undefined || body.createdAt !== undefined) {
1659
1663
  updates.created_at = body.created_at ?? body.createdAt;
@@ -2093,12 +2097,27 @@ export async function handleTags(
2093
2097
  fieldsPatch = Object.keys(merged).length > 0 ? merged : null;
2094
2098
  }
2095
2099
 
2096
- const result = await store.upsertTagRecord(tagName, {
2097
- ...(body.description !== undefined ? { description: body.description } : {}),
2098
- ...(fieldsPatch !== undefined ? { fields: fieldsPatch } : {}),
2099
- ...(relationshipsPatch !== undefined ? { relationships: relationshipsPatch } : {}),
2100
- ...(parentNamesPatch !== undefined ? { parent_names: parentNamesPatch } : {}),
2101
- });
2100
+ // A bad indexed-field name (or an unindexable type, or a cross-tag type
2101
+ // mismatch) is a CLIENT error return 400, not the catch-all 500. The
2102
+ // store pre-validates and is transactional, so the schema is left
2103
+ // unchanged on failure (no orphan/lying index). vault#478.
2104
+ let result;
2105
+ try {
2106
+ result = await store.upsertTagRecord(tagName, {
2107
+ ...(body.description !== undefined ? { description: body.description } : {}),
2108
+ ...(fieldsPatch !== undefined ? { fields: fieldsPatch } : {}),
2109
+ ...(relationshipsPatch !== undefined ? { relationships: relationshipsPatch } : {}),
2110
+ ...(parentNamesPatch !== undefined ? { parent_names: parentNamesPatch } : {}),
2111
+ });
2112
+ } catch (err) {
2113
+ if (err instanceof IndexedFieldError) {
2114
+ return json(
2115
+ { error: err.message, error_type: "invalid_indexed_field" },
2116
+ 400,
2117
+ );
2118
+ }
2119
+ throw err;
2120
+ }
2102
2121
  return json(result);
2103
2122
  }
2104
2123
 
@@ -1262,12 +1262,17 @@ describe("/.parachute/config/schema + /.parachute/config", () => {
1262
1262
  expect(body.type).toBe("object");
1263
1263
  expect(body.properties.audio_retention?.type).toBe("string");
1264
1264
  expect(body.properties.audio_retention?.enum).toEqual(["keep", "until_transcribed", "never"]);
1265
- expect(body.properties.scribe_url?.type).toBe("string");
1266
- expect(body.properties.scribe_token?.writeOnly).toBe(true);
1267
- expect(body.properties.port?.readOnly).toBe(true);
1265
+ expect(body.properties.autoTranscribe?.type).toBe("object");
1266
+ // vault#478 scope boundary: daemon-GLOBAL fields are NOT described by the
1267
+ // per-vault admin config schema. The per-vault admin surface is "admin
1268
+ // over *your* vault only" — port / scribe URL / scribe bearer are
1269
+ // deployment-wide and live behind the operator-only surface.
1270
+ expect(body.properties).not.toHaveProperty("scribe_url");
1271
+ expect(body.properties).not.toHaveProperty("scribe_token");
1272
+ expect(body.properties).not.toHaveProperty("port");
1268
1273
  });
1269
1274
 
1270
- test("config returns current values with writeOnly fields excluded", async () => {
1275
+ test("config returns ONLY per-vault values, never daemon-global ones (scope boundary, vault#478)", async () => {
1271
1276
  createVault("journal");
1272
1277
  const token = await createAdminToken("journal");
1273
1278
  const path = "/vault/journal/.parachute/config";
@@ -1284,13 +1289,22 @@ describe("/.parachute/config/schema + /.parachute/config", () => {
1284
1289
  );
1285
1290
  expect(res.status).toBe(200);
1286
1291
  const body = (await res.json()) as Record<string, unknown>;
1292
+ // Per-vault config is present.
1287
1293
  expect(body.audio_retention).toBe("keep"); // default when unset
1288
- expect(body.scribe_url).toBe("https://scribe.example/v1");
1289
- expect(body.port).toBe(1940);
1294
+ expect(body).toHaveProperty("autoTranscribe");
1295
+ // Daemon-GLOBAL values must NOT cross the per-vault admin boundary.
1296
+ expect(body).not.toHaveProperty("port");
1297
+ expect(body).not.toHaveProperty("scribe_url");
1298
+ // The discovery-resolved scribe URL is daemon-global → must not leak,
1299
+ // even nested under autoTranscribe.
1300
+ const at = body.autoTranscribe as Record<string, unknown>;
1301
+ expect(at).not.toHaveProperty("scribeUrl");
1290
1302
  // writeOnly field must not appear in GET.
1291
1303
  expect(body).not.toHaveProperty("scribe_token");
1292
- // Defense in depth: grep the raw body for the token value.
1293
- expect(JSON.stringify(body)).not.toContain("super-secret-should-never-appear");
1304
+ // Defense in depth: no daemon-global value (URL or secret) anywhere.
1305
+ const raw = JSON.stringify(body);
1306
+ expect(raw).not.toContain("super-secret-should-never-appear");
1307
+ expect(raw).not.toContain("https://scribe.example/v1");
1294
1308
  } finally {
1295
1309
  if (origScribeToken === undefined) delete process.env.SCRIBE_TOKEN;
1296
1310
  else process.env.SCRIBE_TOKEN = origScribeToken;
@@ -1318,24 +1332,27 @@ describe("/.parachute/config/schema + /.parachute/config", () => {
1318
1332
  expect(body.audio_retention).toBe("until_transcribed");
1319
1333
  });
1320
1334
 
1321
- test("config scribe_url falls back to empty string when SCRIBE_URL env is unset", async () => {
1322
- createVault("journal");
1335
+ test("autoTranscribe.enabled reports the per-vault override (vault#478)", async () => {
1336
+ // Server default ON; this vault explicitly opts OUT — the per-vault value
1337
+ // must win, proving the field is reported per-vault (not the raw global).
1338
+ writeGlobalConfig({ port: 1940, auto_transcribe: { enabled: true } });
1339
+ writeVaultConfig({
1340
+ name: "journal",
1341
+ api_keys: [],
1342
+ created_at: new Date().toISOString(),
1343
+ auto_transcribe: { enabled: false },
1344
+ });
1323
1345
  const token = await createAdminToken("journal");
1324
- const orig = process.env.SCRIBE_URL;
1325
- delete process.env.SCRIBE_URL;
1326
- try {
1327
- const path = "/vault/journal/.parachute/config";
1328
- const res = await route(
1329
- new Request(`http://localhost:1940${path}`, {
1330
- headers: { authorization: `Bearer ${token}` },
1331
- }),
1332
- path,
1333
- );
1334
- const body = (await res.json()) as { scribe_url: string };
1335
- expect(body.scribe_url).toBe("");
1336
- } finally {
1337
- if (orig !== undefined) process.env.SCRIBE_URL = orig;
1338
- }
1346
+ const path = "/vault/journal/.parachute/config";
1347
+ const res = await route(
1348
+ new Request(`http://localhost:1940${path}`, {
1349
+ headers: { authorization: `Bearer ${token}` },
1350
+ }),
1351
+ path,
1352
+ );
1353
+ const body = (await res.json()) as { autoTranscribe: { enabled: boolean } };
1354
+ expect(body.autoTranscribe.enabled).toBe(false);
1355
+ writeGlobalConfig({ port: 1940 });
1339
1356
  });
1340
1357
 
1341
1358
  test("unknown vault returns 404 before reaching the config handlers", async () => {
package/src/vault.test.ts CHANGED
@@ -4012,6 +4012,36 @@ describe("HTTP PATCH /notes/:idOrPath (update)", async () => {
4012
4012
  expect(body.metadata).toEqual({ a: 1, b: 2 });
4013
4013
  });
4014
4014
 
4015
+ test("PATCH metadata null DELETES the key (RFC 7386), not a literal null (vault#478/#479)", async () => {
4016
+ await store.createNote("doc", { id: "x", metadata: { keep: "yes", drop: "old", n: 3 } });
4017
+ const res = await handleNotes(
4018
+ mkReq("PATCH", "/notes/x", { metadata: { drop: null }, force: true }),
4019
+ store,
4020
+ "/x",
4021
+ );
4022
+ expect(res.status).toBe(200);
4023
+ const body = await res.json() as any;
4024
+ // Key removed entirely — must NOT survive as a literal JSON null.
4025
+ expect(body.metadata).not.toHaveProperty("drop");
4026
+ expect(body.metadata).toEqual({ keep: "yes", n: 3 });
4027
+ // Persisted state matches the response (round-trips).
4028
+ const fresh = await store.getNote("x");
4029
+ expect(fresh!.metadata).not.toHaveProperty("drop");
4030
+ expect(fresh!.metadata).toEqual({ keep: "yes", n: 3 });
4031
+ });
4032
+
4033
+ test("PATCH metadata key-rename in one call: set new, null-delete old (vault#478)", async () => {
4034
+ await store.createNote("doc", { id: "x", metadata: { "old-key": "v", stable: true } });
4035
+ const res = await handleNotes(
4036
+ mkReq("PATCH", "/notes/x", { metadata: { new_key: "v", "old-key": null }, force: true }),
4037
+ store,
4038
+ "/x",
4039
+ );
4040
+ const body = await res.json() as any;
4041
+ expect(body.metadata).not.toHaveProperty("old-key");
4042
+ expect(body.metadata).toEqual({ new_key: "v", stable: true });
4043
+ });
4044
+
4015
4045
  test("PATCH adds/removes tags", async () => {
4016
4046
  await store.createNote("x", { id: "x", tags: ["old"] });
4017
4047
  const res = await handleNotes(
@@ -4789,6 +4819,28 @@ describe("HTTP /tags", async () => {
4789
4819
  .filter((n) => n.startsWith("meta_"));
4790
4820
  }
4791
4821
 
4822
+ test("PUT /tags/:name with a bad indexed-field name returns 400 + leaves schema unchanged (vault#478)", async () => {
4823
+ // kebab-case indexed field violates [A-Za-z0-9_]. Pre-fix this persisted
4824
+ // the declaration then 500'd on index creation, leaving a tag claiming an
4825
+ // index the engine couldn't build (the "lying schema" loop).
4826
+ const res = await handleTags(
4827
+ mkReq("PUT", "/tags/meeting", { fields: { "meeting-type": { type: "string", indexed: true } } }),
4828
+ store,
4829
+ "/meeting",
4830
+ );
4831
+ expect(res.status).toBe(400);
4832
+ const body = await res.json() as any;
4833
+ expect(body.error_type).toBe("invalid_indexed_field");
4834
+ expect(body.error).toMatch(/invalid field name/);
4835
+
4836
+ // Schema is untouched — no poisoned field declared.
4837
+ const record = await store.getTagRecord("meeting");
4838
+ expect(record?.fields?.["meeting-type"]).toBeUndefined();
4839
+ // No orphan/lying index: neither the generated column nor an indexed_fields row.
4840
+ expect(notesMetaCols()).not.toContain("meta_meeting-type");
4841
+ expect(buildVaultProjection(db).indexed_fields.map((f) => f.name)).not.toContain("meeting-type");
4842
+ });
4843
+
4792
4844
  test("PUT /tags/:name {fields:null} drops the orphaned generated column", async () => {
4793
4845
  // Declare an indexed field via REST PUT — column materializes.
4794
4846
  await handleTags(