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>
180 lines
9.3 KiB
PL/PgSQL
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.
|
|
-- =============================================================================
|