-- ============================================================================= -- 005_doc_actions.sql — withdrawing a document, restoring one, purging one. -- -- psql -h pg-ai -U postgres -d plant -f 005_doc_actions.sql -- -- Applied at Phase 9, after 004. Additive. -- -- 004 covers documents arriving. This covers them leaving, which is the other -- half of the same job: a procedure gets cancelled, a manual outlives the -- equipment it describes, someone uploads the wrong site's document. Without a -- way out, the only way to stop citing something is `--supersede`, which needs -- a REPLACEMENT revision to keep — so a document with no successor cannot be -- withdrawn at all today. See section 16.10 of BUILD-AI-CONTAINERS.md. -- -- THREE OPERATIONS, AND THE DIFFERENCE BETWEEN THEM IS THE WHOLE POINT: -- -- withdraw superseded = TRUE. Chunks stay, stop being citable, immediately. -- Reversible. This is what "remove it" almost always means, and it -- is the default the UI offers. -- restore superseded = FALSE again. Refused while another revision of the -- same document is live — un-withdrawing the old rev of a -- procedure alongside the new one is the failure this whole -- project exists to avoid. -- purge DELETE the chunks. Irreversible, off by default, and it still -- does not destroy the file or this audit row. For the upload that -- should never have happened, not for the document that is merely -- out of date. -- ============================================================================= -- ----------------------------------------------------------------------------- -- doc_actions — who took a document out, when, why, and what it affected. -- -- One row per action, written BEFORE the action and completed after, so an -- interrupted purge leaves evidence rather than a silence. `chunks_affected` is -- recorded because after a purge it is the only remaining count. -- ----------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS doc_actions ( action_id UUID PRIMARY KEY, action TEXT NOT NULL, -- withdraw | restore | purge status TEXT NOT NULL DEFAULT 'pending', -- --- what was targeted -------------------------------------------------- -- Either (doc_number, revision) or source_file. Both are kept on the row -- whichever was used to select, because doc_number can be NULL on a chunk -- whose header never parsed, and source_file is then the only handle. doc_number TEXT, revision TEXT, source_file TEXT, -- --- who ---------------------------------------------------------------- -- From Authelia's forwarded headers, never from the request body. actor TEXT NOT NULL, actor_name TEXT, actor_groups TEXT, -- as presented, for the audit trail reason TEXT NOT NULL, -- required on every action, including restore acted_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- --- outcome ------------------------------------------------------------ chunks_affected INT, file_moved_to TEXT, -- /datadisk/ai-docs-withdrawn// error TEXT, completed_at TIMESTAMPTZ, CONSTRAINT doc_actions_action_ck CHECK (action IN ('withdraw','restore','purge')), CONSTRAINT doc_actions_status_ck CHECK (status IN ('pending','complete','failed')), CONSTRAINT doc_actions_target_ck CHECK ( source_file IS NOT NULL OR doc_number IS NOT NULL), -- A blank reason is not a reason. "Outdated" is a poor one but it is the -- reviewer's to give; an empty string is the API failing to ask. CONSTRAINT doc_actions_reason_ck CHECK (length(btrim(reason)) >= 10) ); CREATE INDEX IF NOT EXISTS doc_actions_doc ON doc_actions (doc_number, revision); CREATE INDEX IF NOT EXISTS doc_actions_recent ON doc_actions (acted_at DESC); CREATE INDEX IF NOT EXISTS doc_actions_pending ON doc_actions (acted_at) WHERE status = 'pending'; -- ----------------------------------------------------------------------------- -- Withdrawal is immediate; the file move is not. -- -- Flipping `superseded` stops citation on the next query — that is the part an -- operator is waiting for, and it happens inside the HTTP request. Moving the -- file out of /datadisk/ai-docs needs the worker, because ai-api has no write -- access to the document tree and is not getting any. -- -- The file move is archival housekeeping, NOT the safety mechanism. It used to -- be both: ingest_file() inserted every chunk with superseded = FALSE, so a -- withdrawn document left in the tree came back LIVE on the next -- `ai-ingest --all`. That is fixed in ingest.py itself - withdrawal state is -- read before chunks are replaced and carried through, and --all skips -- withdrawn documents - so citation stops on the database flip alone, whatever -- folder the file is in. The move keeps /datadisk/ai-docs meaning "the -- documents this plant runs on". Until it completes, doc_actions.status stays -- 'pending' and the UI says so. -- ----------------------------------------------------------------------------- -- ============================================================================= -- Grants — the API may make a document LESS visible, never more. -- -- Column-level UPDATE on `superseded` lets ai-api withdraw inside the request, -- which is what makes withdrawal immediate. It cannot touch chunk_text, -- doc_number, revision or the embedding, and it cannot INSERT or DELETE. -- -- But a column grant cannot express "may set TRUE only", so the trigger below -- does. Restore therefore goes through the worker, the same as publishing: -- anything that makes a document citable passes through the component that has -- no HTTP surface, and through a person who gave a reason. -- ============================================================================= GRANT UPDATE (superseded) ON doc_chunks TO uploads_rw; GRANT INSERT, UPDATE ON doc_actions TO uploads_rw; GRANT SELECT ON doc_actions TO uploads_rw, ingest_rw, agent_ro; GRANT INSERT, UPDATE ON doc_actions TO ingest_rw; -- No DELETE for anyone. An audit trail that can be edited is a log, not a -- trail; purge deletes chunks and keeps its own receipt. REVOKE DELETE ON doc_actions FROM uploads_rw, ingest_rw, agent_ro; CREATE OR REPLACE FUNCTION doc_chunks_withdraw_only() RETURNS TRIGGER AS $fn$ BEGIN -- uploads_rw is the web-facing role. It may withdraw. It may not restore, -- and it may not un-supersede a revision that a person withdrew on purpose. IF current_user = 'uploads_rw' AND NEW.superseded IS DISTINCT FROM TRUE THEN RAISE EXCEPTION 'uploads_rw may set superseded = TRUE only; restoring a document ' 'goes through ai-docs-worker (see db/005_doc_actions.sql)'; END IF; RETURN NEW; END; $fn$ LANGUAGE plpgsql; DROP TRIGGER IF EXISTS doc_chunks_withdraw_only_tr ON doc_chunks; CREATE TRIGGER doc_chunks_withdraw_only_tr BEFORE UPDATE ON doc_chunks FOR EACH ROW EXECUTE FUNCTION doc_chunks_withdraw_only(); -- ----------------------------------------------------------------------------- -- withdrawn_documents — the counterpart of live_documents. -- -- What has been taken out, when and by whom. The UI needs it for the Withdrawn -- tab, and an engineer needs it for "why can the assistant no longer find -- WRPS-OPS-014?", which is otherwise a question with no answer anywhere. -- ----------------------------------------------------------------------------- CREATE OR REPLACE VIEW withdrawn_documents AS SELECT c.doc_number, c.revision, c.doc_type, max(c.effective_date) AS effective_date, min(c.source_file) AS source_file, count(*) AS chunk_count, max(a.acted_at) AS withdrawn_at, max(a.actor) AS withdrawn_by, max(a.reason) AS reason FROM doc_chunks c LEFT JOIN doc_actions a ON a.action = 'withdraw' AND a.status = 'complete' AND (a.source_file = c.source_file OR (a.doc_number = c.doc_number AND a.revision = c.revision)) WHERE c.superseded = TRUE GROUP BY c.doc_number, c.revision, c.doc_type; GRANT SELECT ON withdrawn_documents TO agent_ro, uploads_rw, ingest_rw; -- A revision superseded by the normal ingest flow, before this table existed, -- appears here with NULL actor and reason. That is accurate: nobody recorded -- who withdrew it, because nothing asked. Do not backfill a name. -- ============================================================================= -- Phase 9 gate — the withdrawal half. Verify, do not assume. -- -- As uploads_rw: -- UPDATE doc_chunks SET superseded = TRUE WHERE ...; -- must work -- UPDATE doc_chunks SET superseded = FALSE WHERE ...; -- must be REJECTED -- UPDATE doc_chunks SET chunk_text = 'x' WHERE ...; -- must be REJECTED -- DELETE FROM doc_chunks WHERE ...; -- must be REJECTED -- INSERT INTO doc_actions (...) VALUES (..., reason => ''); -- -- must be REJECTED -- DELETE FROM doc_actions; -- must be REJECTED -- -- End to end: withdraw a test procedure, then ask the question that used to -- cite it. The answer must stop citing it IMMEDIATELY - not after a restart, -- not after a re-index. Then confirm the file left /datadisk/ai-docs, and -- run `ai-ingest --all`, and confirm it did NOT come back. -- =============================================================================