yau-plant-assistant/db/004_doc_uploads.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

269 lines
13 KiB
PL/PgSQL

-- =============================================================================
-- 004_doc_uploads.sql — operator document upload, review and publication.
--
-- psql -h pg-ai -U postgres -d plant -f 004_doc_uploads.sql
--
-- Applied at Phase 9. Additive: it does not alter doc_chunks, and the answer
-- path behaves identically whether this table exists or not.
--
-- WHAT THIS TABLE IS. The queue and the audit trail for documents that arrive
-- through the UI instead of by SSH. One row per uploaded file, from the moment
-- it lands in the inbox to the moment its chunks are citable — including who
-- uploaded it, who confirmed its identity, and exactly what they confirmed.
--
-- WHAT IT IS NOT. It is not document control. It records what a named person
-- asserted about a file on a date; it does not know what the current revision
-- of WRPS-OPS-014 actually is. See section 16 of BUILD-AI-CONTAINERS.md.
--
-- THE RULE THIS TABLE EXISTS TO PRESERVE: a wrong revision on a procedure is a
-- safety issue, not a data-quality one. ingest.py asks a human at a terminal.
-- Nothing about a web form removes that requirement — it moves the question
-- from a terminal prompt to a review screen, and records the answer instead of
-- discarding it.
-- =============================================================================
-- -----------------------------------------------------------------------------
-- The state machine. A CHECK rather than an enum, because adding a value to an
-- enum needs an ALTER TYPE that will not run inside the rest of a migration.
--
-- uploaded file written to the inbox, nothing parsed yet
-- | (ai-docs-worker picks it up)
-- v
-- scanning Docling parsing; header proposal being extracted
-- |
-- v
-- awaiting_review proposal ready; a human must confirm or correct it
-- | \
-- | \--> rejected reviewer refused it; file stays in the inbox
-- v
-- approved header CONFIRMED BY A NAMED PERSON; supersede decided
-- | (worker claims it)
-- v
-- ingesting file moved into /docs/<folder>/, chunked and embedded
-- | \
-- | \--> failed error recorded; retryable, file is in place
-- v
-- published chunks live in doc_chunks and citable
--
-- Only `approved` causes chunks to be written. There is no transition from
-- `uploaded` to `ingesting`. That is the design, not an omission.
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS doc_uploads (
upload_id UUID PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'uploaded',
-- --- the file -----------------------------------------------------------
original_filename TEXT NOT NULL, -- as the operator's browser sent it
stored_path TEXT NOT NULL, -- /inbox/<upload_id>/<safe_filename>
content_type TEXT,
size_bytes BIGINT NOT NULL,
sha256 TEXT NOT NULL, -- dedupe, and proof the bytes are unchanged
page_count INT,
preview_text TEXT, -- first page, for the review screen
-- --- who ----------------------------------------------------------------
-- From Authelia's Remote-User / Remote-Name, forwarded by Caddy. NEVER from
-- the request body: a browser must not be able to name its own uploader.
uploaded_by TEXT NOT NULL,
uploaded_by_name TEXT,
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
uploader_note TEXT, -- "assist pump start logic changed, rev C"
-- --- what the uploader said it is (a proposal, never authoritative) ------
proposed_doc_type TEXT,
detected_doc_number TEXT,
detected_revision TEXT,
detected_effective_date DATE,
-- --- what a human confirmed ---------------------------------------------
-- These are the values that reach doc_chunks. They stay NULL until somebody
-- in the publisher group types or accepts them.
confirmed_doc_type TEXT,
confirmed_doc_number TEXT,
confirmed_revision TEXT,
confirmed_effective_date DATE,
supersede_previous BOOLEAN, -- withdraw other revisions of this doc_number
reference_data_checked BOOLEAN, -- see the note below — not cosmetic
reviewed_by TEXT,
reviewed_by_name TEXT,
reviewed_at TIMESTAMPTZ,
review_note TEXT, -- required when rejecting
-- --- outcome ------------------------------------------------------------
published_source_file TEXT, -- the doc_chunks.source_file key
chunk_count INT,
superseded_count INT,
error TEXT, -- operator-readable; never a stack trace
attempts INT NOT NULL DEFAULT 0,
claimed_at TIMESTAMPTZ, -- worker lease, for stuck-job detection
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT doc_uploads_status_ck CHECK (status IN (
'uploaded','scanning','awaiting_review','rejected',
'approved','ingesting','published','failed')),
-- doc_type still comes from a fixed list, and on approval the file is moved
-- into the folder that matches it. The folder stays the on-disk truth.
CONSTRAINT doc_uploads_proposed_type_ck CHECK (
proposed_doc_type IS NULL OR proposed_doc_type IN
('procedure','manual','rationalisation','design')),
CONSTRAINT doc_uploads_confirmed_type_ck CHECK (
confirmed_doc_type IS NULL OR confirmed_doc_type IN
('procedure','manual','rationalisation','design')),
-- An approved row with an incomplete confirmed header is the exact defect
-- this design exists to prevent. Refuse it in the database, not only in the
-- API — the API is one bad deployment away from being bypassed.
CONSTRAINT doc_uploads_approved_needs_header_ck CHECK (
status NOT IN ('approved','ingesting','published')
OR (confirmed_doc_type IS NOT NULL
AND confirmed_doc_number IS NOT NULL
AND confirmed_revision IS NOT NULL
AND confirmed_effective_date IS NOT NULL
AND supersede_previous IS NOT NULL
AND reviewed_by IS NOT NULL)),
CONSTRAINT doc_uploads_rejected_needs_reason_ck CHECK (
status <> 'rejected'
OR (reviewed_by IS NOT NULL AND review_note IS NOT NULL))
);
-- The scan the worker runs every few seconds. Partial: the interesting rows are
-- a handful at a time, the published ones accumulate forever.
CREATE INDEX IF NOT EXISTS doc_uploads_pending
ON doc_uploads (uploaded_at)
WHERE status IN ('uploaded','approved');
CREATE INDEX IF NOT EXISTS doc_uploads_status_recent
ON doc_uploads (status, uploaded_at DESC);
-- The same bytes uploaded twice is almost always a double-click or a re-send,
-- not a second document. Not UNIQUE: re-uploading after a rejection is
-- legitimate, and the API decides, having seen the earlier row.
CREATE INDEX IF NOT EXISTS doc_uploads_sha256 ON doc_uploads (sha256);
CREATE OR REPLACE FUNCTION doc_uploads_touch() RETURNS TRIGGER AS $fn$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$fn$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS doc_uploads_touch_tr ON doc_uploads;
CREATE TRIGGER doc_uploads_touch_tr BEFORE UPDATE ON doc_uploads
FOR EACH ROW EXECUTE FUNCTION doc_uploads_touch();
-- -----------------------------------------------------------------------------
-- reference_data_checked — why a boolean on this table earns its place.
--
-- This upload path exists because PLC logic and SCADA programs change and a new
-- document is issued. Ingesting that document changes what the assistant can
-- CITE. It does not change db/seed/tags.csv, the Cube models, the alarm
-- setpoints in `tags`, or anything else the numeric answers are computed from.
--
-- So a design document describing a new interlock can go live while every
-- Historical and Advisory answer is still built on the old tag metadata — and
-- both look equally confident on screen.
--
-- The review screen asks the reviewer to confirm they have considered that. It
-- is an acknowledgement, not a check; nothing here can verify it. It exists so
-- the gap is visible at the one moment somebody can still act on it, and so the
-- audit trail shows who was asked.
-- -----------------------------------------------------------------------------
-- -----------------------------------------------------------------------------
-- live_documents — what is currently citable, one row per document revision.
--
-- The review screen needs this to answer "what am I about to supersede?" before
-- the reviewer ticks the box, not after.
-- -----------------------------------------------------------------------------
CREATE OR REPLACE VIEW live_documents AS
SELECT doc_number,
revision,
doc_type,
max(effective_date) AS effective_date,
min(source_file) AS source_file,
count(*) AS chunk_count,
max(created_at) AS ingested_at
FROM doc_chunks
WHERE superseded = FALSE
GROUP BY doc_number, revision, doc_type;
-- =============================================================================
-- Roles. Three, not one, and the split is the point.
--
-- agent_ro the answer path. SELECT only, everywhere. UNCHANGED here — it
-- gains no write anywhere, including on this table.
-- uploads_rw ai-api. Writes the QUEUE and nothing else. It cannot write
-- doc_chunks, so no defect in an HTTP endpoint can put a chunk in
-- front of an operator without a human approval in between.
-- ingest_rw ai-docs-worker. Writes doc_chunks. Has no HTTP surface at all
-- and is not on the proxy network.
--
-- ai-api therefore holds two connections: the existing agent_ro one for
-- answering, and a uploads_rw one for the document screens. Do not collapse
-- them into one role that can do both.
-- =============================================================================
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'uploads_rw') THEN
CREATE ROLE uploads_rw LOGIN;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'ingest_rw') THEN
CREATE ROLE ingest_rw LOGIN;
END IF;
END
$$;
-- Passwords are NOT in this file. Set them from the 0600 env files, as in
-- 003_roles.sql.
-- --- uploads_rw — the queue, and read-only on everything else ----------------
GRANT CONNECT ON DATABASE plant TO uploads_rw;
GRANT USAGE ON SCHEMA public TO uploads_rw;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO uploads_rw;
GRANT INSERT, UPDATE ON doc_uploads TO uploads_rw;
-- No DELETE: a rejected upload is history, not a mistake to erase.
REVOKE DELETE ON doc_uploads FROM uploads_rw;
-- Explicit, and re-checked at the Phase 9 gate: no write on doc_chunks.
REVOKE INSERT, UPDATE, DELETE ON doc_chunks FROM uploads_rw;
REVOKE CREATE ON SCHEMA public FROM uploads_rw;
REVOKE TEMPORARY ON DATABASE plant FROM uploads_rw;
-- --- ingest_rw — the worker -------------------------------------------------
GRANT CONNECT ON DATABASE plant TO ingest_rw;
GRANT USAGE ON SCHEMA public TO ingest_rw;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO ingest_rw;
GRANT INSERT, UPDATE, DELETE ON doc_chunks TO ingest_rw;
GRANT USAGE, SELECT ON SEQUENCE doc_chunks_id_seq TO ingest_rw;
GRANT INSERT, UPDATE ON doc_uploads TO ingest_rw;
REVOKE CREATE ON SCHEMA public FROM ingest_rw;
REVOKE TEMPORARY ON DATABASE plant FROM ingest_rw;
-- The answer path reads the queue — so the UI can tell an operator a document
-- is pending rather than silently not finding it — and writes nothing.
GRANT SELECT ON doc_uploads TO agent_ro;
GRANT SELECT ON live_documents TO agent_ro, uploads_rw, ingest_rw;
-- =============================================================================
-- Phase 9 gate — verify, do not assume. Prove each role separately.
--
-- As agent_ro:
-- SELECT count(*) FROM doc_uploads; -- must work
-- UPDATE doc_uploads SET status = 'approved'; -- must be REJECTED
--
-- As uploads_rw:
-- INSERT INTO doc_uploads (...) VALUES (...); -- must work
-- INSERT INTO doc_chunks (source_file, doc_type, chunk_text)
-- VALUES ('x','manual','x'); -- must be REJECTED
-- DELETE FROM doc_uploads; -- must be REJECTED
--
-- As anyone — the constraint that carries the safety rule:
-- UPDATE doc_uploads SET status = 'approved'
-- WHERE upload_id = '<a row with no confirmed header>';
-- -- must be REJECTED
--
-- An approved row with a NULL confirmed_revision is a Phase 9 failure, not a
-- detail to fix later.
-- =============================================================================