@cerefox/memory 1.6.1 → 1.7.0

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.
@@ -35,7 +35,7 @@ import {
35
35
  sha256hex,
36
36
  } from "./_chunker.ts";
37
37
  import { activeEmbedderName, embedBatch, resolveEmbedderKind } from "../embeddings/index.ts";
38
- import { logUsage } from "./_utils.ts";
38
+ import { extractConflictHashes, isMissingFunctionError, logUsage } from "./_utils.ts";
39
39
  import { McpInvalidParams, type MCPSupabaseClient, type ToolContext, type ToolDefinition } from "./types.ts";
40
40
 
41
41
  /**
@@ -175,6 +175,28 @@ async function readDocument(
175
175
  supabase: MCPSupabaseClient,
176
176
  documentId: string,
177
177
  ): Promise<{ title: string; content: string; hash: string }> {
178
+ // Trashed check BEFORE anything else: a partial edit of a trashed document
179
+ // would otherwise pay for embeddings and then hit the RPC's CEREFOX_DELETED
180
+ // refusal as an unshaped error, on every retry (round-4 review). ADVISORY
181
+ // only — the RPC guard is authoritative — so a client without the .from()
182
+ // surface (test doubles) skips it rather than failing.
183
+ try {
184
+ const { data: lifecycle } = await supabase
185
+ .from("cerefox_documents")
186
+ .select("deleted_at")
187
+ .eq("id", documentId)
188
+ .maybeSingle();
189
+ if (lifecycle?.deleted_at) {
190
+ throw new McpInvalidParams(
191
+ `Document ${documentId} is soft-deleted (in the trash). A trashed document ` +
192
+ `cannot be edited — restore it first with cerefox_restore_document, then retry.`,
193
+ );
194
+ }
195
+ } catch (e) {
196
+ if (e instanceof McpInvalidParams) throw e;
197
+ // fall through — the RPC's CEREFOX_DELETED guard still refuses the write
198
+ }
199
+
178
200
  const { data, error } = await supabase.rpc("cerefox_get_document", {
179
201
  p_document_id: documentId,
180
202
  p_version_id: null,
@@ -303,8 +325,7 @@ async function applyAndWrite(
303
325
  if (error) {
304
326
  const message = error.message ?? "";
305
327
  if (message.includes("CEREFOX_CONFLICT")) {
306
- const current = message.match(/current hash ([0-9a-f]{64})/)?.[1] ?? "unknown";
307
- throw conflictError(documentId, expectedHash, current);
328
+ throw conflictError(documentId, expectedHash, extractConflictHashes(message).current);
308
329
  }
309
330
  if (message.includes("cerefox_documents_hash_unique")) {
310
331
  // content_hash is UNIQUE store-wide, so an edit whose result matches
@@ -318,7 +339,25 @@ async function applyAndWrite(
318
339
  `cerefox_search for the resulting content to find it.`,
319
340
  );
320
341
  }
321
- if (message.includes("does not exist") && message.includes("cerefox_ingest_document")) {
342
+ if (message.includes("CEREFOX_UNRESOLVED_LINKS")) {
343
+ // Only ids INTRODUCED by this write reject (the RPC tolerates dead
344
+ // links the document already carried), so the diagnosis is specific.
345
+ const ids = message.match(/do not exist: ([^.]+)\./)?.[1] ?? "(unparsed)";
346
+ throw new Error(
347
+ `Edit rejected — this edit introduces link(s) to document id(s) that do not ` +
348
+ `exist: ${ids}. The UUIDs are almost certainly mangled — re-read the source ` +
349
+ `you copied each link from, correct the id(s), and retry. Do not retry ` +
350
+ `unchanged. Deliberate examples belong in code formatting (backticks).`,
351
+ );
352
+ }
353
+ if (message.includes("CEREFOX_DELETED")) {
354
+ // Race backstop: the doc was trashed between our read and the write.
355
+ throw new Error(
356
+ `Document ${documentId} was soft-deleted while this edit was in flight. ` +
357
+ `Restore it first with cerefox_restore_document, then retry.`,
358
+ );
359
+ }
360
+ if (isMissingFunctionError(message, "cerefox_ingest_document")) {
322
361
  throw new Error(
323
362
  `This server is behind: partial edits need schema 0.11.0 or newer. ` +
324
363
  `Run \`cerefox server deploy\`, then retry. (${message})`,
@@ -0,0 +1,130 @@
1
+ /**
2
+ * `cerefox_restore_document` — bring a soft-deleted document back (#210).
3
+ *
4
+ * The counterpart to `cerefox_delete_document`, added by maintainer decision
5
+ * (2026-08-13) reversing the earlier "restore is human-only" posture: every
6
+ * restore is audited with author attribution, restoring cannot destroy
7
+ * content, and CLI/MCP parity (`cerefox document restore` existed all along)
8
+ * beats a boundary the audit surface had already outgrown.
9
+ *
10
+ * What still cannot happen from here: **permanent purge**, which remains
11
+ * web-UI-only — the one action that actually destroys data keeps its
12
+ * human-in-the-loop confirmation.
13
+ *
14
+ * No `expected_content_hash`, matching the CLI contract: a trashed document
15
+ * cannot be rewritten while in the trash (cerefox_ingest_document refuses
16
+ * updates to soft-deleted documents as of 0.12.0), so what was reviewed in
17
+ * the trash is what comes back — there is no read-freshness to prove.
18
+ */
19
+
20
+ import type { MCPSupabaseClient } from "./types.ts";
21
+
22
+ import { isDocumentNotFoundError, isMissingFunctionError, logUsage } from "./_utils.ts";
23
+ import { McpInvalidParams, type ToolContext, type ToolDefinition } from "./types.ts";
24
+
25
+ async function handler(
26
+ supabase: MCPSupabaseClient,
27
+ args: Record<string, unknown>,
28
+ ctx: ToolContext,
29
+ ): Promise<string> {
30
+ const document_id = args.document_id as string | undefined;
31
+ const reason = args.reason as string | undefined;
32
+
33
+ if (!document_id) throw new McpInvalidParams("document_id is required");
34
+
35
+ const author = (args.author as string | undefined) ?? (args.requestor as string | undefined);
36
+ // Derived from the transport, never taken from the caller: an agent must not
37
+ // be able to record itself as a user. Matches the delete handler.
38
+ const authorType = ctx.accessPath === "cli" ? "user" : "agent";
39
+
40
+ const { data, error } = await supabase.rpc("cerefox_restore_document", {
41
+ p_document_id: document_id,
42
+ p_author: author ?? "unknown",
43
+ p_author_type: authorType,
44
+ p_reason: reason ?? null,
45
+ });
46
+
47
+ if (error) {
48
+ const message = error.message ?? String(error);
49
+ if (isMissingFunctionError(message, "cerefox_restore_document")) {
50
+ throw new Error(
51
+ `This server is behind: cerefox_restore_document needs schema 0.12.0 or newer. ` +
52
+ `Run \`cerefox server deploy\`, then retry. (${message})`,
53
+ );
54
+ }
55
+ if (isDocumentNotFoundError(error)) {
56
+ throw new McpInvalidParams(`Document ${document_id} not found.`);
57
+ }
58
+ throw new Error(`RPC error: ${message}`);
59
+ }
60
+
61
+ const row = data as
62
+ | {
63
+ document_id?: string;
64
+ title?: string;
65
+ total_chars?: number;
66
+ restored?: boolean;
67
+ was_deleted?: boolean;
68
+ }
69
+ | undefined;
70
+ if (!row) throw new Error("cerefox_restore_document returned no data");
71
+
72
+ if (!row.restored) {
73
+ // Report what happened, not what was asked for: the document was never in
74
+ // the trash, nothing changed, no audit entry was written.
75
+ return (
76
+ `Document ${document_id} ("${row.title ?? "untitled"}") is NOT deleted — ` +
77
+ `there is nothing to restore. No change was made.`
78
+ );
79
+ }
80
+
81
+ logUsage(supabase, {
82
+ operation: "restore",
83
+ accessPath: ctx.accessPath,
84
+ requestor: args.requestor as string | undefined,
85
+ document_id,
86
+ result_count: 1,
87
+ });
88
+
89
+ return (
90
+ `Restored "${row.title ?? "untitled"}" (id: ${document_id}, ` +
91
+ `${row.total_chars ?? "?"} chars) from the trash. It is searchable again ` +
92
+ `and the restore is recorded in the audit log.\n` +
93
+ `Tell your user what you restored and why.`
94
+ );
95
+ }
96
+
97
+ export const restoreDocumentTool: ToolDefinition = {
98
+ name: "cerefox_restore_document",
99
+ description:
100
+ "Restore a soft-deleted document from the trash — the inverse of cerefox_delete_document. The document becomes searchable again; the restore is recorded in the audit log with your identity. Restoring a document that is not deleted is a reported no-op. Pass a short reason — like a delete's reason, it is what the human reviewing the audit trail goes on. Permanent purge has no agent surface: once a human purges a document from the web UI, it is gone and cannot be restored.",
101
+ annotations: {
102
+ title: "Restore document from trash",
103
+ readOnlyHint: false,
104
+ // Recovery only: it can bring content back, never remove it.
105
+ destructiveHint: false,
106
+ idempotentHint: true,
107
+ openWorldHint: false,
108
+ },
109
+ inputSchema: {
110
+ type: "object",
111
+ required: ["document_id"],
112
+ properties: {
113
+ document_id: { type: "string", description: "UUID of the soft-deleted document to restore." },
114
+ reason: {
115
+ type: "string",
116
+ description:
117
+ "Why this document is being restored. Recorded in the audit-log entry. Short and specific beats long.",
118
+ },
119
+ author: {
120
+ type: "string",
121
+ description: "Who is making this change. Recorded in the audit log.",
122
+ },
123
+ requestor: {
124
+ type: "string",
125
+ description: "Name of the agent or user making this request. Recorded in the usage log.",
126
+ },
127
+ },
128
+ },
129
+ handler,
130
+ };
@@ -0,0 +1,26 @@
1
+ -- 0024_mcp_delete_document.sql — MCP soft-delete parity (#208).
2
+ --
3
+ -- Reworks cerefox_delete_document: optional CAS via p_expected_content_hash
4
+ -- (CEREFOX_CONFLICT / PT409 on mismatch, same pattern as the ingest CAS),
5
+ -- p_reason appended to the audit description, JSONB return instead of VOID,
6
+ -- and idempotent re-delete (original deleted_at preserved, no duplicate audit
7
+ -- entry). No table DDL: the function itself ships in rpcs.sql, which
8
+ -- `cerefox server deploy` re-applies wholesale. This migration exists so the
9
+ -- schema version advances in step, which is what tells an existing deployment
10
+ -- it needs that redeploy.
11
+ --
12
+ -- Schema version 0.11.3 → 0.12.0. Restore moves OUT of the web-UI-only tier
13
+ -- (#210, maintainer decision): both delete and restore are agent-reachable
14
+ -- and audited; permanent purge is now the single web-UI-only action.
15
+ -- cerefox_ingest_document additionally refuses to rewrite a soft-deleted
16
+ -- document (restore first), which is what makes restore safe without a
17
+ -- freshness token.
18
+
19
+ DO $$
20
+ BEGIN
21
+ RAISE NOTICE
22
+ 'Migration 0024: cerefox_delete_document + cerefox_restore_document '
23
+ 'rework arrives with rpcs.sql on this deploy — CAS on delete, p_reason, '
24
+ 'JSONB returns, honest no-ops. Backs the new cerefox_delete_document '
25
+ 'and cerefox_restore_document MCP tools (#208, #210). Schema 0.12.0.';
26
+ END $$;
@@ -1127,33 +1127,88 @@ $$;
1127
1127
  -- Soft-deletes a document by setting deleted_at = NOW(). The document, its
1128
1128
  -- chunks, and versions remain in the database but are excluded from search.
1129
1129
  -- Use cerefox_purge_document for permanent deletion.
1130
- -- Use cerefox_restore_document to undo a soft delete.
1130
+ -- Use cerefox_restore_document to undo a soft delete (agent-reachable since
1131
+ -- 0.12.0, #210). Permanent purge is web-UI-only by design (access-paths.md →
1132
+ -- "Destructive operations and the trust model").
1133
+ --
1134
+ -- p_expected_content_hash (0.12.0, #208): optional CAS. When provided, the
1135
+ -- delete proceeds only if it matches the document's current content_hash —
1136
+ -- proof the caller read what it is deleting. Checked under the same FOR UPDATE
1137
+ -- lock as the ingest CAS (iter-32); mismatch → CEREFOX_CONFLICT under PT409,
1138
+ -- never a retryable SQLSTATE. NULL/blank skips the check: the CLI confirms
1139
+ -- interactively instead, and the MCP tool makes the parameter required at the
1140
+ -- transport layer (its callers have no interactive prompt).
1141
+ -- p_reason (0.12.0, #208): optional, appended to the audit description — for
1142
+ -- the human reviewing the trash, who otherwise sees only what was deleted.
1143
+ --
1144
+ -- Deleting an already-deleted document is a reported no-op: the original
1145
+ -- deleted_at is preserved and no duplicate audit entry is written.
1131
1146
 
1147
+ DROP FUNCTION IF EXISTS cerefox_delete_document(UUID, TEXT, TEXT, TEXT, TEXT);
1132
1148
  DROP FUNCTION IF EXISTS cerefox_delete_document(UUID, TEXT, TEXT);
1133
1149
  DROP FUNCTION IF EXISTS cerefox_delete_document(UUID);
1134
1150
  CREATE FUNCTION cerefox_delete_document(
1135
- p_document_id UUID,
1136
- p_author TEXT DEFAULT 'unknown',
1137
- p_author_type TEXT DEFAULT 'user'
1151
+ p_document_id UUID,
1152
+ p_author TEXT DEFAULT 'unknown',
1153
+ p_author_type TEXT DEFAULT 'user',
1154
+ p_expected_content_hash TEXT DEFAULT NULL,
1155
+ p_reason TEXT DEFAULT NULL
1138
1156
  )
1139
- RETURNS VOID
1157
+ RETURNS JSONB
1140
1158
  LANGUAGE plpgsql
1141
1159
  SECURITY DEFINER
1142
1160
  SET search_path = public, pg_catalog
1143
1161
  AS $$
1144
1162
  DECLARE
1145
- v_title TEXT;
1146
- v_total_chars INT;
1163
+ v_title TEXT;
1164
+ v_total_chars INT;
1165
+ v_current_hash TEXT;
1166
+ v_deleted_at TIMESTAMPTZ;
1147
1167
  BEGIN
1148
- SELECT title, total_chars INTO v_title, v_total_chars
1149
- FROM cerefox_documents WHERE id = p_document_id;
1168
+ -- FOR UPDATE: makes the hash check atomic with the delete — a concurrent
1169
+ -- content update serializes here, and a stale deleter sees its hash.
1170
+ SELECT title, total_chars, content_hash, deleted_at
1171
+ INTO v_title, v_total_chars, v_current_hash, v_deleted_at
1172
+ FROM cerefox_documents WHERE id = p_document_id
1173
+ FOR UPDATE;
1150
1174
 
1151
1175
  IF NOT FOUND THEN
1152
- RAISE EXCEPTION 'Document % not found', p_document_id;
1176
+ RAISE EXCEPTION 'Document % not found', p_document_id
1177
+ USING ERRCODE = '22023'; -- invalid_parameter_value
1153
1178
  END IF;
1154
1179
 
1155
- -- Soft delete: set deleted_at timestamp
1156
- UPDATE cerefox_documents SET deleted_at = NOW() WHERE id = p_document_id;
1180
+ -- Optimistic concurrency: blank is ABSENT, not stale (same rule and same
1181
+ -- reason as cerefox_ingest_document '' can never equal a real hash, so
1182
+ -- classifying it as a conflict would be a permanent failure reported as a
1183
+ -- resolvable one).
1184
+ -- Compare TRIMMED, matching the presence check: a correct hash with a
1185
+ -- stray trailing newline must not be misreported as a stale-hash
1186
+ -- conflict — that reads as "changed since it was read" with two hashes
1187
+ -- that look identical, and re-reading can never fix it.
1188
+ -- Validated BEFORE the already-deleted no-op: "a delete proves a read"
1189
+ -- has to hold for trashed documents too, or a garbage hash gets reported
1190
+ -- as a successful no-op and the caller learns nothing.
1191
+ IF NULLIF(BTRIM(p_expected_content_hash), '') IS NOT NULL
1192
+ AND BTRIM(p_expected_content_hash) <> v_current_hash THEN
1193
+ RAISE EXCEPTION
1194
+ 'CEREFOX_CONFLICT: document % changed since it was read (expected hash %, current hash %). Re-read the document, check it still warrants deletion, and retry with the new hash.',
1195
+ p_document_id, p_expected_content_hash, v_current_hash
1196
+ USING ERRCODE = 'PT409'; -- deterministic conflict; see ingest CAS
1197
+ END IF;
1198
+
1199
+ IF v_deleted_at IS NOT NULL THEN
1200
+ RETURN jsonb_build_object(
1201
+ 'document_id', p_document_id,
1202
+ 'title', v_title,
1203
+ 'total_chars', v_total_chars,
1204
+ 'deleted_at', v_deleted_at,
1205
+ 'already_deleted', TRUE
1206
+ );
1207
+ END IF;
1208
+
1209
+ UPDATE cerefox_documents SET deleted_at = NOW()
1210
+ WHERE id = p_document_id
1211
+ RETURNING deleted_at INTO v_deleted_at;
1157
1212
 
1158
1213
  PERFORM cerefox_create_audit_entry(
1159
1214
  p_document_id := p_document_id,
@@ -1163,33 +1218,69 @@ BEGIN
1163
1218
  p_size_before := v_total_chars,
1164
1219
  p_size_after := 0,
1165
1220
  p_description := 'Soft-deleted document: ' || COALESCE(v_title, '(untitled)') ||
1166
- ' (' || COALESCE(v_total_chars, 0) || ' chars)'
1221
+ ' (' || COALESCE(v_total_chars, 0) || ' chars)' ||
1222
+ COALESCE('; reason: ' || NULLIF(BTRIM(p_reason), ''), '')
1223
+ );
1224
+
1225
+ RETURN jsonb_build_object(
1226
+ 'document_id', p_document_id,
1227
+ 'title', v_title,
1228
+ 'total_chars', v_total_chars,
1229
+ 'deleted_at', v_deleted_at,
1230
+ 'already_deleted', FALSE
1167
1231
  );
1168
1232
  END;
1169
1233
  $$;
1170
1234
 
1171
1235
  -- ── cerefox_restore_document ─────────────────────────────────────────────────
1172
1236
  -- Restores a soft-deleted document by clearing deleted_at.
1173
-
1174
- CREATE OR REPLACE FUNCTION cerefox_restore_document(
1237
+ --
1238
+ -- 0.12.0 (#210): agent-reachable — exposed over MCP as cerefox_restore_document
1239
+ -- alongside the CLI verb, by maintainer decision (2026-08-13): everything is
1240
+ -- audited, restore cannot destroy content, and CLI/MCP parity outweighs the
1241
+ -- earlier "an agent must not undo its own delete" posture, which predated the
1242
+ -- audit surface. Permanent purge remains web-UI-only.
1243
+ -- Same honesty contract as the reworked delete: JSONB return; restoring a
1244
+ -- document that is not deleted is a reported no-op (no audit entry); a missing
1245
+ -- document raises rather than silently returning. p_reason lands in the audit
1246
+ -- description.
1247
+
1248
+ DROP FUNCTION IF EXISTS cerefox_restore_document(UUID, TEXT, TEXT, TEXT);
1249
+ DROP FUNCTION IF EXISTS cerefox_restore_document(UUID, TEXT, TEXT);
1250
+ CREATE FUNCTION cerefox_restore_document(
1175
1251
  p_document_id UUID,
1176
1252
  p_author TEXT DEFAULT 'unknown',
1177
- p_author_type TEXT DEFAULT 'user'
1253
+ p_author_type TEXT DEFAULT 'user',
1254
+ p_reason TEXT DEFAULT NULL
1178
1255
  )
1179
- RETURNS VOID
1256
+ RETURNS JSONB
1180
1257
  LANGUAGE plpgsql
1181
1258
  SECURITY DEFINER
1182
1259
  SET search_path = public, pg_catalog
1183
1260
  AS $$
1184
1261
  DECLARE
1185
- v_title TEXT;
1262
+ v_title TEXT;
1186
1263
  v_total_chars INT;
1264
+ v_deleted_at TIMESTAMPTZ;
1187
1265
  BEGIN
1188
- SELECT title, total_chars INTO v_title, v_total_chars
1189
- FROM cerefox_documents WHERE id = p_document_id AND deleted_at IS NOT NULL;
1266
+ SELECT title, total_chars, deleted_at
1267
+ INTO v_title, v_total_chars, v_deleted_at
1268
+ FROM cerefox_documents WHERE id = p_document_id
1269
+ FOR UPDATE;
1190
1270
 
1191
- IF v_title IS NULL THEN
1192
- RETURN; -- Not found or not deleted
1271
+ IF NOT FOUND THEN
1272
+ RAISE EXCEPTION 'Document % not found', p_document_id
1273
+ USING ERRCODE = '22023'; -- invalid_parameter_value
1274
+ END IF;
1275
+
1276
+ IF v_deleted_at IS NULL THEN
1277
+ RETURN jsonb_build_object(
1278
+ 'document_id', p_document_id,
1279
+ 'title', v_title,
1280
+ 'total_chars', v_total_chars,
1281
+ 'restored', FALSE,
1282
+ 'was_deleted', FALSE
1283
+ );
1193
1284
  END IF;
1194
1285
 
1195
1286
  UPDATE cerefox_documents SET deleted_at = NULL WHERE id = p_document_id;
@@ -1201,7 +1292,16 @@ BEGIN
1201
1292
  p_author_type := p_author_type,
1202
1293
  p_size_before := 0,
1203
1294
  p_size_after := v_total_chars,
1204
- p_description := 'Restored document: ' || COALESCE(v_title, '(untitled)')
1295
+ p_description := 'Restored document: ' || COALESCE(v_title, '(untitled)') ||
1296
+ COALESCE('; reason: ' || NULLIF(BTRIM(p_reason), ''), '')
1297
+ );
1298
+
1299
+ RETURN jsonb_build_object(
1300
+ 'document_id', p_document_id,
1301
+ 'title', v_title,
1302
+ 'total_chars', v_total_chars,
1303
+ 'restored', TRUE,
1304
+ 'was_deleted', TRUE
1205
1305
  );
1206
1306
  END;
1207
1307
  $$;
@@ -1371,6 +1471,9 @@ DECLARE
1371
1471
  v_op JSONB; -- iter-33: per-operation audit element
1372
1472
  v_size_warn_at INT;
1373
1473
  v_size_warning BOOLEAN := FALSE;
1474
+ v_doc_deleted TIMESTAMPTZ;
1475
+ v_scannable TEXT;
1476
+ v_missing TEXT[];
1374
1477
  BEGIN
1375
1478
  -- ── Zero-chunk guard (v0.3.1) ────────────────────────────────────────
1376
1479
  -- Refuse to create or update a document with no chunks. Three reasons:
@@ -1391,6 +1494,60 @@ BEGIN
1391
1494
  USING ERRCODE = '22023'; -- invalid_parameter_value
1392
1495
  END IF;
1393
1496
 
1497
+ -- ── Link integrity (#214, 0.12.0) ────────────────────────────────────
1498
+ -- Validate [Text](uuid) document links against the store: agents mangle
1499
+ -- long random ids when regenerating text, and a mangled id silently
1500
+ -- becomes a dead link. Fenced code blocks and inline code spans are
1501
+ -- stripped first — code formatting is the markdown-native way to write
1502
+ -- an EXAMPLE link, and the escape mechanism here (no bypass flag, by
1503
+ -- design). Trashed targets resolve: the id denotes a document. One PK
1504
+ -- lookup for all candidates; ~1-2ms. Runs on create AND update, so a
1505
+ -- link whose target was later purged surfaces on the next edit.
1506
+ -- See docs/specs/link-integrity-design.md.
1507
+ SELECT string_agg(c->>'content', E'\n') INTO v_scannable
1508
+ FROM jsonb_array_elements(p_chunks) c;
1509
+ -- Fences are LINE-ANCHORED, matching markdown semantics: only ``` at a
1510
+ -- line start opens/closes a block, so a stray backtick run mid-prose
1511
+ -- cannot mis-pair the fences and un-escape a later real code block. An
1512
+ -- unterminated fence strips to end-of-content (under-validates, never
1513
+ -- false-rejects). Inline code is stripped after, so fence markers are
1514
+ -- intact when pairing runs.
1515
+ v_scannable := regexp_replace(
1516
+ COALESCE(v_scannable, ''),
1517
+ E'(^|\\n)[ \\t]*```.*?(\\n[ \\t]*```[^\\n]*|$)', ' ', 'g');
1518
+ v_scannable := regexp_replace(v_scannable, '`[^`]*`', ' ', 'g');
1519
+
1520
+ SELECT array_agg(DISTINCT m[1]) INTO v_missing
1521
+ FROM regexp_matches(
1522
+ v_scannable,
1523
+ '\]\(([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\)',
1524
+ 'g') m
1525
+ WHERE NOT EXISTS (SELECT 1 FROM cerefox_documents d WHERE d.id = m[1]::uuid);
1526
+
1527
+ -- On UPDATE, tolerate dead links the document ALREADY carries: a target
1528
+ -- purged after linking must not make the document unwritable — sync
1529
+ -- flows re-send content verbatim from disk and could never converge, and
1530
+ -- an unrelated partial edit re-sends the untouched section holding the
1531
+ -- link. Only NEWLY-INTRODUCED unresolvable ids reject; legacy dead links
1532
+ -- are the phase-2 sweep's job (#214). Creates validate everything.
1533
+ IF v_missing IS NOT NULL AND p_document_id IS NOT NULL THEN
1534
+ SELECT array_agg(x) INTO v_missing
1535
+ FROM unnest(v_missing) x
1536
+ WHERE strpos(
1537
+ lower(COALESCE((SELECT string_agg(ch.content, E'\n')
1538
+ FROM cerefox_chunks ch
1539
+ WHERE ch.document_id = p_document_id
1540
+ AND ch.version_id IS NULL), '')),
1541
+ lower(x)) = 0;
1542
+ END IF;
1543
+
1544
+ IF v_missing IS NOT NULL THEN
1545
+ RAISE EXCEPTION
1546
+ 'CEREFOX_UNRESOLVED_LINKS: % linked document id(s) do not exist: %. If these were meant to link existing documents, the ids are mangled — re-read the source and correct them. If they are examples, put them in code formatting (backticks or a fence).',
1547
+ array_length(v_missing, 1), array_to_string(v_missing, ', ')
1548
+ USING ERRCODE = '22023'; -- deterministic; never a retryable SQLSTATE
1549
+ END IF;
1550
+
1394
1551
  -- Validate review_status
1395
1552
  v_status := CASE WHEN p_review_status IN ('approved', 'pending_review')
1396
1553
  THEN p_review_status ELSE 'approved' END;
@@ -1412,8 +1569,8 @@ BEGIN
1412
1569
  -- updaters serialize here, and the second one sees the first one's
1413
1570
  -- hash — the race window (chunk + embed latency) is closed at the
1414
1571
  -- only place all transports share (iter-32).
1415
- SELECT COALESCE(d.total_chars, 0), d.content_hash
1416
- INTO v_old_chars, v_current_hash
1572
+ SELECT COALESCE(d.total_chars, 0), d.content_hash, d.deleted_at
1573
+ INTO v_old_chars, v_current_hash, v_doc_deleted
1417
1574
  FROM cerefox_documents d WHERE d.id = v_doc_id
1418
1575
  FOR UPDATE;
1419
1576
 
@@ -1422,6 +1579,20 @@ BEGIN
1422
1579
  USING ERRCODE = '22023'; -- invalid_parameter_value
1423
1580
  END IF;
1424
1581
 
1582
+ -- Refuse to rewrite a trashed document (0.12.0, #211 review). Before
1583
+ -- this guard an update by document_id landed content in a document
1584
+ -- excluded from search — a write into a black hole — and it silently
1585
+ -- broke the restore contract: restore takes no freshness token on the
1586
+ -- premise that what was reviewed in the trash is what comes back.
1587
+ IF v_doc_deleted IS NOT NULL THEN
1588
+ -- CEREFOX_ prefix per the convention below: transport handlers
1589
+ -- detect it and rephrase for their caller.
1590
+ RAISE EXCEPTION
1591
+ 'CEREFOX_DELETED: document % is soft-deleted; restore it first (cerefox_restore_document / cerefox document restore) or create a new document.',
1592
+ v_doc_id
1593
+ USING ERRCODE = '22023'; -- invalid_parameter_value
1594
+ END IF;
1595
+
1425
1596
  -- ── Optimistic concurrency check (iter-32) ───────────────────
1426
1597
  -- Content updates must prove freshness (expected hash) or explicitly
1427
1598
  -- choose last-write-wins. Message prefixes are machine-detectable:
@@ -1442,7 +1613,10 @@ BEGIN
1442
1613
  'CEREFOX_TOKEN_REQUIRED: content updates require expected_content_hash (the content_hash you read) or last_write_wins=true. Current hash: %',
1443
1614
  v_current_hash
1444
1615
  USING ERRCODE = '22023'; -- invalid_parameter_value
1445
- ELSIF p_expected_content_hash <> v_current_hash THEN
1616
+ -- Trimmed comparison, matching the presence check above: a correct
1617
+ -- hash with stray whitespace is not a stale one (found via the
1618
+ -- delete CAS review, #208 — same flaw existed here since iter-32).
1619
+ ELSIF BTRIM(p_expected_content_hash) <> v_current_hash THEN
1446
1620
  RAISE EXCEPTION
1447
1621
  'CEREFOX_CONFLICT: document % changed since it was read (expected hash %, current hash %). Re-read the document, merge your changes, and retry with the new hash.',
1448
1622
  v_doc_id, p_expected_content_hash, v_current_hash
@@ -2539,6 +2713,12 @@ SET search_path = public, pg_catalog
2539
2713
  AS $$
2540
2714
  -- Keep in lockstep with the `@version:` marker in schema.sql (cut_release.ts
2541
2715
  -- enforces it). Bump whenever schema.sql OR rpcs.sql changes.
2716
+ -- 0.12.0 (#208, #210): cerefox_delete_document — CAS
2717
+ -- (p_expected_content_hash), p_reason in audit description, JSONB return,
2718
+ -- idempotent re-delete; cerefox_restore_document — same rework (JSONB,
2719
+ -- p_reason, honest no-op). Back the new cerefox_delete_document and
2720
+ -- cerefox_restore_document MCP tools (CLI parity). Ingest CAS compares
2721
+ -- trimmed.
2542
2722
  -- 0.11.3 (#204): cerefox_set_document_metadata — metadata-only writes.
2543
2723
  -- 0.11.2 (iteration 36): RLS enabled on cerefox_document_relations,
2544
2724
  -- which iteration 29 left off the list (Supabase rls_disabled_in_public).
@@ -2546,7 +2726,7 @@ AS $$
2546
2726
  -- 0.11.0 supersedes 0.10.6 (v1.2.1, #191): this branch carries that fix plus
2547
2727
  -- the partial-edit surface, and both migrations (0019, 0020) are in the
2548
2728
  -- sequence, so a store deploying this gets everything from both lines.
2549
- SELECT '0.11.3'::TEXT;
2729
+ SELECT '0.12.0'::TEXT;
2550
2730
  $$;
2551
2731
 
2552
2732
  -- ── cerefox_content_format_stats ─────────────────────────────────────────────
@@ -5,7 +5,7 @@
5
5
  -- Requires extensions: vector (pgvector), uuid-ossp
6
6
  -- These are enabled at the top of db_deploy.py before this file is applied.
7
7
  --
8
- -- @version: 0.11.3
8
+ -- @version: 0.12.0
9
9
  -- The `@version` marker above is read by the schema-version-mismatch banner
10
10
  -- (see /api/v1/schema-version). Bump it whenever schema.sql OR rpcs.sql
11
11
  -- changes in a way that requires `cerefox server deploy` to be re-run —
@@ -88,6 +88,32 @@ function concurrencyErrorResponse(
88
88
  { status: 409, headers },
89
89
  );
90
90
  }
91
+ if (message.includes("CEREFOX_UNRESOLVED_LINKS")) {
92
+ // Link integrity (#214): the content links document id(s) that do not
93
+ // exist — almost always a mangled UUID. 422: well-formed request whose
94
+ // content fails a semantic check the caller can fix.
95
+ return new Response(
96
+ JSON.stringify({
97
+ error: "unresolved_links",
98
+ message:
99
+ "The content links document id(s) that do not exist. Re-read the source each link was copied from and correct the id(s) — do not retry unchanged. Wrap deliberate example ids in code formatting (backticks).",
100
+ detail: message,
101
+ }),
102
+ { status: 422, headers },
103
+ );
104
+ }
105
+ if (message.includes("CEREFOX_DELETED")) {
106
+ // 0.12.0: a trashed document refuses content updates until restored.
107
+ return new Response(
108
+ JSON.stringify({
109
+ error: "document_deleted",
110
+ message:
111
+ "This document is soft-deleted (in the trash). Restore it first, then retry the update — or create a new document.",
112
+ detail: message,
113
+ }),
114
+ { status: 409, headers },
115
+ );
116
+ }
91
117
  if (message.includes("CEREFOX_TOKEN_REQUIRED")) {
92
118
  return new Response(
93
119
  JSON.stringify({