@cerefox/memory 1.7.1 → 1.9.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.
@@ -598,63 +598,18 @@ AS $$
598
598
  GROUP BY d.id, d.title, d.source, d.metadata;
599
599
  $$;
600
600
 
601
- -- ── cerefox_save_note ─────────────────────────────────────────────────────────
602
- -- Agent write tool: create a minimal document record for a short text note.
603
- -- Embedding and chunking are NOT done server-side in V1 the Python ingestion
604
- -- pipeline should be used for full ingest. This RPC is intended for quick
605
- -- one-shot note capture from AI agents that want to store something immediately.
601
+ -- ── retired V1 RPC (dropped in 0.14.0) ──────────────────────────────────────
602
+ -- cerefox_save_note was a Python-era V1 tool with zero callers since the TS
603
+ -- rewrite nothing in the CLI, MCP tools, Edge Functions, web app, OR the
604
+ -- SQL layer ever called it. The DROP stays here so re-applying rpcs.sql also
605
+ -- cleans long-lived databases (migration 0028 carries the same).
606
606
  --
607
- -- Parameters:
608
- -- p_title : Note title (required)
609
- -- p_content : Markdown content (required)
610
- -- p_source : Origin label, e.g. 'agent' (default: 'agent')
611
- -- p_project_id : Optional project UUID (assigns to a single project)
612
- -- p_metadata : Optional JSONB metadata (e.g. agent name, session id)
613
- --
614
- -- Returns: the created document row (id, title, created_at)
615
-
616
- CREATE OR REPLACE FUNCTION cerefox_save_note(
617
- p_title TEXT,
618
- p_content TEXT,
619
- p_source TEXT DEFAULT 'agent',
620
- p_project_id UUID DEFAULT NULL,
621
- p_metadata JSONB DEFAULT '{}'::JSONB
622
- )
623
- RETURNS TABLE (
624
- id UUID,
625
- title TEXT,
626
- created_at TIMESTAMPTZ
627
- )
628
- LANGUAGE plpgsql
629
- SECURITY DEFINER
630
- SET search_path = public, pg_catalog
631
- AS $$
632
- DECLARE
633
- v_hash TEXT;
634
- v_doc_id UUID;
635
- v_created_at TIMESTAMPTZ;
636
- BEGIN
637
- -- Compute content hash to support deduplication on the caller side.
638
- v_hash := encode(sha256(p_content::BYTEA), 'hex');
639
-
640
- INSERT INTO cerefox_documents (
641
- title, source, content_hash, metadata, chunk_count, total_chars
642
- ) VALUES (
643
- p_title, p_source, v_hash, p_metadata, 0, length(p_content)
644
- )
645
- RETURNING cerefox_documents.id, cerefox_documents.created_at
646
- INTO v_doc_id, v_created_at;
647
-
648
- -- Assign to project if provided (many-to-many junction).
649
- IF p_project_id IS NOT NULL THEN
650
- INSERT INTO cerefox_document_projects (document_id, project_id)
651
- VALUES (v_doc_id, p_project_id)
652
- ON CONFLICT DO NOTHING;
653
- END IF;
654
-
655
- RETURN QUERY SELECT v_doc_id, p_title, v_created_at;
656
- END;
657
- $$;
607
+ -- cerefox_context_expand (below) looked like the same class but is NOT dead:
608
+ -- cerefox_search_docs calls it for small-to-big retrieval. TS-side grep alone
609
+ -- cannot establish an RPC is unused — SQL functions have SQL callers. It was
610
+ -- briefly slated for removal in this release; the sandbox validation caught
611
+ -- the breakage (42883 from search_docs) before anything shipped.
612
+ DROP FUNCTION IF EXISTS cerefox_save_note(TEXT, TEXT, TEXT, UUID, JSONB);
658
613
 
659
614
  -- ── cerefox_context_expand ────────────────────────────────────────────────────
660
615
  -- Small-to-big retrieval: given a set of chunk IDs from a search result,
@@ -1009,9 +964,19 @@ BEGIN
1009
964
  )
1010
965
  RETURNING id INTO v_version_id;
1011
966
 
1012
- -- Archive all current chunks by pointing them at the new version
967
+ -- Archive all current chunks by pointing them at the new version, and
968
+ -- NULL their search artifacts in the same write (0.13.0, #216 — full
969
+ -- rationale in migration 0027). The content — the actual safety copy —
970
+ -- is untouched. embedder_upgrade is nulled with its vector;
971
+ -- embedder_primary is deliberately KEPT (it is NOT NULL, and the label
972
+ -- is harmless provenance for a vector that no longer exists — nothing
973
+ -- reads embedder columns without a version_id IS NULL filter).
1013
974
  UPDATE cerefox_chunks c
1014
- SET version_id = v_version_id
975
+ SET version_id = v_version_id,
976
+ embedding_primary = NULL,
977
+ embedding_upgrade = NULL,
978
+ embedder_upgrade = NULL,
979
+ fts = NULL
1015
980
  WHERE c.document_id = p_document_id
1016
981
  AND c.version_id IS NULL;
1017
982
 
@@ -1248,7 +1213,7 @@ $$;
1248
1213
  DROP FUNCTION IF EXISTS cerefox_restore_document(UUID, TEXT, TEXT, TEXT);
1249
1214
  DROP FUNCTION IF EXISTS cerefox_restore_document(UUID, TEXT, TEXT);
1250
1215
  -- 0.12.1: the pre-author 1-arg overload survived every CREATE OR REPLACE
1251
- -- since the signature grew (same orphan class as purge; found live on prod).
1216
+ -- since the signature grew (same orphan class as purge; found live on a long-lived database).
1252
1217
  DROP FUNCTION IF EXISTS cerefox_restore_document(UUID);
1253
1218
  CREATE FUNCTION cerefox_restore_document(
1254
1219
  p_document_id UUID,
@@ -1829,9 +1794,9 @@ $$;
1829
1794
  -- Reads chunk title and content directly from the DB -- caller only needs to
1830
1795
  -- supply the new document title.
1831
1796
  --
1832
- -- Only affects current chunks (version_id IS NULL). Archived chunks retain their
1833
- -- original tsvectors (they are excluded from all search indexes and require
1834
- -- re-ingestion to restore anyway).
1797
+ -- Only affects current chunks (version_id IS NULL). Archived chunks carry NO
1798
+ -- tsvector at all since 0.13.0 (#216) fts is nulled at archive time, and a
1799
+ -- restore is a re-ingest that recomputes everything.
1835
1800
 
1836
1801
  DROP FUNCTION IF EXISTS cerefox_update_chunk_fts(UUID, TEXT);
1837
1802
  CREATE FUNCTION cerefox_update_chunk_fts(
@@ -2010,6 +1975,211 @@ AS $$
2010
1975
  ORDER BY p.name;
2011
1976
  $$;
2012
1977
 
1978
+ -- ── Project write RPCs (0.14.0, #147/#219) ──────────────────────────────────
1979
+ -- Project writes carried no business logic until the audit trail arrived —
1980
+ -- then every caller (CLI, web, shared helpers, ingestion pipeline, the ingest
1981
+ -- EF) grew its own follow-up cerefox_create_audit_entry call: non-atomic,
1982
+ -- warn-on-failure, and empirically forgettable (review round 1 caught two
1983
+ -- unwired paths in the release that introduced the convention). Single
1984
+ -- Implementation Principle applies the moment a write has a side effect: the
1985
+ -- write AND its audit entry live here, in one transaction. Callers are thin.
1986
+
1987
+ -- Return shape changed in review round 4 (full row: the web routes were
1988
+ -- re-reading the row the RPC had just written). A changed RETURNS TABLE
1989
+ -- cannot go through CREATE OR REPLACE — drop first (house pattern).
1990
+ DROP FUNCTION IF EXISTS cerefox_create_project(TEXT, TEXT, TEXT, TEXT, TEXT);
1991
+
1992
+ CREATE FUNCTION cerefox_create_project(
1993
+ p_name TEXT,
1994
+ p_description TEXT DEFAULT '',
1995
+ p_author TEXT DEFAULT 'unknown',
1996
+ p_author_type TEXT DEFAULT 'user',
1997
+ -- 'error' → explicit creation (CLI/web): duplicate name raises.
1998
+ -- 'return' → get-or-create (implicit creation during document
1999
+ -- assignment): an existing project is returned untouched and
2000
+ -- NOT audited — only an actual create writes an entry.
2001
+ p_if_exists TEXT DEFAULT 'error'
2002
+ )
2003
+ RETURNS TABLE (
2004
+ project_id UUID,
2005
+ project_name TEXT,
2006
+ project_description TEXT,
2007
+ created BOOLEAN,
2008
+ created_at TIMESTAMPTZ,
2009
+ updated_at TIMESTAMPTZ
2010
+ )
2011
+ LANGUAGE plpgsql
2012
+ SECURITY DEFINER
2013
+ SET search_path = public, pg_catalog
2014
+ AS $$
2015
+ DECLARE
2016
+ v_row cerefox_projects%ROWTYPE;
2017
+ BEGIN
2018
+ IF NULLIF(BTRIM(p_name), '') IS NULL THEN
2019
+ RAISE EXCEPTION 'Project name is required' USING ERRCODE = '22023';
2020
+ END IF;
2021
+ IF p_if_exists NOT IN ('error', 'return') THEN
2022
+ RAISE EXCEPTION 'p_if_exists must be ''error'' or ''return''' USING ERRCODE = '22023';
2023
+ END IF;
2024
+
2025
+ IF p_if_exists = 'return' THEN
2026
+ -- Case-insensitive, matching the name resolution the assignment
2027
+ -- paths have always used. (A case-colliding pair created directly is
2028
+ -- pre-existing behavior: the unique constraint is exact-match; this
2029
+ -- resolver returns the first match.)
2030
+ SELECT p.* INTO v_row
2031
+ FROM cerefox_projects p WHERE lower(p.name) = lower(BTRIM(p_name)) LIMIT 1;
2032
+ IF v_row.id IS NOT NULL THEN
2033
+ RETURN QUERY SELECT v_row.id, v_row.name, v_row.description, FALSE,
2034
+ v_row.created_at, v_row.updated_at;
2035
+ RETURN;
2036
+ END IF;
2037
+ END IF;
2038
+
2039
+ BEGIN
2040
+ INSERT INTO cerefox_projects (name, description)
2041
+ VALUES (BTRIM(p_name), COALESCE(p_description, ''))
2042
+ RETURNING * INTO v_row;
2043
+ EXCEPTION WHEN unique_violation THEN
2044
+ -- TOCTOU (round 4): a concurrent create won the race between the
2045
+ -- resolve above and this insert. In 'return' mode that is exactly
2046
+ -- the get-or-create contract — hand back the winner, no audit entry
2047
+ -- (this call created nothing). In 'error' mode a duplicate is the
2048
+ -- caller's error, exactly as if there had been no race.
2049
+ IF p_if_exists = 'return' THEN
2050
+ SELECT p.* INTO v_row
2051
+ FROM cerefox_projects p WHERE lower(p.name) = lower(BTRIM(p_name)) LIMIT 1;
2052
+ IF v_row.id IS NOT NULL THEN
2053
+ RETURN QUERY SELECT v_row.id, v_row.name, v_row.description, FALSE,
2054
+ v_row.created_at, v_row.updated_at;
2055
+ RETURN;
2056
+ END IF;
2057
+ END IF;
2058
+ RAISE;
2059
+ END;
2060
+
2061
+ PERFORM cerefox_create_audit_entry(
2062
+ p_operation := 'project-create',
2063
+ p_author := p_author,
2064
+ p_author_type := p_author_type,
2065
+ p_description := 'Project ''' || v_row.name || ''' created'
2066
+ || CASE WHEN p_if_exists = 'return'
2067
+ THEN ' implicitly (document assignment)' ELSE '' END
2068
+ );
2069
+ RETURN QUERY SELECT v_row.id, v_row.name, v_row.description, TRUE,
2070
+ v_row.created_at, v_row.updated_at;
2071
+ END;
2072
+ $$;
2073
+
2074
+ DROP FUNCTION IF EXISTS cerefox_update_project(UUID, TEXT, TEXT, TEXT, TEXT);
2075
+
2076
+ CREATE FUNCTION cerefox_update_project(
2077
+ p_project_id UUID,
2078
+ -- NULL = keep the current value (the #191/#204 "NULL means not provided"
2079
+ -- convention). An explicit empty name is rejected.
2080
+ p_name TEXT DEFAULT NULL,
2081
+ p_description TEXT DEFAULT NULL,
2082
+ p_author TEXT DEFAULT 'unknown',
2083
+ p_author_type TEXT DEFAULT 'user'
2084
+ )
2085
+ RETURNS TABLE (
2086
+ project_id UUID,
2087
+ project_name TEXT,
2088
+ project_description TEXT,
2089
+ created_at TIMESTAMPTZ,
2090
+ updated_at TIMESTAMPTZ
2091
+ )
2092
+ LANGUAGE plpgsql
2093
+ SECURITY DEFINER
2094
+ SET search_path = public, pg_catalog
2095
+ AS $$
2096
+ DECLARE
2097
+ v_old cerefox_projects%ROWTYPE;
2098
+ v_new cerefox_projects%ROWTYPE;
2099
+ v_changes TEXT[] := '{}';
2100
+ BEGIN
2101
+ IF p_name IS NULL AND p_description IS NULL THEN
2102
+ RAISE EXCEPTION 'Nothing to update: pass p_name and/or p_description' USING ERRCODE = '22023';
2103
+ END IF;
2104
+ IF p_name IS NOT NULL AND NULLIF(BTRIM(p_name), '') IS NULL THEN
2105
+ RAISE EXCEPTION 'Project name cannot be empty' USING ERRCODE = '22023';
2106
+ END IF;
2107
+
2108
+ SELECT p.* INTO v_old FROM cerefox_projects p WHERE p.id = p_project_id FOR UPDATE;
2109
+ IF NOT FOUND THEN
2110
+ RAISE EXCEPTION 'Project not found: %', p_project_id USING ERRCODE = '22023';
2111
+ END IF;
2112
+
2113
+ UPDATE cerefox_projects SET
2114
+ name = COALESCE(BTRIM(p_name), name),
2115
+ description = COALESCE(p_description, description),
2116
+ updated_at = NOW()
2117
+ WHERE id = p_project_id
2118
+ RETURNING * INTO v_new;
2119
+
2120
+ -- The trail records what actually changed, not which arguments arrived.
2121
+ IF v_new.name <> v_old.name THEN
2122
+ v_changes := v_changes || ('renamed ''' || v_old.name || ''' → ''' || v_new.name || '''');
2123
+ END IF;
2124
+ IF COALESCE(v_new.description, '') <> COALESCE(v_old.description, '') THEN
2125
+ v_changes := v_changes || 'description changed';
2126
+ END IF;
2127
+
2128
+ PERFORM cerefox_create_audit_entry(
2129
+ p_operation := 'project-edit',
2130
+ p_author := p_author,
2131
+ p_author_type := p_author_type,
2132
+ p_description := 'Project ''' || v_new.name || ''' edited ('
2133
+ || COALESCE(NULLIF(array_to_string(v_changes, '; '), ''), 'no-op') || ')'
2134
+ );
2135
+ RETURN QUERY SELECT v_new.id, v_new.name, v_new.description,
2136
+ v_new.created_at, v_new.updated_at;
2137
+ END;
2138
+ $$;
2139
+
2140
+ CREATE OR REPLACE FUNCTION cerefox_delete_project(
2141
+ p_project_id UUID,
2142
+ p_author TEXT DEFAULT 'unknown',
2143
+ p_author_type TEXT DEFAULT 'user'
2144
+ )
2145
+ RETURNS TABLE (deleted BOOLEAN, project_name TEXT)
2146
+ LANGUAGE plpgsql
2147
+ SECURITY DEFINER
2148
+ SET search_path = public, pg_catalog
2149
+ AS $$
2150
+ DECLARE
2151
+ v_name TEXT;
2152
+ v_links INT;
2153
+ BEGIN
2154
+ -- Lock + read name and link count in one pass: the memberships CASCADE
2155
+ -- with the row, so the count must be read pre-DELETE — but the zero-row
2156
+ -- path (repeat delete) pays for nothing (round 4).
2157
+ SELECT p.name, (SELECT COUNT(*) FROM cerefox_document_projects dp
2158
+ WHERE dp.project_id = p.id)
2159
+ INTO v_name, v_links
2160
+ FROM cerefox_projects p WHERE p.id = p_project_id FOR UPDATE;
2161
+
2162
+ IF v_name IS NULL THEN
2163
+ -- Zero rows: nothing happened, so nothing is audited — the trail
2164
+ -- must never assert an event that did not occur. Callers decide
2165
+ -- whether "already gone" is an error (CLI) or a 404 (web).
2166
+ RETURN QUERY SELECT FALSE, NULL::TEXT;
2167
+ RETURN;
2168
+ END IF;
2169
+
2170
+ DELETE FROM cerefox_projects WHERE id = p_project_id;
2171
+
2172
+ PERFORM cerefox_create_audit_entry(
2173
+ p_operation := 'project-delete',
2174
+ p_author := p_author,
2175
+ p_author_type := p_author_type,
2176
+ p_description := 'Project ''' || v_name || ''' deleted ('
2177
+ || v_links || ' document link(s) removed)'
2178
+ );
2179
+ RETURN QUERY SELECT TRUE, v_name;
2180
+ END;
2181
+ $$;
2182
+
2013
2183
  -- ── cerefox_metadata_search ──────────────────────────────────────────────────
2014
2184
  -- Query documents by metadata key-value criteria without a text search term.
2015
2185
  -- Uses JSONB containment (@>) which leverages the existing GIN index on
@@ -2432,7 +2602,23 @@ BEGIN
2432
2602
  END;
2433
2603
  $$;
2434
2604
 
2435
- CREATE OR REPLACE FUNCTION cerefox_set_config(p_key TEXT, p_value TEXT)
2605
+ -- The 2-arg signature grew to 4 in 0.14.0 (audit trail). CREATE OR REPLACE
2606
+ -- never removes the old overload, and a surviving one makes named calls
2607
+ -- ambiguous under PostgREST (PGRST203) — the v1.7.0 purge/restore lesson.
2608
+ -- Both signatures dropped (house pattern, cf. delete_document): the old one
2609
+ -- so it cannot linger, the current one so re-applying this file stays
2610
+ -- idempotent after migration 0028 has already created it.
2611
+ DROP FUNCTION IF EXISTS cerefox_set_config(TEXT, TEXT);
2612
+ DROP FUNCTION IF EXISTS cerefox_set_config(TEXT, TEXT, TEXT, TEXT);
2613
+
2614
+ CREATE FUNCTION cerefox_set_config(
2615
+ p_key TEXT,
2616
+ p_value TEXT,
2617
+ -- 0.14.0: config changes are governance decisions ("who turned retention
2618
+ -- off, and when?") and belong in the audit trail like any other write.
2619
+ p_author TEXT DEFAULT 'unknown',
2620
+ p_author_type TEXT DEFAULT 'user'
2621
+ )
2436
2622
  RETURNS VOID
2437
2623
  LANGUAGE plpgsql
2438
2624
  SECURITY DEFINER
@@ -2457,14 +2643,30 @@ DECLARE
2457
2643
  -- point. A signal in the write's response, never a refusal.
2458
2644
  'document_size_warning_chars'
2459
2645
  ];
2646
+ v_old TEXT;
2460
2647
  BEGIN
2461
2648
  IF NOT (p_key = ANY(v_allowed)) THEN
2462
2649
  RAISE EXCEPTION 'Unknown config key: %. Allowed keys: %', p_key, v_allowed;
2463
2650
  END IF;
2464
2651
 
2652
+ SELECT value INTO v_old FROM cerefox_config WHERE key = p_key;
2653
+
2465
2654
  INSERT INTO cerefox_config (key, value)
2466
2655
  VALUES (p_key, p_value)
2467
2656
  ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
2657
+
2658
+ -- Same-transaction audit entry (document_id NULL — a store-level write).
2659
+ -- A no-op set (same value) is still recorded: "checked and confirmed" is
2660
+ -- itself a governance action, and skipping it would make the trail lie by
2661
+ -- omission when someone re-asserts a setting.
2662
+ PERFORM cerefox_create_audit_entry(
2663
+ p_operation := 'config-change',
2664
+ p_author := p_author,
2665
+ p_author_type := p_author_type,
2666
+ p_description := 'config: ' || p_key || ': '
2667
+ || COALESCE('''' || v_old || '''', '(unset)')
2668
+ || ' → ''' || p_value || ''''
2669
+ );
2468
2670
  END;
2469
2671
  $$;
2470
2672
 
@@ -2788,6 +2990,10 @@ SET search_path = public, pg_catalog
2788
2990
  AS $$
2789
2991
  -- Keep in lockstep with the `@version:` marker in schema.sql (cut_release.ts
2790
2992
  -- enforces it). Bump whenever schema.sql OR rpcs.sql changes.
2993
+ -- 0.13.0 (#216): archived chunks carry no search artifacts —
2994
+ -- cerefox_snapshot_version nulls embedding_primary/embedding_upgrade/fts
2995
+ -- at archive time; embedding_primary becomes nullable; migration 0027
2996
+ -- strips existing archived rows.
2791
2997
  -- 0.12.2 (#212, #214): metadata must be a JSON object (ingest input guard
2792
2998
  -- + set_document_metadata stored-state merge guard); cerefox_find_dead_links
2793
2999
  -- (link-integrity phase-2 sweep); cerefox_metadata_health (doctor check).
@@ -2806,7 +3012,7 @@ AS $$
2806
3012
  -- 0.11.0 supersedes 0.10.6 (v1.2.1, #191): this branch carries that fix plus
2807
3013
  -- the partial-edit surface, and both migrations (0019, 0020) are in the
2808
3014
  -- sequence, so a store deploying this gets everything from both lines.
2809
- SELECT '0.12.2'::TEXT;
3015
+ SELECT '0.14.0'::TEXT;
2810
3016
  $$;
2811
3017
 
2812
3018
  -- ── cerefox_find_dead_links ──────────────────────────────────────────────────
@@ -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.12.2
8
+ -- @version: 0.14.0
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 —
@@ -140,7 +140,13 @@ CREATE TABLE IF NOT EXISTS cerefox_audit_log (
140
140
  'insert', 'replace-section', 'delete-section',
141
141
  -- iteration 35 (#197): renaming a heading is neither a
142
142
  -- rewrite nor a removal, and the trail should say so.
143
- 'rename-section')
143
+ 'rename-section',
144
+ -- 0.14.0 (iteration 38, #147): store-level writes join the
145
+ -- trail. These entries carry document_id NULL — like the
146
+ -- rows a purge cascade orphans, so every reader already
147
+ -- tolerates them.
148
+ 'config-change', 'project-create', 'project-edit',
149
+ 'project-delete')
144
150
  ),
145
151
  CONSTRAINT cerefox_audit_log_author_type_check CHECK (author_type IN ('user', 'agent'))
146
152
  );
@@ -212,8 +218,16 @@ CREATE TABLE IF NOT EXISTS cerefox_chunks (
212
218
  -- reconstructs with its OWN format. See docs/guides/content-format.md.
213
219
  content_format SMALLINT NOT NULL DEFAULT 1,
214
220
 
215
- -- Primary embedding: always computed, cloud API (default: OpenAI text-embedding-3-small)
216
- embedding_primary VECTOR(768) NOT NULL,
221
+ -- Primary embedding: always computed on write, cloud API (default: OpenAI
222
+ -- text-embedding-3-small). NULL only on ARCHIVED rows (0.13.0, #216 —
223
+ -- rationale in migration 0027): archiving nulls the search artifacts.
224
+ -- The CHECK below preserves the pre-0.13.0 guarantee that a CURRENT
225
+ -- chunk always has an embedding — without it, a short embedding-API
226
+ -- response could insert a silently search-invisible chunk where the old
227
+ -- NOT NULL failed loudly.
228
+ embedding_primary VECTOR(768)
229
+ CONSTRAINT cerefox_chunks_current_has_embedding
230
+ CHECK (version_id IS NOT NULL OR embedding_primary IS NOT NULL),
217
231
  -- Upgrade embedding: optional, alternative model (Fireworks, Vertex, etc.)
218
232
  embedding_upgrade VECTOR(768),
219
233
 
@@ -3,6 +3,10 @@ import { createClient } from "jsr:@supabase/supabase-js@2";
3
3
  import { isVersionRequest, versionResponse } from "../../../_shared/ef-meta/index.ts";
4
4
  import { efAuthGate } from "../../../_shared/ef-auth/index.ts";
5
5
  import { capEmbeddingInput } from "../../../_shared/embeddings/index.ts";
6
+ import {
7
+ ensureDocumentInProject,
8
+ setDocumentProjectsByName,
9
+ } from "../../../_shared/mcp-tools/_projects.ts";
6
10
  import {
7
11
  chunkMarkdown,
8
12
  embeddingInputFor,
@@ -220,97 +224,10 @@ async function sha256hex(text: string): Promise<string> {
220
224
  //
221
225
  // Used by both update branches AND the create path so resolution is consistent.
222
226
 
223
- // deno-lint-ignore no-explicit-any
224
- async function ensureDocumentInProject(
225
- // deno-lint-ignore no-explicit-any
226
- supabase: any,
227
- documentId: string,
228
- projectName: string,
229
- ): Promise<string | null> {
230
- // Resolve project name → id (look up; create if absent).
231
- let projectId: string | null = null;
232
- const { data: proj } = await supabase
233
- .from("cerefox_projects")
234
- .select("id")
235
- .ilike("name", projectName)
236
- .limit(1);
237
- if (proj?.length) {
238
- projectId = proj[0].id;
239
- } else {
240
- const { data: newProj } = await supabase
241
- .from("cerefox_projects")
242
- .insert({ name: projectName })
243
- .select("id");
244
- projectId = newProj?.[0]?.id ?? null;
245
- }
246
- if (!projectId) return null;
247
-
248
- // Check membership; INSERT only if missing. PRIMARY KEY (document_id, project_id)
249
- // guarantees uniqueness, so this is safe under concurrent calls (worst case:
250
- // one of two concurrent inserts fails with 23505 unique_violation — we log
251
- // and treat as "already a member"; outcome is identical).
252
- const { data: existing } = await supabase
253
- .from("cerefox_document_projects")
254
- .select("document_id")
255
- .eq("document_id", documentId)
256
- .eq("project_id", projectId)
257
- .limit(1);
258
- if (existing?.length) return projectId; // Already a member — non-destructive
259
-
260
- const { error: insertErr } = await supabase
261
- .from("cerefox_document_projects")
262
- .insert({ document_id: documentId, project_id: projectId });
263
- if (insertErr && !String(insertErr.message ?? "").includes("duplicate key")) {
264
- console.warn("ensureDocumentInProject: insert failed", insertErr);
265
- }
266
- return projectId;
267
- }
268
-
269
- // ── Destructive set-the-full-list helper (project_names list form) ─────────
270
- //
271
- // Resolves each name to a project_id (creating if absent), then REPLACES the
272
- // document's project memberships with exactly that set. Used by the
273
- // project_names: string[] form on cerefox_ingest (full-set semantics).
274
- //
275
- // Empty list = remove from all projects.
276
-
277
- // deno-lint-ignore no-explicit-any
278
- async function setDocumentProjectsByName(
279
- // deno-lint-ignore no-explicit-any
280
- supabase: any,
281
- documentId: string,
282
- projectNames: string[],
283
- ): Promise<string[]> {
284
- const projectIds: string[] = [];
285
- for (const name of projectNames) {
286
- if (!name) continue;
287
- const { data: proj } = await supabase
288
- .from("cerefox_projects")
289
- .select("id")
290
- .ilike("name", name)
291
- .limit(1);
292
- if (proj?.length) {
293
- projectIds.push(proj[0].id);
294
- } else {
295
- const { data: newProj } = await supabase
296
- .from("cerefox_projects")
297
- .insert({ name })
298
- .select("id");
299
- if (newProj?.[0]?.id) projectIds.push(newProj[0].id);
300
- }
301
- }
302
-
303
- // DELETE-then-INSERT replace (matches Python assign_document_projects).
304
- await supabase
305
- .from("cerefox_document_projects")
306
- .delete()
307
- .eq("document_id", documentId);
308
- if (projectIds.length > 0) {
309
- const rows = projectIds.map((pid) => ({ document_id: documentId, project_id: pid }));
310
- await supabase.from("cerefox_document_projects").insert(rows);
311
- }
312
- return projectIds;
313
- }
227
+ // Project resolution + membership helpers are the SHARED implementations
228
+ // (_shared/mcp-tools/_projects.ts) — the EF carried textual clones until
229
+ // v1.9.0, which is exactly how forks drift (round-3 review). Both route
230
+ // project creation through cerefox_create_project (audits in-transaction).
314
231
 
315
232
  // ── Main handler ───────────────────────────────────────────────────────────
316
233
 
@@ -535,9 +452,9 @@ Deno.serve(async (req: Request) => {
535
452
  // - project_names (list) → destructive replace (full-set semantics)
536
453
  // - project_name (singular) → non-destructive add (only if project_names absent)
537
454
  if (project_names !== null) {
538
- await setDocumentProjectsByName(supabase, existingDoc.id, project_names);
455
+ await setDocumentProjectsByName(supabase, existingDoc.id, project_names, { author, authorType: author_type });
539
456
  } else if (project_name) {
540
- await ensureDocumentInProject(supabase, existingDoc.id, project_name);
457
+ await ensureDocumentInProject(supabase, existingDoc.id, project_name, { author, authorType: author_type });
541
458
  }
542
459
 
543
460
  const note = update_if_exists ? undefined : "update_if_exists flag was overridden by document_id";
@@ -658,9 +575,9 @@ Deno.serve(async (req: Request) => {
658
575
  // - project_names (list) → destructive replace (full-set semantics)
659
576
  // - project_name (singular) → non-destructive add (only if project_names absent)
660
577
  if (project_names !== null) {
661
- await setDocumentProjectsByName(supabase, existingDoc.id, project_names);
578
+ await setDocumentProjectsByName(supabase, existingDoc.id, project_names, { author, authorType: author_type });
662
579
  } else if (project_name) {
663
- await ensureDocumentInProject(supabase, existingDoc.id, project_name);
580
+ await ensureDocumentInProject(supabase, existingDoc.id, project_name, { author, authorType: author_type });
664
581
  }
665
582
 
666
583
  return new Response(
@@ -759,9 +676,9 @@ Deno.serve(async (req: Request) => {
759
676
  // - project_name (singular) → assign one via the non-destructive helper
760
677
  let projectId: string | null = null;
761
678
  if (project_names !== null && project_names.length > 0) {
762
- await setDocumentProjectsByName(supabase, documentId, project_names);
679
+ await setDocumentProjectsByName(supabase, documentId, project_names, { author, authorType: author_type });
763
680
  } else if (project_name) {
764
- projectId = await ensureDocumentInProject(supabase, documentId, project_name);
681
+ projectId = await ensureDocumentInProject(supabase, documentId, project_name, { author, authorType: author_type });
765
682
  }
766
683
 
767
684
  // Fire-and-forget usage logging for ingest
@@ -453,6 +453,8 @@ cerefox project delete [OPTIONS] PROJECT
453
453
  | `--description TEXT` | str | _none_ | Project description (`create` / `edit`). |
454
454
  | `--name TEXT` | str | _unchanged_ | New name (`edit`). |
455
455
  | `-y, --yes` | flag | off | Skip confirmation (`delete`; required for non-interactive use). |
456
+ | `-a, --author TEXT` | str | `CEREFOX_AUTHOR_NAME` or `unknown` | Identity recorded in the audit log (v1.9.0 — project writes are audited in-transaction by the server). |
457
+ | `--author-type TEXT` | user\|agent | `user` | Recorded alongside the author. |
456
458
 
457
459
  **Examples**:
458
460
  ```bash
@@ -461,6 +463,10 @@ cerefox project edit research --name research-archive
461
463
  cerefox project delete research-archive --yes
462
464
  ```
463
465
 
466
+ Since v1.9.0 every project mutation is recorded in the audit log by the
467
+ server itself (`project-create` / `project-edit` / `project-delete`), with
468
+ the audit entry written in the same transaction as the change.
469
+
464
470
  ---
465
471
 
466
472
  ### `cerefox metadata keys`
@@ -519,7 +525,7 @@ cerefox audit list [OPTIONS]
519
525
  |---|---|---|---|
520
526
  | `--document-id TEXT` | UUID | _none_ | Filter to a single document. |
521
527
  | `--author TEXT` | str | _none_ | Filter by author name (exact match). |
522
- | `--operation [create\|update-content\|update-metadata\|delete\|status-change\|archive\|unarchive\|restore]` | choice | _none_ | Filter by operation type. |
528
+ | `--operation TEXT` | choice | _none_ | Filter by operation type: `create`, `update-content`, `update-metadata`, `insert`, `replace-section`, `delete-section`, `rename-section`, `delete`, `restore`, `status-change`, `archive`, `unarchive`, `config-change`, `project-create`, `project-edit`, `project-delete`. |
523
529
  | `--since TEXT` | ISO-8601 | _none_ | Lower bound on `created_at`. |
524
530
  | `--until TEXT` | ISO-8601 | _none_ | Upper bound on `created_at`. |
525
531
  | `--limit INTEGER` | int | `50` | Max rows. |
@@ -698,9 +704,14 @@ Agents see 15 tools with the flag off and 19 with it on. See
698
704
  ```
699
705
  cerefox config list # all current key/value pairs
700
706
  cerefox config get KEY
701
- cerefox config set KEY VALUE
707
+ cerefox config set KEY VALUE [--author NAME] [--author-type user|agent]
702
708
  ```
703
709
 
710
+ Since v1.9.0 every `config set` is recorded in the audit log by the server
711
+ itself (`config-change`, with the old → new value), in the same transaction
712
+ as the write — pass `--author` (or set `CEREFOX_AUTHOR_NAME`) so the entry
713
+ is attributed; it records `unknown` otherwise, with a warning.
714
+
704
715
  Used for toggling features at runtime without a redeploy — see the "Decision Log Q1 Part 2 — usage tracking opt-in" entry (stored in the Cerefox knowledge base).
705
716
 
706
717
  ---