feat(privacy): audited redaction that keeps history's shape and removes its content

Rows in append-only history are never deleted; their content can now be
made irrecoverable through one audited path. A redaction replaces the
content columns of an event, memory, message, model call, belief or
assertion with a fixed marker, keeps identifiers, links, timestamps,
scores and breakdowns, writes an append-only ledger row with a SHA-256 of
the removed content, appends a redaction event, optionally cascades to
derived rows and to the events that quote them, and scrubs stored snapshot
archives before re-signing them. noema_redact() is the only write the
append-only trigger admits, for UPDATE only, under a transaction-local flag
the application never sets; plain updates and all deletes stay refused.
Replay labels redacted stimuli instead of inventing them.

Cryptographic erasure was considered and deferred with reasons (ADR-0009);
docs/privacy.md defines retained, redacted, irrecoverable and deleted and
states what backups and earlier exports are not covered. Migration 0023.
This commit is contained in:
Andy Dixon
2026-08-23 23:52:07 +01:00
parent 9eed6e2a18
commit 7d608c2783
33 changed files with 1245 additions and 45 deletions
+13
View File
@@ -0,0 +1,13 @@
-- Redacted content cannot be restored; this only removes the machinery.
DROP FUNCTION IF EXISTS noema_redact(text, uuid, uuid);
CREATE OR REPLACE FUNCTION noema_forbid_change() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
RAISE EXCEPTION 'table % is append-only', TG_TABLE_NAME USING ERRCODE = 'integrity_constraint_violation';
END $$;
ALTER TABLE assertions DROP COLUMN IF EXISTS redaction_id, DROP COLUMN IF EXISTS redacted_at;
ALTER TABLE beliefs DROP COLUMN IF EXISTS redaction_id, DROP COLUMN IF EXISTS redacted_at;
ALTER TABLE llm_calls DROP COLUMN IF EXISTS redaction_id, DROP COLUMN IF EXISTS redacted_at;
ALTER TABLE messages DROP COLUMN IF EXISTS redaction_id, DROP COLUMN IF EXISTS redacted_at;
ALTER TABLE memories DROP COLUMN IF EXISTS redaction_id, DROP COLUMN IF EXISTS redacted_at;
ALTER TABLE cognitive_events DROP COLUMN IF EXISTS redaction_id, DROP COLUMN IF EXISTS redacted_at;
DROP TABLE IF EXISTS redactions;
+81
View File
@@ -0,0 +1,81 @@
-- Privacy redaction (review pass §7.2 item 8): the audited maintenance path
-- ADR-0002 promised. Redaction replaces the sensitive content of a row with a
-- fixed marker while keeping the row, its identifiers, its links and a hash
-- of what was removed. It is the only write ever permitted on append-only
-- tables, it is confined to content columns, and it runs through
-- noema_redact() so the application cannot reach the bypass by accident.
CREATE TABLE redactions (
id uuid PRIMARY KEY,
mind_id uuid NOT NULL REFERENCES minds(id),
target_kind text NOT NULL CHECK (target_kind IN ('event','memory','message','llm_call','belief','assertion')),
target_id uuid NOT NULL,
reason text NOT NULL,
policy text NOT NULL DEFAULT '', -- retention or privacy policy invoked
content_hash text NOT NULL, -- sha256 of the removed content, for later attestation
requested_by text NOT NULL,
cascade_of uuid REFERENCES redactions(id),
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (target_kind, target_id)
);
CREATE INDEX redactions_mind_idx ON redactions (mind_id, created_at DESC);
CREATE TRIGGER redactions_append_only BEFORE UPDATE OR DELETE ON redactions
FOR EACH ROW EXECUTE FUNCTION noema_forbid_change();
ALTER TABLE cognitive_events ADD COLUMN IF NOT EXISTS redacted_at timestamptz, ADD COLUMN IF NOT EXISTS redaction_id uuid;
ALTER TABLE memories ADD COLUMN IF NOT EXISTS redacted_at timestamptz, ADD COLUMN IF NOT EXISTS redaction_id uuid;
ALTER TABLE messages ADD COLUMN IF NOT EXISTS redacted_at timestamptz, ADD COLUMN IF NOT EXISTS redaction_id uuid;
ALTER TABLE llm_calls ADD COLUMN IF NOT EXISTS redacted_at timestamptz, ADD COLUMN IF NOT EXISTS redaction_id uuid;
ALTER TABLE beliefs ADD COLUMN IF NOT EXISTS redacted_at timestamptz, ADD COLUMN IF NOT EXISTS redaction_id uuid;
ALTER TABLE assertions ADD COLUMN IF NOT EXISTS redacted_at timestamptz, ADD COLUMN IF NOT EXISTS redaction_id uuid;
-- The append-only trigger admits exactly one kind of UPDATE: a redaction
-- running under the session flag that only noema_redact() sets. DELETE
-- stays forbidden in every case.
CREATE OR REPLACE FUNCTION noema_forbid_change() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'UPDATE' AND current_setting('noema.redaction', true) = 'on' THEN
RETURN NEW;
END IF;
RAISE EXCEPTION 'table % is append-only', TG_TABLE_NAME USING ERRCODE = 'integrity_constraint_violation';
END $$;
-- noema_redact replaces content columns for one row. It sets the bypass
-- flag for the current transaction only, touches nothing but content and
-- the redaction bookkeeping, and returns the number of rows changed.
CREATE OR REPLACE FUNCTION noema_redact(kind text, target uuid, redaction uuid) RETURNS integer LANGUAGE plpgsql SECURITY DEFINER AS $$
DECLARE n integer := 0;
BEGIN
PERFORM set_config('noema.redaction', 'on', true);
IF kind = 'event' THEN
UPDATE cognitive_events SET summary = '[redacted]', payload = '{"redacted":true}'::jsonb,
metadata = (SELECT coalesce(jsonb_object_agg(key, value), '{}'::jsonb) FROM jsonb_each(metadata)
WHERE key IN ('conversation_id','message_id','cycle','belief_id','memory_id','integration_id','integration','kind','trust','method','replay_of','replay_id'))
|| jsonb_build_object('redacted', true),
redacted_at = now(), redaction_id = redaction WHERE id = target AND redacted_at IS NULL;
ELSIF kind = 'memory' THEN
UPDATE memories SET content = '[redacted]', detail = '{"redacted":true}'::jsonb, redacted_at = now(), redaction_id = redaction WHERE id = target AND redacted_at IS NULL;
ELSIF kind = 'message' THEN
UPDATE messages SET content = '[redacted]', intent = NULL, redacted_at = now(), redaction_id = redaction WHERE id = target AND redacted_at IS NULL;
ELSIF kind = 'llm_call' THEN
UPDATE llm_calls SET request = '{"redacted":true}'::jsonb, response = NULL, error = CASE WHEN error = '' THEN '' ELSE '[redacted]' END, redacted_at = now(), redaction_id = redaction WHERE id = target AND redacted_at IS NULL;
ELSIF kind = 'belief' THEN
UPDATE beliefs SET proposition = '[redacted]', detail = '{"redacted":true}'::jsonb, redacted_at = now(), redaction_id = redaction WHERE id = target AND redacted_at IS NULL;
GET DIAGNOSTICS n = ROW_COUNT;
IF n > 0 THEN
UPDATE belief_versions SET proposition = '[redacted]', reason = '[redacted]' WHERE belief_id = target;
UPDATE belief_evidence SET note = '[redacted]' WHERE belief_id = target;
UPDATE belief_alternatives SET proposition = '[redacted]', note = '[redacted]' WHERE belief_id = target;
END IF;
PERFORM set_config('noema.redaction', 'off', true);
RETURN n;
ELSIF kind = 'assertion' THEN
UPDATE assertions SET proposition = '[redacted]', context = '[redacted]', interpretation_notes = '{}', redacted_at = now(), redaction_id = redaction WHERE id = target AND redacted_at IS NULL;
ELSE
RAISE EXCEPTION 'unknown redaction kind %', kind;
END IF;
GET DIAGNOSTICS n = ROW_COUNT;
PERFORM set_config('noema.redaction', 'off', true);
RETURN n;
END $$;
REVOKE ALL ON FUNCTION noema_redact(text, uuid, uuid) FROM PUBLIC;