@cerefox/memory 1.2.0 → 1.3.0-beta.1

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.
@@ -0,0 +1,489 @@
1
+ /**
2
+ * `cerefox_insert` and `cerefox_edit` — partial document edits (iteration 34).
3
+ *
4
+ * Spec: docs/specs/partial-document-edits-design.md (frozen).
5
+ * Technical design: docs/specs/partial-edits-technical-design.md §3.
6
+ *
7
+ * Both tools share one flow, so the additive tool cannot drift from the batch's
8
+ * insert semantics:
9
+ *
10
+ * read → apply (pure, _shared/partial-edits) → chunk → embed → ingest RPC
11
+ *
12
+ * The agent sends what changed; the server assembles the result. The document
13
+ * body never enters the agent's context on the way in *or* out — that is the
14
+ * whole point, and it is why the response carries the new hash and size rather
15
+ * than the document (spec §3.8).
16
+ *
17
+ * Two tools rather than one because MCP annotations are per-tool: `cerefox_edit`
18
+ * must declare itself destructive, and folding insert into it would make every
19
+ * additive write prompt like a delete (spec §3.2). `cerefox_insert` is
20
+ * structurally incapable of removing content, which is exactly the guarantee
21
+ * that prevents the scope-confusion data loss in spec §1.
22
+ */
23
+
24
+ import {
25
+ applyOperations,
26
+ validateOperations,
27
+ type AppliedOperation,
28
+ type EditOperation,
29
+ } from "../partial-edits/index.ts";
30
+ import {
31
+ chunkMarkdown,
32
+ embeddingInputFor,
33
+ CONTENT_FORMAT_BLIND_STITCH,
34
+ normalizeContent,
35
+ sha256hex,
36
+ } from "./_chunker.ts";
37
+ import { activeEmbedderName, embedBatch, resolveEmbedderKind } from "../embeddings/index.ts";
38
+ import { logUsage } from "./_utils.ts";
39
+ import { McpInvalidParams, type MCPSupabaseClient, type ToolContext, type ToolDefinition } from "./types.ts";
40
+
41
+ /**
42
+ * Who to record as the author. Derived from the access path rather than taken
43
+ * on trust: an agent calling over MCP must not be able to claim `author_type:
44
+ * "user"` and route its writes around a governance filter. Only the CLI, where
45
+ * a human ran the command, may state it — and only to say `user`.
46
+ */
47
+ function resolveAuthorType(ctx: ToolContext, args: Record<string, unknown>): "user" | "agent" {
48
+ if (ctx.accessPath !== "cli") return "agent";
49
+ return args.author_type === "agent" ? "agent" : "user";
50
+ }
51
+
52
+ function defaultRequestor(ctx: ToolContext): string {
53
+ return ctx.accessPath === "cli" ? "cli-user" : "mcp-agent";
54
+ }
55
+
56
+ /** Audit `operation` values, matching the CHECK constraint widened by migration 0019. */
57
+ const AUDIT_OP: Record<AppliedOperation["op"], string> = {
58
+ insert: "insert",
59
+ replace_section: "replace-section",
60
+ delete_section: "delete-section",
61
+ };
62
+
63
+ /**
64
+ * Conflict text mirrors `ingest.ts`'s, but points at the partial-edit retry:
65
+ * re-read, re-decide, retry — deliberately NOT "overwrite anyway", because
66
+ * these tools have no last_write_wins (spec §5: a conflict is information the
67
+ * agent needs, and suppressing it is what destroys another writer's work).
68
+ */
69
+ function conflictError(documentId: string, expectedHash: string, currentHash: string): Error {
70
+ return new Error(
71
+ `Conflict: document ${documentId} changed since you read it ` +
72
+ `(your base hash: ${expectedHash}, current hash: ${currentHash}). No write was performed. ` +
73
+ `To resolve: (1) cerefox_get_document("${documentId}", outline=true) to see the current ` +
74
+ `structure and hash cheaply, or a full read if you need the text, (2) decide whether your ` +
75
+ `edit still applies — another writer may have already made it, or made something it ` +
76
+ `contradicts, (3) retry with expected_content_hash set to the current hash. ` +
77
+ `These tools have no last-write-wins: the other writer's work is not yours to discard.`,
78
+ );
79
+ }
80
+
81
+ interface DocRow {
82
+ doc_title?: string;
83
+ full_content?: string;
84
+ content_hash?: string;
85
+ }
86
+
87
+ async function readDocument(
88
+ supabase: MCPSupabaseClient,
89
+ documentId: string,
90
+ ): Promise<{ title: string; content: string; hash: string }> {
91
+ const { data, error } = await supabase.rpc("cerefox_get_document", {
92
+ p_document_id: documentId,
93
+ p_version_id: null,
94
+ });
95
+ if (error) throw new Error(`Could not read document: ${error.message}`);
96
+ const row = (data?.[0] as DocRow | undefined) ?? undefined;
97
+ if (!row || row.full_content === undefined) {
98
+ throw new McpInvalidParams(
99
+ `Document not found: ${documentId}. Partial edits apply to an existing document; ` +
100
+ `use cerefox_ingest to create one.`,
101
+ );
102
+ }
103
+ return {
104
+ title: row.doc_title ?? "Untitled",
105
+ content: row.full_content,
106
+ hash: row.content_hash ?? "",
107
+ };
108
+ }
109
+
110
+ /**
111
+ * The shared write path. Everything above it is argument shape; everything
112
+ * below is the same code the whole-document ingest path uses.
113
+ */
114
+ async function applyAndWrite(
115
+ supabase: MCPSupabaseClient,
116
+ ctx: ToolContext,
117
+ args: {
118
+ documentId: string;
119
+ operations: EditOperation[];
120
+ expectedHash: string;
121
+ requestor: string;
122
+ toolLabel: string;
123
+ authorType: "user" | "agent";
124
+ },
125
+ ): Promise<string> {
126
+ const { documentId, operations, expectedHash, requestor, toolLabel, authorType } = args;
127
+
128
+ if (!ctx.openaiApiKey && resolveEmbedderKind() !== "local") {
129
+ throw new Error(
130
+ "OpenAI API key not configured. Set OPENAI_API_KEY (Edge Function) or CEREFOX_OPENAI_API_KEY (.env, local).",
131
+ );
132
+ }
133
+
134
+ const doc = await readDocument(supabase, documentId);
135
+
136
+ // Advisory fast-fail before the embedding spend. The authoritative, race-free
137
+ // check is the RPC's FOR UPDATE compare-and-swap.
138
+ if (doc.hash && expectedHash !== doc.hash) {
139
+ throw conflictError(documentId, expectedHash, doc.hash);
140
+ }
141
+
142
+ // Pure. Anchor/position problems raise here, before anything is written or
143
+ // paid for, carrying the candidates that resolve them.
144
+ let assembled: string;
145
+ let applied: AppliedOperation[];
146
+ try {
147
+ const result = applyOperations(doc.content, operations);
148
+ assembled = result.content;
149
+ applied = result.applied;
150
+ } catch (err) {
151
+ // These are agent-correctable: surface as invalid-params so the client
152
+ // reports them as a bad call rather than a server failure.
153
+ throw new McpInvalidParams(err instanceof Error ? err.message : String(err));
154
+ }
155
+
156
+ if (assembled === doc.content) {
157
+ return (
158
+ `No change: the ${toolLabel} produced content identical to the current document ` +
159
+ `"${doc.title}" (id: ${documentId}). content_hash: ${doc.hash} (unchanged).`
160
+ );
161
+ }
162
+
163
+ const newHash = await sha256hex(normalizeContent(assembled));
164
+ const chunks = chunkMarkdown(assembled);
165
+ if (chunks.length === 0) {
166
+ throw new McpInvalidParams(
167
+ "The result of this edit produced no chunks (the document would be empty). " +
168
+ "To remove a document use cerefox_ingest or the delete path, not a partial edit.",
169
+ );
170
+ }
171
+
172
+ const texts = chunks.map((c) => embeddingInputFor(doc.title, c));
173
+ const embeddings = await embedBatch(texts, ctx.openaiApiKey ?? "");
174
+ const totalChars = chunks.reduce((s, c) => s + c.char_count, 0);
175
+
176
+ const chunkData = chunks.map((chunk, i) => ({
177
+ chunk_index: i,
178
+ heading_path: chunk.heading_path,
179
+ heading_level: chunk.heading_level,
180
+ title: chunk.title,
181
+ content: chunk.content,
182
+ char_count: chunk.char_count,
183
+ embedding: embeddings[i],
184
+ embedder: activeEmbedderName(),
185
+ }));
186
+
187
+ const { data, error } = await supabase.rpc("cerefox_ingest_document", {
188
+ p_document_id: documentId,
189
+ p_title: doc.title, // partial edits never retitle
190
+ // NOT "agent": a partial edit changes a document's CONTENT, never where it
191
+ // came from. Passing a literal here would relabel provenance on every edit
192
+ // — the #191 defect, and an explicit value survives even that fix, so this
193
+ // has to be omitted rather than corrected server-side. NULL = keep existing
194
+ // NULL = keep existing. The RPC's default is now also NULL (#191), but this
195
+ // stays explicit: it makes intent legible at the call site and remains
196
+ // correct if that default is ever restored.
197
+ p_source: null,
198
+ p_content_hash: newHash,
199
+ p_metadata: null, // null = keep existing metadata
200
+ // Agent writes land in review; a human at the CLI is the reviewer.
201
+ p_review_status: authorType === "agent" ? "pending_review" : "approved",
202
+ p_chunks: chunkData,
203
+ p_author: requestor,
204
+ // Who actually made the write, not which module executed it. The CLI is a
205
+ // human at a shell (or their script); MCP is an agent. Recording everything
206
+ // as "agent" would mis-attribute every CLI edit in the audit trail, and
207
+ // governance filters keyed on author_type would silently miss them.
208
+ p_author_type: authorType,
209
+ p_source_label: authorType === "user" ? "manual" : "agent",
210
+ p_expected_content_hash: expectedHash,
211
+ p_last_write_wins: false,
212
+ p_content_format: CONTENT_FORMAT_BLIND_STITCH,
213
+ p_operations: applied.map((a) => ({ op: AUDIT_OP[a.op], detail: a.detail })),
214
+ });
215
+
216
+ if (error) {
217
+ const message = error.message ?? "";
218
+ if (message.includes("CEREFOX_CONFLICT")) {
219
+ const current = message.match(/current hash ([0-9a-f]{64})/)?.[1] ?? "unknown";
220
+ throw conflictError(documentId, expectedHash, current);
221
+ }
222
+ if (message.includes("cerefox_documents_hash_unique")) {
223
+ // content_hash is UNIQUE store-wide, so an edit whose result matches
224
+ // ANOTHER document collides. Raw, this reads as a database fault; it is
225
+ // actually a meaningful statement about the store.
226
+ throw new Error(
227
+ `This edit would make "${doc.title}" (id: ${documentId}) byte-identical to another ` +
228
+ `document in the store, and content must be unique. No write was performed. ` +
229
+ `Usually this means the two documents have converged and one should be removed or ` +
230
+ `merged, or that this edit was already applied to the other one — ` +
231
+ `cerefox_search for the resulting content to find it.`,
232
+ );
233
+ }
234
+ if (message.includes("does not exist") && message.includes("cerefox_ingest_document")) {
235
+ throw new Error(
236
+ `This server is behind: partial edits need schema 0.11.0 or newer. ` +
237
+ `Run \`cerefox server deploy\`, then retry. (${message})`,
238
+ );
239
+ }
240
+ throw new Error(`Edit failed: ${message}`);
241
+ }
242
+
243
+ const row = (data?.[0] as
244
+ | { content_hash?: string; total_chars?: number; size_warning?: boolean }
245
+ | undefined) ?? undefined;
246
+
247
+ logUsage(supabase, {
248
+ operation: toolLabel,
249
+ accessPath: ctx.accessPath,
250
+ requestor,
251
+ document_id: documentId,
252
+ result_count: applied.length,
253
+ });
254
+
255
+ const summary = applied.map((a, i) => ` ${i + 1}. ${a.detail} → ${a.path}`).join("\n");
256
+ const warning = row?.size_warning
257
+ ? `\n\n⚠ This document has passed the configured size threshold ` +
258
+ `(document_size_warning_chars). Consider splitting it.`
259
+ : "";
260
+
261
+ // Deliberately no document body: returning it would spend exactly the tokens
262
+ // this feature saves, on the response side (spec §3.8).
263
+ return (
264
+ `Applied ${applied.length} operation(s) to "${doc.title}" (id: ${documentId}):\n${summary}\n\n` +
265
+ `New content_hash: ${row?.content_hash ?? newHash}\n` +
266
+ `Size: ${row?.total_chars ?? totalChars} chars, ${chunks.length} chunk(s).\n` +
267
+ `Pass the new content_hash as expected_content_hash on your next edit.${warning}`
268
+ );
269
+ }
270
+
271
+ // ── cerefox_insert ─────────────────────────────────────────────────────────
272
+
273
+ async function insertHandler(
274
+ supabase: MCPSupabaseClient,
275
+ args: Record<string, unknown>,
276
+ ctx: ToolContext,
277
+ ): Promise<string> {
278
+ const documentId = (args.document_id as string | undefined)?.trim();
279
+ const text = args.text as string | undefined;
280
+ const position = args.position as string | undefined;
281
+ const expectedHash = (args.expected_content_hash as string | undefined)?.trim();
282
+
283
+ if (!documentId) throw new McpInvalidParams("document_id is required");
284
+ if (!text?.trim()) throw new McpInvalidParams("text is required and cannot be empty");
285
+ if (!expectedHash) {
286
+ throw new McpInvalidParams(
287
+ "expected_content_hash is required. It is the content_hash of the version you are " +
288
+ "basing this insert on — returned by cerefox_get_document (including outline mode), " +
289
+ "cerefox_search, cerefox_metadata_search, and by every write. There is no " +
290
+ "last-write-wins here: knowing the document changed under you is the point.",
291
+ );
292
+ }
293
+
294
+ const operations = validateOperations([
295
+ {
296
+ op: "insert",
297
+ text,
298
+ position,
299
+ ...(args.anchor_heading !== undefined ? { anchor_heading: args.anchor_heading } : {}),
300
+ ...(args.section_part !== undefined ? { section_part: args.section_part } : {}),
301
+ },
302
+ ]);
303
+
304
+ return applyAndWrite(supabase, ctx, {
305
+ documentId,
306
+ operations,
307
+ expectedHash,
308
+ requestor: (args.requestor as string | undefined) ?? defaultRequestor(ctx),
309
+ toolLabel: "insert",
310
+ authorType: resolveAuthorType(ctx, args),
311
+ });
312
+ }
313
+
314
+ export const insertTool: ToolDefinition = {
315
+ name: "cerefox_insert",
316
+ description:
317
+ "Add text to a document without resending the whole thing. Purely additive: it cannot " +
318
+ "remove or overwrite existing content, so it is the safe way to append. Positions: " +
319
+ "end_of_document (a plain append), end_of_section (add to the end of a section's body — " +
320
+ "the most common mid-document add), after_heading (lead-in text), before_heading (a new " +
321
+ "block above a section). Anchors are the exact heading line ('## Intake') or a parent path " +
322
+ "('## Intake > ### Notes') when a heading appears more than once. Requires " +
323
+ "expected_content_hash; returns the new hash, not the document.",
324
+ annotations: {
325
+ title: "Insert into document",
326
+ readOnlyHint: false,
327
+ // Structurally incapable of destroying content — the distinction that lets a
328
+ // client grant this freely while still prompting on cerefox_edit.
329
+ destructiveHint: false,
330
+ idempotentHint: false,
331
+ openWorldHint: false,
332
+ },
333
+ inputSchema: {
334
+ type: "object",
335
+ required: ["document_id", "text", "position", "expected_content_hash"],
336
+ properties: {
337
+ document_id: { type: "string", description: "UUID of the document to add to" },
338
+ text: { type: "string", description: "Markdown to insert. Sent as-is; blank-line separation is handled for you." },
339
+ position: {
340
+ type: "string",
341
+ enum: ["end_of_document", "end_of_section", "after_heading", "before_heading"],
342
+ description:
343
+ "Where to insert. end_of_document needs no anchor; the other three require anchor_heading.",
344
+ },
345
+ anchor_heading: {
346
+ type: "string",
347
+ description:
348
+ "Exact heading line, or a ' > ' path for a heading that appears more than once. Required unless position is end_of_document.",
349
+ },
350
+ section_part: {
351
+ type: "string",
352
+ enum: ["own_body", "subtree"],
353
+ description:
354
+ "Only for end_of_section when the section has BOTH its own content and child sections: own_body = before the first child, subtree = after everything nested under it. Omit otherwise; you will be told (with both options) if it is needed.",
355
+ },
356
+ expected_content_hash: {
357
+ type: "string",
358
+ description:
359
+ "content_hash of the version you are basing this on. Required — no last-write-wins.",
360
+ },
361
+ requestor: {
362
+ type: "string",
363
+ description: 'Agent or user making this request. Recorded in the usage log. Defaults to "mcp-agent".',
364
+ },
365
+ author_type: {
366
+ type: "string",
367
+ enum: ["user", "agent"],
368
+ description:
369
+ "Honoured on the CLI only, where a human ran the command; over MCP the write is always recorded as an agent write regardless of what is passed.",
370
+ },
371
+ },
372
+ },
373
+ handler: insertHandler,
374
+ };
375
+
376
+ // ── cerefox_edit ───────────────────────────────────────────────────────────
377
+
378
+ async function editHandler(
379
+ supabase: MCPSupabaseClient,
380
+ args: Record<string, unknown>,
381
+ ctx: ToolContext,
382
+ ): Promise<string> {
383
+ const documentId = (args.document_id as string | undefined)?.trim();
384
+ const expectedHash = (args.expected_content_hash as string | undefined)?.trim();
385
+
386
+ if (!documentId) throw new McpInvalidParams("document_id is required");
387
+ if (!expectedHash) {
388
+ throw new McpInvalidParams(
389
+ "expected_content_hash is required. It is the content_hash of the version you are " +
390
+ "basing these edits on — returned by cerefox_get_document (including outline mode), " +
391
+ "cerefox_search, cerefox_metadata_search, and by every write.",
392
+ );
393
+ }
394
+
395
+ let operations: EditOperation[];
396
+ try {
397
+ operations = validateOperations(args.operations);
398
+ } catch (err) {
399
+ throw new McpInvalidParams(err instanceof Error ? err.message : String(err));
400
+ }
401
+
402
+ return applyAndWrite(supabase, ctx, {
403
+ documentId,
404
+ operations,
405
+ expectedHash,
406
+ requestor: (args.requestor as string | undefined) ?? defaultRequestor(ctx),
407
+ toolLabel: "edit",
408
+ authorType: resolveAuthorType(ctx, args),
409
+ });
410
+ }
411
+
412
+ export const editTool: ToolDefinition = {
413
+ name: "cerefox_edit",
414
+ description:
415
+ "Change parts of a document without resending the whole thing: one or many operations " +
416
+ "applied ATOMICALLY in one write. Operations: insert (same positions as cerefox_insert), " +
417
+ "replace_section (swap a section's body, heading kept), delete_section (remove a section, " +
418
+ "scope body_only or heading_and_body). Use one call for changes that belong together — a " +
419
+ "half-applied edit is impossible, so a row and the total it feeds cannot disagree. " +
420
+ "Operations apply in order and each sees the previous one's result. To change a single " +
421
+ "line, replace_section on its smallest enclosing heading. Requires expected_content_hash; " +
422
+ "returns the new hash, not the document.",
423
+ annotations: {
424
+ title: "Edit document sections",
425
+ readOnlyHint: false,
426
+ // replace_section and delete_section overwrite/remove content.
427
+ destructiveHint: true,
428
+ idempotentHint: false,
429
+ openWorldHint: false,
430
+ },
431
+ inputSchema: {
432
+ type: "object",
433
+ required: ["document_id", "operations", "expected_content_hash"],
434
+ properties: {
435
+ document_id: { type: "string", description: "UUID of the document to edit" },
436
+ operations: {
437
+ type: "array",
438
+ minItems: 1,
439
+ description:
440
+ "Operations applied in order, all-or-nothing. If any fails (bad anchor, ambiguity), nothing is written.",
441
+ items: {
442
+ type: "object",
443
+ required: ["op"],
444
+ properties: {
445
+ op: { type: "string", enum: ["insert", "replace_section", "delete_section"] },
446
+ text: { type: "string", description: "Markdown. Required for insert and replace_section." },
447
+ position: {
448
+ type: "string",
449
+ enum: ["end_of_document", "end_of_section", "after_heading", "before_heading"],
450
+ description: "Required for insert.",
451
+ },
452
+ anchor_heading: {
453
+ type: "string",
454
+ description:
455
+ "Exact heading line, or a ' > ' path when the heading is not unique. Required for replace_section, delete_section, and any insert other than end_of_document.",
456
+ },
457
+ section_part: {
458
+ type: "string",
459
+ enum: ["own_body", "subtree"],
460
+ description:
461
+ "Only when the target section has BOTH its own content and child sections. You will be told (with both options) if it is needed.",
462
+ },
463
+ scope: {
464
+ type: "string",
465
+ enum: ["body_only", "heading_and_body"],
466
+ description: "delete_section only. Defaults to body_only, which keeps the heading.",
467
+ },
468
+ },
469
+ },
470
+ },
471
+ expected_content_hash: {
472
+ type: "string",
473
+ description:
474
+ "content_hash of the version you are basing these edits on. Required — no last-write-wins.",
475
+ },
476
+ requestor: {
477
+ type: "string",
478
+ description: 'Agent or user making this request. Recorded in the usage log. Defaults to "mcp-agent".',
479
+ },
480
+ author_type: {
481
+ type: "string",
482
+ enum: ["user", "agent"],
483
+ description:
484
+ "Honoured on the CLI only, where a human ran the command; over MCP the write is always recorded as an agent write regardless of what is passed.",
485
+ },
486
+ },
487
+ },
488
+ handler: editHandler,
489
+ };
@@ -0,0 +1,49 @@
1
+ -- 0019_partial_edit_audit_ops.sql — make partial edits auditable (iteration 33).
2
+ --
3
+ -- `cerefox_audit_log.operation` is CHECK-constrained, so the partial-edit
4
+ -- operations need to be admitted before `cerefox_ingest_document` can record
5
+ -- them. Three new values:
6
+ --
7
+ -- insert -- cerefox_insert, and insert operations inside cerefox_edit
8
+ -- replace-section -- cerefox_edit
9
+ -- delete-section -- cerefox_edit
10
+ --
11
+ -- Why distinct values rather than logging everything as 'update-content': the
12
+ -- audit trail exists to answer "what did someone do to this document", and
13
+ -- *added a paragraph*, *rewrote a section* and *removed a section* are
14
+ -- different answers even though all three are implemented with the same ingest
15
+ -- primitive underneath. A trail that flattens them cannot distinguish an agent
16
+ -- that appended from one that re-sent the whole document and dropped half of
17
+ -- it, which is much of what the trail is for. Design:
18
+ -- docs/specs/partial-document-edits-design.md §6.1.
19
+ --
20
+ -- Three values, not one per position: whether an insert landed at
21
+ -- `end_of_document` or `end_of_section` is detail about the same intent and
22
+ -- lives in the entry's description, so adding a position later never needs a
23
+ -- schema change.
24
+ --
25
+ -- The constraint stays the allow-list. A handler label that drifts from this
26
+ -- set aborts its transaction rather than silently recording an operation the
27
+ -- readers of the trail cannot interpret.
28
+ --
29
+ -- Idempotent: drops and re-adds the constraint.
30
+
31
+ ALTER TABLE cerefox_audit_log
32
+ DROP CONSTRAINT IF EXISTS cerefox_audit_log_operation_check;
33
+
34
+ ALTER TABLE cerefox_audit_log
35
+ ADD CONSTRAINT cerefox_audit_log_operation_check CHECK (
36
+ operation IN ('create', 'update-content', 'update-metadata', 'delete',
37
+ 'status-change', 'archive', 'unarchive', 'restore',
38
+ 'relation-set', 'relation-delete',
39
+ 'insert', 'replace-section', 'delete-section')
40
+ );
41
+
42
+ DO $$
43
+ BEGIN
44
+ RAISE NOTICE
45
+ 'Migration 0019: audit log accepts insert / replace-section / '
46
+ 'delete-section (iteration 33, partial document edits). '
47
+ 'cerefox_ingest_document also now returns content_hash on create (#189) '
48
+ 'and a size_warning flag; both arrive with rpcs.sql on this deploy.';
49
+ END $$;
@@ -0,0 +1,59 @@
1
+ -- 0020_ingest_preserves_source.sql — stop content updates from silently
2
+ -- overwriting a document's provenance (#191, reported by @tdebasis).
3
+ --
4
+ -- `cerefox_ingest_document`'s UPDATE branch assigned the source column
5
+ -- unconditionally:
6
+ --
7
+ -- source = p_source -- unconditional
8
+ -- source_path = COALESCE(p_source_path, source_path) -- preserved
9
+ -- metadata = COALESCE(p_metadata, metadata) -- preserved
10
+ --
11
+ -- with `p_source TEXT DEFAULT 'agent'` in the signature. The two columns either
12
+ -- side of it already implement the "absent means keep" rule — metadata got it in
13
+ -- v0.11.1, after content updates without metadata were found to be wiping tags.
14
+ -- source was left out of that fix.
15
+ --
16
+ -- Two consequences, both silent:
17
+ --
18
+ -- * Any caller that updates a document without passing p_source rewrites that
19
+ -- document's source to the parameter default 'agent'. Nothing in the RPC's
20
+ -- output, the audit entry, or the version row records that it happened: the
21
+ -- audit operation is 'update-content', and version rows carry their own
22
+ -- source label rather than the document's prior value.
23
+ -- * `cerefox server migrate-format` hit this at corpus scale. It hardcoded
24
+ -- source: "migrate-format" for every document it converted, even though it
25
+ -- reads each document first and cerefox_get_document returns doc_source. A
26
+ -- format conversion is not a change of origin, so every converted document
27
+ -- lost the label it came in with.
28
+ --
29
+ -- Reported impact on one store: 1,317 documents rewritten to 'migrate-format' in
30
+ -- a single run, 201 of which carried no metadata.source_agent and so had no
31
+ -- other provenance field to fall back on. A second store on the same instance
32
+ -- independently reported 509 of 553. Recovery required a point-in-time dump from
33
+ -- before the run.
34
+ --
35
+ -- Fix: p_source defaults to NULL and the UPDATE branch coalesces, matching
36
+ -- metadata and source_path exactly. The CREATE path keeps 'agent' as its
37
+ -- concrete fallback via COALESCE(p_source, 'agent'), so new documents are
38
+ -- unchanged. An explicit value still relabels, so deliberate callers are
39
+ -- unaffected.
40
+ --
41
+ -- Note the distinction this preserves: p_source is the document's origin, while
42
+ -- p_source_label records how a particular write was triggered and is stored on
43
+ -- the version row. migrate-format now passes the document's own source for the
44
+ -- former and keeps "migrate-format" for the latter, so the version history still
45
+ -- shows which run performed the conversion.
46
+ --
47
+ -- Lives in rpcs.sql, which `cerefox server deploy` re-applies. This migration
48
+ -- exists so the schema version moves and operators are told to redeploy.
49
+ --
50
+ -- Idempotent: safe to re-run.
51
+
52
+ DO $$
53
+ BEGIN
54
+ RAISE NOTICE
55
+ 'Migration 0020: cerefox_ingest_document now preserves a document''s '
56
+ 'source when p_source is omitted. Before this, any content update '
57
+ 'without an explicit source silently rewrote provenance to ''agent'', '
58
+ 'and migrate-format relabelled every document it converted (#191).';
59
+ END $$;