@openparachute/vault 0.7.2 → 0.7.3-rc.10

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 (74) hide show
  1. package/README.md +5 -3
  2. package/core/src/attachment/policy.test.ts +66 -0
  3. package/core/src/attachment/policy.ts +131 -0
  4. package/core/src/attachment/tickets.test.ts +45 -0
  5. package/core/src/attachment/tickets.ts +117 -0
  6. package/core/src/attachment-tickets-tool.test.ts +286 -0
  7. package/core/src/conformance.ts +2 -1
  8. package/core/src/contract-typed-index.test.ts +4 -3
  9. package/core/src/core.test.ts +607 -11
  10. package/core/src/display-title.test.ts +190 -0
  11. package/core/src/embedding/chunker.test.ts +97 -0
  12. package/core/src/embedding/chunker.ts +180 -0
  13. package/core/src/embedding/provider.ts +108 -0
  14. package/core/src/embedding/staleness.test.ts +87 -0
  15. package/core/src/embedding/staleness.ts +83 -0
  16. package/core/src/embedding/vector-codec.test.ts +99 -0
  17. package/core/src/embedding/vector-codec.ts +60 -0
  18. package/core/src/embedding/vectors.test.ts +163 -0
  19. package/core/src/embedding/vectors.ts +135 -0
  20. package/core/src/expand.ts +11 -3
  21. package/core/src/indexed-fields.test.ts +9 -3
  22. package/core/src/indexed-fields.ts +9 -1
  23. package/core/src/lede.test.ts +96 -0
  24. package/core/src/mcp-semantic-search.test.ts +160 -0
  25. package/core/src/mcp.ts +405 -10
  26. package/core/src/notes.semantic-search.test.ts +131 -0
  27. package/core/src/notes.ts +319 -7
  28. package/core/src/query-warnings.ts +40 -0
  29. package/core/src/schema-defaults.ts +85 -1
  30. package/core/src/schema-v27-note-vectors.test.ts +178 -0
  31. package/core/src/schema.ts +81 -1
  32. package/core/src/search-fts-v25.test.ts +9 -1
  33. package/core/src/search-query.test.ts +42 -0
  34. package/core/src/search-query.ts +27 -0
  35. package/core/src/search-title-boost.test.ts +125 -0
  36. package/core/src/seed-packs.test.ts +117 -1
  37. package/core/src/seed-packs.ts +217 -1
  38. package/core/src/store.semantic-search.test.ts +236 -0
  39. package/core/src/store.ts +162 -5
  40. package/core/src/tag-schemas.ts +27 -12
  41. package/core/src/types.ts +63 -1
  42. package/core/src/vault-projection.ts +48 -0
  43. package/package.json +6 -1
  44. package/src/add-pack.test.ts +32 -0
  45. package/src/attachment-tickets.test.ts +350 -0
  46. package/src/attachment-tickets.ts +264 -0
  47. package/src/auth.ts +8 -2
  48. package/src/cli.ts +5 -1
  49. package/src/contract-errors.test.ts +2 -1
  50. package/src/contract-honest-queries.test.ts +88 -0
  51. package/src/contract-search.test.ts +41 -0
  52. package/src/embedding/capability.test.ts +33 -0
  53. package/src/embedding/capability.ts +34 -0
  54. package/src/embedding/external-api.test.ts +154 -0
  55. package/src/embedding/external-api.ts +144 -0
  56. package/src/embedding/onnx-transformers.test.ts +113 -0
  57. package/src/embedding/onnx-transformers.ts +141 -0
  58. package/src/embedding/select.test.ts +99 -0
  59. package/src/embedding/select.ts +92 -0
  60. package/src/embedding-worker.test.ts +300 -0
  61. package/src/embedding-worker.ts +226 -0
  62. package/src/mcp-http.ts +13 -1
  63. package/src/mcp-tools.ts +49 -14
  64. package/src/onboarding-seed.test.ts +64 -0
  65. package/src/routes.ts +173 -92
  66. package/src/routing.ts +17 -0
  67. package/src/scribe-discovery.test.ts +76 -1
  68. package/src/scribe-discovery.ts +28 -9
  69. package/src/semantic-search-routes.test.ts +161 -0
  70. package/src/server.ts +21 -2
  71. package/src/vault-embeddings-capability.test.ts +82 -0
  72. package/src/vault-store-embedding-wiring.test.ts +69 -0
  73. package/src/vault-store.ts +53 -2
  74. package/src/vault.test.ts +62 -13
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Embed-on-write worker (semantic search MVP — EXPERIMENTAL).
3
+ *
4
+ * Event-driven happy path + a sweep safety net — structurally the same
5
+ * shape as `transcription-worker.ts`, simplified (no retry/backoff state
6
+ * machine; a failed embed just leaves the chunk stale for the next edit
7
+ * or sweep pass to retry):
8
+ *
9
+ * - **Event path (hot):** a note create/update dispatches the
10
+ * `HookRegistry`'s "created"/"updated" event. `registerEmbeddingHook`
11
+ * calls `worker.kick(vault, note)`, which chunks the note
12
+ * (`chunkNoteContent`), diffs against existing `note_vectors` rows
13
+ * (`planStaleness`), prunes anything obsolete (the note shrank, or the
14
+ * model changed), and embeds ONLY the stale chunks. A no-op edit
15
+ * (identical content, same model) makes ZERO provider calls — the
16
+ * freshness gate short-circuits before the network/CPU work.
17
+ * - **Sweep path (backfill + safety net):** every `sweepIntervalMs`,
18
+ * walks each vault's notes lacking a fresh vector for the active
19
+ * model (`getNotesPendingEmbedding`) and embeds them. This is what
20
+ * actually drains the v27 migration's implicit backfill (the
21
+ * migration itself does no embedding — see `schema.ts`'s
22
+ * `migrateToV27` doc comment) and catches anything a dropped hook
23
+ * dispatch or a mid-backfill restart left behind.
24
+ *
25
+ * Concurrency: the event path rides `HookRegistry`'s own
26
+ * `HOOK_CONCURRENCY` cap for free (the handler IS the capped unit of
27
+ * work). The sweep processes its candidate list SEQUENTIALLY — simple,
28
+ * safe, and matches the real per-note embed latency the P0 eval measured
29
+ * (roughly 1–5 notes/sec depending on model); a bulk backfill of a few
30
+ * thousand notes finishes in minutes, not hours, and never spawns
31
+ * unbounded parallel provider calls.
32
+ */
33
+
34
+ import type { Store, Note } from "../core/src/types.ts";
35
+ import type { HookRegistry, NoteHookPayload } from "../core/src/hooks.ts";
36
+ import type { EmbeddingProvider } from "../core/src/embedding/provider.ts";
37
+ import { chunkNoteContent } from "../core/src/embedding/chunker.ts";
38
+ import { planStaleness, contentHash } from "../core/src/embedding/staleness.ts";
39
+ import { getNoteVectorRows, deleteObsoleteVectorRows, upsertNoteVector, getNotesPendingEmbedding } from "../core/src/embedding/vectors.ts";
40
+ import { normalize } from "../core/src/embedding/vector-codec.ts";
41
+
42
+ /** Mirrors transcription-worker's safety-net cadence. */
43
+ const DEFAULT_SWEEP_MS = 30_000;
44
+
45
+ export interface EmbeddingWorkerOpts {
46
+ /**
47
+ * `undefined` when `EMBEDDINGS_ENABLED=false` (the off switch — see
48
+ * `src/embedding/select.ts`). The worker is still constructed and
49
+ * started either way (simpler wiring in `server.ts`, always one
50
+ * lifecycle to manage) — `embedNote`/`sweepOnce` just no-op when there's
51
+ * no provider to invoke, which is the entire "drain no-ops" contract.
52
+ */
53
+ provider: EmbeddingProvider | undefined;
54
+ /** Every vault name currently open (mirrors the transcription worker's `vaultList`). */
55
+ vaultList: () => string[];
56
+ /** Resolve (or open) a vault's `Store` by name. */
57
+ getStore: (name: string) => Store;
58
+ /** Sweep cadence (ms). Default 30s. */
59
+ sweepIntervalMs?: number;
60
+ logger?: { error: (...args: unknown[]) => void; log?: (...args: unknown[]) => void };
61
+ }
62
+
63
+ export class EmbeddingWorker {
64
+ private readonly provider: EmbeddingProvider | undefined;
65
+ private readonly vaultList: () => string[];
66
+ private readonly getStore: (name: string) => Store;
67
+ private readonly sweepIntervalMs: number;
68
+ private readonly logger: { error: (...args: unknown[]) => void; log?: (...args: unknown[]) => void };
69
+ private timer: ReturnType<typeof setInterval> | null = null;
70
+ private sweeping = false;
71
+
72
+ constructor(opts: EmbeddingWorkerOpts) {
73
+ this.provider = opts.provider;
74
+ this.vaultList = opts.vaultList;
75
+ this.getStore = opts.getStore;
76
+ this.sweepIntervalMs = opts.sweepIntervalMs ?? DEFAULT_SWEEP_MS;
77
+ this.logger = opts.logger ?? console;
78
+ }
79
+
80
+ /**
81
+ * (Re-)embed one note's stale chunks. Cheap-first: chunking + the
82
+ * staleness diff are pure CPU, no provider call; obsolete-row pruning is
83
+ * a plain DELETE. Only when at least one chunk is genuinely stale does
84
+ * this reach the provider — and if the provider reports itself
85
+ * unavailable, this returns without erroring (the chunk stays stale;
86
+ * the next edit or sweep pass retries once a provider is configured).
87
+ *
88
+ * No provider at all (`EMBEDDINGS_ENABLED=false`) → no-op, immediately —
89
+ * the drain contract's off switch (see `EmbeddingWorkerOpts.provider`).
90
+ *
91
+ * Empty-content chunks (a blank note, or a note whose content is only
92
+ * whitespace) are filtered out of the staleness plan before it reaches
93
+ * the provider — an empty chunk is never meaningfully embeddable, and
94
+ * without this filter a blank note would re-enter `getNotesPendingEmbedding`
95
+ * every sweep forever (nothing to write makes it "pending" again next
96
+ * pass), hammering a provider that may reject empty input outright.
97
+ */
98
+ async embedNote(store: Store, note: Note): Promise<void> {
99
+ if (!this.provider) return; // off switch — see EmbeddingWorkerOpts.provider
100
+ if (typeof note.content !== "string") return;
101
+ const db = store.db;
102
+ const chunks = chunkNoteContent(note.content).filter((c) => c.text.trim().length > 0);
103
+ const existing = getNoteVectorRows(db, note.id);
104
+ const plan = planStaleness(existing, chunks, this.provider.model);
105
+
106
+ // Cheap sync write — prunes ghost chunks from a shrunk note or a model
107
+ // change BEFORE any embed call, so a concurrent semantic search never
108
+ // ranks against stale content. See core/src/embedding/staleness.ts.
109
+ // An empty note's chunks were just filtered to [] above, so this also
110
+ // prunes any leftover rows from before the note went empty.
111
+ deleteObsoleteVectorRows(db, note.id, plan.obsoleteIxs);
112
+
113
+ if (plan.stale.length === 0) return; // no-op edit (or nothing embeddable) — zero provider calls
114
+
115
+ const avail = await this.provider.available();
116
+ if (!avail.ok) return; // not configured/ready — leave stale, retried later
117
+
118
+ try {
119
+ const { vectors } = await this.provider.embed({ texts: plan.stale.map((c) => c.text) });
120
+ for (let i = 0; i < plan.stale.length; i++) {
121
+ const chunk = plan.stale[i]!;
122
+ const vector = vectors[i];
123
+ if (!vector) continue; // defensive: provider returned fewer vectors than requested
124
+ upsertNoteVector(db, note.id, chunk, normalize(vector), this.provider.model, contentHash(chunk.text));
125
+ }
126
+ } catch (err) {
127
+ this.logger.error(`[embedding] embed failed for note ${note.id}:`, err);
128
+ // Leave stale — idempotent: the next edit (or the sweep) re-derives
129
+ // the same plan and retries.
130
+ }
131
+ }
132
+
133
+ /** Event-path entry point — called from the registered `onNote` hook. */
134
+ async kick(vaultName: string, note: Note): Promise<void> {
135
+ const store = this.getStore(vaultName);
136
+ await this.embedNote(store, note);
137
+ }
138
+
139
+ /**
140
+ * One backfill/safety-net pass across every open vault. Returns counts
141
+ * for caller logging. Re-entrancy guarded (`sweeping`) — a slow pass
142
+ * (a big vault, a slow provider) never overlaps with the next timer tick.
143
+ * No provider at all → no-op immediately (same off switch as `embedNote`).
144
+ *
145
+ * L1 (reviewer): no per-note lock between this sweep and a concurrent
146
+ * `embedNote` triggered by the event-path hook for the SAME note — by
147
+ * design, matching `HookRegistry`'s own documented "no per-note
148
+ * serialization, last write wins" contract. Worst case is a transient
149
+ * window where a stored vector reflects a slightly-stale version; it
150
+ * self-heals on the NEXT edit (or the next sweep pass), since
151
+ * `planStaleness` re-diffs against the note's CURRENT content_hash every
152
+ * time, never trusting a prior run's result.
153
+ */
154
+ async sweepOnce(): Promise<{ processed: number; vaults: number }> {
155
+ if (!this.provider) return { processed: 0, vaults: 0 }; // off switch
156
+ if (this.sweeping) return { processed: 0, vaults: 0 };
157
+ this.sweeping = true;
158
+ try {
159
+ let processed = 0;
160
+ const vaults = this.vaultList();
161
+ for (const vaultName of vaults) {
162
+ const store = this.getStore(vaultName);
163
+ const pending = getNotesPendingEmbedding(store.db, this.provider.model);
164
+ for (const row of pending) {
165
+ const note = await store.getNote(row.id);
166
+ if (!note) continue; // deleted between the scan and now
167
+ await this.embedNote(store, note);
168
+ processed++;
169
+ }
170
+ }
171
+ return { processed, vaults: vaults.length };
172
+ } finally {
173
+ this.sweeping = false;
174
+ }
175
+ }
176
+
177
+ /** Start the recurring sweep. No-op if already started. */
178
+ start(): void {
179
+ if (this.timer) return;
180
+ this.timer = setInterval(() => {
181
+ void this.sweepOnce().catch((err) => this.logger.error("[embedding] sweep failed:", err));
182
+ }, this.sweepIntervalMs);
183
+ this.timer.unref?.();
184
+ }
185
+
186
+ /** Stop the recurring sweep. */
187
+ stop(): void {
188
+ if (this.timer) {
189
+ clearInterval(this.timer);
190
+ this.timer = null;
191
+ }
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Register the embed-on-write hook. No `when` predicate (unlike
197
+ * `registerTranscriptionHook`'s `transcribe_status === "pending"` check)
198
+ * — a `NoteHook` predicate only receives the note payload, not the store,
199
+ * so it has no way to cheaply consult `note_vectors`. Instead every
200
+ * create/update dispatches the handler, and `embedNote`'s OWN
201
+ * content-hash diff (checked first, before any provider call) is what
202
+ * makes a no-op edit cost nothing — the same end result, just checked one
203
+ * layer in. `HookRegistry`'s concurrency cap still bounds how many of
204
+ * these run at once.
205
+ */
206
+ export function registerEmbeddingHook(
207
+ registry: HookRegistry,
208
+ worker: EmbeddingWorker,
209
+ resolveVault: (store: Store) => string | undefined,
210
+ logger: { error: (...args: unknown[]) => void } = console,
211
+ ): () => void {
212
+ return registry.onNote({
213
+ name: "embedding-on-write",
214
+ event: ["created", "updated"],
215
+ handler: async (payload: NoteHookPayload, store: Store) => {
216
+ const vault = resolveVault(store);
217
+ if (!vault) {
218
+ logger.error(
219
+ `[embedding] could not resolve vault for note ${(payload as Note).id}; sweep will pick it up`,
220
+ );
221
+ return;
222
+ }
223
+ await worker.kick(vault, payload as Note);
224
+ },
225
+ });
226
+ }
package/src/mcp-http.ts CHANGED
@@ -109,7 +109,7 @@ export async function handleScopedMcp(
109
109
  const instruction = await getServerInstruction(vaultName, auth);
110
110
  return handleMcp(
111
111
  req,
112
- () => generateScopedMcpTools(vaultName, auth, callerBearer ?? null),
112
+ () => generateScopedMcpTools(vaultName, auth, callerBearer ?? null, req),
113
113
  `parachute-vault/${vaultName}`,
114
114
  vaultName,
115
115
  auth,
@@ -229,6 +229,8 @@ export async function handleMcp(
229
229
  tag?: string;
230
230
  cycle?: unknown;
231
231
  referencing_tags?: unknown;
232
+ /** Attachment-tickets design (§2c "errors as JIT docs") — a short, imperative next-step, distinct from the older free-form `hint`. */
233
+ how_to?: string;
232
234
  };
233
235
  // Honest-queries validation errors (vault#550) — `limit`/`offset`/date
234
236
  // values that are structurally invalid rather than merely "no
@@ -399,6 +401,16 @@ export async function handleMcp(
399
401
  error_type: e.error_type,
400
402
  field: e.field,
401
403
  hint: e.hint,
404
+ // Forwarded when present (undefined keys are fine — JSON-RPC
405
+ // `data` just omits them) so a `structuredError()` call site that
406
+ // stamps extra fields — `limit`/`got`/`extension` (size/type
407
+ // refusals) or `how_to` (attachment-tickets' JIT-docs field, §2c)
408
+ // — isn't silently truncated down to error_type/field/hint by
409
+ // this backstop.
410
+ ...(e.limit !== undefined ? { limit: e.limit } : {}),
411
+ ...(e.got !== undefined ? { got: e.got } : {}),
412
+ ...(e.extension !== undefined ? { extension: e.extension } : {}),
413
+ ...(e.how_to !== undefined ? { how_to: e.how_to } : {}),
402
414
  });
403
415
  }
404
416
  return {
package/src/mcp-tools.ts CHANGED
@@ -43,6 +43,8 @@ import {
43
43
  import { chooseHubOrigin, mintHubJwt, revokeHubJwt } from "./mcp-install.ts";
44
44
  import { looksLikeJwt } from "./hub-jwt.ts";
45
45
  import { readGlobalConfig, DEFAULT_PORT } from "./config.ts";
46
+ import { getBaseUrl } from "./oauth-discovery.ts";
47
+ import { getSharedAttachmentTicketProvider } from "./attachment-tickets.ts";
46
48
 
47
49
  /**
48
50
  * Filter a vault projection to entries an in-scope tag contributes to.
@@ -110,6 +112,10 @@ export async function getServerInstruction(
110
112
  description: config?.description ?? null,
111
113
  projection,
112
114
  coordinates: resolveVaultCoordinates(),
115
+ // Bun always wires an in-process AttachmentTicketProvider (see
116
+ // `generateScopedMcpTools` below) — the connect-time brief can
117
+ // unconditionally teach the ticket tools on this door.
118
+ attachments: { ticketsEnabled: true },
113
119
  });
114
120
  }
115
121
 
@@ -143,6 +149,16 @@ export function generateScopedMcpTools(
143
149
  vaultName: string,
144
150
  auth?: AuthResult,
145
151
  callerBearer?: string | null,
152
+ /**
153
+ * The incoming MCP request, when available (omitted by the many
154
+ * test-only call sites that construct tools without a live request).
155
+ * Threaded through ONLY so the attachment-ticket tools can resolve a
156
+ * request-accurate `urlBase` (honoring `X-Forwarded-Host`/proto, same as
157
+ * `getBaseUrl`) — falls back to `resolveVaultCoordinates()`'s configured/
158
+ * expose-state origin when omitted, so ticket tools are still present
159
+ * (just less precisely origined) in that case.
160
+ */
161
+ req?: Request,
146
162
  ): McpToolDef[] {
147
163
  const store = getVaultStore(vaultName);
148
164
 
@@ -225,20 +241,39 @@ export function generateScopedMcpTools(
225
241
  ? (info) => logStrictBypass(info)
226
242
  : undefined;
227
243
 
228
- const tools = generateMcpTools(
229
- store,
230
- expandVisibility || nearTraversable || ifExistsVisible || aggregateVisibility || writeContext || strictBypass
231
- ? {
232
- ...(expandVisibility ? { expandVisibility } : {}),
233
- ...(nearTraversable ? { nearTraversable } : {}),
234
- ...(ifExistsVisible ? { ifExistsVisible } : {}),
235
- ...(aggregateVisibility ? { aggregateVisibility } : {}),
236
- ...(writeContext ? { writeContext } : {}),
237
- ...(strictBypass ? { strictBypass } : {}),
238
- ...(onStrictBypass ? { onStrictBypass } : {}),
239
- }
240
- : undefined,
241
- );
244
+ // Attachment tickets (Wave 1): bun always wires the in-process provider
245
+ // (see src/attachment-tickets.ts) — unlike the tag-scope predicates
246
+ // above, this isn't conditional on `scoped` because the tools should be
247
+ // listed for every session, scoped or not. Tag-scope confidentiality is
248
+ // still enforced (`noteVisible`, awaited inline inside the ticket tools'
249
+ // own async execute see its doc comment in generateMcpTools for why
250
+ // this doesn't need the shared allowedHolder machinery the OTHER
251
+ // predicates above do).
252
+ const ticketNoteVisible = scoped
253
+ ? async (note: Note) => {
254
+ const allowed = await expandTokenTagScope(store, rawTags);
255
+ return noteWithinTagScope(note, allowed, rawTags);
256
+ }
257
+ : undefined;
258
+ const ticketUrlBase = req
259
+ ? `${getBaseUrl(req).replace(/\/$/, "")}/vault/${vaultName}`
260
+ : `${resolveVaultCoordinates().hubOrigin.replace(/\/$/, "")}/vault/${vaultName}`;
261
+
262
+ const tools = generateMcpTools(store, {
263
+ ...(expandVisibility ? { expandVisibility } : {}),
264
+ ...(nearTraversable ? { nearTraversable } : {}),
265
+ ...(ifExistsVisible ? { ifExistsVisible } : {}),
266
+ ...(aggregateVisibility ? { aggregateVisibility } : {}),
267
+ ...(writeContext ? { writeContext } : {}),
268
+ ...(strictBypass ? { strictBypass } : {}),
269
+ ...(onStrictBypass ? { onStrictBypass } : {}),
270
+ attachmentTickets: {
271
+ provider: getSharedAttachmentTicketProvider(),
272
+ vaultName,
273
+ urlBase: ticketUrlBase,
274
+ ...(ticketNoteVisible ? { noteVisible: ticketNoteVisible } : {}),
275
+ },
276
+ });
242
277
 
243
278
  overrideVaultInfo(tools, vaultName, auth);
244
279
  applyTagDependencyGuards(tools, vaultName);
@@ -23,14 +23,17 @@ import { BunStore } from "./vault-store.ts";
23
23
  import { seedOnboardingNotes } from "./onboarding-seed.ts";
24
24
  import {
25
25
  applySeedPack,
26
+ ARCHIVED_TAG,
26
27
  CAPTURE_ANYTHING_PATH,
27
28
  CONNECT_AI_PATH,
28
29
  GETTING_STARTED_PATH,
29
30
  NOTES_REQUIRED_TAGS,
31
+ STARTER_ONTOLOGY_PACK,
30
32
  SURFACE_STARTER_PACK,
31
33
  SURFACE_STARTER_PATH,
32
34
  TAGS_GRAPH_PATH,
33
35
  WELCOME_PATH,
36
+ welcomePack,
34
37
  YOURS_TO_KEEP_PATH,
35
38
  } from "../core/src/seed-packs.ts";
36
39
  import {
@@ -290,6 +293,67 @@ describe("applySeedPack — surface-starter via add-pack", () => {
290
293
  });
291
294
  });
292
295
 
296
+ describe("applySeedPack — tag description preservation on re-apply (Aaron-ratified 2026-07-17)", () => {
297
+ test("fresh apply: a brand-new tag gets the pack's description, reported as touched not preserved", async () => {
298
+ const result = await applySeedPack(store, STARTER_ONTOLOGY_PACK);
299
+ expect(result.tags).toContain(ARCHIVED_TAG.name);
300
+ expect(result.preservedTagDescriptions).toEqual([]);
301
+
302
+ const record = await store.getTagRecord(ARCHIVED_TAG.name);
303
+ expect(record!.description).toBe(ARCHIVED_TAG.description);
304
+ });
305
+
306
+ test("re-apply unmodified: description is still the pack's text — upserted, not preserved", async () => {
307
+ await applySeedPack(store, STARTER_ONTOLOGY_PACK);
308
+ const rerun = await applySeedPack(store, STARTER_ONTOLOGY_PACK);
309
+ expect(rerun.preservedTagDescriptions).toEqual([]);
310
+
311
+ const record = await store.getTagRecord(ARCHIVED_TAG.name);
312
+ expect(record!.description).toBe(ARCHIVED_TAG.description);
313
+ });
314
+
315
+ test("re-apply after a user edit: the hand-tuned description survives and is reported preserved", async () => {
316
+ await applySeedPack(store, STARTER_ONTOLOGY_PACK);
317
+ const edited = "My own house rules for archiving — nothing like the default.";
318
+ await store.upsertTagRecord(ARCHIVED_TAG.name, { description: edited });
319
+
320
+ const rerun = await applySeedPack(store, STARTER_ONTOLOGY_PACK);
321
+ expect(rerun.preservedTagDescriptions).toContain(ARCHIVED_TAG.name);
322
+ // Still listed in `tags` — the tag itself was touched (fields/parent_names
323
+ // upsert unconditionally); only the description write was skipped.
324
+ expect(rerun.tags).toContain(ARCHIVED_TAG.name);
325
+
326
+ const record = await store.getTagRecord(ARCHIVED_TAG.name);
327
+ expect(record!.description).toBe(edited);
328
+ });
329
+
330
+ test("capture byte-identity anchor (NOTES_REQUIRED_TAGS) holds under either apply order", async () => {
331
+ // welcome, then starter-ontology.
332
+ await applySeedPack(store, welcomePack());
333
+ expect((await store.getTagRecord("capture"))!.description).toBe(
334
+ NOTES_REQUIRED_TAGS[0]!.description,
335
+ );
336
+ const afterOntology = await applySeedPack(store, STARTER_ONTOLOGY_PACK);
337
+ expect(afterOntology.preservedTagDescriptions).not.toContain("capture");
338
+ expect((await store.getTagRecord("capture"))!.description).toBe(
339
+ NOTES_REQUIRED_TAGS[0]!.description,
340
+ );
341
+ });
342
+
343
+ test("capture byte-identity anchor holds in the reverse order too", async () => {
344
+ // starter-ontology, then welcome.
345
+ await applySeedPack(store, STARTER_ONTOLOGY_PACK);
346
+ expect((await store.getTagRecord("capture"))!.description).toBe(
347
+ NOTES_REQUIRED_TAGS[0]!.description,
348
+ );
349
+ const afterWelcome = await applySeedPack(store, welcomePack());
350
+ expect(afterWelcome.preservedTagDescriptions).not.toContain("capture");
351
+ expect((await store.getTagRecord("capture"))!.description).toBe(
352
+ NOTES_REQUIRED_TAGS[0]!.description,
353
+ );
354
+ });
355
+ });
356
+
293
357
  describe("vault-info / projection pointer (A2)", () => {
294
358
  test("projection carries getting_started when the note exists", async () => {
295
359
  // Absent before seeding → no pointer.