-- 010: hash the audit diff and serialize appends to each chain. BEGIN; ALTER TABLE audit_log ADD COLUMN hash_version smallint NOT NULL DEFAULT 1 CHECK (hash_version IN (1, 2)); CREATE FUNCTION audit_hash_v2(p_prev text, p_actor uuid, p_action text, p_target_id text, p_at timestamptz, p_diff jsonb) RETURNS text LANGUAGE sql IMMUTABLE AS $$ SELECT encode(digest( coalesce(p_prev, '') || '|' || coalesce(p_actor::text, '') || '|' || p_action || '|' || p_target_id || '|' || extract(epoch FROM p_at)::text || '|' || coalesce(p_diff::text, ''), 'sha256'), 'hex') $$; CREATE OR REPLACE FUNCTION audit_append( p_workspace_id uuid, p_actor_user_id uuid, p_actor_role text, p_action text, p_target_type text, p_target_id text, p_diff jsonb DEFAULT NULL ) RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ DECLARE v_id uuid; v_prev text; v_hash text; v_now timestamptz := clock_timestamp(); BEGIN PERFORM fn_assert_tenant(p_workspace_id); PERFORM pg_advisory_xact_lock(hashtext('audit:' || p_workspace_id::text)); SELECT hash INTO v_prev FROM audit_log WHERE workspace_id = p_workspace_id ORDER BY seq DESC LIMIT 1; v_hash := audit_hash_v2(v_prev, p_actor_user_id, p_action, p_target_id, v_now, p_diff); INSERT INTO audit_log (workspace_id, actor_user_id, actor_role, action, target_type, target_id, diff, at, prev_hash, hash, hash_version) VALUES (p_workspace_id, p_actor_user_id, p_actor_role, p_action, p_target_type, p_target_id, p_diff, v_now, v_prev, v_hash, 2) RETURNING id INTO v_id; RETURN v_id; END $$; CREATE OR REPLACE FUNCTION audit_append_system( p_actor_user_id uuid, p_action text, p_target_type text, p_target_id text, p_diff jsonb DEFAULT NULL ) RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ DECLARE v_id uuid; v_prev text; v_hash text; v_now timestamptz := clock_timestamp(); BEGIN IF coalesce(current_setting('app.workspace_id', true), '') <> '' THEN RAISE EXCEPTION 'system append must not run inside a tenant context (use audit_append)'; END IF; PERFORM pg_advisory_xact_lock(hashtext('audit:system')); SELECT hash INTO v_prev FROM audit_log WHERE workspace_id IS NULL ORDER BY seq DESC LIMIT 1; v_hash := audit_hash_v2(v_prev, p_actor_user_id, p_action, p_target_id, v_now, p_diff); INSERT INTO audit_log (workspace_id, actor_user_id, action, target_type, target_id, diff, at, prev_hash, hash, hash_version) VALUES (NULL, p_actor_user_id, p_action, p_target_type, p_target_id, p_diff, v_now, v_prev, v_hash, 2) RETURNING id INTO v_id; RETURN v_id; END $$; CREATE OR REPLACE FUNCTION audit_verify_chain(p_workspace_id uuid DEFAULT NULL) RETURNS boolean LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = public AS $$ DECLARE r record; v_prev text := NULL; v_expected text; BEGIN FOR r IN SELECT * FROM audit_log WHERE workspace_id IS NOT DISTINCT FROM p_workspace_id ORDER BY seq ASC LOOP IF r.prev_hash IS DISTINCT FROM v_prev THEN RETURN false; END IF; IF r.hash_version = 1 THEN v_expected := audit_hash(r.prev_hash, r.actor_user_id, r.action, r.target_id, r.at); ELSE v_expected := audit_hash_v2(r.prev_hash, r.actor_user_id, r.action, r.target_id, r.at, r.diff); END IF; IF v_expected IS DISTINCT FROM r.hash THEN RETURN false; END IF; v_prev := r.hash; END LOOP; RETURN true; END $$; COMMIT;