@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.
Files changed (28) hide show
  1. package/AGENT_GUIDE.md +80 -6
  2. package/AGENT_QUICK_REFERENCE.md +9 -7
  3. package/README.md +5 -3
  4. package/dist/bin/cerefox.js +790 -319
  5. package/dist/frontend/assets/index-D8E0mTnp.js +121 -0
  6. package/dist/frontend/assets/index-D8E0mTnp.js.map +1 -0
  7. package/dist/frontend/index.html +1 -1
  8. package/dist/server-assets/_shared/ef-meta/index.ts +3 -3
  9. package/dist/server-assets/_shared/mcp-tools/_utils.ts +41 -0
  10. package/dist/server-assets/_shared/mcp-tools/delete-document.ts +181 -0
  11. package/dist/server-assets/_shared/mcp-tools/get-help-content.ts +4 -4
  12. package/dist/server-assets/_shared/mcp-tools/index.ts +8 -1
  13. package/dist/server-assets/_shared/mcp-tools/ingest.ts +75 -8
  14. package/dist/server-assets/_shared/mcp-tools/partial-edits.ts +43 -4
  15. package/dist/server-assets/_shared/mcp-tools/restore-document.ts +130 -0
  16. package/dist/server-assets/db/migrations/0024_mcp_delete_document.sql +26 -0
  17. package/dist/server-assets/db/migrations/0025_drop_orphaned_overloads.sql +15 -0
  18. package/dist/server-assets/db/migrations/0026_metadata_guard_and_dead_links.sql +46 -0
  19. package/dist/server-assets/db/rpcs.sql +355 -27
  20. package/dist/server-assets/db/schema.sql +10 -2
  21. package/dist/server-assets/supabase/functions/cerefox-ingest/index.ts +26 -0
  22. package/docs/guides/access-paths.md +24 -15
  23. package/docs/guides/cli.md +34 -7
  24. package/docs/guides/connect-agents.md +41 -17
  25. package/docs/guides/operational-cost.md +1 -1
  26. package/package.json +1 -1
  27. package/dist/frontend/assets/index-OqloGFwv.js +0 -121
  28. 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 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,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
- 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
+ -- 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 VOID
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 TEXT;
1265
+ v_title TEXT;
1186
1266
  v_total_chars INT;
1267
+ v_deleted_at TIMESTAMPTZ;
1187
1268
  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;
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 v_title IS NULL THEN
1192
- RETURN; -- Not found or not deleted
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
- ELSIF p_expected_content_hash <> v_current_hash THEN
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.11.3'::TEXT;
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.11.3
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
- metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
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 or restore to agent-facing access
254
- paths.
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 via web UI | All paths (CLI: `cerefox document delete`; web UI; **not** MCP or Edge Functions today) |
262
- | 3. **Hard-destructive** | `purge_document` (permanent), `restore_document` (un-trash), `set_version_archived` (toggle version retention) | no (purge) / yes (restore, but recovers from a destructive action) | **Web UI only** |
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 / restore are web-UI-only
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.** Soft-delete on its own is not enough — an agent that
268
- mistakenly soft-deletes a document needs to be unable to silently restore the same
269
- document later (covering its tracks), and certainly unable to escalate from soft-delete
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 or soft-delete freely.** Every
275
- such operation is recorded in `cerefox_audit_log` with `author`, `author_type`, and
276
- `created_at`. Soft-deleted documents land in trash and are excluded from search.
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 or restore from agent code.** There is intentionally no
300
- programmatic path. If your workflow needs purge / restore, that workflow needs human
301
- intervention — the design is correct, not incomplete.
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