yau-plant-assistant/db/005_doc_actions.sql
Claude 98083cd8d6 Design Phase 9 - operator document management
Operators cannot add a document today: ingestion is CLI-only, needs a host
login and a TTY for confirm_header(), /datadisk/ai-docs is mounted read-only,
ai-api has no identity, and nothing in the stack has a role that can write
doc_chunks. This designs the way in, the way out, and control over what is in
the retrieval pool. Design and schema only - no router, worker or UI code yet.

Documents in (16.1-16.9, db/004):
  upload -> pre-scan -> review -> approve -> published, with the header
  confirmation moved from a terminal prompt to a review screen and recorded
  rather than discarded. A CHECK constraint refuses an approved row without a
  confirmed number, revision and effective date, so an API bug cannot skip it.
  Three roles: agent_ro unchanged, uploads_rw writes the queue only, ingest_rw
  writes doc_chunks and has no HTTP surface.

Documents out (16.10-16.11, db/005):
  --supersede needs a revision to keep, so a cancelled procedure cannot be
  withdrawn at all. Adds withdraw (immediate, reversible, audited), restore
  (refused while another revision is live) and purge (off by default). A
  column grant plus a trigger let the web-facing role make a document less
  citable and never more.

The pool (16.13-16.15, db/006):
  pool_enabled, orthogonal to superseded: one is a claim about the document,
  the other about the corpus. Retrieval requires both, so re-enabling a
  withdrawn document does not make it citable. Named profiles and a
  per-request override let a demo trim the corpus without mutating state on a
  shared live host, and every reduced-pool answer carries a banner with the
  document count, following the used_fixture_data precedent.

Two existing defects found and documented while designing this:
  - ai-ingest takes PGUSER=agent_ro from api.env, a SELECT-only role, so the
    Phase 3 command in the README cannot write doc_chunks (16.1).
  - ingest_file() always inserts superseded = FALSE, so `--all` re-ingests a
    superseded revision as live. --supersede survives only until the next bulk
    run (16.10).

One commit rather than three: the upload, withdrawal and pool designs
interleave in the same spec, README and compose files.

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

180 lines
9.3 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 NOT OPTIONAL TIDYING. `ingest_file()` inserts every chunk
-- with superseded = FALSE, so `ai-ingest --all` re-ingests a withdrawn document
-- as LIVE. A withdrawn procedure left in the document tree is one bulk re-run
-- away from being citable again, and nobody would be watching for it. Until the
-- move 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.
-- =============================================================================