@cerefox/memory 1.8.0 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,349 @@
1
+ -- Migration 0028 — store-level writes join the audit trail (schema 0.14.0)
2
+ --
3
+ -- WHY (canonical rationale — other docs reference this header):
4
+ --
5
+ -- The audit log covered every *document* write but nothing store-level, so the
6
+ -- two questions that motivated this release were unanswerable from the trail:
7
+ -- "who changed this config value, and when?" (cerefox_config has no
8
+ -- timestamps) and "when was this project created/renamed/removed?" (#147).
9
+ -- Both are governance decisions of exactly the kind an append-only trail
10
+ -- exists to answer.
11
+ --
12
+ -- This migration:
13
+ -- 1. Extends the cerefox_audit_log.operation allow-list with the four
14
+ -- store-level operations: 'config-change', 'project-create',
15
+ -- 'project-edit', 'project-delete'. These entries carry document_id NULL
16
+ -- — the same shape as rows a purge cascade orphans, so every existing
17
+ -- reader (CLI, web, EF) already tolerates them.
18
+ -- 2. Replaces cerefox_set_config with the 4-arg auditing version and DROPs
19
+ -- the old 2-arg signature (CREATE OR REPLACE never removes a grown-out
20
+ -- overload; a survivor makes named PostgREST calls ambiguous — PGRST203,
21
+ -- the v1.7.0 purge/restore lesson). The full new body ships INSIDE this
22
+ -- migration so neither `db_migrate.ts` alone nor a deploy that fails
23
+ -- between the migration step and the RPC refresh can leave the old,
24
+ -- non-auditing function behind while 0028 is stamped applied (same
25
+ -- repair-path closure as 0027).
26
+ -- 3. Drops the dead V1 RPC cerefox_save_note: a Python-era tool with zero
27
+ -- callers since the TS rewrite — no CLI command, MCP tool, Edge
28
+ -- Function, web route, or SQL function references it. (Its sibling
29
+ -- cerefox_context_expand looked identical but is load-bearing:
30
+ -- cerefox_search_docs calls it for small-to-big retrieval, so it stays.)
31
+ --
32
+ -- Project create/edit/delete write through three new RPCs
33
+ -- (cerefox_create_project / cerefox_update_project / cerefox_delete_project)
34
+ -- that audit IN-TRANSACTION, exactly like cerefox_set_config — the
35
+ -- single-implementation principle applies the moment a write carries a side
36
+ -- effect (#219; an earlier draft audited client-side at every call site and
37
+ -- review caught two forgotten paths before it ever shipped). This migration
38
+ -- carries their bodies too (repair-path closure), plus their ACL lockdown.
39
+ --
40
+ -- Re-runnable: every statement is guarded or idempotent.
41
+
42
+ -- 1. Operation allow-list. Constraint swap is atomic within the migration's
43
+ -- transaction; the list must stay in lockstep with schema.sql.
44
+ ALTER TABLE cerefox_audit_log
45
+ DROP CONSTRAINT IF EXISTS cerefox_audit_log_operation_check;
46
+ ALTER TABLE cerefox_audit_log
47
+ ADD CONSTRAINT cerefox_audit_log_operation_check CHECK (
48
+ operation IN ('create', 'update-content', 'update-metadata', 'delete',
49
+ 'status-change', 'archive', 'unarchive', 'restore',
50
+ 'relation-set', 'relation-delete',
51
+ 'insert', 'replace-section', 'delete-section',
52
+ 'rename-section',
53
+ 'config-change', 'project-create', 'project-edit',
54
+ 'project-delete')
55
+ );
56
+
57
+ -- 2. Auditing cerefox_set_config (and the old signature's removal).
58
+ DROP FUNCTION IF EXISTS cerefox_set_config(TEXT, TEXT);
59
+
60
+ CREATE OR REPLACE FUNCTION cerefox_set_config(
61
+ p_key TEXT,
62
+ p_value TEXT,
63
+ p_author TEXT DEFAULT 'unknown',
64
+ p_author_type TEXT DEFAULT 'user'
65
+ )
66
+ RETURNS VOID
67
+ LANGUAGE plpgsql
68
+ SECURITY DEFINER
69
+ SET search_path = public, pg_catalog
70
+ AS $$
71
+ DECLARE
72
+ v_allowed TEXT[] := ARRAY[
73
+ 'usage_tracking_enabled', 'require_requestor_identity', 'requestor_identity_format',
74
+ 'min_search_score', 'min_term_coverage', 'search_alpha',
75
+ 'version_retention_hours', 'version_cleanup_enabled',
76
+ 'relations_enabled',
77
+ 'document_size_warning_chars'
78
+ ];
79
+ v_old TEXT;
80
+ BEGIN
81
+ IF NOT (p_key = ANY(v_allowed)) THEN
82
+ RAISE EXCEPTION 'Unknown config key: %. Allowed keys: %', p_key, v_allowed;
83
+ END IF;
84
+
85
+ SELECT value INTO v_old FROM cerefox_config WHERE key = p_key;
86
+
87
+ INSERT INTO cerefox_config (key, value)
88
+ VALUES (p_key, p_value)
89
+ ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
90
+
91
+ PERFORM cerefox_create_audit_entry(
92
+ p_operation := 'config-change',
93
+ p_author := p_author,
94
+ p_author_type := p_author_type,
95
+ p_description := 'config: ' || p_key || ': '
96
+ || COALESCE('''' || v_old || '''', '(unset)')
97
+ || ' → ''' || p_value || ''''
98
+ );
99
+ END;
100
+ $$;
101
+
102
+ -- 2b. Lock the new signature down. A freshly CREATEd function gets Postgres'
103
+ -- default EXECUTE-to-PUBLIC, and the repo's blanket REVOKE/GRANT loop
104
+ -- lives at the bottom of rpcs.sql — which `db_migrate.ts` alone never
105
+ -- applies, and which a deploy failing between the migration step and the
106
+ -- RPC refresh also misses. Without this, the anon/publishable key could
107
+ -- call the one function that WRITES store governance. Same guarded shape
108
+ -- as the rpcs.sql block (safe on non-Supabase Postgres).
109
+ DO $$
110
+ DECLARE
111
+ r TEXT;
112
+ BEGIN
113
+ REVOKE EXECUTE ON FUNCTION cerefox_set_config(TEXT, TEXT, TEXT, TEXT) FROM PUBLIC;
114
+ FOREACH r IN ARRAY ARRAY['anon', 'authenticated'] LOOP
115
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = r) THEN
116
+ EXECUTE format('REVOKE EXECUTE ON FUNCTION cerefox_set_config(TEXT, TEXT, TEXT, TEXT) FROM %I', r);
117
+ END IF;
118
+ END LOOP;
119
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role') THEN
120
+ GRANT EXECUTE ON FUNCTION cerefox_set_config(TEXT, TEXT, TEXT, TEXT) TO service_role;
121
+ END IF;
122
+ END $$;
123
+
124
+ -- 2c. Project write RPCs (bodies identical to rpcs.sql — extracted, not
125
+ -- retyped; an invariant test compares them byte-for-byte) and their
126
+ -- lockdown, same rationale as 2b.
127
+ DROP FUNCTION IF EXISTS cerefox_create_project(TEXT, TEXT, TEXT, TEXT, TEXT);
128
+
129
+ CREATE FUNCTION cerefox_create_project(
130
+ p_name TEXT,
131
+ p_description TEXT DEFAULT '',
132
+ p_author TEXT DEFAULT 'unknown',
133
+ p_author_type TEXT DEFAULT 'user',
134
+ -- 'error' → explicit creation (CLI/web): duplicate name raises.
135
+ -- 'return' → get-or-create (implicit creation during document
136
+ -- assignment): an existing project is returned untouched and
137
+ -- NOT audited — only an actual create writes an entry.
138
+ p_if_exists TEXT DEFAULT 'error'
139
+ )
140
+ RETURNS TABLE (
141
+ project_id UUID,
142
+ project_name TEXT,
143
+ project_description TEXT,
144
+ created BOOLEAN,
145
+ created_at TIMESTAMPTZ,
146
+ updated_at TIMESTAMPTZ
147
+ )
148
+ LANGUAGE plpgsql
149
+ SECURITY DEFINER
150
+ SET search_path = public, pg_catalog
151
+ AS $$
152
+ DECLARE
153
+ v_row cerefox_projects%ROWTYPE;
154
+ BEGIN
155
+ IF NULLIF(BTRIM(p_name), '') IS NULL THEN
156
+ RAISE EXCEPTION 'Project name is required' USING ERRCODE = '22023';
157
+ END IF;
158
+ IF p_if_exists NOT IN ('error', 'return') THEN
159
+ RAISE EXCEPTION 'p_if_exists must be ''error'' or ''return''' USING ERRCODE = '22023';
160
+ END IF;
161
+
162
+ IF p_if_exists = 'return' THEN
163
+ -- Case-insensitive, matching the name resolution the assignment
164
+ -- paths have always used. (A case-colliding pair created directly is
165
+ -- pre-existing behavior: the unique constraint is exact-match; this
166
+ -- resolver returns the first match.)
167
+ SELECT p.* INTO v_row
168
+ FROM cerefox_projects p WHERE lower(p.name) = lower(BTRIM(p_name)) LIMIT 1;
169
+ IF v_row.id IS NOT NULL THEN
170
+ RETURN QUERY SELECT v_row.id, v_row.name, v_row.description, FALSE,
171
+ v_row.created_at, v_row.updated_at;
172
+ RETURN;
173
+ END IF;
174
+ END IF;
175
+
176
+ BEGIN
177
+ INSERT INTO cerefox_projects (name, description)
178
+ VALUES (BTRIM(p_name), COALESCE(p_description, ''))
179
+ RETURNING * INTO v_row;
180
+ EXCEPTION WHEN unique_violation THEN
181
+ -- TOCTOU (round 4): a concurrent create won the race between the
182
+ -- resolve above and this insert. In 'return' mode that is exactly
183
+ -- the get-or-create contract — hand back the winner, no audit entry
184
+ -- (this call created nothing). In 'error' mode a duplicate is the
185
+ -- caller's error, exactly as if there had been no race.
186
+ IF p_if_exists = 'return' THEN
187
+ SELECT p.* INTO v_row
188
+ FROM cerefox_projects p WHERE lower(p.name) = lower(BTRIM(p_name)) LIMIT 1;
189
+ IF v_row.id IS NOT NULL THEN
190
+ RETURN QUERY SELECT v_row.id, v_row.name, v_row.description, FALSE,
191
+ v_row.created_at, v_row.updated_at;
192
+ RETURN;
193
+ END IF;
194
+ END IF;
195
+ RAISE;
196
+ END;
197
+
198
+ PERFORM cerefox_create_audit_entry(
199
+ p_operation := 'project-create',
200
+ p_author := p_author,
201
+ p_author_type := p_author_type,
202
+ p_description := 'Project ''' || v_row.name || ''' created'
203
+ || CASE WHEN p_if_exists = 'return'
204
+ THEN ' implicitly (document assignment)' ELSE '' END
205
+ );
206
+ RETURN QUERY SELECT v_row.id, v_row.name, v_row.description, TRUE,
207
+ v_row.created_at, v_row.updated_at;
208
+ END;
209
+ $$;
210
+
211
+ DROP FUNCTION IF EXISTS cerefox_update_project(UUID, TEXT, TEXT, TEXT, TEXT);
212
+
213
+ CREATE FUNCTION cerefox_update_project(
214
+ p_project_id UUID,
215
+ -- NULL = keep the current value (the #191/#204 "NULL means not provided"
216
+ -- convention). An explicit empty name is rejected.
217
+ p_name TEXT DEFAULT NULL,
218
+ p_description TEXT DEFAULT NULL,
219
+ p_author TEXT DEFAULT 'unknown',
220
+ p_author_type TEXT DEFAULT 'user'
221
+ )
222
+ RETURNS TABLE (
223
+ project_id UUID,
224
+ project_name TEXT,
225
+ project_description TEXT,
226
+ created_at TIMESTAMPTZ,
227
+ updated_at TIMESTAMPTZ
228
+ )
229
+ LANGUAGE plpgsql
230
+ SECURITY DEFINER
231
+ SET search_path = public, pg_catalog
232
+ AS $$
233
+ DECLARE
234
+ v_old cerefox_projects%ROWTYPE;
235
+ v_new cerefox_projects%ROWTYPE;
236
+ v_changes TEXT[] := '{}';
237
+ BEGIN
238
+ IF p_name IS NULL AND p_description IS NULL THEN
239
+ RAISE EXCEPTION 'Nothing to update: pass p_name and/or p_description' USING ERRCODE = '22023';
240
+ END IF;
241
+ IF p_name IS NOT NULL AND NULLIF(BTRIM(p_name), '') IS NULL THEN
242
+ RAISE EXCEPTION 'Project name cannot be empty' USING ERRCODE = '22023';
243
+ END IF;
244
+
245
+ SELECT p.* INTO v_old FROM cerefox_projects p WHERE p.id = p_project_id FOR UPDATE;
246
+ IF NOT FOUND THEN
247
+ RAISE EXCEPTION 'Project not found: %', p_project_id USING ERRCODE = '22023';
248
+ END IF;
249
+
250
+ UPDATE cerefox_projects SET
251
+ name = COALESCE(BTRIM(p_name), name),
252
+ description = COALESCE(p_description, description),
253
+ updated_at = NOW()
254
+ WHERE id = p_project_id
255
+ RETURNING * INTO v_new;
256
+
257
+ -- The trail records what actually changed, not which arguments arrived.
258
+ IF v_new.name <> v_old.name THEN
259
+ v_changes := v_changes || ('renamed ''' || v_old.name || ''' → ''' || v_new.name || '''');
260
+ END IF;
261
+ IF COALESCE(v_new.description, '') <> COALESCE(v_old.description, '') THEN
262
+ v_changes := v_changes || 'description changed';
263
+ END IF;
264
+
265
+ PERFORM cerefox_create_audit_entry(
266
+ p_operation := 'project-edit',
267
+ p_author := p_author,
268
+ p_author_type := p_author_type,
269
+ p_description := 'Project ''' || v_new.name || ''' edited ('
270
+ || COALESCE(NULLIF(array_to_string(v_changes, '; '), ''), 'no-op') || ')'
271
+ );
272
+ RETURN QUERY SELECT v_new.id, v_new.name, v_new.description,
273
+ v_new.created_at, v_new.updated_at;
274
+ END;
275
+ $$;
276
+
277
+ CREATE OR REPLACE FUNCTION cerefox_delete_project(
278
+ p_project_id UUID,
279
+ p_author TEXT DEFAULT 'unknown',
280
+ p_author_type TEXT DEFAULT 'user'
281
+ )
282
+ RETURNS TABLE (deleted BOOLEAN, project_name TEXT)
283
+ LANGUAGE plpgsql
284
+ SECURITY DEFINER
285
+ SET search_path = public, pg_catalog
286
+ AS $$
287
+ DECLARE
288
+ v_name TEXT;
289
+ v_links INT;
290
+ BEGIN
291
+ -- Lock + read name and link count in one pass: the memberships CASCADE
292
+ -- with the row, so the count must be read pre-DELETE — but the zero-row
293
+ -- path (repeat delete) pays for nothing (round 4).
294
+ SELECT p.name, (SELECT COUNT(*) FROM cerefox_document_projects dp
295
+ WHERE dp.project_id = p.id)
296
+ INTO v_name, v_links
297
+ FROM cerefox_projects p WHERE p.id = p_project_id FOR UPDATE;
298
+
299
+ IF v_name IS NULL THEN
300
+ -- Zero rows: nothing happened, so nothing is audited — the trail
301
+ -- must never assert an event that did not occur. Callers decide
302
+ -- whether "already gone" is an error (CLI) or a 404 (web).
303
+ RETURN QUERY SELECT FALSE, NULL::TEXT;
304
+ RETURN;
305
+ END IF;
306
+
307
+ DELETE FROM cerefox_projects WHERE id = p_project_id;
308
+
309
+ PERFORM cerefox_create_audit_entry(
310
+ p_operation := 'project-delete',
311
+ p_author := p_author,
312
+ p_author_type := p_author_type,
313
+ p_description := 'Project ''' || v_name || ''' deleted ('
314
+ || v_links || ' document link(s) removed)'
315
+ );
316
+ RETURN QUERY SELECT TRUE, v_name;
317
+ END;
318
+ $$;
319
+
320
+ DO $$
321
+ DECLARE
322
+ fn TEXT;
323
+ r TEXT;
324
+ BEGIN
325
+ FOREACH fn IN ARRAY ARRAY[
326
+ 'cerefox_create_project(TEXT, TEXT, TEXT, TEXT, TEXT)',
327
+ 'cerefox_update_project(UUID, TEXT, TEXT, TEXT, TEXT)',
328
+ 'cerefox_delete_project(UUID, TEXT, TEXT)'
329
+ ] LOOP
330
+ EXECUTE format('REVOKE EXECUTE ON FUNCTION %s FROM PUBLIC', fn);
331
+ FOREACH r IN ARRAY ARRAY['anon', 'authenticated'] LOOP
332
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = r) THEN
333
+ EXECUTE format('REVOKE EXECUTE ON FUNCTION %s FROM %I', fn, r);
334
+ END IF;
335
+ END LOOP;
336
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role') THEN
337
+ EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO service_role', fn);
338
+ END IF;
339
+ END LOOP;
340
+ END $$;
341
+
342
+ -- 3. Dead V1 RPC.
343
+ DROP FUNCTION IF EXISTS cerefox_save_note(TEXT, TEXT, TEXT, UUID, JSONB);
344
+
345
+ DO $$
346
+ BEGIN
347
+ RAISE NOTICE
348
+ 'Migration 0028: audit trail now covers store-level writes (config-change, project-create/edit/delete). Dropped the retired V1 RPC cerefox_save_note.';
349
+ END $$;
@@ -0,0 +1,105 @@
1
+ -- Migration 0029 — fix cerefox_update_project's description-change audit
2
+ -- (schema 0.14.1)
3
+ --
4
+ -- v1.9.0's cerefox_update_project appended the audit-diff fragment with
5
+ -- `v_changes || 'description changed'`; Postgres resolves the untyped
6
+ -- literal via the array||array overload and raises `malformed array
7
+ -- literal`, rolling back EVERY project edit that changes a description
8
+ -- (rename-only edits worked — their concatenation is typed TEXT). Found
9
+ -- live in the v1.9.0 staging dress rehearsal before any production
10
+ -- deployment. The fix is array_append on both branches.
11
+ --
12
+ -- The full corrected body ships inside this migration (repair-path closure,
13
+ -- as 0027/0028), plus the same ACL lockdown 0028 gave the original.
14
+ -- Re-runnable.
15
+
16
+ DROP FUNCTION IF EXISTS cerefox_update_project(UUID, TEXT, TEXT, TEXT, TEXT);
17
+
18
+ CREATE FUNCTION cerefox_update_project(
19
+ p_project_id UUID,
20
+ -- NULL = keep the current value (the #191/#204 "NULL means not provided"
21
+ -- convention). An explicit empty name is rejected.
22
+ p_name TEXT DEFAULT NULL,
23
+ p_description TEXT DEFAULT NULL,
24
+ p_author TEXT DEFAULT 'unknown',
25
+ p_author_type TEXT DEFAULT 'user'
26
+ )
27
+ RETURNS TABLE (
28
+ project_id UUID,
29
+ project_name TEXT,
30
+ project_description TEXT,
31
+ created_at TIMESTAMPTZ,
32
+ updated_at TIMESTAMPTZ
33
+ )
34
+ LANGUAGE plpgsql
35
+ SECURITY DEFINER
36
+ SET search_path = public, pg_catalog
37
+ AS $$
38
+ DECLARE
39
+ v_old cerefox_projects%ROWTYPE;
40
+ v_new cerefox_projects%ROWTYPE;
41
+ v_changes TEXT[] := '{}';
42
+ BEGIN
43
+ IF p_name IS NULL AND p_description IS NULL THEN
44
+ RAISE EXCEPTION 'Nothing to update: pass p_name and/or p_description' USING ERRCODE = '22023';
45
+ END IF;
46
+ IF p_name IS NOT NULL AND NULLIF(BTRIM(p_name), '') IS NULL THEN
47
+ RAISE EXCEPTION 'Project name cannot be empty' USING ERRCODE = '22023';
48
+ END IF;
49
+
50
+ SELECT p.* INTO v_old FROM cerefox_projects p WHERE p.id = p_project_id FOR UPDATE;
51
+ IF NOT FOUND THEN
52
+ RAISE EXCEPTION 'Project not found: %', p_project_id USING ERRCODE = '22023';
53
+ END IF;
54
+
55
+ UPDATE cerefox_projects SET
56
+ name = COALESCE(BTRIM(p_name), name),
57
+ description = COALESCE(p_description, description),
58
+ updated_at = NOW()
59
+ WHERE id = p_project_id
60
+ RETURNING * INTO v_new;
61
+
62
+ -- The trail records what actually changed, not which arguments arrived.
63
+ -- array_append, NOT `||`: with an untyped string literal on the right,
64
+ -- `TEXT[] || 'literal'` resolves to the array||array overload and dies
65
+ -- with `malformed array literal` — found LIVE on staging (v1.9.0; every
66
+ -- description-only edit failed; the rename branch only survived because
67
+ -- its parenthesized concatenation is typed TEXT).
68
+ IF v_new.name <> v_old.name THEN
69
+ v_changes := array_append(v_changes, 'renamed ''' || v_old.name || ''' → ''' || v_new.name || '''');
70
+ END IF;
71
+ IF COALESCE(v_new.description, '') <> COALESCE(v_old.description, '') THEN
72
+ v_changes := array_append(v_changes, 'description changed');
73
+ END IF;
74
+
75
+ PERFORM cerefox_create_audit_entry(
76
+ p_operation := 'project-edit',
77
+ p_author := p_author,
78
+ p_author_type := p_author_type,
79
+ p_description := 'Project ''' || v_new.name || ''' edited ('
80
+ || COALESCE(NULLIF(array_to_string(v_changes, '; '), ''), 'no-op') || ')'
81
+ );
82
+ RETURN QUERY SELECT v_new.id, v_new.name, v_new.description,
83
+ v_new.created_at, v_new.updated_at;
84
+ END;
85
+ $$;
86
+
87
+ DO $$
88
+ DECLARE
89
+ r TEXT;
90
+ BEGIN
91
+ REVOKE EXECUTE ON FUNCTION cerefox_update_project(UUID, TEXT, TEXT, TEXT, TEXT) FROM PUBLIC;
92
+ FOREACH r IN ARRAY ARRAY['anon', 'authenticated'] LOOP
93
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = r) THEN
94
+ EXECUTE format('REVOKE EXECUTE ON FUNCTION cerefox_update_project(UUID, TEXT, TEXT, TEXT, TEXT) FROM %I', r);
95
+ END IF;
96
+ END LOOP;
97
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role') THEN
98
+ GRANT EXECUTE ON FUNCTION cerefox_update_project(UUID, TEXT, TEXT, TEXT, TEXT) TO service_role;
99
+ END IF;
100
+ END $$;
101
+
102
+ DO $$
103
+ BEGIN
104
+ RAISE NOTICE 'Migration 0029: fixed the project-edit description audit (array_append; description-only edits no longer fail).';
105
+ END $$;