yau-plant-assistant/db/005_doc_actions.sql
Claude 0c0bd0ef6d Stop a bulk re-ingest resurrecting withdrawn documents
ingest_file() inserted every chunk with superseded = FALSE, so replacing a
document's chunks reset its withdrawal. One `ai-ingest --all` made every
superseded revision citable again - including the old revision of a procedure -
silently, and only after a bulk run, so a supersede survived exactly until the
next full ingest.

  - ingest_file() reads the existing state with superseded_state() before the
    delete and carries it through the insert. A withdrawn document is
    re-ingested as withdrawn, and logs that it did so.
  - --all skips withdrawn documents, so a bulk run does not spend an embeddings
    call on a document that will not be cited either way. --include-superseded
    overrides it; the chunks still come back withdrawn.
  - --restore DOC_NUMBER REVISION is the counterpart to --supersede, refusing
    while another revision of the same document is live. Without it the
    conservative default would be a dead end - mark_superseded() only ever sets
    TRUE, so there was no way back.
  - Rule 5 in the module docstring, beside the other four.

Corrects a claim in the Phase 9 design: 16.10 said moving the withdrawn file
out of /datadisk/ai-docs "is not tidying" because otherwise --all resurrects it.
That was true when written and is not now. The guard belongs in the ingest code,
because a rule that depends on somebody remembering to move a file is not a
rule. The move stays as archival housekeeping and is documented as such in
16.10, db/005_doc_actions.sql and README.md.

Phase 3's gate gains the proof: supersede a revision, run --all again, ask the
question that used to cite it.

Not executed anywhere - no Postgres or Docker on this machine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:50:34 +10:00

184 lines
9.5 KiB
PL/PgSQL

-- =============================================================================
-- 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/<date>/<file>
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.
-- =============================================================================