yau-plant-assistant/db/004_doc_uploads.sql
Claude 3262f1a050 Give ingestion a role that can write
ai-ingest built its DSN from PGUSER/PGPASSWORD and takes its environment from
~/ai/api.env, where PGUSER=agent_ro - SELECT and nothing else, deliberately,
because it is what the answer path runs as. So

    docker compose -f ~/ai-compose.yml run --rm ai-ingest --all

connected as a role that cannot INSERT INTO doc_chunks, and Phase 3 was
unrunnable exactly as the README documents it. Nothing had reached Phase 3 yet,
so nobody had hit it.

The failure would also have landed at the worst possible moment: at the final
INSERT, after the Docling parse, after a person had typed the header
confirmations for every file, and after a billed embeddings call - with a
permission error naming no cause.

  - ingest_rw moves to 003_roles.sql, at Phase 1 with the other roles. It is
    not a Phase 9 concept; ingestion has needed a writing role since Phase 3
    and never had one. 004 keeps only its grants on the upload queue, and its
    idempotent role creation so it still applies to an older database.
  - ingest.py connects through INGEST_DB_USER / INGEST_DB_PASSWORD, falling
    back to PGUSER only for a local shell where one pair is set.
  - require_write_access() checks INSERT, UPDATE and DELETE on doc_chunks
    before anything is parsed or embedded, and fails with the fix in the
    message. Falling back to PGUSER cannot smuggle agent_ro past it.
  - Keyword connection parameters rather than a URL: a generated password
    containing @ or / breaks a DSN string silently.
  - A missing doc_chunks now says "apply 001_schema.sql" instead of raising
    UndefinedTable.

Phase 1's gate gains the check that would have caught this: ingest_rw must be
able to write doc_chunks. An ingestion role that cannot write is the same class
of failure as an API role that can - it just surfaces two phases later.

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

266 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, and the ai-ingest CLI. Writes doc_chunks. Has
-- no HTTP surface at all and is not on the proxy network. CREATED
-- IN 003_roles.sql, because ingestion has needed it since Phase 3
-- - this file only adds its grants on the queue.
--
-- 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;
-- ingest_rw belongs to 003_roles.sql. Kept idempotently here so this file
-- still applies cleanly against a database that predates that change.
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. Its doc_chunks grants are in 003_roles.sql. ----
GRANT INSERT, UPDATE ON doc_uploads TO 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.
-- =============================================================================