@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.
- package/AGENT_GUIDE.md +79 -5
- package/AGENT_QUICK_REFERENCE.md +9 -7
- package/README.md +5 -3
- package/dist/bin/cerefox.js +591 -227
- package/dist/frontend/assets/index-D8E0mTnp.js +121 -0
- package/dist/frontend/assets/index-D8E0mTnp.js.map +1 -0
- package/dist/frontend/index.html +1 -1
- package/dist/server-assets/_shared/ef-meta/index.ts +3 -3
- package/dist/server-assets/_shared/mcp-tools/_utils.ts +41 -0
- package/dist/server-assets/_shared/mcp-tools/delete-document.ts +181 -0
- package/dist/server-assets/_shared/mcp-tools/get-help-content.ts +4 -4
- package/dist/server-assets/_shared/mcp-tools/index.ts +8 -1
- package/dist/server-assets/_shared/mcp-tools/ingest.ts +75 -8
- package/dist/server-assets/_shared/mcp-tools/partial-edits.ts +43 -4
- package/dist/server-assets/_shared/mcp-tools/restore-document.ts +130 -0
- package/dist/server-assets/db/migrations/0024_mcp_delete_document.sql +26 -0
- package/dist/server-assets/db/rpcs.sql +207 -27
- package/dist/server-assets/db/schema.sql +1 -1
- package/dist/server-assets/supabase/functions/cerefox-ingest/index.ts +26 -0
- package/docs/guides/access-paths.md +24 -15
- package/docs/guides/cli.md +13 -7
- package/docs/guides/connect-agents.md +41 -17
- package/docs/guides/operational-cost.md +1 -1
- package/package.json +1 -1
- package/dist/frontend/assets/index-OqloGFwv.js +0 -121
- package/dist/frontend/assets/index-OqloGFwv.js.map +0 -1
|
@@ -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
|
-
|
|
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("
|
|
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
|
|
1136
|
-
p_author
|
|
1137
|
-
p_author_type
|
|
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
|
|
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
|
|
1146
|
-
v_total_chars
|
|
1163
|
+
v_title TEXT;
|
|
1164
|
+
v_total_chars INT;
|
|
1165
|
+
v_current_hash TEXT;
|
|
1166
|
+
v_deleted_at TIMESTAMPTZ;
|
|
1147
1167
|
BEGIN
|
|
1148
|
-
|
|
1149
|
-
|
|
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
|
-
--
|
|
1156
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1262
|
+
v_title TEXT;
|
|
1186
1263
|
v_total_chars INT;
|
|
1264
|
+
v_deleted_at TIMESTAMPTZ;
|
|
1187
1265
|
BEGIN
|
|
1188
|
-
SELECT title, total_chars
|
|
1189
|
-
|
|
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
|
|
1192
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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({
|