@cerefox/memory 1.6.1 → 1.7.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.
- package/AGENT_GUIDE.md +80 -6
- package/AGENT_QUICK_REFERENCE.md +9 -7
- package/README.md +5 -3
- package/dist/bin/cerefox.js +790 -319
- 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/migrations/0025_drop_orphaned_overloads.sql +15 -0
- package/dist/server-assets/db/migrations/0026_metadata_guard_and_dead_links.sql +46 -0
- package/dist/server-assets/db/rpcs.sql +355 -27
- package/dist/server-assets/db/schema.sql +10 -2
- 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 +34 -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
|
@@ -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,72 @@ 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
|
+
-- 0.12.1: the pre-author 1-arg overload survived every CREATE OR REPLACE
|
|
1251
|
+
-- since the signature grew (same orphan class as purge; found live on prod).
|
|
1252
|
+
DROP FUNCTION IF EXISTS cerefox_restore_document(UUID);
|
|
1253
|
+
CREATE FUNCTION cerefox_restore_document(
|
|
1175
1254
|
p_document_id UUID,
|
|
1176
1255
|
p_author TEXT DEFAULT 'unknown',
|
|
1177
|
-
p_author_type TEXT DEFAULT 'user'
|
|
1256
|
+
p_author_type TEXT DEFAULT 'user',
|
|
1257
|
+
p_reason TEXT DEFAULT NULL
|
|
1178
1258
|
)
|
|
1179
|
-
RETURNS
|
|
1259
|
+
RETURNS JSONB
|
|
1180
1260
|
LANGUAGE plpgsql
|
|
1181
1261
|
SECURITY DEFINER
|
|
1182
1262
|
SET search_path = public, pg_catalog
|
|
1183
1263
|
AS $$
|
|
1184
1264
|
DECLARE
|
|
1185
|
-
v_title
|
|
1265
|
+
v_title TEXT;
|
|
1186
1266
|
v_total_chars INT;
|
|
1267
|
+
v_deleted_at TIMESTAMPTZ;
|
|
1187
1268
|
BEGIN
|
|
1188
|
-
SELECT title, total_chars
|
|
1189
|
-
|
|
1269
|
+
SELECT title, total_chars, deleted_at
|
|
1270
|
+
INTO v_title, v_total_chars, v_deleted_at
|
|
1271
|
+
FROM cerefox_documents WHERE id = p_document_id
|
|
1272
|
+
FOR UPDATE;
|
|
1190
1273
|
|
|
1191
|
-
IF
|
|
1192
|
-
|
|
1274
|
+
IF NOT FOUND THEN
|
|
1275
|
+
RAISE EXCEPTION 'Document % not found', p_document_id
|
|
1276
|
+
USING ERRCODE = '22023'; -- invalid_parameter_value
|
|
1277
|
+
END IF;
|
|
1278
|
+
|
|
1279
|
+
IF v_deleted_at IS NULL THEN
|
|
1280
|
+
RETURN jsonb_build_object(
|
|
1281
|
+
'document_id', p_document_id,
|
|
1282
|
+
'title', v_title,
|
|
1283
|
+
'total_chars', v_total_chars,
|
|
1284
|
+
'restored', FALSE,
|
|
1285
|
+
'was_deleted', FALSE
|
|
1286
|
+
);
|
|
1193
1287
|
END IF;
|
|
1194
1288
|
|
|
1195
1289
|
UPDATE cerefox_documents SET deleted_at = NULL WHERE id = p_document_id;
|
|
@@ -1201,7 +1295,16 @@ BEGIN
|
|
|
1201
1295
|
p_author_type := p_author_type,
|
|
1202
1296
|
p_size_before := 0,
|
|
1203
1297
|
p_size_after := v_total_chars,
|
|
1204
|
-
p_description := 'Restored document: ' || COALESCE(v_title, '(untitled)')
|
|
1298
|
+
p_description := 'Restored document: ' || COALESCE(v_title, '(untitled)') ||
|
|
1299
|
+
COALESCE('; reason: ' || NULLIF(BTRIM(p_reason), ''), '')
|
|
1300
|
+
);
|
|
1301
|
+
|
|
1302
|
+
RETURN jsonb_build_object(
|
|
1303
|
+
'document_id', p_document_id,
|
|
1304
|
+
'title', v_title,
|
|
1305
|
+
'total_chars', v_total_chars,
|
|
1306
|
+
'restored', TRUE,
|
|
1307
|
+
'was_deleted', TRUE
|
|
1205
1308
|
);
|
|
1206
1309
|
END;
|
|
1207
1310
|
$$;
|
|
@@ -1209,7 +1312,14 @@ $$;
|
|
|
1209
1312
|
-- ── cerefox_purge_document ───────────────────────────────────────────────────
|
|
1210
1313
|
-- Permanently deletes a soft-deleted document (CASCADE). Only works on
|
|
1211
1314
|
-- documents that are already soft-deleted (deleted_at IS NOT NULL).
|
|
1315
|
+
--
|
|
1316
|
+
-- 0.12.1: drop the pre-author 1-arg overload. CREATE OR REPLACE never removed
|
|
1317
|
+
-- it when the signature grew, so long-lived databases carried BOTH — and a
|
|
1318
|
+
-- 1-arg named call was ambiguous there (PGRST203), which is how the first
|
|
1319
|
+
-- prod acceptance run failed to purge its fixtures. Same cleanup for
|
|
1320
|
+
-- cerefox_restore_document below.
|
|
1212
1321
|
|
|
1322
|
+
DROP FUNCTION IF EXISTS cerefox_purge_document(UUID);
|
|
1213
1323
|
CREATE OR REPLACE FUNCTION cerefox_purge_document(
|
|
1214
1324
|
p_document_id UUID,
|
|
1215
1325
|
p_author TEXT DEFAULT 'unknown',
|
|
@@ -1247,6 +1357,43 @@ END;
|
|
|
1247
1357
|
$$;
|
|
1248
1358
|
|
|
1249
1359
|
|
|
1360
|
+
-- ── cerefox_extract_doc_link_ids ─────────────────────────────────────────────
|
|
1361
|
+
-- The ONE implementation of [Text](uuid) link scanning (#214), shared by the
|
|
1362
|
+
-- write-time guard in cerefox_ingest_document and the cerefox_find_dead_links
|
|
1363
|
+
-- sweep — "same scanning rules" is enforced by this being the only copy.
|
|
1364
|
+
--
|
|
1365
|
+
-- Fences are LINE-ANCHORED and handled by SPLITTING, not by a paired-fence
|
|
1366
|
+
-- regex: Postgres AREs give the whole RE the greediness of their first
|
|
1367
|
+
-- quantified atom, which silently overrode a later .*? and made a closed
|
|
1368
|
+
-- fence strip everything to end-of-string (round-5 review, verified live) —
|
|
1369
|
+
-- blinding the scan to every link after any code block. Odd-numbered split
|
|
1370
|
+
-- segments are outside fences; an unterminated fence leaves its tail inside
|
|
1371
|
+
-- an even segment, dropped (under-validate, never false-reject). Inline code
|
|
1372
|
+
-- spans are stripped after (their regex has no greediness hazard).
|
|
1373
|
+
|
|
1374
|
+
CREATE OR REPLACE FUNCTION cerefox_extract_doc_link_ids(p_content TEXT)
|
|
1375
|
+
RETURNS TABLE (link_id TEXT)
|
|
1376
|
+
LANGUAGE sql
|
|
1377
|
+
IMMUTABLE
|
|
1378
|
+
SET search_path = public, pg_catalog
|
|
1379
|
+
AS $$
|
|
1380
|
+
WITH segments AS (
|
|
1381
|
+
SELECT seg, row_number() OVER () AS rn
|
|
1382
|
+
FROM regexp_split_to_table(COALESCE(p_content, ''), '^[ \t]*```.*$', 'n') AS seg
|
|
1383
|
+
),
|
|
1384
|
+
outside AS (
|
|
1385
|
+
SELECT string_agg(regexp_replace(seg, '`[^`]*`', ' ', 'g'), ' ') AS s
|
|
1386
|
+
FROM segments
|
|
1387
|
+
WHERE rn % 2 = 1
|
|
1388
|
+
)
|
|
1389
|
+
SELECT lower(m[1])
|
|
1390
|
+
FROM outside,
|
|
1391
|
+
LATERAL regexp_matches(
|
|
1392
|
+
outside.s,
|
|
1393
|
+
'\]\(([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})\)',
|
|
1394
|
+
'g') AS m;
|
|
1395
|
+
$$;
|
|
1396
|
+
|
|
1250
1397
|
-- ── cerefox_ingest_document ──────────────────────────────────────────────────
|
|
1251
1398
|
-- Single RPC for ingesting a document (create or update). Handles:
|
|
1252
1399
|
-- - Create: insert document row, insert chunks, set review_status, create audit entry
|
|
@@ -1371,6 +1518,9 @@ DECLARE
|
|
|
1371
1518
|
v_op JSONB; -- iter-33: per-operation audit element
|
|
1372
1519
|
v_size_warn_at INT;
|
|
1373
1520
|
v_size_warning BOOLEAN := FALSE;
|
|
1521
|
+
v_doc_deleted TIMESTAMPTZ;
|
|
1522
|
+
v_scannable TEXT;
|
|
1523
|
+
v_missing TEXT[];
|
|
1374
1524
|
BEGIN
|
|
1375
1525
|
-- ── Zero-chunk guard (v0.3.1) ────────────────────────────────────────
|
|
1376
1526
|
-- Refuse to create or update a document with no chunks. Three reasons:
|
|
@@ -1391,6 +1541,64 @@ BEGIN
|
|
|
1391
1541
|
USING ERRCODE = '22023'; -- invalid_parameter_value
|
|
1392
1542
|
END IF;
|
|
1393
1543
|
|
|
1544
|
+
-- ── Metadata type guard (#212, 0.12.2) ───────────────────────────────
|
|
1545
|
+
-- metadata is jsonb and accepts ANY JSON value, but every reader assumes
|
|
1546
|
+
-- an object — and `document edit`'s JS spread DECOMPOSED a stored string
|
|
1547
|
+
-- into per-character keys (13 real documents were in the vulnerable
|
|
1548
|
+
-- state). The MCP layer always validated this; the RPC now does too, so
|
|
1549
|
+
-- every write path agrees (CLI, scripts, direct PostgREST included).
|
|
1550
|
+
IF p_metadata IS NOT NULL AND jsonb_typeof(p_metadata) <> 'object' THEN
|
|
1551
|
+
RAISE EXCEPTION
|
|
1552
|
+
'cerefox_ingest_document: metadata must be a JSON object, got %. Wrap scalar values in a key ({"value": ...}).',
|
|
1553
|
+
jsonb_typeof(p_metadata)
|
|
1554
|
+
USING ERRCODE = '22023'; -- invalid_parameter_value
|
|
1555
|
+
END IF;
|
|
1556
|
+
|
|
1557
|
+
-- ── Link integrity (#214, 0.12.0) ────────────────────────────────────
|
|
1558
|
+
-- Validate [Text](uuid) document links against the store: agents mangle
|
|
1559
|
+
-- long random ids when regenerating text, and a mangled id silently
|
|
1560
|
+
-- becomes a dead link. Fenced code blocks and inline code spans are
|
|
1561
|
+
-- stripped first — code formatting is the markdown-native way to write
|
|
1562
|
+
-- an EXAMPLE link, and the escape mechanism here (no bypass flag, by
|
|
1563
|
+
-- design). Trashed targets resolve: the id denotes a document. One PK
|
|
1564
|
+
-- lookup for all candidates; ~1-2ms. Runs on create AND update, so a
|
|
1565
|
+
-- link whose target was later purged surfaces on the next edit.
|
|
1566
|
+
-- See docs/specs/link-integrity-design.md.
|
|
1567
|
+
SELECT string_agg(c->>'content', E'\n') INTO v_scannable
|
|
1568
|
+
FROM jsonb_array_elements(p_chunks) c;
|
|
1569
|
+
|
|
1570
|
+
-- Scanning delegated to cerefox_extract_doc_link_ids — the one copy of
|
|
1571
|
+
-- the fence/inline-code/uuid rules, shared with the dead-link sweep
|
|
1572
|
+
-- (0.12.2; the previous inline regex was defeated by ARE whole-RE
|
|
1573
|
+
-- greediness — see the helper's header).
|
|
1574
|
+
SELECT array_agg(DISTINCT l.link_id) INTO v_missing
|
|
1575
|
+
FROM cerefox_extract_doc_link_ids(v_scannable) l
|
|
1576
|
+
WHERE NOT EXISTS (SELECT 1 FROM cerefox_documents d WHERE d.id = l.link_id::uuid);
|
|
1577
|
+
|
|
1578
|
+
-- On UPDATE, tolerate dead links the document ALREADY carries: a target
|
|
1579
|
+
-- purged after linking must not make the document unwritable — sync
|
|
1580
|
+
-- flows re-send content verbatim from disk and could never converge, and
|
|
1581
|
+
-- an unrelated partial edit re-sends the untouched section holding the
|
|
1582
|
+
-- link. Only NEWLY-INTRODUCED unresolvable ids reject; legacy dead links
|
|
1583
|
+
-- are the phase-2 sweep's job (#214). Creates validate everything.
|
|
1584
|
+
IF v_missing IS NOT NULL AND p_document_id IS NOT NULL THEN
|
|
1585
|
+
SELECT array_agg(x) INTO v_missing
|
|
1586
|
+
FROM unnest(v_missing) x
|
|
1587
|
+
WHERE strpos(
|
|
1588
|
+
lower(COALESCE((SELECT string_agg(ch.content, E'\n')
|
|
1589
|
+
FROM cerefox_chunks ch
|
|
1590
|
+
WHERE ch.document_id = p_document_id
|
|
1591
|
+
AND ch.version_id IS NULL), '')),
|
|
1592
|
+
lower(x)) = 0;
|
|
1593
|
+
END IF;
|
|
1594
|
+
|
|
1595
|
+
IF v_missing IS NOT NULL THEN
|
|
1596
|
+
RAISE EXCEPTION
|
|
1597
|
+
'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).',
|
|
1598
|
+
array_length(v_missing, 1), array_to_string(v_missing, ', ')
|
|
1599
|
+
USING ERRCODE = '22023'; -- deterministic; never a retryable SQLSTATE
|
|
1600
|
+
END IF;
|
|
1601
|
+
|
|
1394
1602
|
-- Validate review_status
|
|
1395
1603
|
v_status := CASE WHEN p_review_status IN ('approved', 'pending_review')
|
|
1396
1604
|
THEN p_review_status ELSE 'approved' END;
|
|
@@ -1412,8 +1620,8 @@ BEGIN
|
|
|
1412
1620
|
-- updaters serialize here, and the second one sees the first one's
|
|
1413
1621
|
-- hash — the race window (chunk + embed latency) is closed at the
|
|
1414
1622
|
-- 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
|
|
1623
|
+
SELECT COALESCE(d.total_chars, 0), d.content_hash, d.deleted_at
|
|
1624
|
+
INTO v_old_chars, v_current_hash, v_doc_deleted
|
|
1417
1625
|
FROM cerefox_documents d WHERE d.id = v_doc_id
|
|
1418
1626
|
FOR UPDATE;
|
|
1419
1627
|
|
|
@@ -1422,6 +1630,20 @@ BEGIN
|
|
|
1422
1630
|
USING ERRCODE = '22023'; -- invalid_parameter_value
|
|
1423
1631
|
END IF;
|
|
1424
1632
|
|
|
1633
|
+
-- Refuse to rewrite a trashed document (0.12.0, #211 review). Before
|
|
1634
|
+
-- this guard an update by document_id landed content in a document
|
|
1635
|
+
-- excluded from search — a write into a black hole — and it silently
|
|
1636
|
+
-- broke the restore contract: restore takes no freshness token on the
|
|
1637
|
+
-- premise that what was reviewed in the trash is what comes back.
|
|
1638
|
+
IF v_doc_deleted IS NOT NULL THEN
|
|
1639
|
+
-- CEREFOX_ prefix per the convention below: transport handlers
|
|
1640
|
+
-- detect it and rephrase for their caller.
|
|
1641
|
+
RAISE EXCEPTION
|
|
1642
|
+
'CEREFOX_DELETED: document % is soft-deleted; restore it first (cerefox_restore_document / cerefox document restore) or create a new document.',
|
|
1643
|
+
v_doc_id
|
|
1644
|
+
USING ERRCODE = '22023'; -- invalid_parameter_value
|
|
1645
|
+
END IF;
|
|
1646
|
+
|
|
1425
1647
|
-- ── Optimistic concurrency check (iter-32) ───────────────────
|
|
1426
1648
|
-- Content updates must prove freshness (expected hash) or explicitly
|
|
1427
1649
|
-- choose last-write-wins. Message prefixes are machine-detectable:
|
|
@@ -1442,7 +1664,10 @@ BEGIN
|
|
|
1442
1664
|
'CEREFOX_TOKEN_REQUIRED: content updates require expected_content_hash (the content_hash you read) or last_write_wins=true. Current hash: %',
|
|
1443
1665
|
v_current_hash
|
|
1444
1666
|
USING ERRCODE = '22023'; -- invalid_parameter_value
|
|
1445
|
-
|
|
1667
|
+
-- Trimmed comparison, matching the presence check above: a correct
|
|
1668
|
+
-- hash with stray whitespace is not a stale one (found via the
|
|
1669
|
+
-- delete CAS review, #208 — same flaw existed here since iter-32).
|
|
1670
|
+
ELSIF BTRIM(p_expected_content_hash) <> v_current_hash THEN
|
|
1446
1671
|
RAISE EXCEPTION
|
|
1447
1672
|
'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
1673
|
v_doc_id, p_expected_content_hash, v_current_hash
|
|
@@ -2488,6 +2713,30 @@ BEGIN
|
|
|
2488
2713
|
USING ERRCODE = 'P0002';
|
|
2489
2714
|
END IF;
|
|
2490
2715
|
|
|
2716
|
+
-- #212: a legacy row can hold NON-OBJECT metadata (the ingest RPC did not
|
|
2717
|
+
-- validate its input until 0.12.2). Merging onto it with || would produce
|
|
2718
|
+
-- an ARRAY — Postgres treats both sides as arrays — burying the stored
|
|
2719
|
+
-- value one level deeper and leaving the row still corrupt. Only replace
|
|
2720
|
+
-- actually repairs such a row, so refuse the merge and say so. jsonb
|
|
2721
|
+
-- 'null' is treated like SQL NULL (empty), matching the CLI.
|
|
2722
|
+
IF NOT p_replace AND v_before IS NOT NULL
|
|
2723
|
+
AND jsonb_typeof(v_before) NOT IN ('object', 'null') THEN
|
|
2724
|
+
RAISE EXCEPTION
|
|
2725
|
+
'CEREFOX_BAD_METADATA: stored metadata on document % is not an object (%). A merge cannot repair it — retry with replace (CLI: --replace; MCP: replace: true), passing the full intended object.',
|
|
2726
|
+
p_document_id, jsonb_typeof(v_before)
|
|
2727
|
+
USING ERRCODE = '22023';
|
|
2728
|
+
END IF;
|
|
2729
|
+
|
|
2730
|
+
-- Normalized BEFORE value for merge and reporting: everything below must
|
|
2731
|
+
-- work when the stored value is a scalar/array/'null' and p_replace is
|
|
2732
|
+
-- true — that is the REPAIR path, and jsonb_object_keys on a scalar
|
|
2733
|
+
-- errors, which would roll back the repair itself (round-5 review,
|
|
2734
|
+
-- verified live).
|
|
2735
|
+
v_before := CASE
|
|
2736
|
+
WHEN v_before IS NULL OR jsonb_typeof(v_before) <> 'object' THEN '{}'::jsonb
|
|
2737
|
+
ELSE v_before
|
|
2738
|
+
END;
|
|
2739
|
+
|
|
2491
2740
|
-- Keys explicitly set to null are removals, never stored values.
|
|
2492
2741
|
SELECT COALESCE(array_agg(key), ARRAY[]::TEXT[]) INTO v_null_keys
|
|
2493
2742
|
FROM jsonb_each(p_metadata)
|
|
@@ -2539,6 +2788,17 @@ SET search_path = public, pg_catalog
|
|
|
2539
2788
|
AS $$
|
|
2540
2789
|
-- Keep in lockstep with the `@version:` marker in schema.sql (cut_release.ts
|
|
2541
2790
|
-- enforces it). Bump whenever schema.sql OR rpcs.sql changes.
|
|
2791
|
+
-- 0.12.2 (#212, #214): metadata must be a JSON object (ingest input guard
|
|
2792
|
+
-- + set_document_metadata stored-state merge guard); cerefox_find_dead_links
|
|
2793
|
+
-- (link-integrity phase-2 sweep); cerefox_metadata_health (doctor check).
|
|
2794
|
+
-- 0.12.1: drop orphaned 1-arg overloads of purge/restore (pre-author era;
|
|
2795
|
+
-- CREATE OR REPLACE never removed them; ambiguous PGRST203 on 1-arg calls).
|
|
2796
|
+
-- 0.12.0 (#208, #210): cerefox_delete_document — CAS
|
|
2797
|
+
-- (p_expected_content_hash), p_reason in audit description, JSONB return,
|
|
2798
|
+
-- idempotent re-delete; cerefox_restore_document — same rework (JSONB,
|
|
2799
|
+
-- p_reason, honest no-op). Back the new cerefox_delete_document and
|
|
2800
|
+
-- cerefox_restore_document MCP tools (CLI parity). Ingest CAS compares
|
|
2801
|
+
-- trimmed.
|
|
2542
2802
|
-- 0.11.3 (#204): cerefox_set_document_metadata — metadata-only writes.
|
|
2543
2803
|
-- 0.11.2 (iteration 36): RLS enabled on cerefox_document_relations,
|
|
2544
2804
|
-- which iteration 29 left off the list (Supabase rls_disabled_in_public).
|
|
@@ -2546,7 +2806,75 @@ AS $$
|
|
|
2546
2806
|
-- 0.11.0 supersedes 0.10.6 (v1.2.1, #191): this branch carries that fix plus
|
|
2547
2807
|
-- the partial-edit surface, and both migrations (0019, 0020) are in the
|
|
2548
2808
|
-- sequence, so a store deploying this gets everything from both lines.
|
|
2549
|
-
SELECT '0.
|
|
2809
|
+
SELECT '0.12.2'::TEXT;
|
|
2810
|
+
$$;
|
|
2811
|
+
|
|
2812
|
+
-- ── cerefox_find_dead_links ──────────────────────────────────────────────────
|
|
2813
|
+
-- Phase 2 of link integrity (#214): a read-only whole-KB sweep for
|
|
2814
|
+
-- [Text](uuid) links whose target document no longer EXISTS (purged after
|
|
2815
|
+
-- linking). Complements the write-time guard, which validates only
|
|
2816
|
+
-- newly-introduced links on updates. Same scanning rules as the guard:
|
|
2817
|
+
-- line-anchored fences and inline code spans are stripped (examples are not
|
|
2818
|
+
-- links); a trashed target still exists and is NOT a dead link.
|
|
2819
|
+
-- Full chunk scan — run on demand (CLI `cerefox document dead-links`), not on
|
|
2820
|
+
-- every doctor.
|
|
2821
|
+
|
|
2822
|
+
CREATE OR REPLACE FUNCTION cerefox_find_dead_links()
|
|
2823
|
+
RETURNS TABLE (
|
|
2824
|
+
document_id UUID,
|
|
2825
|
+
document_title TEXT,
|
|
2826
|
+
dead_link_id UUID,
|
|
2827
|
+
occurrences BIGINT
|
|
2828
|
+
)
|
|
2829
|
+
LANGUAGE sql
|
|
2830
|
+
STABLE
|
|
2831
|
+
SECURITY DEFINER
|
|
2832
|
+
SET search_path = public, pg_catalog
|
|
2833
|
+
AS $$
|
|
2834
|
+
-- Scanning delegated to cerefox_extract_doc_link_ids — the one copy of
|
|
2835
|
+
-- the fence/inline-code/uuid rules, shared with the write-time guard.
|
|
2836
|
+
-- Deliberate scope: trashed LINKER documents are excluded (they are
|
|
2837
|
+
-- inert until restored; restoring one re-enters it into the next sweep).
|
|
2838
|
+
WITH doc_content AS (
|
|
2839
|
+
SELECT d.id, d.title, string_agg(c.content, E'\n' ORDER BY c.chunk_index) AS content
|
|
2840
|
+
FROM cerefox_documents d
|
|
2841
|
+
JOIN cerefox_chunks c ON c.document_id = d.id AND c.version_id IS NULL
|
|
2842
|
+
WHERE d.deleted_at IS NULL
|
|
2843
|
+
GROUP BY d.id, d.title
|
|
2844
|
+
),
|
|
2845
|
+
links AS (
|
|
2846
|
+
SELECT dc.id, dc.title, l.link_id AS target
|
|
2847
|
+
FROM doc_content dc,
|
|
2848
|
+
LATERAL cerefox_extract_doc_link_ids(dc.content) l
|
|
2849
|
+
)
|
|
2850
|
+
SELECT l.id, l.title, l.target::uuid, count(*)
|
|
2851
|
+
FROM links l
|
|
2852
|
+
WHERE NOT EXISTS (SELECT 1 FROM cerefox_documents t WHERE t.id = l.target::uuid)
|
|
2853
|
+
GROUP BY l.id, l.title, l.target
|
|
2854
|
+
ORDER BY l.title, l.target;
|
|
2855
|
+
$$;
|
|
2856
|
+
|
|
2857
|
+
-- ── cerefox_metadata_health ──────────────────────────────────────────────────
|
|
2858
|
+
-- #212: rows whose stored metadata is not a JSON object — the state the
|
|
2859
|
+
-- 0.12.2 write guards now prevent, but which legacy rows may still be in.
|
|
2860
|
+
-- Cheap (documents table only); surfaced by `cerefox doctor`. Repair:
|
|
2861
|
+
-- `cerefox document set-metadata <id> --replace --json '<object>'`.
|
|
2862
|
+
|
|
2863
|
+
CREATE OR REPLACE FUNCTION cerefox_metadata_health()
|
|
2864
|
+
RETURNS TABLE (
|
|
2865
|
+
document_id UUID,
|
|
2866
|
+
document_title TEXT,
|
|
2867
|
+
metadata_type TEXT
|
|
2868
|
+
)
|
|
2869
|
+
LANGUAGE sql
|
|
2870
|
+
STABLE
|
|
2871
|
+
SECURITY DEFINER
|
|
2872
|
+
SET search_path = public, pg_catalog
|
|
2873
|
+
AS $$
|
|
2874
|
+
SELECT d.id, d.title, jsonb_typeof(d.metadata)
|
|
2875
|
+
FROM cerefox_documents d
|
|
2876
|
+
WHERE d.metadata IS NOT NULL AND jsonb_typeof(d.metadata) <> 'object'
|
|
2877
|
+
ORDER BY d.title;
|
|
2550
2878
|
$$;
|
|
2551
2879
|
|
|
2552
2880
|
-- ── 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.2
|
|
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 —
|
|
@@ -42,7 +42,15 @@ CREATE TABLE IF NOT EXISTS cerefox_documents (
|
|
|
42
42
|
source_path TEXT,
|
|
43
43
|
-- SHA-256 of raw markdown content; used for deduplication
|
|
44
44
|
content_hash TEXT NOT NULL,
|
|
45
|
-
|
|
45
|
+
-- #212 (0.12.2): must be a JSON OBJECT — jsonb accepts any JSON value,
|
|
46
|
+
-- but every reader assumes an object, and non-object values were silently
|
|
47
|
+
-- destroyed by read-modify-write paths. The table-level CHECK closes all
|
|
48
|
+
-- current AND future direct writers at once; existing databases get it as
|
|
49
|
+
-- NOT VALID via migration 0026 (legacy rows survive until repaired with
|
|
50
|
+
-- `document set-metadata --replace`, which the constraint then validates).
|
|
51
|
+
metadata JSONB NOT NULL DEFAULT '{}'::jsonb
|
|
52
|
+
CONSTRAINT cerefox_documents_metadata_object
|
|
53
|
+
CHECK (jsonb_typeof(metadata) = 'object'),
|
|
46
54
|
chunk_count INT NOT NULL DEFAULT 0,
|
|
47
55
|
total_chars INT NOT NULL DEFAULT 0,
|
|
48
56
|
-- review_status: human governance flag. 'approved' = validated by human,
|
|
@@ -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({
|
|
@@ -250,30 +250,37 @@ client-facing configuration or committed to the repository.
|
|
|
250
250
|
Cerefox classifies write operations into three tiers based on how irreversible they are.
|
|
251
251
|
The access surface for each tier is **not** the same — this asymmetry is a deliberate
|
|
252
252
|
architectural property, not an oversight. Future contributors should read this section
|
|
253
|
-
before "completing" the parity table by adding purge
|
|
254
|
-
|
|
253
|
+
before "completing" the parity table by adding purge to agent-facing access paths.
|
|
254
|
+
|
|
255
|
+
> **History**: until v1.7.0 restore sat in tier 3 with purge, on the theory that an
|
|
256
|
+
> agent must not be able to silently undo its own delete. The maintainer reversed
|
|
257
|
+
> that in #210 (2026-08-13): every delete and restore is audited with author
|
|
258
|
+
> attribution, restore cannot destroy content, and the CLI had `document restore`
|
|
259
|
+
> all along — the boundary the docs described had already outgrown the code. The
|
|
260
|
+
> guarded property is now exactly one thing: **no agent path to permanent purge.**
|
|
255
261
|
|
|
256
262
|
### The three tiers
|
|
257
263
|
|
|
258
264
|
| Tier | Operations | Reversible? | Where exposed |
|
|
259
265
|
|---|---|---|---|
|
|
260
266
|
| 1. Reads + soft mutations | search, get, list-*, ingest (create/update), metadata-search, get-audit-log | n/a (reads) / yes (versioned) | All paths — MCP, Edge Functions, CLI, web UI |
|
|
261
|
-
| 2. Soft-destructive | `delete_document` (soft delete to trash), `set_review_status` | yes — restorable
|
|
262
|
-
| 3. **Hard-destructive** | `purge_document` (permanent), `
|
|
267
|
+
| 2. Soft-destructive + recovery | `delete_document` (soft delete to trash), `restore_document` (un-trash), `set_review_status` | yes — delete is restorable; restore recovers | CLI (`cerefox document delete` / `restore`), web UI, and — since v1.7.0 (#208, #210) — MCP (`cerefox_delete_document`, which requires the caller's read-hash, and `cerefox_restore_document`). **Not** the primitive GPT-Actions Edge Functions (deliberately deferred). |
|
|
268
|
+
| 3. **Hard-destructive** | `purge_document` (permanent), `set_version_archived` (toggle version retention) | no (purge) | **Web UI only** |
|
|
263
269
|
|
|
264
|
-
### Why purge
|
|
270
|
+
### Why purge is web-UI-only
|
|
265
271
|
|
|
266
272
|
The recovery story behind Cerefox depends on a **human-in-the-loop confirmation step
|
|
267
|
-
before irreversible action.**
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
to permanent purge.
|
|
273
|
+
before irreversible action.** Everything an agent can do — write, soft-delete,
|
|
274
|
+
restore — is reversible and audited; the one action that destroys data outright is
|
|
275
|
+
reserved for a human who has just looked at what they are about to destroy.
|
|
271
276
|
|
|
272
277
|
So the access model is:
|
|
273
278
|
|
|
274
|
-
1. **An agent (via MCP, Edge Function, or CLI) can write
|
|
275
|
-
such operation is recorded in `cerefox_audit_log` with `author`,
|
|
276
|
-
`created_at`. Soft-deleted documents land in trash and are
|
|
279
|
+
1. **An agent (via MCP, Edge Function, or CLI) can write, soft-delete, and restore
|
|
280
|
+
freely.** Every such operation is recorded in `cerefox_audit_log` with `author`,
|
|
281
|
+
`author_type`, and `created_at`. Soft-deleted documents land in trash and are
|
|
282
|
+
excluded from search; a restore puts them back and is itself an audit event, so
|
|
283
|
+
a delete-then-restore leaves a visible trail rather than silence.
|
|
277
284
|
2. **A human reviews the trash through the Cerefox web UI.** They see the audit history
|
|
278
285
|
for each document, decide whether the agent's action was correct, and either restore
|
|
279
286
|
the document or — only after seeing what they're about to destroy — purge it.
|
|
@@ -296,9 +303,11 @@ If you're building tooling that uses the CLI (Path C) or any MCP/Edge Function p
|
|
|
296
303
|
- **Surface the soft-delete to the user.** When your agent decides to delete something,
|
|
297
304
|
tell the user explicitly: "I soft-deleted X (recoverable from the Cerefox trash in
|
|
298
305
|
the web UI)." This gives them the visibility to review and either restore or commit.
|
|
299
|
-
- **Do not attempt to purge
|
|
300
|
-
|
|
301
|
-
intervention
|
|
306
|
+
- **Do not attempt to purge from agent code.** There is intentionally no programmatic
|
|
307
|
+
path to permanent deletion — if your workflow needs purge, that workflow needs human
|
|
308
|
+
intervention. Restore, by contrast, is freely available since v1.7.0
|
|
309
|
+
(`cerefox_restore_document` over MCP, `cerefox document restore` on the CLI), audited
|
|
310
|
+
like every other write.
|
|
302
311
|
|
|
303
312
|
### CLI delete-doc — interactive vs scripted
|
|
304
313
|
|