@cerefox/memory 1.17.4 → 1.19.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.
Files changed (48) hide show
  1. package/AGENT_GUIDE.md +8 -3
  2. package/AGENT_QUICK_REFERENCE.md +3 -3
  3. package/README.md +1 -1
  4. package/dist/bin/cerefox.js +1542 -1123
  5. package/dist/frontend/assets/index-01k5C3sG.css +1 -0
  6. package/dist/frontend/assets/index-HNs8rgY1.js +121 -0
  7. package/dist/frontend/assets/index-HNs8rgY1.js.map +1 -0
  8. package/dist/frontend/index.html +2 -2
  9. package/dist/server-assets/_shared/ef-meta/index.ts +3 -3
  10. package/dist/server-assets/_shared/embeddings/onnx-embedder.ts +25 -9
  11. package/dist/server-assets/_shared/mcp-tools/_document-meta.ts +24 -2
  12. package/dist/server-assets/_shared/mcp-tools/_utils.ts +92 -0
  13. package/dist/server-assets/_shared/mcp-tools/audit-log.ts +6 -2
  14. package/dist/server-assets/_shared/mcp-tools/audit-ops.ts +27 -0
  15. package/dist/server-assets/_shared/mcp-tools/get-document.ts +11 -1
  16. package/dist/server-assets/_shared/mcp-tools/get-help-content.ts +3 -3
  17. package/dist/server-assets/_shared/mcp-tools/ingest.ts +55 -17
  18. package/dist/server-assets/_shared/mcp-tools/metadata-search.ts +3 -1
  19. package/dist/server-assets/_shared/mcp-tools/partial-edits.ts +8 -13
  20. package/dist/server-assets/_shared/mcp-tools/search.ts +11 -2
  21. package/dist/server-assets/_shared/mcp-tools/set-document-metadata.ts +31 -5
  22. package/dist/server-assets/db/migrations/0012_content_format.sql +1 -1
  23. package/dist/server-assets/db/migrations/0014_document_relations.sql +1 -1
  24. package/dist/server-assets/db/migrations/0020_ingest_preserves_source.sql +1 -1
  25. package/dist/server-assets/db/migrations/0022_rls_on_document_relations.sql +1 -1
  26. package/dist/server-assets/db/migrations/0032_derive_grants.sql +1 -1
  27. package/dist/server-assets/db/migrations/0034_drop_vector_indexes.sql +22 -0
  28. package/dist/server-assets/db/migrations/0035_drop_content_hash_unique.sql +21 -0
  29. package/dist/server-assets/db/migrations/0036_metadata_revision.sql +43 -0
  30. package/dist/server-assets/db/rpcs.sql +260 -58
  31. package/dist/server-assets/db/schema.sql +49 -18
  32. package/dist/server-assets/supabase/functions/cerefox-get-document/index.ts +8 -1
  33. package/dist/server-assets/supabase/functions/cerefox-ingest/index.ts +42 -6
  34. package/docs/api/openapi.json +77 -3
  35. package/docs/guides/access-paths.md +7 -3
  36. package/docs/guides/api.md +1 -1
  37. package/docs/guides/cli.md +5 -3
  38. package/docs/guides/configuration.md +23 -2
  39. package/docs/guides/connect-agents.md +60 -25
  40. package/docs/guides/ops-scripts.md +1 -1
  41. package/docs/guides/quickstart.md +7 -3
  42. package/docs/guides/setup-local.md +30 -6
  43. package/docs/guides/setup-supabase.md +5 -0
  44. package/docs/guides/upgrading.md +16 -1
  45. package/package.json +7 -7
  46. package/dist/frontend/assets/index-B0H_N4if.css +0 -1
  47. package/dist/frontend/assets/index-D1kdLLr8.js +0 -121
  48. package/dist/frontend/assets/index-D1kdLLr8.js.map +0 -1
@@ -81,6 +81,11 @@ DROP FUNCTION IF EXISTS cerefox_search_docs(TEXT, VECTOR(768), INT, FLOAT, UUID,
81
81
  DROP FUNCTION IF EXISTS cerefox_hybrid_search(TEXT, VECTOR(768), INT, FLOAT, BOOLEAN, UUID, FLOAT, JSONB, FLOAT);
82
82
  DROP FUNCTION IF EXISTS cerefox_search_docs(TEXT, VECTOR(768), INT, FLOAT, UUID, FLOAT, INT, INT, JSONB, FLOAT);
83
83
 
84
+ -- #333 (0.21.0): metadata_revision added to the return types of the
85
+ -- document-shaped reads, next to content_hash. Drop the current signatures.
86
+ DROP FUNCTION IF EXISTS cerefox_search_docs(TEXT, VECTOR(768), INT, FLOAT, UUID, FLOAT, INT, INT, JSONB, FLOAT, TEXT);
87
+ DROP FUNCTION IF EXISTS cerefox_metadata_search(JSONB, UUID, TIMESTAMPTZ, TIMESTAMPTZ, INT, BOOLEAN, INT);
88
+
84
89
  -- ── Shared return type note ────────────────────────────────────────────────────
85
90
  -- All chunk-level search RPCs return the same shape for consistency:
86
91
  -- chunk_id, document_id, chunk_index, title, content, heading_path,
@@ -236,7 +241,7 @@ BEGIN
236
241
  fts_results AS (
237
242
  SELECT
238
243
  c.id,
239
- -- 0.18.0: normalisation 32 bounds the keyword score to
244
+ -- 0.18.0: normalization 32 bounds the keyword score to
240
245
  -- rank/(rank+1), in [0, 1) like the cosine it is fused with.
241
246
  -- Unbounded (up to ~4 when every term matches), one keyword
242
247
  -- hit outweighed any semantic evidence, and the same document
@@ -305,7 +310,8 @@ BEGIN
305
310
  -- hnsw.ef_search (40) rows: on a store with heavy version churn it
306
311
  -- returned far fewer candidates than asked for, mostly not the
307
312
  -- nearest ones. An exact scan is also faster at knowledge-base
308
- -- scale (thousands of chunks).
313
+ -- scale (thousands of chunks). 0.19.0 dropped the HNSW indexes
314
+ -- altogether (migration 0034).
309
315
  ORDER BY vec_score DESC
310
316
  LIMIT candidate_count
311
317
  ),
@@ -787,7 +793,10 @@ RETURNS TABLE (
787
793
  content_hash TEXT,
788
794
  -- 28I: TRUE when this is a below-confidence fallback response (nothing
789
795
  -- cleared the hybrid pass-filter). See cerefox_hybrid_search.
790
- below_confidence BOOLEAN
796
+ below_confidence BOOLEAN,
797
+ -- #333: the metadata's concurrency token, to pass back as
798
+ -- expected_metadata_revision on a metadata write.
799
+ metadata_revision INT
791
800
  )
792
801
  LANGUAGE sql
793
802
  SECURITY DEFINER
@@ -824,7 +833,8 @@ AS $$
824
833
  cr.version_count,
825
834
  cr.below_confidence,
826
835
  d.updated_at AS doc_updated_at,
827
- d.content_hash
836
+ d.content_hash,
837
+ d.metadata_revision
828
838
  FROM chunk_results cr
829
839
  JOIN cerefox_documents d ON d.id = cr.document_id
830
840
  ORDER BY cr.document_id, cr.score DESC
@@ -909,7 +919,8 @@ AS $$
909
919
  td.version_count,
910
920
  ac.is_partial,
911
921
  td.content_hash,
912
- td.below_confidence
922
+ td.below_confidence,
923
+ td.metadata_revision
913
924
  FROM top_docs td
914
925
  JOIN doc_sizes ds ON ds.document_id = td.document_id
915
926
  JOIN all_content ac ON ac.document_id = td.document_id
@@ -1068,7 +1079,9 @@ RETURNS TABLE (
1068
1079
  -- Current content_hash of the document — the optimistic-concurrency token
1069
1080
  -- to pass back as expected_content_hash on update (iter-32). Note: always
1070
1081
  -- the CURRENT hash, even when an archived version is being retrieved.
1071
- content_hash TEXT
1082
+ content_hash TEXT,
1083
+ -- #333: the metadata's concurrency token (always the current one).
1084
+ metadata_revision INT
1072
1085
  )
1073
1086
  LANGUAGE sql
1074
1087
  SECURITY DEFINER
@@ -1090,7 +1103,8 @@ AS $$
1090
1103
  COUNT(*)::INT AS chunk_count,
1091
1104
  SUM(c.char_count)::INT AS total_chars,
1092
1105
  d.created_at,
1093
- d.content_hash
1106
+ d.content_hash,
1107
+ d.metadata_revision
1094
1108
  FROM cerefox_documents d
1095
1109
  JOIN cerefox_chunks c ON c.document_id = d.id
1096
1110
  WHERE d.id = p_document_id
@@ -1098,7 +1112,8 @@ AS $$
1098
1112
  (p_version_id IS NULL AND c.version_id IS NULL) OR
1099
1113
  (p_version_id IS NOT NULL AND c.version_id = p_version_id)
1100
1114
  )
1101
- GROUP BY d.id, d.title, d.source, d.metadata, d.created_at, d.content_hash;
1115
+ GROUP BY d.id, d.title, d.source, d.metadata, d.created_at, d.content_hash,
1116
+ d.metadata_revision;
1102
1117
  $$;
1103
1118
 
1104
1119
  -- ── cerefox_list_document_versions ────────────────────────────────────────────
@@ -1511,8 +1526,11 @@ $$;
1511
1526
  -- p_source_label, which records how THIS write was
1512
1527
  -- triggered and is stored on the version row.
1513
1528
  -- p_metadata : JSONB metadata. NULL = "not provided" → create uses '{}',
1514
- -- update keeps the existing metadata (v0.11.1). Pass '{}'
1515
- -- explicitly to clear all metadata.
1529
+ -- update keeps the existing metadata (v0.11.1). On update
1530
+ -- it MERGES (JSON null removes a key) unless
1531
+ -- p_replace_metadata (#333, 0.21.0); '{}' is a no-op merge.
1532
+ -- p_expected_metadata_revision, p_replace_metadata : the metadata token and
1533
+ -- the replace switch (#333); see cerefox_check_metadata_revision.
1516
1534
  -- p_review_status : ACCEPTED AND IGNORED since 0.16.0 (#241). The status is
1517
1535
  -- decided here from p_author_type alone (agent →
1518
1536
  -- pending_review, user → approved), so every transport
@@ -1538,8 +1556,67 @@ $$;
1538
1556
  -- flows where an external source of truth makes conflicts
1539
1557
  -- meaningless). Recorded in the audit description when used.
1540
1558
  --
1541
- -- Returns: document_id, chunk_count, total_chars, operation ('create' or 'update-content'),
1542
- -- version_id (UUID of snapshot, null on create)
1559
+ -- Returns: document_id, chunk_count, total_chars, operation ('create' or
1560
+ -- 'update-content'), version_id (UUID of snapshot, null on create).
1561
+ -- A create whose content_hash matches a LIVE document raises
1562
+ -- CEREFOX_DUPLICATE_CONTENT (23505) naming that document; nothing is
1563
+ -- written (0.20.0 #332; raised rather than returned since 0.21.1)
1564
+
1565
+ -- ── Metadata helpers (#333, 0.21.0) ──────────────────────────────────────────
1566
+ -- One copy of the merge/replace rule and of the token rule, shared by
1567
+ -- cerefox_set_document_metadata and cerefox_ingest_document.
1568
+ --
1569
+ -- cerefox_metadata_apply: RFC 7386-style. Merge = stored || patch; replace =
1570
+ -- the patch alone. Either way, keys whose value is JSON null are REMOVED, never
1571
+ -- stored (Cerefox metadata values are strings by convention, so a null is
1572
+ -- never a legitimate value).
1573
+ CREATE OR REPLACE FUNCTION cerefox_metadata_apply(
1574
+ p_stored JSONB,
1575
+ p_patch JSONB,
1576
+ p_replace BOOLEAN
1577
+ )
1578
+ RETURNS JSONB
1579
+ LANGUAGE sql
1580
+ IMMUTABLE
1581
+ SET search_path = public, pg_catalog
1582
+ AS $$
1583
+ SELECT (CASE WHEN p_replace THEN p_patch
1584
+ ELSE COALESCE(p_stored, '{}'::jsonb) || p_patch END)
1585
+ - COALESCE((SELECT array_agg(key) FROM jsonb_each(p_patch)
1586
+ WHERE value = 'null'::jsonb), ARRAY[]::TEXT[]);
1587
+ $$;
1588
+
1589
+ -- cerefox_check_metadata_revision: raise unless the write may proceed.
1590
+ -- expected passed and stale → CEREFOX_METADATA_CONFLICT (PT409)
1591
+ -- replace without token or LWW → CEREFOX_METADATA_TOKEN_REQUIRED (22023)
1592
+ -- PT409, never a retryable SQLSTATE: a stale token fails identically forever
1593
+ -- (see the CEREFOX_CONFLICT note in cerefox_ingest_document).
1594
+ CREATE OR REPLACE FUNCTION cerefox_check_metadata_revision(
1595
+ p_document_id UUID,
1596
+ p_current INT,
1597
+ p_expected INT,
1598
+ p_replace BOOLEAN,
1599
+ p_last_write_wins BOOLEAN
1600
+ )
1601
+ RETURNS VOID
1602
+ LANGUAGE plpgsql
1603
+ SET search_path = public, pg_catalog
1604
+ AS $$
1605
+ BEGIN
1606
+ IF p_expected IS NOT NULL AND p_expected <> p_current THEN
1607
+ RAISE EXCEPTION
1608
+ 'CEREFOX_METADATA_CONFLICT: the metadata of document % changed since it was read (expected metadata_revision %, current %). Re-read the document, reapply your change, and retry with the new metadata_revision.',
1609
+ p_document_id, p_expected, p_current
1610
+ USING ERRCODE = 'PT409';
1611
+ END IF;
1612
+ IF p_replace AND p_expected IS NULL AND NOT COALESCE(p_last_write_wins, FALSE) THEN
1613
+ RAISE EXCEPTION
1614
+ 'CEREFOX_METADATA_TOKEN_REQUIRED: replacing all metadata deletes keys, so it requires expected_metadata_revision (the metadata_revision you read) or last_write_wins=true. Current metadata_revision: %',
1615
+ p_current
1616
+ USING ERRCODE = '22023';
1617
+ END IF;
1618
+ END;
1619
+ $$;
1543
1620
 
1544
1621
  DROP FUNCTION IF EXISTS cerefox_ingest_document(UUID, TEXT, TEXT, TEXT, TEXT, JSONB, TEXT, JSONB, TEXT, TEXT, TEXT, INT, BOOLEAN);
1545
1622
  DROP FUNCTION IF EXISTS cerefox_ingest_document(UUID, TEXT, TEXT, TEXT, TEXT, JSONB, TEXT, JSONB, TEXT, TEXT, TEXT, INT, BOOLEAN, TEXT, BOOLEAN);
@@ -1548,6 +1625,9 @@ DROP FUNCTION IF EXISTS cerefox_ingest_document(UUID, TEXT, TEXT, TEXT, TEXT, JS
1548
1625
  -- must be dropped before the 17-arg CREATE below (Postgres will not replace a
1549
1626
  -- function whose RETURNS TABLE changed).
1550
1627
  DROP FUNCTION IF EXISTS cerefox_ingest_document(UUID, TEXT, TEXT, TEXT, TEXT, JSONB, TEXT, JSONB, TEXT, TEXT, TEXT, INT, BOOLEAN, TEXT, BOOLEAN, SMALLINT, JSONB);
1628
+ -- #333 (0.21.0): + p_expected_metadata_revision, p_replace_metadata; returns
1629
+ -- metadata_revision.
1630
+ DROP FUNCTION IF EXISTS cerefox_ingest_document(UUID, TEXT, TEXT, TEXT, TEXT, JSONB, TEXT, JSONB, TEXT, TEXT, TEXT, INT, BOOLEAN, TEXT, BOOLEAN, SMALLINT, JSONB, INT, BOOLEAN);
1551
1631
  CREATE FUNCTION cerefox_ingest_document(
1552
1632
  p_document_id UUID DEFAULT NULL,
1553
1633
  p_title TEXT DEFAULT 'Untitled',
@@ -1559,7 +1639,9 @@ CREATE FUNCTION cerefox_ingest_document(
1559
1639
  p_content_hash TEXT DEFAULT '',
1560
1640
  -- NULL = "not provided": create uses '{}', update KEEPS existing metadata
1561
1641
  -- (v0.11.1 fix — content updates without metadata used to wipe tags).
1562
- -- Pass '{}' explicitly to deliberately clear all metadata.
1642
+ -- #333 (0.21.0): on update this MERGES (a JSON null removes a key), unless
1643
+ -- p_replace_metadata. It used to replace the whole object, checked only
1644
+ -- against the content token, which could wipe another writer's change.
1563
1645
  p_metadata JSONB DEFAULT NULL,
1564
1646
  p_review_status TEXT DEFAULT 'approved',
1565
1647
  p_chunks JSONB DEFAULT '[]',
@@ -1587,7 +1669,15 @@ CREATE FUNCTION cerefox_ingest_document(
1587
1669
  -- flattening to 'update-content'. NULL-means-today, per the #183 lesson:
1588
1670
  -- a parameter that substitutes its own concrete default is how store policy
1589
1671
  -- got silently overridden for a whole release.
1590
- p_operations JSONB DEFAULT NULL
1672
+ p_operations JSONB DEFAULT NULL,
1673
+ -- #333: the metadata's concurrency token. Checked on update when
1674
+ -- p_metadata is passed: strict when given; required (or p_last_write_wins)
1675
+ -- when p_replace_metadata. Ignored on create.
1676
+ p_expected_metadata_revision INT DEFAULT NULL,
1677
+ -- #333: replace the whole metadata object instead of merging. For callers
1678
+ -- whose metadata has an external source of truth (front matter synced
1679
+ -- with last_write_wins); everyone else merges.
1680
+ p_replace_metadata BOOLEAN DEFAULT FALSE
1591
1681
  )
1592
1682
  RETURNS TABLE (
1593
1683
  document_id UUID,
@@ -1602,7 +1692,9 @@ RETURNS TABLE (
1602
1692
  -- iter-33: true when the new size crosses `document_size_warning_chars`
1603
1693
  -- (dormant when that config is unset/0). A signal, never a refusal: an
1604
1694
  -- insert-only workflow otherwise never sees a document grow.
1605
- size_warning BOOLEAN
1695
+ size_warning BOOLEAN,
1696
+ -- #333: the metadata's token after this write.
1697
+ metadata_revision INT
1606
1698
  )
1607
1699
  LANGUAGE plpgsql
1608
1700
  SECURITY DEFINER
@@ -1610,6 +1702,7 @@ SET search_path = public, pg_catalog
1610
1702
  AS $$
1611
1703
  DECLARE
1612
1704
  v_doc_id UUID;
1705
+ v_duplicate_id UUID;
1613
1706
  v_chunk_count INT;
1614
1707
  v_total_chars INT;
1615
1708
  v_operation TEXT;
@@ -1625,6 +1718,9 @@ DECLARE
1625
1718
  v_doc_deleted TIMESTAMPTZ;
1626
1719
  v_scannable TEXT;
1627
1720
  v_missing TEXT[];
1721
+ v_meta_before JSONB;
1722
+ v_meta_after JSONB;
1723
+ v_meta_rev INT;
1628
1724
  BEGIN
1629
1725
  -- ── Zero-chunk guard (v0.3.1) ────────────────────────────────────────
1630
1726
  -- Refuse to create or update a document with no chunks. Three reasons:
@@ -1735,8 +1831,9 @@ BEGIN
1735
1831
  -- updaters serialize here, and the second one sees the first one's
1736
1832
  -- hash — the race window (chunk + embed latency) is closed at the
1737
1833
  -- only place all transports share (iter-32).
1738
- SELECT COALESCE(d.total_chars, 0), d.content_hash, d.deleted_at
1739
- INTO v_old_chars, v_current_hash, v_doc_deleted
1834
+ SELECT COALESCE(d.total_chars, 0), d.content_hash, d.deleted_at,
1835
+ d.metadata, d.metadata_revision
1836
+ INTO v_old_chars, v_current_hash, v_doc_deleted, v_meta_before, v_meta_rev
1740
1837
  FROM cerefox_documents d WHERE d.id = v_doc_id
1741
1838
  FOR UPDATE;
1742
1839
 
@@ -1810,6 +1907,21 @@ BEGIN
1810
1907
  END IF;
1811
1908
  END IF;
1812
1909
 
1910
+ -- ── Metadata token (#333) ─────────────────────────────────────
1911
+ -- Only when this write touches metadata. Checked after the content
1912
+ -- token, so a caller holding a stale body learns that first.
1913
+ IF p_metadata IS NOT NULL THEN
1914
+ PERFORM cerefox_check_metadata_revision(
1915
+ v_doc_id, v_meta_rev, p_expected_metadata_revision,
1916
+ p_replace_metadata, p_last_write_wins);
1917
+ v_meta_after := cerefox_metadata_apply(
1918
+ CASE WHEN jsonb_typeof(v_meta_before) = 'object'
1919
+ THEN v_meta_before ELSE '{}'::jsonb END,
1920
+ p_metadata, p_replace_metadata);
1921
+ ELSE
1922
+ v_meta_after := v_meta_before;
1923
+ END IF;
1924
+
1813
1925
  -- Snapshot old version (archives current chunks, runs retention cleanup)
1814
1926
  SELECT sv.version_id INTO v_version_id
1815
1927
  FROM cerefox_snapshot_version(v_doc_id, p_source_label, p_retention_hours, p_cleanup_enabled) sv;
@@ -1823,7 +1935,7 @@ BEGIN
1823
1935
  source = COALESCE(p_source, source),
1824
1936
  source_path = COALESCE(p_source_path, source_path),
1825
1937
  content_hash = p_content_hash,
1826
- metadata = COALESCE(p_metadata, metadata),
1938
+ metadata = v_meta_after,
1827
1939
  chunk_count = v_chunk_count,
1828
1940
  total_chars = v_total_chars,
1829
1941
  review_status = v_status,
@@ -1834,11 +1946,45 @@ BEGIN
1834
1946
  -- ── CREATE PATH ──────────────────────────────────────────────
1835
1947
  v_operation := 'create';
1836
1948
 
1949
+ -- Duplicate check (0.20.0, #332). Content is no longer UNIQUE across
1950
+ -- the store: that constraint refused updates that happened to match
1951
+ -- some other document, including ones in the trash. Duplicates are
1952
+ -- now caught only on CREATE, and only against LIVE documents. Every
1953
+ -- caller pre-checks before embedding (to save the spend) and reports
1954
+ -- a match as skipped; this is the authoritative copy, made race-free
1955
+ -- by the advisory lock.
1956
+ --
1957
+ -- Losing the race RAISES rather than returning the winner (0.21.1,
1958
+ -- #334 review). A 1.18 client never read `operation`: handed the
1959
+ -- winner's id, it reported a create and replaced the winner's
1960
+ -- projects with its own. Raising under 23505 gives such a client what
1961
+ -- the old constraint gave it (a failed write, nothing after it), and
1962
+ -- current clients turn CEREFOX_DUPLICATE_CONTENT into a skip.
1963
+ IF NULLIF(BTRIM(p_content_hash), '') IS NOT NULL THEN
1964
+ PERFORM pg_advisory_xact_lock(
1965
+ hashtextextended('cerefox_content_hash:' || p_content_hash, 0));
1966
+
1967
+ -- Read into a dedicated variable, never into the create's own:
1968
+ -- a no-row SELECT INTO sets its targets to NULL, which once wiped
1969
+ -- v_chunk_count before the INSERT below.
1970
+ SELECT d.id INTO v_duplicate_id
1971
+ FROM cerefox_documents d
1972
+ WHERE d.content_hash = p_content_hash
1973
+ AND d.deleted_at IS NULL
1974
+ ORDER BY d.created_at
1975
+ LIMIT 1;
1976
+ IF v_duplicate_id IS NOT NULL THEN
1977
+ RAISE EXCEPTION 'CEREFOX_DUPLICATE_CONTENT: document % already has this content; nothing was written', v_duplicate_id
1978
+ USING ERRCODE = '23505';
1979
+ END IF;
1980
+ END IF;
1981
+
1837
1982
  INSERT INTO cerefox_documents (
1838
1983
  title, source, source_path, content_hash, metadata,
1839
1984
  chunk_count, total_chars, review_status
1840
1985
  ) VALUES (
1841
- p_title, COALESCE(p_source, 'agent'), p_source_path, p_content_hash, COALESCE(p_metadata, '{}'::JSONB),
1986
+ p_title, COALESCE(p_source, 'agent'), p_source_path, p_content_hash,
1987
+ cerefox_metadata_apply('{}'::jsonb, COALESCE(p_metadata, '{}'::jsonb), TRUE),
1842
1988
  v_chunk_count, v_total_chars, v_status
1843
1989
  )
1844
1990
  RETURNING id INTO v_doc_id;
@@ -1920,6 +2066,24 @@ BEGIN
1920
2066
  );
1921
2067
  END IF;
1922
2068
 
2069
+ -- ── Metadata history (#333) ──────────────────────────────────────
2070
+ -- A content update that changed metadata also records it as its own
2071
+ -- 'update-metadata' entry, with the before/after values, so the metadata
2072
+ -- history is complete whichever path wrote it.
2073
+ IF v_operation = 'update-content' AND v_meta_after IS DISTINCT FROM v_meta_before THEN
2074
+ PERFORM cerefox_create_audit_entry(
2075
+ p_document_id := v_doc_id,
2076
+ p_version_id := v_version_id,
2077
+ p_operation := 'update-metadata',
2078
+ p_author := p_author,
2079
+ p_author_type := p_author_type,
2080
+ p_description := format('Metadata %s on "%s" (with a content update)',
2081
+ CASE WHEN p_replace_metadata THEN 'replaced' ELSE 'merged' END, p_title),
2082
+ p_metadata_before := v_meta_before,
2083
+ p_metadata_after := v_meta_after
2084
+ );
2085
+ END IF;
2086
+
1923
2087
  -- ── Size signal (iter-33) ────────────────────────────────────────
1924
2088
  -- Dormant unless an operator sets a threshold. Never blocks the write: a
1925
2089
  -- size policy is not a correctness rule, and an agent that only ever
@@ -1930,7 +2094,9 @@ BEGIN
1930
2094
  END IF;
1931
2095
 
1932
2096
  RETURN QUERY SELECT v_doc_id, v_chunk_count, v_total_chars, v_operation,
1933
- v_version_id, p_content_hash, v_size_warning;
2097
+ v_version_id, p_content_hash, v_size_warning,
2098
+ (SELECT d.metadata_revision FROM cerefox_documents d
2099
+ WHERE d.id = v_doc_id);
1934
2100
  END;
1935
2101
  $$;
1936
2102
 
@@ -1974,6 +2140,8 @@ $$;
1974
2140
  -- Returns the created entry's id and created_at.
1975
2141
 
1976
2142
  DROP FUNCTION IF EXISTS cerefox_create_audit_entry(UUID, UUID, TEXT, TEXT, TEXT, INT, INT, TEXT);
2143
+ -- #333 (0.21.0): + metadata before/after, for 'update-metadata' entries.
2144
+ DROP FUNCTION IF EXISTS cerefox_create_audit_entry(UUID, UUID, TEXT, TEXT, TEXT, INT, INT, TEXT, JSONB, JSONB);
1977
2145
  CREATE FUNCTION cerefox_create_audit_entry(
1978
2146
  p_document_id UUID DEFAULT NULL,
1979
2147
  p_version_id UUID DEFAULT NULL,
@@ -1982,7 +2150,9 @@ CREATE FUNCTION cerefox_create_audit_entry(
1982
2150
  p_author_type TEXT DEFAULT 'user',
1983
2151
  p_size_before INT DEFAULT NULL,
1984
2152
  p_size_after INT DEFAULT NULL,
1985
- p_description TEXT DEFAULT ''
2153
+ p_description TEXT DEFAULT '',
2154
+ p_metadata_before JSONB DEFAULT NULL,
2155
+ p_metadata_after JSONB DEFAULT NULL
1986
2156
  )
1987
2157
  RETURNS TABLE (
1988
2158
  audit_id UUID,
@@ -1994,18 +2164,19 @@ SET search_path = public, pg_catalog
1994
2164
  AS $$
1995
2165
  INSERT INTO cerefox_audit_log (
1996
2166
  document_id, version_id, operation, author, author_type,
1997
- size_before, size_after, description, created_at
2167
+ size_before, size_after, description, metadata_before, metadata_after,
2168
+ created_at
1998
2169
  )
1999
2170
  VALUES (
2000
2171
  p_document_id, p_version_id, p_operation, p_author,
2001
2172
  CASE WHEN p_author_type IN ('user', 'agent') THEN p_author_type ELSE 'user' END,
2002
- p_size_before, p_size_after, p_description,
2173
+ p_size_before, p_size_after, p_description, p_metadata_before, p_metadata_after,
2003
2174
  -- clock_timestamp(), not the NOW() default: NOW() is the TRANSACTION's
2004
2175
  -- start time, so every audit entry written by one cerefox_edit batch
2005
2176
  -- would share a timestamp and the order of operations inside the batch
2006
2177
  -- would be unrecoverable from the trail (iter-33). clock_timestamp()
2007
2178
  -- advances within a transaction, so entries stay orderable. Outside a
2008
- -- batch this is indistinguishable from the old behaviour.
2179
+ -- batch this is indistinguishable from the old behavior.
2009
2180
  clock_timestamp()
2010
2181
  )
2011
2182
  RETURNING id AS audit_id, cerefox_audit_log.created_at;
@@ -2043,7 +2214,10 @@ RETURNS TABLE (
2043
2214
  size_before INT,
2044
2215
  size_after INT,
2045
2216
  description TEXT,
2046
- created_at TIMESTAMPTZ
2217
+ created_at TIMESTAMPTZ,
2218
+ -- #333 (0.21.0): set on 'update-metadata' entries, NULL otherwise.
2219
+ metadata_before JSONB,
2220
+ metadata_after JSONB
2047
2221
  )
2048
2222
  LANGUAGE sql
2049
2223
  SECURITY DEFINER
@@ -2061,7 +2235,9 @@ AS $$
2061
2235
  a.size_before,
2062
2236
  a.size_after,
2063
2237
  a.description,
2064
- a.created_at
2238
+ a.created_at,
2239
+ a.metadata_before,
2240
+ a.metadata_after
2065
2241
  FROM cerefox_audit_log a
2066
2242
  LEFT JOIN cerefox_documents d ON d.id = a.document_id
2067
2243
  WHERE (p_document_id IS NULL OR a.document_id = p_document_id)
@@ -2436,7 +2612,9 @@ RETURNS TABLE (
2436
2612
  -- Optimistic-concurrency token (iter-32): pass back as
2437
2613
  -- expected_content_hash on update.
2438
2614
  content_hash TEXT,
2439
- content TEXT
2615
+ content TEXT,
2616
+ -- #333: pass back as expected_metadata_revision on a metadata write.
2617
+ metadata_revision INT
2440
2618
  )
2441
2619
  LANGUAGE plpgsql
2442
2620
  SECURITY DEFINER
@@ -2466,6 +2644,7 @@ BEGIN
2466
2644
  (SELECT COUNT(*)::INT FROM cerefox_document_versions dv
2467
2645
  WHERE dv.document_id = d.id) AS version_count,
2468
2646
  d.content_hash,
2647
+ d.metadata_revision,
2469
2648
  CASE WHEN p_include_content THEN
2470
2649
  (SELECT CASE WHEN MAX(c.content_format) >= 2 THEN STRING_AGG(c.content, '' ORDER BY c.chunk_index) ELSE STRING_AGG(c.content, E'\n\n' ORDER BY c.chunk_index) END
2471
2650
  FROM cerefox_chunks c
@@ -2506,6 +2685,7 @@ BEGIN
2506
2685
  version_count := v_row.version_count;
2507
2686
  content_hash := v_row.content_hash;
2508
2687
  content := v_row.content;
2688
+ metadata_revision := v_row.metadata_revision;
2509
2689
  RETURN NEXT;
2510
2690
  END LOOP;
2511
2691
  END;
@@ -2516,7 +2696,7 @@ $$;
2516
2696
  -- docs/research/document-relations-and-semantic-graph.md
2517
2697
  --
2518
2698
  -- Type dictionary: rel_type is free text (any string is accepted and returned),
2519
- -- but a few KNOWN types carry behaviour. Keeping the dictionary in one place
2699
+ -- but a few KNOWN types carry behavior. Keeping the dictionary in one place
2520
2700
  -- here — rather than scattered CASE expressions — is what lets the set/delete
2521
2701
  -- RPCs stay symmetric with each other.
2522
2702
  -- symmetric both directions are written/removed together
@@ -3117,19 +3297,29 @@ $$;
3117
3297
  --
3118
3298
  -- Metadata-only: no re-chunk, no re-embed, no content version snapshot. The
3119
3299
  -- audit trail records it as 'update-metadata', which the CHECK already allows.
3300
+ -- #333 (0.21.0): a concurrency token for metadata. `metadata_revision` goes up
3301
+ -- by one on every actual change (trigger). Merge writes MAY pass
3302
+ -- p_expected_metadata_revision (strict when passed); a replace MUST pass it, or
3303
+ -- p_last_write_wins, because a replace deletes keys. A merge of disjoint keys
3304
+ -- was already safe, and requiring a token there would force a read before
3305
+ -- every tag. The audit entry now records the metadata before and after.
3120
3306
  DROP FUNCTION IF EXISTS cerefox_set_document_metadata(UUID, JSONB, BOOLEAN, TEXT, TEXT);
3307
+ DROP FUNCTION IF EXISTS cerefox_set_document_metadata(UUID, JSONB, BOOLEAN, TEXT, TEXT, INT, BOOLEAN);
3121
3308
  CREATE FUNCTION cerefox_set_document_metadata(
3122
3309
  p_document_id UUID,
3123
3310
  p_metadata JSONB,
3124
3311
  p_replace BOOLEAN DEFAULT FALSE,
3125
3312
  p_author TEXT DEFAULT 'unknown',
3126
- p_author_type TEXT DEFAULT 'user'
3313
+ p_author_type TEXT DEFAULT 'user',
3314
+ p_expected_metadata_revision INT DEFAULT NULL,
3315
+ p_last_write_wins BOOLEAN DEFAULT FALSE
3127
3316
  )
3128
3317
  RETURNS TABLE (
3129
3318
  document_id UUID,
3130
3319
  metadata JSONB,
3131
3320
  keys_set INT,
3132
- keys_removed INT
3321
+ keys_removed INT,
3322
+ metadata_revision INT
3133
3323
  )
3134
3324
  LANGUAGE plpgsql
3135
3325
  SECURITY DEFINER
@@ -3137,11 +3327,12 @@ SET search_path = public, pg_catalog
3137
3327
  AS $$
3138
3328
  DECLARE
3139
3329
  v_before JSONB;
3330
+ v_stored JSONB;
3140
3331
  v_after JSONB;
3141
- v_null_keys TEXT[];
3142
3332
  v_title TEXT;
3143
3333
  v_removed INT;
3144
3334
  v_set INT;
3335
+ v_revision INT;
3145
3336
  BEGIN
3146
3337
  IF p_metadata IS NULL OR jsonb_typeof(p_metadata) <> 'object' THEN
3147
3338
  RAISE EXCEPTION 'metadata must be a JSON object'
@@ -3149,8 +3340,8 @@ BEGIN
3149
3340
  END IF;
3150
3341
 
3151
3342
  -- Lock the row so a concurrent metadata write cannot interleave between
3152
- -- the read and the merge.
3153
- SELECT d.metadata, d.title INTO v_before, v_title
3343
+ -- the read, the token check and the merge.
3344
+ SELECT d.metadata, d.title, d.metadata_revision INTO v_stored, v_title, v_revision
3154
3345
  FROM cerefox_documents d
3155
3346
  WHERE d.id = p_document_id AND d.deleted_at IS NULL
3156
3347
  FOR UPDATE;
@@ -3160,17 +3351,21 @@ BEGIN
3160
3351
  USING ERRCODE = 'P0002';
3161
3352
  END IF;
3162
3353
 
3354
+ PERFORM cerefox_check_metadata_revision(
3355
+ p_document_id, v_revision, p_expected_metadata_revision,
3356
+ p_replace, p_last_write_wins);
3357
+
3163
3358
  -- #212: a legacy row can hold NON-OBJECT metadata (the ingest RPC did not
3164
3359
  -- validate its input until 0.12.2). Merging onto it with || would produce
3165
3360
  -- an ARRAY — Postgres treats both sides as arrays — burying the stored
3166
3361
  -- value one level deeper and leaving the row still corrupt. Only replace
3167
3362
  -- actually repairs such a row, so refuse the merge and say so. jsonb
3168
3363
  -- 'null' is treated like SQL NULL (empty), matching the CLI.
3169
- IF NOT p_replace AND v_before IS NOT NULL
3170
- AND jsonb_typeof(v_before) NOT IN ('object', 'null') THEN
3364
+ IF NOT p_replace AND v_stored IS NOT NULL
3365
+ AND jsonb_typeof(v_stored) NOT IN ('object', 'null') THEN
3171
3366
  RAISE EXCEPTION
3172
3367
  '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.',
3173
- p_document_id, jsonb_typeof(v_before)
3368
+ p_document_id, jsonb_typeof(v_stored)
3174
3369
  USING ERRCODE = '22023';
3175
3370
  END IF;
3176
3371
 
@@ -3180,35 +3375,27 @@ BEGIN
3180
3375
  -- errors, which would roll back the repair itself (round-5 review,
3181
3376
  -- verified live).
3182
3377
  v_before := CASE
3183
- WHEN v_before IS NULL OR jsonb_typeof(v_before) <> 'object' THEN '{}'::jsonb
3184
- ELSE v_before
3378
+ WHEN v_stored IS NULL OR jsonb_typeof(v_stored) <> 'object' THEN '{}'::jsonb
3379
+ ELSE v_stored
3185
3380
  END;
3186
3381
 
3187
- -- Keys explicitly set to null are removals, never stored values.
3188
- SELECT COALESCE(array_agg(key), ARRAY[]::TEXT[]) INTO v_null_keys
3189
- FROM jsonb_each(p_metadata)
3190
- WHERE value = 'null'::jsonb;
3382
+ v_after := cerefox_metadata_apply(v_before, p_metadata, p_replace);
3191
3383
 
3192
- IF p_replace THEN
3193
- v_after := p_metadata - v_null_keys;
3194
- ELSE
3195
- v_after := (COALESCE(v_before, '{}'::jsonb) || p_metadata) - v_null_keys;
3196
- END IF;
3197
-
3198
- UPDATE cerefox_documents
3384
+ UPDATE cerefox_documents d
3199
3385
  SET metadata = v_after, updated_at = NOW()
3200
- WHERE id = p_document_id;
3386
+ WHERE d.id = p_document_id
3387
+ RETURNING d.metadata_revision INTO v_revision;
3201
3388
 
3202
3389
  -- Report what actually changed, not what was asked for: a caller that sets
3203
3390
  -- a key to the value it already had should see 0, so "nothing happened" is
3204
3391
  -- distinguishable from "it worked".
3205
3392
  SELECT count(*)::INT INTO v_removed
3206
- FROM jsonb_object_keys(COALESCE(v_before, '{}'::jsonb)) k
3393
+ FROM jsonb_object_keys(v_before) k
3207
3394
  WHERE NOT v_after ? k;
3208
3395
 
3209
3396
  SELECT count(*)::INT INTO v_set
3210
3397
  FROM jsonb_each(v_after) e
3211
- WHERE COALESCE(v_before, '{}'::jsonb) -> e.key IS DISTINCT FROM e.value;
3398
+ WHERE v_before -> e.key IS DISTINCT FROM e.value;
3212
3399
 
3213
3400
  PERFORM cerefox_create_audit_entry(
3214
3401
  p_document_id := p_document_id,
@@ -3216,13 +3403,18 @@ BEGIN
3216
3403
  p_author := p_author,
3217
3404
  p_author_type := p_author_type,
3218
3405
  p_description := format(
3219
- 'Metadata %s on "%s": %s key(s) set, %s removed',
3406
+ 'Metadata %s on "%s": %s key(s) set, %s removed%s',
3220
3407
  CASE WHEN p_replace THEN 'replaced' ELSE 'merged' END,
3221
- v_title, v_set, v_removed
3222
- )
3408
+ v_title, v_set, v_removed,
3409
+ CASE WHEN p_replace AND p_last_write_wins
3410
+ AND p_expected_metadata_revision IS NULL
3411
+ THEN ' [last-write-wins]' ELSE '' END
3412
+ ),
3413
+ p_metadata_before := v_stored,
3414
+ p_metadata_after := v_after
3223
3415
  );
3224
3416
 
3225
- RETURN QUERY SELECT p_document_id, v_after, v_set, v_removed;
3417
+ RETURN QUERY SELECT p_document_id, v_after, v_set, v_removed, v_revision;
3226
3418
  END;
3227
3419
  $$;
3228
3420
 
@@ -3235,6 +3427,16 @@ SET search_path = public, pg_catalog
3235
3427
  AS $$
3236
3428
  -- Keep in lockstep with the `@version:` marker in schema.sql (cut_release.ts
3237
3429
  -- enforces it). Bump whenever schema.sql OR rpcs.sql changes.
3430
+ -- 0.21.1: a create that loses the duplicate race raises
3431
+ -- CEREFOX_DUPLICATE_CONTENT (23505) instead of returning 'skipped'.
3432
+ -- 0.21.0: migration 0036 adds metadata_revision (+ trigger) and audit
3433
+ -- metadata_before/after; the metadata RPC and ingest RPC check the token,
3434
+ -- content updates merge metadata instead of replacing it (#333).
3435
+ -- 0.20.0: migration 0035 drops the store-wide UNIQUE on content_hash; the
3436
+ -- duplicate check moves into cerefox_ingest_document's create path, live
3437
+ -- documents only, returning operation 'skipped' (#332).
3438
+ -- 0.19.0: migration 0034 drops the unused HNSW vector indexes (search has
3439
+ -- scanned exactly since 0.18.1). No RPC behavior change.
3238
3440
  -- 0.18.2: min_term_coverage built-in default 0.66 (two of three terms).
3239
3441
  -- 0.18.0's 0.67 required all three for a three-term query. RPC-only.
3240
3442
  -- 0.18.1: exact vector candidates. Hybrid and semantic search ordered by
@@ -3243,7 +3445,7 @@ AS $$
3243
3445
  -- they now order by the computed similarity (exact scan). Keyword matches
3244
3446
  -- carry their exact cosine into fusion. RPC-only.
3245
3447
  -- 0.18.0 (iteration 48): search calibration, RPC-only. Bounded keyword
3246
- -- score in hybrid fusion (ts_rank_cd normalisation 32); min_term_coverage
3448
+ -- score in hybrid fusion (ts_rank_cd normalization 32); min_term_coverage
3247
3449
  -- default 0.67; built-in min_search_score follows the store's embedder
3248
3450
  -- (new cerefox_default_min_search_score). docs/specs/search-calibration.md.
3249
3451
  -- 0.17.0 (#251): trash auto-purge. New cerefox_purge_expired_trash, called
@@ -3287,7 +3489,7 @@ AS $$
3287
3489
  -- 0.11.0 supersedes 0.10.6 (v1.2.1, #191): this branch carries that fix plus
3288
3490
  -- the partial-edit surface, and both migrations (0019, 0020) are in the
3289
3491
  -- sequence, so a store deploying this gets everything from both lines.
3290
- SELECT '0.18.2'::TEXT;
3492
+ SELECT '0.21.1'::TEXT;
3291
3493
  $$;
3292
3494
 
3293
3495
  -- ── cerefox_find_dead_links ──────────────────────────────────────────────────