@cerefox/memory 1.18.0 → 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 (37) hide show
  1. package/AGENT_GUIDE.md +8 -3
  2. package/AGENT_QUICK_REFERENCE.md +3 -3
  3. package/dist/bin/cerefox.js +846 -657
  4. package/dist/frontend/assets/index-01k5C3sG.css +1 -0
  5. package/dist/frontend/assets/index-HNs8rgY1.js +121 -0
  6. package/dist/frontend/assets/index-HNs8rgY1.js.map +1 -0
  7. package/dist/frontend/index.html +2 -2
  8. package/dist/server-assets/_shared/ef-meta/index.ts +3 -3
  9. package/dist/server-assets/_shared/embeddings/onnx-embedder.ts +25 -9
  10. package/dist/server-assets/_shared/mcp-tools/_document-meta.ts +24 -2
  11. package/dist/server-assets/_shared/mcp-tools/_utils.ts +92 -0
  12. package/dist/server-assets/_shared/mcp-tools/audit-log.ts +6 -2
  13. package/dist/server-assets/_shared/mcp-tools/audit-ops.ts +27 -0
  14. package/dist/server-assets/_shared/mcp-tools/get-document.ts +11 -1
  15. package/dist/server-assets/_shared/mcp-tools/get-help-content.ts +3 -3
  16. package/dist/server-assets/_shared/mcp-tools/ingest.ts +55 -17
  17. package/dist/server-assets/_shared/mcp-tools/metadata-search.ts +3 -1
  18. package/dist/server-assets/_shared/mcp-tools/partial-edits.ts +8 -13
  19. package/dist/server-assets/_shared/mcp-tools/search.ts +11 -2
  20. package/dist/server-assets/_shared/mcp-tools/set-document-metadata.ts +31 -5
  21. package/dist/server-assets/db/migrations/0035_drop_content_hash_unique.sql +21 -0
  22. package/dist/server-assets/db/migrations/0036_metadata_revision.sql +43 -0
  23. package/dist/server-assets/db/rpcs.sql +252 -53
  24. package/dist/server-assets/db/schema.sql +41 -4
  25. package/dist/server-assets/supabase/functions/cerefox-get-document/index.ts +8 -1
  26. package/dist/server-assets/supabase/functions/cerefox-ingest/index.ts +42 -6
  27. package/docs/api/openapi.json +61 -1
  28. package/docs/guides/cli.md +3 -1
  29. package/docs/guides/configuration.md +1 -1
  30. package/docs/guides/connect-agents.md +18 -7
  31. package/docs/guides/ops-scripts.md +1 -1
  32. package/docs/guides/setup-local.md +30 -6
  33. package/docs/guides/upgrading.md +14 -0
  34. package/package.json +6 -6
  35. package/dist/frontend/assets/index-B0H_N4if.css +0 -1
  36. package/dist/frontend/assets/index-CxMT7hjH.js +0 -121
  37. package/dist/frontend/assets/index-CxMT7hjH.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,
@@ -788,7 +793,10 @@ RETURNS TABLE (
788
793
  content_hash TEXT,
789
794
  -- 28I: TRUE when this is a below-confidence fallback response (nothing
790
795
  -- cleared the hybrid pass-filter). See cerefox_hybrid_search.
791
- 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
792
800
  )
793
801
  LANGUAGE sql
794
802
  SECURITY DEFINER
@@ -825,7 +833,8 @@ AS $$
825
833
  cr.version_count,
826
834
  cr.below_confidence,
827
835
  d.updated_at AS doc_updated_at,
828
- d.content_hash
836
+ d.content_hash,
837
+ d.metadata_revision
829
838
  FROM chunk_results cr
830
839
  JOIN cerefox_documents d ON d.id = cr.document_id
831
840
  ORDER BY cr.document_id, cr.score DESC
@@ -910,7 +919,8 @@ AS $$
910
919
  td.version_count,
911
920
  ac.is_partial,
912
921
  td.content_hash,
913
- td.below_confidence
922
+ td.below_confidence,
923
+ td.metadata_revision
914
924
  FROM top_docs td
915
925
  JOIN doc_sizes ds ON ds.document_id = td.document_id
916
926
  JOIN all_content ac ON ac.document_id = td.document_id
@@ -1069,7 +1079,9 @@ RETURNS TABLE (
1069
1079
  -- Current content_hash of the document — the optimistic-concurrency token
1070
1080
  -- to pass back as expected_content_hash on update (iter-32). Note: always
1071
1081
  -- the CURRENT hash, even when an archived version is being retrieved.
1072
- content_hash TEXT
1082
+ content_hash TEXT,
1083
+ -- #333: the metadata's concurrency token (always the current one).
1084
+ metadata_revision INT
1073
1085
  )
1074
1086
  LANGUAGE sql
1075
1087
  SECURITY DEFINER
@@ -1091,7 +1103,8 @@ AS $$
1091
1103
  COUNT(*)::INT AS chunk_count,
1092
1104
  SUM(c.char_count)::INT AS total_chars,
1093
1105
  d.created_at,
1094
- d.content_hash
1106
+ d.content_hash,
1107
+ d.metadata_revision
1095
1108
  FROM cerefox_documents d
1096
1109
  JOIN cerefox_chunks c ON c.document_id = d.id
1097
1110
  WHERE d.id = p_document_id
@@ -1099,7 +1112,8 @@ AS $$
1099
1112
  (p_version_id IS NULL AND c.version_id IS NULL) OR
1100
1113
  (p_version_id IS NOT NULL AND c.version_id = p_version_id)
1101
1114
  )
1102
- 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;
1103
1117
  $$;
1104
1118
 
1105
1119
  -- ── cerefox_list_document_versions ────────────────────────────────────────────
@@ -1512,8 +1526,11 @@ $$;
1512
1526
  -- p_source_label, which records how THIS write was
1513
1527
  -- triggered and is stored on the version row.
1514
1528
  -- p_metadata : JSONB metadata. NULL = "not provided" → create uses '{}',
1515
- -- update keeps the existing metadata (v0.11.1). Pass '{}'
1516
- -- 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.
1517
1534
  -- p_review_status : ACCEPTED AND IGNORED since 0.16.0 (#241). The status is
1518
1535
  -- decided here from p_author_type alone (agent →
1519
1536
  -- pending_review, user → approved), so every transport
@@ -1539,8 +1556,67 @@ $$;
1539
1556
  -- flows where an external source of truth makes conflicts
1540
1557
  -- meaningless). Recorded in the audit description when used.
1541
1558
  --
1542
- -- Returns: document_id, chunk_count, total_chars, operation ('create' or 'update-content'),
1543
- -- 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
+ $$;
1544
1620
 
1545
1621
  DROP FUNCTION IF EXISTS cerefox_ingest_document(UUID, TEXT, TEXT, TEXT, TEXT, JSONB, TEXT, JSONB, TEXT, TEXT, TEXT, INT, BOOLEAN);
1546
1622
  DROP FUNCTION IF EXISTS cerefox_ingest_document(UUID, TEXT, TEXT, TEXT, TEXT, JSONB, TEXT, JSONB, TEXT, TEXT, TEXT, INT, BOOLEAN, TEXT, BOOLEAN);
@@ -1549,6 +1625,9 @@ DROP FUNCTION IF EXISTS cerefox_ingest_document(UUID, TEXT, TEXT, TEXT, TEXT, JS
1549
1625
  -- must be dropped before the 17-arg CREATE below (Postgres will not replace a
1550
1626
  -- function whose RETURNS TABLE changed).
1551
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);
1552
1631
  CREATE FUNCTION cerefox_ingest_document(
1553
1632
  p_document_id UUID DEFAULT NULL,
1554
1633
  p_title TEXT DEFAULT 'Untitled',
@@ -1560,7 +1639,9 @@ CREATE FUNCTION cerefox_ingest_document(
1560
1639
  p_content_hash TEXT DEFAULT '',
1561
1640
  -- NULL = "not provided": create uses '{}', update KEEPS existing metadata
1562
1641
  -- (v0.11.1 fix — content updates without metadata used to wipe tags).
1563
- -- 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.
1564
1645
  p_metadata JSONB DEFAULT NULL,
1565
1646
  p_review_status TEXT DEFAULT 'approved',
1566
1647
  p_chunks JSONB DEFAULT '[]',
@@ -1588,7 +1669,15 @@ CREATE FUNCTION cerefox_ingest_document(
1588
1669
  -- flattening to 'update-content'. NULL-means-today, per the #183 lesson:
1589
1670
  -- a parameter that substitutes its own concrete default is how store policy
1590
1671
  -- got silently overridden for a whole release.
1591
- 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
1592
1681
  )
1593
1682
  RETURNS TABLE (
1594
1683
  document_id UUID,
@@ -1603,7 +1692,9 @@ RETURNS TABLE (
1603
1692
  -- iter-33: true when the new size crosses `document_size_warning_chars`
1604
1693
  -- (dormant when that config is unset/0). A signal, never a refusal: an
1605
1694
  -- insert-only workflow otherwise never sees a document grow.
1606
- size_warning BOOLEAN
1695
+ size_warning BOOLEAN,
1696
+ -- #333: the metadata's token after this write.
1697
+ metadata_revision INT
1607
1698
  )
1608
1699
  LANGUAGE plpgsql
1609
1700
  SECURITY DEFINER
@@ -1611,6 +1702,7 @@ SET search_path = public, pg_catalog
1611
1702
  AS $$
1612
1703
  DECLARE
1613
1704
  v_doc_id UUID;
1705
+ v_duplicate_id UUID;
1614
1706
  v_chunk_count INT;
1615
1707
  v_total_chars INT;
1616
1708
  v_operation TEXT;
@@ -1626,6 +1718,9 @@ DECLARE
1626
1718
  v_doc_deleted TIMESTAMPTZ;
1627
1719
  v_scannable TEXT;
1628
1720
  v_missing TEXT[];
1721
+ v_meta_before JSONB;
1722
+ v_meta_after JSONB;
1723
+ v_meta_rev INT;
1629
1724
  BEGIN
1630
1725
  -- ── Zero-chunk guard (v0.3.1) ────────────────────────────────────────
1631
1726
  -- Refuse to create or update a document with no chunks. Three reasons:
@@ -1736,8 +1831,9 @@ BEGIN
1736
1831
  -- updaters serialize here, and the second one sees the first one's
1737
1832
  -- hash — the race window (chunk + embed latency) is closed at the
1738
1833
  -- only place all transports share (iter-32).
1739
- SELECT COALESCE(d.total_chars, 0), d.content_hash, d.deleted_at
1740
- 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
1741
1837
  FROM cerefox_documents d WHERE d.id = v_doc_id
1742
1838
  FOR UPDATE;
1743
1839
 
@@ -1811,6 +1907,21 @@ BEGIN
1811
1907
  END IF;
1812
1908
  END IF;
1813
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
+
1814
1925
  -- Snapshot old version (archives current chunks, runs retention cleanup)
1815
1926
  SELECT sv.version_id INTO v_version_id
1816
1927
  FROM cerefox_snapshot_version(v_doc_id, p_source_label, p_retention_hours, p_cleanup_enabled) sv;
@@ -1824,7 +1935,7 @@ BEGIN
1824
1935
  source = COALESCE(p_source, source),
1825
1936
  source_path = COALESCE(p_source_path, source_path),
1826
1937
  content_hash = p_content_hash,
1827
- metadata = COALESCE(p_metadata, metadata),
1938
+ metadata = v_meta_after,
1828
1939
  chunk_count = v_chunk_count,
1829
1940
  total_chars = v_total_chars,
1830
1941
  review_status = v_status,
@@ -1835,11 +1946,45 @@ BEGIN
1835
1946
  -- ── CREATE PATH ──────────────────────────────────────────────
1836
1947
  v_operation := 'create';
1837
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
+
1838
1982
  INSERT INTO cerefox_documents (
1839
1983
  title, source, source_path, content_hash, metadata,
1840
1984
  chunk_count, total_chars, review_status
1841
1985
  ) VALUES (
1842
- 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),
1843
1988
  v_chunk_count, v_total_chars, v_status
1844
1989
  )
1845
1990
  RETURNING id INTO v_doc_id;
@@ -1921,6 +2066,24 @@ BEGIN
1921
2066
  );
1922
2067
  END IF;
1923
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
+
1924
2087
  -- ── Size signal (iter-33) ────────────────────────────────────────
1925
2088
  -- Dormant unless an operator sets a threshold. Never blocks the write: a
1926
2089
  -- size policy is not a correctness rule, and an agent that only ever
@@ -1931,7 +2094,9 @@ BEGIN
1931
2094
  END IF;
1932
2095
 
1933
2096
  RETURN QUERY SELECT v_doc_id, v_chunk_count, v_total_chars, v_operation,
1934
- 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);
1935
2100
  END;
1936
2101
  $$;
1937
2102
 
@@ -1975,6 +2140,8 @@ $$;
1975
2140
  -- Returns the created entry's id and created_at.
1976
2141
 
1977
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);
1978
2145
  CREATE FUNCTION cerefox_create_audit_entry(
1979
2146
  p_document_id UUID DEFAULT NULL,
1980
2147
  p_version_id UUID DEFAULT NULL,
@@ -1983,7 +2150,9 @@ CREATE FUNCTION cerefox_create_audit_entry(
1983
2150
  p_author_type TEXT DEFAULT 'user',
1984
2151
  p_size_before INT DEFAULT NULL,
1985
2152
  p_size_after INT DEFAULT NULL,
1986
- p_description TEXT DEFAULT ''
2153
+ p_description TEXT DEFAULT '',
2154
+ p_metadata_before JSONB DEFAULT NULL,
2155
+ p_metadata_after JSONB DEFAULT NULL
1987
2156
  )
1988
2157
  RETURNS TABLE (
1989
2158
  audit_id UUID,
@@ -1995,12 +2164,13 @@ SET search_path = public, pg_catalog
1995
2164
  AS $$
1996
2165
  INSERT INTO cerefox_audit_log (
1997
2166
  document_id, version_id, operation, author, author_type,
1998
- size_before, size_after, description, created_at
2167
+ size_before, size_after, description, metadata_before, metadata_after,
2168
+ created_at
1999
2169
  )
2000
2170
  VALUES (
2001
2171
  p_document_id, p_version_id, p_operation, p_author,
2002
2172
  CASE WHEN p_author_type IN ('user', 'agent') THEN p_author_type ELSE 'user' END,
2003
- p_size_before, p_size_after, p_description,
2173
+ p_size_before, p_size_after, p_description, p_metadata_before, p_metadata_after,
2004
2174
  -- clock_timestamp(), not the NOW() default: NOW() is the TRANSACTION's
2005
2175
  -- start time, so every audit entry written by one cerefox_edit batch
2006
2176
  -- would share a timestamp and the order of operations inside the batch
@@ -2044,7 +2214,10 @@ RETURNS TABLE (
2044
2214
  size_before INT,
2045
2215
  size_after INT,
2046
2216
  description TEXT,
2047
- 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
2048
2221
  )
2049
2222
  LANGUAGE sql
2050
2223
  SECURITY DEFINER
@@ -2062,7 +2235,9 @@ AS $$
2062
2235
  a.size_before,
2063
2236
  a.size_after,
2064
2237
  a.description,
2065
- a.created_at
2238
+ a.created_at,
2239
+ a.metadata_before,
2240
+ a.metadata_after
2066
2241
  FROM cerefox_audit_log a
2067
2242
  LEFT JOIN cerefox_documents d ON d.id = a.document_id
2068
2243
  WHERE (p_document_id IS NULL OR a.document_id = p_document_id)
@@ -2437,7 +2612,9 @@ RETURNS TABLE (
2437
2612
  -- Optimistic-concurrency token (iter-32): pass back as
2438
2613
  -- expected_content_hash on update.
2439
2614
  content_hash TEXT,
2440
- content TEXT
2615
+ content TEXT,
2616
+ -- #333: pass back as expected_metadata_revision on a metadata write.
2617
+ metadata_revision INT
2441
2618
  )
2442
2619
  LANGUAGE plpgsql
2443
2620
  SECURITY DEFINER
@@ -2467,6 +2644,7 @@ BEGIN
2467
2644
  (SELECT COUNT(*)::INT FROM cerefox_document_versions dv
2468
2645
  WHERE dv.document_id = d.id) AS version_count,
2469
2646
  d.content_hash,
2647
+ d.metadata_revision,
2470
2648
  CASE WHEN p_include_content THEN
2471
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
2472
2650
  FROM cerefox_chunks c
@@ -2507,6 +2685,7 @@ BEGIN
2507
2685
  version_count := v_row.version_count;
2508
2686
  content_hash := v_row.content_hash;
2509
2687
  content := v_row.content;
2688
+ metadata_revision := v_row.metadata_revision;
2510
2689
  RETURN NEXT;
2511
2690
  END LOOP;
2512
2691
  END;
@@ -3118,19 +3297,29 @@ $$;
3118
3297
  --
3119
3298
  -- Metadata-only: no re-chunk, no re-embed, no content version snapshot. The
3120
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.
3121
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);
3122
3308
  CREATE FUNCTION cerefox_set_document_metadata(
3123
3309
  p_document_id UUID,
3124
3310
  p_metadata JSONB,
3125
3311
  p_replace BOOLEAN DEFAULT FALSE,
3126
3312
  p_author TEXT DEFAULT 'unknown',
3127
- 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
3128
3316
  )
3129
3317
  RETURNS TABLE (
3130
3318
  document_id UUID,
3131
3319
  metadata JSONB,
3132
3320
  keys_set INT,
3133
- keys_removed INT
3321
+ keys_removed INT,
3322
+ metadata_revision INT
3134
3323
  )
3135
3324
  LANGUAGE plpgsql
3136
3325
  SECURITY DEFINER
@@ -3138,11 +3327,12 @@ SET search_path = public, pg_catalog
3138
3327
  AS $$
3139
3328
  DECLARE
3140
3329
  v_before JSONB;
3330
+ v_stored JSONB;
3141
3331
  v_after JSONB;
3142
- v_null_keys TEXT[];
3143
3332
  v_title TEXT;
3144
3333
  v_removed INT;
3145
3334
  v_set INT;
3335
+ v_revision INT;
3146
3336
  BEGIN
3147
3337
  IF p_metadata IS NULL OR jsonb_typeof(p_metadata) <> 'object' THEN
3148
3338
  RAISE EXCEPTION 'metadata must be a JSON object'
@@ -3150,8 +3340,8 @@ BEGIN
3150
3340
  END IF;
3151
3341
 
3152
3342
  -- Lock the row so a concurrent metadata write cannot interleave between
3153
- -- the read and the merge.
3154
- 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
3155
3345
  FROM cerefox_documents d
3156
3346
  WHERE d.id = p_document_id AND d.deleted_at IS NULL
3157
3347
  FOR UPDATE;
@@ -3161,17 +3351,21 @@ BEGIN
3161
3351
  USING ERRCODE = 'P0002';
3162
3352
  END IF;
3163
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
+
3164
3358
  -- #212: a legacy row can hold NON-OBJECT metadata (the ingest RPC did not
3165
3359
  -- validate its input until 0.12.2). Merging onto it with || would produce
3166
3360
  -- an ARRAY — Postgres treats both sides as arrays — burying the stored
3167
3361
  -- value one level deeper and leaving the row still corrupt. Only replace
3168
3362
  -- actually repairs such a row, so refuse the merge and say so. jsonb
3169
3363
  -- 'null' is treated like SQL NULL (empty), matching the CLI.
3170
- IF NOT p_replace AND v_before IS NOT NULL
3171
- 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
3172
3366
  RAISE EXCEPTION
3173
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.',
3174
- p_document_id, jsonb_typeof(v_before)
3368
+ p_document_id, jsonb_typeof(v_stored)
3175
3369
  USING ERRCODE = '22023';
3176
3370
  END IF;
3177
3371
 
@@ -3181,35 +3375,27 @@ BEGIN
3181
3375
  -- errors, which would roll back the repair itself (round-5 review,
3182
3376
  -- verified live).
3183
3377
  v_before := CASE
3184
- WHEN v_before IS NULL OR jsonb_typeof(v_before) <> 'object' THEN '{}'::jsonb
3185
- ELSE v_before
3378
+ WHEN v_stored IS NULL OR jsonb_typeof(v_stored) <> 'object' THEN '{}'::jsonb
3379
+ ELSE v_stored
3186
3380
  END;
3187
3381
 
3188
- -- Keys explicitly set to null are removals, never stored values.
3189
- SELECT COALESCE(array_agg(key), ARRAY[]::TEXT[]) INTO v_null_keys
3190
- FROM jsonb_each(p_metadata)
3191
- WHERE value = 'null'::jsonb;
3382
+ v_after := cerefox_metadata_apply(v_before, p_metadata, p_replace);
3192
3383
 
3193
- IF p_replace THEN
3194
- v_after := p_metadata - v_null_keys;
3195
- ELSE
3196
- v_after := (COALESCE(v_before, '{}'::jsonb) || p_metadata) - v_null_keys;
3197
- END IF;
3198
-
3199
- UPDATE cerefox_documents
3384
+ UPDATE cerefox_documents d
3200
3385
  SET metadata = v_after, updated_at = NOW()
3201
- WHERE id = p_document_id;
3386
+ WHERE d.id = p_document_id
3387
+ RETURNING d.metadata_revision INTO v_revision;
3202
3388
 
3203
3389
  -- Report what actually changed, not what was asked for: a caller that sets
3204
3390
  -- a key to the value it already had should see 0, so "nothing happened" is
3205
3391
  -- distinguishable from "it worked".
3206
3392
  SELECT count(*)::INT INTO v_removed
3207
- FROM jsonb_object_keys(COALESCE(v_before, '{}'::jsonb)) k
3393
+ FROM jsonb_object_keys(v_before) k
3208
3394
  WHERE NOT v_after ? k;
3209
3395
 
3210
3396
  SELECT count(*)::INT INTO v_set
3211
3397
  FROM jsonb_each(v_after) e
3212
- WHERE COALESCE(v_before, '{}'::jsonb) -> e.key IS DISTINCT FROM e.value;
3398
+ WHERE v_before -> e.key IS DISTINCT FROM e.value;
3213
3399
 
3214
3400
  PERFORM cerefox_create_audit_entry(
3215
3401
  p_document_id := p_document_id,
@@ -3217,13 +3403,18 @@ BEGIN
3217
3403
  p_author := p_author,
3218
3404
  p_author_type := p_author_type,
3219
3405
  p_description := format(
3220
- 'Metadata %s on "%s": %s key(s) set, %s removed',
3406
+ 'Metadata %s on "%s": %s key(s) set, %s removed%s',
3221
3407
  CASE WHEN p_replace THEN 'replaced' ELSE 'merged' END,
3222
- v_title, v_set, v_removed
3223
- )
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
3224
3415
  );
3225
3416
 
3226
- 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;
3227
3418
  END;
3228
3419
  $$;
3229
3420
 
@@ -3236,6 +3427,14 @@ SET search_path = public, pg_catalog
3236
3427
  AS $$
3237
3428
  -- Keep in lockstep with the `@version:` marker in schema.sql (cut_release.ts
3238
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).
3239
3438
  -- 0.19.0: migration 0034 drops the unused HNSW vector indexes (search has
3240
3439
  -- scanned exactly since 0.18.1). No RPC behavior change.
3241
3440
  -- 0.18.2: min_term_coverage built-in default 0.66 (two of three terms).
@@ -3290,7 +3489,7 @@ AS $$
3290
3489
  -- 0.11.0 supersedes 0.10.6 (v1.2.1, #191): this branch carries that fix plus
3291
3490
  -- the partial-edit surface, and both migrations (0019, 0020) are in the
3292
3491
  -- sequence, so a store deploying this gets everything from both lines.
3293
- SELECT '0.19.0'::TEXT;
3492
+ SELECT '0.21.1'::TEXT;
3294
3493
  $$;
3295
3494
 
3296
3495
  -- ── cerefox_find_dead_links ──────────────────────────────────────────────────