@cerefox/memory 1.0.8 → 1.1.0-beta.2

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.
@@ -0,0 +1,64 @@
1
+ -- 0014_document_relations.sql — typed edges between documents (iteration 29).
2
+ --
3
+ -- Adds the relation graph on top of the existing document model:
4
+ -- * cerefox_document_relations — typed, directed edges (source → target)
5
+ -- * cerefox_documents.lifecycle_status — 'active' | 'superseded' | 'stale' | 'archived'
6
+ --
7
+ -- Design: docs/research/document-relations-and-semantic-graph.md §2.2, §3.
8
+ -- Idempotent: safe to re-run.
9
+
10
+ CREATE TABLE IF NOT EXISTS cerefox_document_relations (
11
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
12
+ source_id UUID NOT NULL REFERENCES cerefox_documents(id) ON DELETE CASCADE,
13
+ target_id UUID NOT NULL REFERENCES cerefox_documents(id) ON DELETE CASCADE,
14
+ -- Free-text by design: agents define new types without a migration. The
15
+ -- type dictionary (in the RPCs) gives known types behaviour; unknown types
16
+ -- are stored and returned, just without special handling.
17
+ rel_type TEXT NOT NULL,
18
+ metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
19
+ author TEXT NOT NULL DEFAULT 'unknown',
20
+ author_type TEXT NOT NULL DEFAULT 'agent',
21
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
22
+ -- One edge of a given type per ordered pair; different types may coexist.
23
+ UNIQUE (source_id, target_id, rel_type),
24
+ -- A document relating to itself is always a mistake, and self-edges would
25
+ -- make traversal loop.
26
+ CONSTRAINT cerefox_relations_no_self_edge CHECK (source_id <> target_id)
27
+ );
28
+
29
+ CREATE INDEX IF NOT EXISTS idx_cerefox_relations_source ON cerefox_document_relations(source_id);
30
+ CREATE INDEX IF NOT EXISTS idx_cerefox_relations_target ON cerefox_document_relations(target_id);
31
+ CREATE INDEX IF NOT EXISTS idx_cerefox_relations_type ON cerefox_document_relations(rel_type);
32
+
33
+ -- Lifecycle status: how a document stands relative to the rest of the graph.
34
+ -- Distinct from review_status (editorial state) and deleted_at (existence).
35
+ ALTER TABLE cerefox_documents
36
+ ADD COLUMN IF NOT EXISTS lifecycle_status TEXT NOT NULL DEFAULT 'active';
37
+
38
+ CREATE INDEX IF NOT EXISTS idx_cerefox_docs_lifecycle
39
+ ON cerefox_documents(lifecycle_status)
40
+ WHERE lifecycle_status <> 'active';
41
+
42
+ -- Data-API grants for the new table (migration 0013 / #26: privileges are
43
+ -- explicit now, and a new table gets none by default).
44
+ DO $$
45
+ BEGIN
46
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role') THEN
47
+ GRANT SELECT, INSERT, UPDATE, DELETE
48
+ ON TABLE public.cerefox_document_relations TO service_role;
49
+ END IF;
50
+ END
51
+ $$;
52
+
53
+ -- Relation writes are auditable operations; widen the audit-log constraint.
54
+ ALTER TABLE cerefox_audit_log DROP CONSTRAINT IF EXISTS cerefox_audit_log_operation_check;
55
+ ALTER TABLE cerefox_audit_log ADD CONSTRAINT cerefox_audit_log_operation_check CHECK (
56
+ operation IN ('create', 'update-content', 'update-metadata', 'delete',
57
+ 'status-change', 'archive', 'unarchive', 'restore',
58
+ 'relation-set', 'relation-delete')
59
+ );
60
+
61
+ -- Relations ship dormant: the MCP tools stay hidden until a deployment opts in.
62
+ INSERT INTO cerefox_config (key, value)
63
+ VALUES ('relations_enabled', 'false')
64
+ ON CONFLICT (key) DO NOTHING;
@@ -99,10 +99,13 @@ CREATE OR REPLACE FUNCTION cerefox_hybrid_search(
99
99
  p_query_text TEXT,
100
100
  p_query_embedding VECTOR(768),
101
101
  p_match_count INT DEFAULT 10,
102
- p_alpha FLOAT DEFAULT 0.7,
102
+ p_alpha FLOAT DEFAULT NULL,
103
103
  p_use_upgrade BOOLEAN DEFAULT FALSE,
104
104
  p_project_id UUID DEFAULT NULL,
105
- p_min_score FLOAT DEFAULT 0.0,
105
+ -- NULL means "not specified by the caller" (#133): resolve from
106
+ -- cerefox_config, else the built-in default. Callers that pass a value
107
+ -- still win — the chain is per-call > client .env > DB config > default.
108
+ p_min_score FLOAT DEFAULT NULL,
106
109
  p_metadata_filter JSONB DEFAULT NULL,
107
110
  -- 28I follow-up (v1.0.4): in OR-fallback mode, the unconditional FTS pass
108
111
  -- requires at least this fraction of the query's meaningful (non-stopword,
@@ -110,7 +113,7 @@ CREATE OR REPLACE FUNCTION cerefox_hybrid_search(
110
113
  -- meant 100% of terms — the pass this gate generalizes. Chunks below the
111
114
  -- bar can still pass via the vector threshold, else they are
112
115
  -- below-confidence material. 0 restores the pre-gate OR behavior.
113
- p_min_term_coverage FLOAT DEFAULT 0.5
116
+ p_min_term_coverage FLOAT DEFAULT NULL
114
117
  )
115
118
  RETURNS TABLE (
116
119
  chunk_id UUID,
@@ -164,6 +167,13 @@ DECLARE
164
167
  seen_tokens TEXT[] := '{}';
165
168
  total_tokens INT;
166
169
  candidate_count INT := p_match_count * 5;
170
+ -- #133 resolution: caller value, else deployment config, else built-in.
171
+ v_min_score FLOAT := COALESCE(p_min_score,
172
+ cerefox_config_float('min_search_score', 0.5));
173
+ v_alpha FLOAT := COALESCE(p_alpha,
174
+ cerefox_config_float('search_alpha', 0.7));
175
+ v_min_coverage FLOAT := COALESCE(p_min_term_coverage,
176
+ cerefox_config_float('min_term_coverage', 0.5));
167
177
  BEGIN
168
178
  -- Build the OR-composed query: plainto each whitespace token (so tokens get
169
179
  -- the same normalization/stemming as the AND path), skip stopword-only
@@ -217,7 +227,7 @@ BEGIN
217
227
  WHEN and_matches OR total_tokens = 0 THEN TRUE
218
228
  ELSE (SELECT COUNT(*) FROM unnest(tok_queries) tq
219
229
  WHERE c.fts @@ tq)::FLOAT
220
- >= p_min_term_coverage * total_tokens
230
+ >= v_min_coverage * total_tokens
221
231
  END AS coverage_ok
222
232
  FROM cerefox_chunks c
223
233
  JOIN cerefox_documents d ON c.document_id = d.id
@@ -261,8 +271,8 @@ BEGIN
261
271
  combined AS (
262
272
  SELECT
263
273
  COALESCE(f.id, v.id) AS id,
264
- ( p_alpha * COALESCE(v.vec_score, 0.0) +
265
- (1.0 - p_alpha) * COALESCE(f.fts_score, 0.0)
274
+ ( v_alpha * COALESCE(v.vec_score, 0.0) +
275
+ (1.0 - v_alpha) * COALESCE(f.fts_score, 0.0)
266
276
  ) AS score,
267
277
  COALESCE(v.vec_score, 0.0) AS vec_score,
268
278
  -- TRUE when the chunk matched the @@ FTS operator WITH enough
@@ -282,7 +292,7 @@ BEGIN
282
292
  -- results (no FTS match) are filtered by the cosine threshold.
283
293
  flagged AS (
284
294
  SELECT *,
285
- (combined.has_fts_match OR combined.vec_score >= p_min_score) AS passes
295
+ (combined.has_fts_match OR combined.vec_score >= v_min_score) AS passes
286
296
  FROM combined
287
297
  ),
288
298
  any_pass AS (SELECT bool_or(fl.passes) AS ok FROM flagged fl),
@@ -349,7 +359,7 @@ CREATE OR REPLACE FUNCTION cerefox_fts_search(
349
359
  p_metadata_filter JSONB DEFAULT NULL,
350
360
  -- v1.0.4: see cerefox_hybrid_search. In OR-fallback mode results must
351
361
  -- match at least this fraction of the query's meaningful terms.
352
- p_min_term_coverage FLOAT DEFAULT 0.5
362
+ p_min_term_coverage FLOAT DEFAULT NULL
353
363
  )
354
364
  RETURNS TABLE (
355
365
  chunk_id UUID,
@@ -383,6 +393,8 @@ DECLARE
383
393
  tok_queries tsquery[] := '{}';
384
394
  seen_tokens TEXT[] := '{}';
385
395
  total_tokens INT;
396
+ v_min_coverage FLOAT := COALESCE(p_min_term_coverage,
397
+ cerefox_config_float('min_term_coverage', 0.5));
386
398
  BEGIN
387
399
  FOR tok IN SELECT unnest(regexp_split_to_array(trim(p_query_text), '\s+')) LOOP
388
400
  tok_q := plainto_tsquery('english', tok);
@@ -443,7 +455,7 @@ BEGIN
443
455
  -- returns only chunks matching enough of the query's terms.
444
456
  AND (and_matches OR total_tokens = 0
445
457
  OR (SELECT COUNT(*) FROM unnest(tok_queries) tq
446
- WHERE c.fts @@ tq)::FLOAT >= p_min_term_coverage * total_tokens)
458
+ WHERE c.fts @@ tq)::FLOAT >= v_min_coverage * total_tokens)
447
459
  AND (p_project_id IS NULL OR EXISTS (
448
460
  SELECT 1 FROM cerefox_document_projects dp
449
461
  WHERE dp.document_id = d.id AND dp.project_id = p_project_id
@@ -749,13 +761,15 @@ CREATE OR REPLACE FUNCTION cerefox_search_docs(
749
761
  p_query_text TEXT,
750
762
  p_query_embedding VECTOR(768),
751
763
  p_match_count INT DEFAULT 5,
752
- p_alpha FLOAT DEFAULT 0.7,
764
+ p_alpha FLOAT DEFAULT NULL,
753
765
  p_project_id UUID DEFAULT NULL,
754
- p_min_score FLOAT DEFAULT 0.0,
766
+ p_min_score FLOAT DEFAULT NULL,
755
767
  p_small_to_big_threshold INT DEFAULT 20000,
756
768
  p_context_window INT DEFAULT 1,
757
769
  p_metadata_filter JSONB DEFAULT NULL,
758
- p_min_term_coverage FLOAT DEFAULT 0.5
770
+ -- NULL flows through to cerefox_hybrid_search, which resolves the
771
+ -- caller > cerefox_config > built-in chain in one place (#133).
772
+ p_min_term_coverage FLOAT DEFAULT NULL
759
773
  )
760
774
  RETURNS TABLE (
761
775
  document_id UUID,
@@ -1740,6 +1754,239 @@ BEGIN
1740
1754
  END;
1741
1755
  $$;
1742
1756
 
1757
+ -- ══ Document relations (iteration 29) ═════════════════════════════════════════
1758
+ -- Typed edges between documents. Design:
1759
+ -- docs/research/document-relations-and-semantic-graph.md
1760
+ --
1761
+ -- Type dictionary: rel_type is free text (any string is accepted and returned),
1762
+ -- but a few KNOWN types carry behaviour. Keeping the dictionary in one place
1763
+ -- here — rather than scattered CASE expressions — is what lets the set/delete
1764
+ -- RPCs stay symmetric with each other.
1765
+ -- symmetric both directions are written/removed together
1766
+ -- supersedes target becomes 'superseded'
1767
+ -- contradicts both documents become 'stale'
1768
+ CREATE OR REPLACE FUNCTION cerefox_relation_is_symmetric(p_rel_type TEXT)
1769
+ RETURNS BOOLEAN
1770
+ LANGUAGE sql
1771
+ IMMUTABLE
1772
+ SET search_path = public, pg_catalog
1773
+ AS $$
1774
+ SELECT p_rel_type IN ('related_to', 'contradicts', 'duplicates');
1775
+ $$;
1776
+
1777
+ -- Create (or update) a relation. Symmetric types write both directions in one
1778
+ -- transaction, so a half-written pair is impossible.
1779
+ CREATE OR REPLACE FUNCTION cerefox_set_relation(
1780
+ p_source_id UUID,
1781
+ p_target_id UUID,
1782
+ p_rel_type TEXT,
1783
+ p_author TEXT DEFAULT 'unknown',
1784
+ p_author_type TEXT DEFAULT 'agent',
1785
+ p_metadata JSONB DEFAULT '{}'::jsonb
1786
+ )
1787
+ RETURNS TABLE (
1788
+ relation_id UUID,
1789
+ source_id UUID,
1790
+ target_id UUID,
1791
+ rel_type TEXT,
1792
+ is_symmetric BOOLEAN
1793
+ )
1794
+ LANGUAGE plpgsql
1795
+ SECURITY DEFINER
1796
+ SET search_path = public, pg_catalog
1797
+ AS $$
1798
+ -- The OUT columns (source_id, target_id, rel_type) share names with the
1799
+ -- table's columns; inside the INSERT/ON CONFLICT below the COLUMN is meant.
1800
+ #variable_conflict use_column
1801
+ DECLARE
1802
+ v_symmetric BOOLEAN := cerefox_relation_is_symmetric(p_rel_type);
1803
+ v_id UUID;
1804
+ BEGIN
1805
+ IF p_source_id = p_target_id THEN
1806
+ RAISE EXCEPTION 'A document cannot relate to itself (%).', p_source_id
1807
+ USING ERRCODE = '22023';
1808
+ END IF;
1809
+ IF p_rel_type IS NULL OR btrim(p_rel_type) = '' THEN
1810
+ RAISE EXCEPTION 'rel_type is required.' USING ERRCODE = '22023';
1811
+ END IF;
1812
+ -- Explicit existence checks give a clear error instead of an FK violation.
1813
+ IF NOT EXISTS (SELECT 1 FROM cerefox_documents d
1814
+ WHERE d.id = p_source_id AND d.deleted_at IS NULL) THEN
1815
+ RAISE EXCEPTION 'Source document % not found (or deleted).', p_source_id
1816
+ USING ERRCODE = '22023';
1817
+ END IF;
1818
+ IF NOT EXISTS (SELECT 1 FROM cerefox_documents d
1819
+ WHERE d.id = p_target_id AND d.deleted_at IS NULL) THEN
1820
+ RAISE EXCEPTION 'Target document % not found (or deleted).', p_target_id
1821
+ USING ERRCODE = '22023';
1822
+ END IF;
1823
+
1824
+ INSERT INTO cerefox_document_relations AS r
1825
+ (source_id, target_id, rel_type, metadata, author, author_type)
1826
+ VALUES (p_source_id, p_target_id, btrim(p_rel_type), COALESCE(p_metadata, '{}'::jsonb),
1827
+ p_author, p_author_type)
1828
+ ON CONFLICT (source_id, target_id, rel_type) DO UPDATE
1829
+ SET metadata = EXCLUDED.metadata,
1830
+ author = EXCLUDED.author,
1831
+ author_type = EXCLUDED.author_type
1832
+ RETURNING r.id INTO v_id;
1833
+
1834
+ IF v_symmetric THEN
1835
+ INSERT INTO cerefox_document_relations
1836
+ (source_id, target_id, rel_type, metadata, author, author_type)
1837
+ VALUES (p_target_id, p_source_id, btrim(p_rel_type),
1838
+ COALESCE(p_metadata, '{}'::jsonb), p_author, p_author_type)
1839
+ ON CONFLICT (source_id, target_id, rel_type) DO UPDATE
1840
+ SET metadata = EXCLUDED.metadata,
1841
+ author = EXCLUDED.author,
1842
+ author_type = EXCLUDED.author_type;
1843
+ END IF;
1844
+
1845
+ -- Lifecycle side effects (type dictionary).
1846
+ IF btrim(p_rel_type) = 'supersedes' THEN
1847
+ UPDATE cerefox_documents SET lifecycle_status = 'superseded'
1848
+ WHERE id = p_target_id;
1849
+ ELSIF btrim(p_rel_type) = 'contradicts' THEN
1850
+ UPDATE cerefox_documents SET lifecycle_status = 'stale'
1851
+ WHERE id IN (p_source_id, p_target_id);
1852
+ END IF;
1853
+
1854
+ INSERT INTO cerefox_audit_log (document_id, operation, author, author_type, description)
1855
+ VALUES (p_source_id, 'relation-set', p_author, p_author_type,
1856
+ format('%s → %s (%s)', p_source_id, p_target_id, btrim(p_rel_type)));
1857
+
1858
+ RETURN QUERY SELECT v_id, p_source_id, p_target_id, btrim(p_rel_type), v_symmetric;
1859
+ END;
1860
+ $$;
1861
+
1862
+ -- Remove a relation. Symmetric types remove both directions. Lifecycle side
1863
+ -- effects are NOT auto-reverted: a document marked superseded may have been
1864
+ -- superseded by something else too, and guessing wrong is worse than leaving
1865
+ -- the operator to set it explicitly.
1866
+ CREATE OR REPLACE FUNCTION cerefox_delete_relation(
1867
+ p_source_id UUID,
1868
+ p_target_id UUID,
1869
+ p_rel_type TEXT,
1870
+ p_author TEXT DEFAULT 'unknown',
1871
+ p_author_type TEXT DEFAULT 'agent'
1872
+ )
1873
+ RETURNS INT
1874
+ LANGUAGE plpgsql
1875
+ SECURITY DEFINER
1876
+ SET search_path = public, pg_catalog
1877
+ AS $$
1878
+ DECLARE
1879
+ v_deleted INT := 0;
1880
+ v_n INT;
1881
+ BEGIN
1882
+ DELETE FROM cerefox_document_relations
1883
+ WHERE source_id = p_source_id AND target_id = p_target_id
1884
+ AND rel_type = btrim(p_rel_type);
1885
+ GET DIAGNOSTICS v_n = ROW_COUNT; v_deleted := v_deleted + v_n;
1886
+
1887
+ IF cerefox_relation_is_symmetric(p_rel_type) THEN
1888
+ DELETE FROM cerefox_document_relations
1889
+ WHERE source_id = p_target_id AND target_id = p_source_id
1890
+ AND rel_type = btrim(p_rel_type);
1891
+ GET DIAGNOSTICS v_n = ROW_COUNT; v_deleted := v_deleted + v_n;
1892
+ END IF;
1893
+
1894
+ IF v_deleted > 0 THEN
1895
+ INSERT INTO cerefox_audit_log (document_id, operation, author, author_type, description)
1896
+ VALUES (p_source_id, 'relation-delete', p_author, p_author_type,
1897
+ format('%s → %s (%s)', p_source_id, p_target_id, btrim(p_rel_type)));
1898
+ END IF;
1899
+ RETURN v_deleted;
1900
+ END;
1901
+ $$;
1902
+
1903
+ -- All edges touching a document, in both directions. `direction` tells the
1904
+ -- caller which way each edge points relative to the document asked about.
1905
+ CREATE OR REPLACE FUNCTION cerefox_get_relations(p_document_id UUID)
1906
+ RETURNS TABLE (
1907
+ relation_id UUID,
1908
+ direction TEXT,
1909
+ rel_type TEXT,
1910
+ other_id UUID,
1911
+ other_title TEXT,
1912
+ other_lifecycle TEXT,
1913
+ metadata JSONB,
1914
+ author TEXT,
1915
+ created_at TIMESTAMPTZ
1916
+ )
1917
+ LANGUAGE sql
1918
+ SECURITY DEFINER
1919
+ STABLE
1920
+ SET search_path = public, pg_catalog
1921
+ AS $$
1922
+ SELECT r.id, 'outbound'::TEXT, r.rel_type, r.target_id, d.title,
1923
+ d.lifecycle_status, r.metadata, r.author, r.created_at
1924
+ FROM cerefox_document_relations r
1925
+ JOIN cerefox_documents d ON d.id = r.target_id
1926
+ WHERE r.source_id = p_document_id AND d.deleted_at IS NULL
1927
+ UNION ALL
1928
+ SELECT r.id, 'inbound'::TEXT, r.rel_type, r.source_id, d.title,
1929
+ d.lifecycle_status, r.metadata, r.author, r.created_at
1930
+ FROM cerefox_document_relations r
1931
+ JOIN cerefox_documents d ON d.id = r.source_id
1932
+ WHERE r.target_id = p_document_id AND d.deleted_at IS NULL
1933
+ ORDER BY 3, 9 DESC;
1934
+ $$;
1935
+
1936
+ -- Walk the graph from a document along ONE relation type. Depth > 1 follows
1937
+ -- chains (meaningful for e.g. `follows` / `reply_to`); a visited-set stops
1938
+ -- cycles, which free-text types make possible.
1939
+ CREATE OR REPLACE FUNCTION cerefox_get_neighbors(
1940
+ p_document_id UUID,
1941
+ p_rel_type TEXT,
1942
+ p_depth INT DEFAULT 1,
1943
+ p_from_time TIMESTAMPTZ DEFAULT NULL,
1944
+ p_to_time TIMESTAMPTZ DEFAULT NULL,
1945
+ p_limit INT DEFAULT 50
1946
+ )
1947
+ RETURNS TABLE (
1948
+ document_id UUID,
1949
+ title TEXT,
1950
+ lifecycle_status TEXT,
1951
+ depth INT,
1952
+ direction TEXT,
1953
+ doc_created_at TIMESTAMPTZ
1954
+ )
1955
+ LANGUAGE sql
1956
+ SECURITY DEFINER
1957
+ STABLE
1958
+ SET search_path = public, pg_catalog
1959
+ AS $$
1960
+ WITH RECURSIVE walk AS (
1961
+ SELECT p_document_id AS id, 0 AS depth, 'self'::TEXT AS direction,
1962
+ ARRAY[p_document_id] AS seen
1963
+ UNION ALL
1964
+ SELECT nxt.id, w.depth + 1, nxt.direction, w.seen || nxt.id
1965
+ FROM walk w
1966
+ CROSS JOIN LATERAL (
1967
+ SELECT r.target_id AS id, 'outbound'::TEXT AS direction
1968
+ FROM cerefox_document_relations r
1969
+ WHERE r.source_id = w.id AND r.rel_type = btrim(p_rel_type)
1970
+ UNION ALL
1971
+ SELECT r.source_id, 'inbound'::TEXT
1972
+ FROM cerefox_document_relations r
1973
+ WHERE r.target_id = w.id AND r.rel_type = btrim(p_rel_type)
1974
+ ) nxt
1975
+ WHERE w.depth < GREATEST(p_depth, 1)
1976
+ AND NOT (nxt.id = ANY(w.seen)) -- cycle guard
1977
+ )
1978
+ SELECT DISTINCT ON (w.id)
1979
+ w.id, d.title, d.lifecycle_status, w.depth, w.direction, d.created_at
1980
+ FROM walk w
1981
+ JOIN cerefox_documents d ON d.id = w.id
1982
+ WHERE w.depth > 0
1983
+ AND d.deleted_at IS NULL
1984
+ AND (p_from_time IS NULL OR d.created_at >= p_from_time)
1985
+ AND (p_to_time IS NULL OR d.created_at <= p_to_time)
1986
+ ORDER BY w.id, w.depth
1987
+ LIMIT GREATEST(p_limit, 1);
1988
+ $$;
1989
+
1743
1990
  -- ── cerefox_get_config / cerefox_set_config ──────────────────────────────────
1744
1991
  -- Read/write key-value config from cerefox_config table.
1745
1992
 
@@ -1753,6 +2000,31 @@ AS $$
1753
2000
  SELECT value FROM cerefox_config WHERE key = p_key;
1754
2001
  $$;
1755
2002
 
2003
+ -- Numeric config reader with a fallback (v1.1.0, #133). Returns p_fallback
2004
+ -- when the key is unset or unparseable, so a malformed row can never break
2005
+ -- search — it just reverts to the built-in default.
2006
+ CREATE OR REPLACE FUNCTION cerefox_config_float(p_key TEXT, p_fallback FLOAT)
2007
+ RETURNS FLOAT
2008
+ LANGUAGE plpgsql
2009
+ SECURITY DEFINER
2010
+ STABLE
2011
+ SET search_path = public, pg_catalog
2012
+ AS $$
2013
+ DECLARE
2014
+ v_raw TEXT;
2015
+ v_num FLOAT;
2016
+ BEGIN
2017
+ SELECT value INTO v_raw FROM cerefox_config WHERE key = p_key;
2018
+ IF v_raw IS NULL OR v_raw = '' THEN RETURN p_fallback; END IF;
2019
+ BEGIN
2020
+ v_num := v_raw::FLOAT;
2021
+ EXCEPTION WHEN others THEN
2022
+ RETURN p_fallback;
2023
+ END;
2024
+ RETURN v_num;
2025
+ END;
2026
+ $$;
2027
+
1756
2028
  CREATE OR REPLACE FUNCTION cerefox_set_config(p_key TEXT, p_value TEXT)
1757
2029
  RETURNS VOID
1758
2030
  LANGUAGE plpgsql
@@ -1760,7 +2032,15 @@ SECURITY DEFINER
1760
2032
  SET search_path = public, pg_catalog
1761
2033
  AS $$
1762
2034
  DECLARE
1763
- v_allowed TEXT[] := ARRAY['usage_tracking_enabled', 'require_requestor_identity', 'requestor_identity_format'];
2035
+ -- Retrieval tunables (#133) join the governance keys: setting one here
2036
+ -- governs EVERY access path (CLI, local + remote MCP, Edge Functions, web),
2037
+ -- because they all resolve through these RPCs.
2038
+ v_allowed TEXT[] := ARRAY[
2039
+ 'usage_tracking_enabled', 'require_requestor_identity', 'requestor_identity_format',
2040
+ 'min_search_score', 'min_term_coverage', 'search_alpha',
2041
+ -- Optional features, off by default (iteration 29).
2042
+ 'relations_enabled'
2043
+ ];
1764
2044
  BEGIN
1765
2045
  IF NOT (p_key = ANY(v_allowed)) THEN
1766
2046
  RAISE EXCEPTION 'Unknown config key: %. Allowed keys: %', p_key, v_allowed;
@@ -1954,7 +2234,7 @@ SET search_path = public, pg_catalog
1954
2234
  AS $$
1955
2235
  -- Keep in lockstep with the `@version:` marker in schema.sql (cut_release.ts
1956
2236
  -- enforces it). Bump whenever schema.sql OR rpcs.sql changes.
1957
- SELECT '0.9.2'::TEXT;
2237
+ SELECT '0.10.1'::TEXT;
1958
2238
  $$;
1959
2239
 
1960
2240
  -- ── 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.9.2
8
+ -- @version: 0.10.1
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 —
@@ -49,6 +49,11 @@ CREATE TABLE IF NOT EXISTS cerefox_documents (
49
49
  -- 'pending_review' = modified by agent, not yet reviewed.
50
50
  -- Content is searchable in both states.
51
51
  review_status TEXT NOT NULL DEFAULT 'approved',
52
+ -- lifecycle_status: where this document stands relative to the graph —
53
+ -- 'active' | 'superseded' | 'stale' | 'archived'. Distinct from
54
+ -- review_status (editorial state) and deleted_at (existence). Maintained by
55
+ -- the relation RPCs (e.g. `supersedes` marks its target superseded).
56
+ lifecycle_status TEXT NOT NULL DEFAULT 'active',
52
57
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
53
58
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
54
59
  -- Soft delete: NULL = active, timestamp = deleted (recoverable).
@@ -118,7 +123,9 @@ CREATE TABLE IF NOT EXISTS cerefox_audit_log (
118
123
 
119
124
  CONSTRAINT cerefox_audit_log_operation_check CHECK (
120
125
  operation IN ('create', 'update-content', 'update-metadata', 'delete',
121
- 'status-change', 'archive', 'unarchive', 'restore')
126
+ 'status-change', 'archive', 'unarchive', 'restore',
127
+ -- iteration 29: graph edges are auditable writes too
128
+ 'relation-set', 'relation-delete')
122
129
  ),
123
130
  CONSTRAINT cerefox_audit_log_author_type_check CHECK (author_type IN ('user', 'agent'))
124
131
  );
@@ -132,6 +139,32 @@ CREATE TABLE IF NOT EXISTS cerefox_document_projects (
132
139
  PRIMARY KEY (document_id, project_id)
133
140
  );
134
141
 
142
+ -- ── Document relations (iteration 29) ─────────────────────────────────────────
143
+ -- Typed, directed edges between documents. rel_type is free text by design so
144
+ -- agents can define new types without a migration; the type dictionary lives in
145
+ -- the RPCs and gives KNOWN types behaviour (symmetry, lifecycle side effects).
146
+ -- Design: docs/research/document-relations-and-semantic-graph.md §2.2.
147
+
148
+ CREATE TABLE IF NOT EXISTS cerefox_document_relations (
149
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
150
+ source_id UUID NOT NULL REFERENCES cerefox_documents(id) ON DELETE CASCADE,
151
+ target_id UUID NOT NULL REFERENCES cerefox_documents(id) ON DELETE CASCADE,
152
+ rel_type TEXT NOT NULL,
153
+ metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
154
+ author TEXT NOT NULL DEFAULT 'unknown',
155
+ author_type TEXT NOT NULL DEFAULT 'agent',
156
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
157
+ UNIQUE (source_id, target_id, rel_type),
158
+ CONSTRAINT cerefox_relations_no_self_edge CHECK (source_id <> target_id)
159
+ );
160
+
161
+ CREATE INDEX IF NOT EXISTS idx_cerefox_relations_source ON cerefox_document_relations(source_id);
162
+ CREATE INDEX IF NOT EXISTS idx_cerefox_relations_target ON cerefox_document_relations(target_id);
163
+ CREATE INDEX IF NOT EXISTS idx_cerefox_relations_type ON cerefox_document_relations(rel_type);
164
+ CREATE INDEX IF NOT EXISTS idx_cerefox_docs_lifecycle
165
+ ON cerefox_documents(lifecycle_status)
166
+ WHERE lifecycle_status <> 'active';
167
+
135
168
  -- ── Chunks ────────────────────────────────────────────────────────────────────
136
169
  -- One row per chunk of a document. Embeddings and FTS live here.
137
170
  --
@@ -335,6 +368,11 @@ ON CONFLICT (key) DO NOTHING;
335
368
  INSERT INTO cerefox_config (key, value)
336
369
  VALUES ('requestor_identity_format', '^[a-zA-Z0-9_:.\- ]+$')
337
370
  ON CONFLICT (key) DO NOTHING;
371
+ -- Document relations ship dormant: the tools stay hidden from agents until a
372
+ -- deployment opts in (iteration 29).
373
+ INSERT INTO cerefox_config (key, value)
374
+ VALUES ('relations_enabled', 'false')
375
+ ON CONFLICT (key) DO NOTHING;
338
376
 
339
377
 
340
378
  -- ── Usage log ────────────────────────────────────────────────────────────────
@@ -34,12 +34,15 @@ import {
34
34
  unauthorizedChallenge,
35
35
  } from "./oauth.ts";
36
36
  import type { AuthResult, McpAuthenticator } from "../../../_shared/mcp-auth/index.ts";
37
+ import type { MCPSupabaseClient } from "../../../_shared/mcp-tools/types.ts";
37
38
  import { checkAccessToken, parseAccessTokens } from "../../../_shared/ef-auth/index.ts";
38
39
  import {
39
40
  ALL_TOOLS,
40
41
  McpInvalidParams,
41
42
  TOOLS_BY_NAME,
42
43
  type ToolContext,
44
+ assertToolEnabled,
45
+ listEnabledTools,
43
46
  } from "../../../_shared/mcp-tools/index.ts";
44
47
  import {
45
48
  type AggregatedVersions,
@@ -57,11 +60,16 @@ const SERVER_VERSION = "0.4.0";
57
60
 
58
61
  // ── Tool list (derived from _shared/mcp-tools/) ─────────────────────────────
59
62
 
60
- const TOOLS = ALL_TOOLS.map((t) => ({
61
- name: t.name,
62
- description: t.description,
63
- inputSchema: t.inputSchema,
64
- }));
63
+ // Built per request rather than at module load: the tool surface depends on
64
+ // deployment config (optional features are hidden until enabled), and an
65
+ // isolate can outlive a config change.
66
+ async function buildToolList(supabase: MCPSupabaseClient) {
67
+ return (await listEnabledTools(supabase)).map((t) => ({
68
+ name: t.name,
69
+ description: t.description,
70
+ inputSchema: t.inputSchema,
71
+ }));
72
+ }
65
73
 
66
74
  // ── Method handlers ──────────────────────────────────────────────────────────
67
75
 
@@ -77,8 +85,8 @@ function handleInitialize(id: unknown): Response {
77
85
  });
78
86
  }
79
87
 
80
- function handleToolsList(id: unknown): Response {
81
- return jsonResponse({ jsonrpc: "2.0", id, result: { tools: TOOLS } });
88
+ async function handleToolsList(id: unknown, supabase: MCPSupabaseClient): Promise<Response> {
89
+ return jsonResponse({ jsonrpc: "2.0", id, result: { tools: await buildToolList(supabase) } });
82
90
  }
83
91
 
84
92
  async function handleToolsCall(
@@ -105,6 +113,14 @@ async function handleToolsCall(
105
113
  // deno-lint-ignore no-explicit-any
106
114
  const supabase: any = makeSupabaseClient();
107
115
 
116
+ // Optional features: a session that listed tools before the flag changed can
117
+ // still name a gated tool.
118
+ try {
119
+ await assertToolEnabled(supabase, toolName);
120
+ } catch (err) {
121
+ return errorResponse(id, -32602, err instanceof Error ? err.message : String(err));
122
+ }
123
+
108
124
  try {
109
125
  const { data: requireConfig } = await supabase.rpc("cerefox_get_config", {
110
126
  p_key: "require_requestor_identity",
@@ -336,7 +352,8 @@ Deno.serve(async (req: Request): Promise<Response> => {
336
352
  case "ping":
337
353
  return jsonResponse({ jsonrpc: "2.0", id, result: {} });
338
354
  case "tools/list":
339
- return handleToolsList(id);
355
+ // deno-lint-ignore no-explicit-any
356
+ return await handleToolsList(id, makeSupabaseClient() as any);
340
357
  case "tools/call":
341
358
  return await handleToolsCall(
342
359
  id,
@@ -544,42 +544,6 @@ Detects fresh vs. existing databases: a fresh DB gets schema + RPCs + migration
544
544
  | `--all` | flag | off | Reindex every chunk regardless of embedder. |
545
545
  | `--dry-run` | flag | off | Count what would be re-embedded; do nothing. |
546
546
 
547
- > **Reindex refreshes embeddings only.** It rewrites `embedding_primary` on
548
- > existing chunk rows and never re-chunks, so it cannot change a document's
549
- > stored `content_format`. Use `server migrate-format` for that (#164).
550
-
551
- ---
552
-
553
- ### `cerefox server migrate-format`
554
-
555
- **Purpose**: convert documents still stored under the legacy chunk-reconstruction
556
- format to the current one, by re-ingesting them through the normal pipeline
557
- (re-chunk → re-embed → stamp the current format). `cerefox doctor` reports how
558
- many documents are affected.
559
-
560
- **Synopsis**: `cerefox server migrate-format [OPTIONS]`
561
-
562
- **Options**:
563
-
564
- | Flag | Type | Default | Description |
565
- |---|---|---|---|
566
- | `--dry-run` | flag | off | Report how many documents would convert; write nothing. |
567
- | `-l, --limit <n>` | int | all | Convert at most N documents (re-run to continue). |
568
- | `--document-id <uuid>` | string | — | Convert a single document. |
569
- | `--author <name>` | string | `CEREFOX_AUTHOR_NAME` | Recorded in the audit log per conversion. |
570
-
571
- **This costs embedding spend** — every converted document is re-embedded — so it
572
- is opt-in and never runs automatically. Legacy-format documents are *not*
573
- broken: they reconstruct exactly as they always have, and convert on their own
574
- the next time they are edited. Each conversion runs under optimistic
575
- concurrency, so a document edited mid-run is skipped rather than overwritten,
576
- and documents whose content is byte-identical to another document cannot be
577
- converted (the ingestion pipeline's dedup check rejects them) — those are
578
- reported rather than failing the run.
579
-
580
- **Take a backup first** (`cerefox backup create`). The previous chunks are
581
- archived as a document version, but version retention defaults to 48 hours.
582
-
583
547
  ---
584
548
 
585
549
  ### `cerefox token generate` / `cerefox token rotate` / `cerefox token list`