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>
This commit is contained in:
Claude 2026-08-21 13:17:38 +10:00
parent 0594fac3fb
commit 98083cd8d6
10 changed files with 1217 additions and 5 deletions

View file

@ -57,3 +57,40 @@ LANGFUSE_DB_PASSWORD=
# --- ingest ------------------------------------------------------------------ # --- ingest ------------------------------------------------------------------
AI_DOCS_ROOT=/datadisk/ai-docs AI_DOCS_ROOT=/datadisk/ai-docs
CHUNK_TOKEN_TARGET=800 CHUNK_TOKEN_TARGET=800
# --- document upload (Phase 9) -----------------------------------------------
# Two more roles, because the component reachable from the internet must not be
# the component that can write doc_chunks. See db/004_doc_uploads.sql.
UPLOADS_DB_USER=uploads_rw # ai-api: the queue only, never doc_chunks
UPLOADS_DB_PASSWORD=
INGEST_DB_USER=ingest_rw # ai-docs-worker AND the ai-ingest CLI:
# doc_chunks + the queue. PGUSER is agent_ro
# and cannot INSERT - see BUILD-AI-CONTAINERS §16.1.
INGEST_DB_PASSWORD=
AI_DOCS_INBOX=/datadisk/ai-docs-inbox # writable staging; NOT the ingest root
AI_DOCS_WITHDRAWN=/datadisk/ai-docs-withdrawn # withdrawn files are moved, not deleted
MAX_UPLOAD_MB=50
UPLOAD_DISK_LIMIT_PCT=90 # refuse uploads above this on /datadisk
ALLOWED_UPLOAD_EXTENSIONS=.pdf,.docx,.md,.txt
# DIRECT membership only — Authelia does not resolve nested groups.
DOC_PUBLISHER_GROUP=AI_DocPublishers
# Who may curate the retrieval pool and run trimmed-pool demos. Defaults to the
# publisher group; point it at a narrower AD group if that should be separate.
DOC_ADMIN_GROUP=AI_DocPublishers
ALLOW_SELF_APPROVAL=false # uploader approving their own document
# Withdrawal (superseded = TRUE) is always available to the publisher group and
# is reversible. PURGE deletes chunks and is not. Leave it off unless there is a
# document that must not be in the database at all.
ALLOW_PURGE=false
# Retrieval pool. pool_enabled is orthogonal to superseded - see db/006_doc_pool.sql.
# Below this share of documents enabled, retrieval drops to an exact scan: the
# HNSW index is built over ALL embeddings and filters afterwards, so a heavily
# trimmed pool can return almost nothing. This bites in exactly the demo that
# trims the pool. Rehearse it.
POOL_EXACT_SCAN_BELOW_PCT=50
WORKER_POLL_SECONDS=10
WORKER_LEASE_MINUTES=30 # an `ingesting` row older than this is a dead worker

View file

@ -248,7 +248,8 @@ Project lives in Forgejo (`git.yokogawa.tech`). Compose files stay in `~` per ho
├── db/ ├── db/
│ ├── 001_schema.sql # equipment, tags, doc_chunks │ ├── 001_schema.sql # equipment, tags, doc_chunks
│ ├── 002_fixtures.sql # interim stand-in for imh — clearly marked │ ├── 002_fixtures.sql # interim stand-in for imh — clearly marked
│ └── 003_roles.sql # agent_ro, SELECT only │ ├── 003_roles.sql # agent_ro, SELECT only
│ └── 004_doc_uploads.sql # Phase 9 — upload queue, uploads_rw / ingest_rw
├── cube/model/ ├── cube/model/
│ ├── alarms.yml │ ├── alarms.yml
│ ├── process_values.yml │ ├── process_values.yml
@ -260,10 +261,13 @@ Project lives in Forgejo (`git.yokogawa.tech`). Compose files stay in `~` per ho
│ ├── agent.py # LangGraph, one branch per class │ ├── agent.py # LangGraph, one branch per class
│ ├── contracts.py # Pydantic model per class + validation │ ├── contracts.py # Pydantic model per class + validation
│ ├── tools/{metrics,retrieval,equipment}.py │ ├── tools/{metrics,retrieval,equipment}.py
│ ├── docs_router.py # Phase 9 — /docs/* upload, review, approve
│ ├── identity.py # Phase 9 — Authelia headers → caller + groups
│ ├── guardrails.py # sqlglot + contract enforcement │ ├── guardrails.py # sqlglot + contract enforcement
│ └── Dockerfile │ └── Dockerfile
├── ingest/ ├── ingest/
│ ├── ingest.py # Docling → chunk → embed → pg-ai │ ├── ingest.py # Docling → chunk → embed → pg-ai
│ ├── worker.py # Phase 9 — pre-scan and publish the upload queue
│ └── Dockerfile │ └── Dockerfile
├── web/ # React + Vite ├── web/ # React + Vite
├── eval/ ├── eval/
@ -568,6 +572,51 @@ Deployed early, deliberately: from here on, every experiment is traced.
- [ ] **Zero contract violations** across the whole run - [ ] **Zero contract violations** across the whole run
- [ ] p95 latency under 12 s - [ ] p95 latency under 12 s
- [ ] Zero SQL executed outside the allow-list - [ ] Zero SQL executed outside the allow-list
---
### Phase 9 — Operator document upload
**Only after Phase 8 has passed.** This phase gives people who are not on the host the ability to change what the assistant cites. Do not build it on top of an unvalidated retrieval path — if a wrong answer can already be produced from the documents that are loaded, adding a way for more documents to arrive makes that harder to diagnose, not easier. Full design in section 16.
**Tasks**
1. `db/004_doc_uploads.sql` — the `doc_uploads` queue, the `live_documents` view, and the `uploads_rw` / `ingest_rw` roles. `agent_ro` gains nothing. Then `db/005_doc_actions.sql` — the `doc_actions` trail, the `withdrawn_documents` view, the column-level `superseded` grant and the withdraw-only trigger.
2. `/datadisk/ai-docs-inbox` — the writable staging area. **Check `df -h /datadisk` first.** Never on `/`.
3. `ai-api`: the `/docs/*` router — upload, list, detail, approve, reject, withdraw, restore, purge. Identity comes from Authelia's forwarded headers, never from the request body. Approval requires the publisher group and is refused without it, whatever Authelia allowed through.
4. `ai-docs-worker` — the `ingest` image with `worker.py` as its entrypoint. Long-running, `ai-internal` only, no published port. Pre-scans uploads for a header proposal, ingests approved ones, and completes withdrawals, restores and purges. `/datadisk/ai-docs-withdrawn` is created and mounted with the other two.
- **While you are in `ingest.py`: make `mark_superseded()` move the superseded file out of the tree as well.** Without that, `--all` re-ingests it as live — see the defect note in section 16.10.
5. `ingest.py`: extract `ingest_file(path, header=...)` so a header confirmed in the UI is passed in. `confirm_header()` stays the CLI path. Neither one gets a way to ingest an unconfirmed header. **Also switch its DSN to `INGEST_DB_USER`/`INGEST_DB_PASSWORD`** — see the defect note in section 16.1; the CLI path is currently connecting as `agent_ro` and cannot write.
6. `db/006_doc_pool.sql``pool_enabled`, the profile tables, `pool_status` / `pool_documents`. Then `tools/retrieval.py` gains the `pool_enabled` predicate and an optional per-request profile, `contracts.py` gains `pool_scope` on `BaseAnswer`, and the UI gains the banner. **Read the HNSW note at the top of 006 before trimming the pool for a demo.**
7. `ai-web`: a **Documents** view — upload form, review queue, review screen, the published list, and the pool screen. The nav entry is hidden without the publisher group; the hiding is cosmetic, the API check is the control.
8. Caddyfile: `copy_headers` on the `api.yokogawa.tech` block and a `request_body max_size`. Authelia: the `/docs/.*` resource rule for `AI_DocPublishers`, **placed before** the general `api.yokogawa.tech` rule.
9. Create `AI_DocPublishers` in AD with **direct** membership. Nested membership silently fails.
**Gate**
- [ ] As `uploads_rw`, `INSERT INTO doc_chunks` is **rejected**; as `agent_ro`, any write to `doc_uploads` is **rejected**
- [ ] `UPDATE doc_uploads SET status='approved'` on a row with no confirmed header is **rejected by the database**
- [ ] A user who is authenticated but **not** in `AI_DocPublishers` gets 403 from `POST /docs/uploads/{id}/approve` — verified by calling `api.yokogawa.tech` directly, not just by the button being hidden
- [ ] A request to `/docs/*` carrying a forged `Remote-User` header from outside Caddy is rejected
- [ ] Upload → pre-scan → review → approve → published works end to end, and the file ends up in the folder matching its confirmed `doc_type`
- [ ] Rejecting an upload leaves `doc_chunks` **byte-identical** — confirm by count and by `max(created_at)`
- [ ] A new revision approved with supersede ticked makes the old revision uncitable: ask the interlock question and confirm the answer cites the new revision only
- [ ] A new revision approved with supersede **unticked** leaves both citable — confirm this is visible on the review screen before approval, because it is the failure that reaches an operator
- [ ] A `.exe` renamed to `.pdf` is refused; a 200 MB file is refused; a filename containing `../` is refused
- [ ] The worker dying mid-ingest leaves a `failed` row with a retryable file, never a half-ingested document
- [ ] `df -h /datadisk` recorded before and after; `df -h /` unchanged
- [ ] Every publication has a named uploader and a named, different reviewer in `doc_uploads`
- [ ] As `uploads_rw`: setting `superseded = TRUE` works, setting it `FALSE` is **rejected by the trigger**, and `chunk_text`, `DELETE` and `INSERT` on `doc_chunks` are all still rejected
- [ ] Withdrawing a procedure stops it being cited on the **very next question** — no restart, no re-index
- [ ] After a withdrawal, the file is out of `/datadisk/ai-docs`, and `ai-ingest --all` does **not** bring it back. Run it and check, because this is the failure that puts a withdrawn procedure back in front of an operator
- [ ] A withdrawal selector matching nothing returns 404, not a cheerful 200
- [ ] Restoring a revision while another revision of the same document is live is **refused**, and the refusal names the live one
- [ ] `POST /docs/purges` returns 403 while `ALLOW_PURGE=false`, and a purge with a mistyped `confirm_doc_number` is refused
- [ ] `doc_actions` rows cannot be deleted by any role the stack uses
- [ ] Re-enabling a **withdrawn** document in the pool does not make it citable — the orthogonality the two flags exist for
- [ ] With 90% of the corpus out of the pool, a question whose answer is in the remaining 10% still finds it. If it does not, see the HNSW note in `db/006_doc_pool.sql` — fix it there, not in the prompt
- [ ] **Every** answer from a reduced pool carries the reduced-pool banner, on all four classes, with the document count. Verified by eye on the operator screen, not just in the JSON
- [ ] A Procedural question whose governing procedure is out of the pool says so *as scope* — "no governing procedure in the active document set (n of m)" — and does not read as "no such procedure exists"
- [ ] `pool_profile` on `/ask` changes nothing stored: run a trimmed request, then a normal one from another browser, and confirm the second is unaffected
- [ ] An unknown `pool_profile` is a 400, never a silent fall back to `full`
- [ ] `run_eval.py` refuses to score a gate run against a non-`full` pool
--- ---
@ -610,9 +659,11 @@ Deployed early, deliberately: from here on, every experiment is traced.
- Shared `azureuser` login; no per-person audit trail on the host - Shared `azureuser` login; no per-person audit trail on the host
- Shared service account to `imh`; no per-operator row-level security - Shared service account to `imh`; no per-operator row-level security
- No automated backup — inherited host issue; `pg-ai` needs adding to whatever backup exists - No automated backup — inherited host issue; `pg-ai` needs adding to whatever backup exists
- Document revision metadata entered semi-manually, not integrated with document control - Document revision metadata entered semi-manually, not integrated with document control. From Phase 9 the upload path at least records *who* asserted a revision and when (`doc_uploads`); it still does not know what the current revision actually is
- Uploaded documents are not scanned for malware — there is no ClamAV on this host. Extension, magic bytes and size only, on a host that also runs the demo PLC
- `ai-api` trusts Authelia's `Remote-User` / `Remote-Groups` headers because nothing outside the `proxy` network can reach it. Any container on `proxy` could forge them; that assumption is exactly as strong as the no-published-ports rule
Production closes these in the order: network segmentation → secrets → SQL guardrails → document control integration → HA. Production closes these in the order: network segmentation → secrets → SQL guardrails → document control integration → HA. Phase 9 makes document control integration the more urgent of those, not less: once operators can add documents, the assistant's document set drifts from the controlled set faster.
--- ---
@ -628,4 +679,242 @@ Production closes these in the order: network segmentation → secrets → SQL g
- [ ] Port 22 on the VM is open to the internet (`20.211.144.151:22`). Key-only auth, but review it in the same NSG pass and restrict to office and VPN ranges - [ ] Port 22 on the VM is open to the internet (`20.211.144.151:22`). Key-only auth, but review it in the same NSG pass and restrict to office and VPN ranges
- [ ] **Section 2 reviewed with an OT/safety representative before any operator sees a demo** - [ ] **Section 2 reviewed with an OT/safety representative before any operator sees a demo**
- [ ] Section 14 reviewed and confirmed as still-accurate shortcuts - [ ] Section 14 reviewed and confirmed as still-accurate shortcuts
- [ ] Every published document has a named uploader and a named, different reviewer in `doc_uploads` (Phase 9)
- [ ] `/datadisk/ai-docs-inbox` growth watched — uploads are retained as evidence and nothing prunes them
- [ ] The new services added to the host documentation, following the existing change-log convention - [ ] The new services added to the host documentation, following the existing change-log convention
---
## 16. Operator document upload — design
Built at Phase 9. The operational need: when the PLC logic or the SCADA program changes, a new or revised document is issued, and the assistant is wrong about the plant from that moment until the document is ingested. Today that requires SSH to a live shared host, so it happens when an engineer gets to it, not when the document is issued.
### 16.1 What exists today, and why it does not cover this
| Piece | Today | Gap |
|---|---|---|
| Getting a file onto the host | `scp` to `/datadisk/ai-docs/<folder>/` | Needs a host login. Operators have neither one nor a reason to have one. |
| Ingesting | `docker compose run --rm ai-ingest --file …` | Needs a shell, a TTY for `confirm_header`, and Compose. |
| Document mount | `/datadisk/ai-docs:/docs:ro` | Nothing in the stack can write a document anywhere. |
| Database write | Nothing in the stack has a role that can write `doc_chunks` — see the defect note below | The API cannot write, and must not be handed a role that can. |
| Identity | none in `ai-api` — Authelia authenticates at the edge and the app never sees who it was | "Who approved this revision" is unanswerable, and every authenticated user is currently equivalent. |
| Superseding | `--supersede WRPS-OPS-014 4`, run by hand | The judgement call has no interface. |
| Choosing which documents are in the pool | nothing — every live chunk is retrievable | No way to curate the corpus, and no way to demonstrate what coverage does to answer quality. Sections 16.1316.14. |
| Removing a document | nothing | `--supersede` needs a revision to *keep*. A cancelled procedure, or a manual for equipment that has been removed, cannot be taken out at all without hand-written SQL. Section 16.10. |
So: **no**, nothing in the current setup allows an operator to add a document, and none of the missing pieces is a small one. The design below adds them without moving any of the human decisions.
> **Defect found while writing this, and fixed by it.** `ingest.py` builds its DSN from `PGUSER`/`PGPASSWORD`, and `ai-ingest` takes its environment from `~/ai/api.env`, where `PGUSER=agent_ro` — a role deliberately granted `SELECT` and nothing else. As configured, `docker compose run --rm ai-ingest --all` connects as a role that cannot `INSERT INTO doc_chunks`, so the Phase 3 command in the README fails at the write unless someone overrides `PGUSER` on the command line. `ingest_rw` in `db/004_doc_uploads.sql` is the role that path should have been using; `ingest.py` should read `INGEST_DB_USER`/`INGEST_DB_PASSWORD` and fall back to `PGUSER` only when they are unset. Fix it with Phase 9, or sooner if Phase 3 is being run before then.
### 16.2 What does not change
The four rules at the top of `ingest/ingest.py` survive intact, and each one is load-bearing here:
1. **The header is confirmed by a human.** `confirm_header()` at a TTY becomes a review screen. The prompt moves; the requirement does not. The `doc_uploads_approved_needs_header_ck` constraint enforces it in the database, so a bug in the API cannot skip it.
2. **A numbered step sequence is never split.** Unchanged — the same `chunk_section()` runs, because the worker calls the same code.
3. **`doc_type` comes from the folder.** The uploader *proposes* a type and the reviewer *confirms* it; on approval the file is **moved into the matching folder** before ingestion. The folder stays the on-disk truth, and a CLI `--all` re-run later produces exactly the same result.
4. **Re-runs replace, never duplicate.** Unchanged — the worker ingests by `source_file` with the same delete-then-insert transaction.
And the three lines from section 2 are untouched: this changes what can be cited, never how an answer is composed.
### 16.3 Shape
```
operator ─► Caddy ─► Authelia ─► ai-web ──POST /docs/uploads──► ai-api
(AD user) (2FA) │ uploads_rw
│ (queue only)
/datadisk/ai-docs-inbox│ │
▲ ▼ ▼
└── file ── doc_uploads (pg-ai)
ai-docs-worker ◄───────────┘ poll
(ingest image, ai-internal,
no port, ingest_rw)
┌─────────────────────────────┼──────────────────┐
▼ ▼ ▼
pre-scan: Docling move to /datadisk/ai-docs doc_chunks
header proposal /<doc_type folder>/ + supersede
```
Two deliberate choices in that picture:
**Docling stays out of `ai-api`.** The layout models are hundreds of megabytes and the parse is CPU-heavy; putting it in the API would make every deployment of the answer path drag it along, and a long parse would block a request. The worker is the `ai-ingest` image with a different entrypoint, so parsing behaviour is identical to the CLI path by construction rather than by discipline.
**The API cannot write `doc_chunks`.** `uploads_rw` writes the queue; `ingest_rw` writes the chunks and has no HTTP surface. The component reachable from the internet is not the component that can put text in front of an operator. Its one exception is deliberate and points the safe way: a column-level grant on `superseded` lets it *withdraw* a document inside the request, and a trigger stops it un-withdrawing one (section 16.10).
### 16.4 Identity and authorisation
`ai-api` has no authentication today because Authelia does it at the edge. That remains true, but the app now needs to know *who*, so:
- Caddy forwards `Remote-User`, `Remote-Name`, `Remote-Email`, `Remote-Groups` on the `api.yokogawa.tech` block. **Check the shared `authelia` snippet first** — if it already sets `copy_headers`, do not duplicate it, and do not edit the shared snippet, because every other service on the host imports it.
- `ai-api` treats those headers as trusted **only** because nothing outside the `proxy` network can reach it. That assumption is exactly as strong as the no-published-ports rule, and no stronger: any container on `proxy` could forge them. Recorded in section 14 as a shortcut.
- A missing `Remote-User` on a `/docs/*` request is a **401**, never an anonymous fallback. Getting to `ai-api` without passing Authelia is not a state in which to accept a document.
- **Two groups, not one.** `HTTPS_UserAccess` (existing) can ask questions and upload. `AI_DocPublishers` (new) can approve, reject and supersede. Membership must be **DIRECT** — nested membership silently fails on this host.
- Authelia enforces the group at the edge with a `resources: ['^/docs/.*']` rule placed *before* the general `api.yokogawa.tech` rule (first match wins). `ai-api` re-checks `Remote-Groups` on every approve, reject, withdraw, restore and purge. Two checks, because the Authelia rule is one careless reorder away from being ineffective and nobody would notice.
- `reviewed_by` must differ from `uploaded_by`. `ALLOW_SELF_APPROVAL=false` by default; setting it true is a decision someone makes and lives with, not a default.
### 16.5 Upload
`POST /docs/uploads`, multipart: the file, a proposed `doc_type`, an optional note. The note is where "PLC logic changed for the assist pump start sequence" belongs, and it is what the reviewer reads first.
Refused, with a specific message rather than a generic 400:
- extension outside `.pdf .docx .md .txt`, **or** magic bytes disagreeing with the extension
- larger than `MAX_UPLOAD_MB` (50 default)
- a filename that is not a plain basename after sanitising — no separators, no traversal, no leading dot
- `/datadisk` above `UPLOAD_DISK_LIMIT_PCT` (90 default). The root disk has hit 100% on this host before; a document upload form is a new and enthusiastic way to fill a disk, and it must refuse before it is the cause
- identical `sha256` to a row already `awaiting_review` or `published`, unless `?duplicate_ok=true` — a double-click is not a second document
Accepted files are written to `/datadisk/ai-docs-inbox/<upload_id>/<safe_filename>`, one directory per upload so two documents with the same name never collide, and a row is inserted as `uploaded`. **Nothing is parsed in the request.** The response is the `upload_id` and a status the UI polls.
The inbox is not `/datadisk/ai-docs`. A file that has been uploaded but not approved must not be visible to a CLI `--all` run, which would ingest it with no confirmed header.
### 16.6 Pre-scan
The worker claims `uploaded` rows (`FOR UPDATE SKIP LOCKED`), sets `scanning`, runs the existing `parse_document()` and `extract_header()`, stores `detected_*`, `page_count` and a first-page `preview_text`, and sets `awaiting_review`.
The proposal is a convenience for the reviewer and nothing more. A scan that finds nothing is not an error — it produces an `awaiting_review` row with empty fields and a review screen the reviewer must fill in by hand, which is the same thing `confirm_header()` does at a terminal when the regexes miss.
### 16.7 Review and approval
The review screen shows: the file, the preview, the uploader and their note, the detected header, and — from `live_documents`**what is currently live for that document number**. The reviewer:
- confirms or corrects `doc_type`, `doc_number`, `revision`, `effective_date`
- decides `supersede_previous`, with the affected revisions listed by number and chunk count next to the checkbox. "Rev 3 will stop being citable" is information the reviewer needs *before* ticking, and it is the decision the CLI silently leaves to a separate `--supersede` command that people forget to run
- ticks `reference_data_checked` — see the note in `db/004_doc_uploads.sql`. A new design document does **not** update `tags.csv`, the Cube models or any setpoint. The document goes live; the numbers behind Historical and Advisory answers do not move. The reviewer is asked to confirm they know that
- approves, or rejects with a required reason
Approval writes the confirmed fields and `approved` in one transaction. Rejection is terminal for that row; the file stays in the inbox for evidence and the row keeps the reason.
### 16.8 Publication
The worker claims `approved` rows, sets `ingesting`, and then, in this order:
1. **Moves** the file to `/datadisk/ai-docs/<folder-for-confirmed-doc_type>/<filename>`. Move first, so `source_file` is stable and a later CLI `--file` retry addresses the same path. If the target name exists, it is treated as a re-ingest of that `source_file` — which rule 4 already handles — **unless** the live chunks for that path carry a different `doc_number`, which is refused as `failed`: silently replacing one document with another is how the wrong procedure ends up under the right name.
2. Ingests via `ingest_file(path, header=<confirmed>)` — the same parse, the same chunking, the same replace-in-one-transaction.
3. Applies `mark_superseded()` when the reviewer ticked it, in the same transaction as the insert. A new revision live alongside its predecessor, even for a few seconds, is a citable withdrawn procedure.
4. Sets `published` with `chunk_count`, `superseded_count` and `published_source_file`.
Any failure sets `failed` with an operator-readable message and increments `attempts`. Nothing retries automatically: an ingest that failed once will usually fail again, and a retry loop against a paid embeddings API is a bill, not a recovery. A row `ingesting` with a `claimed_at` older than `WORKER_LEASE_MINUTES` is a dead worker and is returned to `approved` on startup.
### 16.9 API surface — getting documents in
All under `/docs`, all requiring `Remote-User`. Publisher group required where marked.
| Method | Path | Group | Purpose |
|---|---|---|---|
| `POST` | `/docs/uploads` | — | Upload a file. 201 with `upload_id` and status. |
| `GET` | `/docs/uploads` | — | Queue list, filterable by status. Own uploads always visible. |
| `GET` | `/docs/uploads/{id}` | — | Detail: detected header, preview, uploader, current live revisions of the same `doc_number`. |
| `POST` | `/docs/uploads/{id}/approve` | ✔ | Confirmed header + `supersede_previous` + `reference_data_checked`. 409 unless the row is `awaiting_review`. |
| `POST` | `/docs/uploads/{id}/reject` | ✔ | Requires a reason. |
| `GET` | `/docs/documents` | — | `live_documents` — what the assistant can currently cite. |
| `GET` | `/docs/me` | — | The caller's name and whether they hold the publisher group, so the UI can hide what it should hide. |
`/ask` is untouched. Adding an upload path must not add a field, a branch or a millisecond to the answer path.
### 16.10 Taking a document out
Documents leave as well as arrive, and today there is no way to do it. `--supersede WRPS-OPS-014 4` needs a revision to *keep*, so it covers "rev 4 replaces rev 3" and nothing else. A procedure that is cancelled outright, a manual for equipment that has been removed, a document uploaded for the wrong site — none of them can be taken out of the assistant at all without hand-written SQL against `doc_chunks`.
Three operations, and the difference between them is the design:
| | What it does | Reversible | Who | Default |
|---|---|---|---|---|
| **Withdraw** | `superseded = TRUE`. Chunks stay, stop being citable, immediately. | yes | `AI_DocPublishers` | this is what the button says |
| **Restore** | `superseded = FALSE`. Refused while another revision of the same document is live. | n/a | `AI_DocPublishers` | rare, and it goes through the worker |
| **Purge** | `DELETE` the chunks. | **no** | `AI_DocPublishers` + `ALLOW_PURGE=true` | off |
**Withdraw is the answer to "remove this document" almost every time.** Retrieval already filters `superseded = FALSE`, so a withdrawal takes effect on the next question — no re-index, no restart, no worker round trip. The chunks stay in the table, which is what lets somebody answer "why did the assistant stop citing WRPS-OPS-014, and who decided that?" a month later. Deleting the rows answers the same question with silence.
**The file must leave the document tree too, and that is not tidying.** `ingest_file()` inserts every chunk with `superseded = FALSE`. A withdrawn document still sitting in `/datadisk/ai-docs/procedures/` comes back **live** the next time anyone runs `ai-ingest --all`, and nobody is watching for it. So a withdrawal moves the file to `/datadisk/ai-docs-withdrawn/<date>/`, outside the four `doc_type` folders that `--all` walks. The database flip is immediate and synchronous; the file move is queued to the worker, because `ai-api` has no write access to the document tree and is not getting any. Until the move completes the `doc_actions` row stays `pending`, and the UI says "withdrawn, file move pending" rather than claiming it is finished.
> **Existing defect, same root cause.** This already bites without any UI: `--supersede` marks rev 3 superseded, rev 3's file stays in `procedures/`, and the next `--all` re-ingests it as live. The supersede survives only until the next bulk run. Moving superseded files out of the tree — which Phase 9 does for withdrawals — is the fix for both, and worth doing to `mark_superseded()` at the same time.
**Restore exists because withdrawing the wrong document is a thing people do.** It refuses while another revision of the same `doc_number` is live: restoring rev 3 next to rev 4 puts two revisions of one procedure in front of an operator, which is the exact failure the superseded filter was built to prevent. It goes through the worker rather than the API, so that everything which makes a document citable — publishing and restoring alike — passes through the component with no HTTP surface.
**Purge is for the upload that should never have happened**, not for the document that is merely out of date: the wrong site's procedure, a file with personal data in it, a duplicate uploaded three times. It is off by default (`ALLOW_PURGE=false`), needs the publisher group, and needs the reviewer to type the document number to confirm — a modal with a Yes button is not a decision. And it still does not destroy anything twice over: the file is moved to `/datadisk/ai-docs-withdrawn/`, not deleted, and the `doc_actions` row survives with the chunk count it removed. "Gone from the assistant" and "gone" are different requests, and only the first one is being served here.
**Every action needs a reason, restore included**, and the reason is a database constraint rather than a form validation, for the same reason the confirmed header is. `doc_actions` records who, when, what and why; nobody can delete from it, including the roles that write it.
**What the API role can and cannot do.** `uploads_rw` gets a column-level `UPDATE (superseded)` grant — enough to withdraw inside the HTTP request, not enough to touch `chunk_text`, `doc_number`, `revision` or the embedding, and no `INSERT` or `DELETE` at all. A column grant cannot express "may set TRUE only", so a trigger does: `uploads_rw` setting `superseded = FALSE` raises. The web-facing role can make a document less visible and never more. Purge runs in the worker under `ingest_rw`.
**Withdrawal is not deletion from the record, and the UI should not imply it is.** The Documents view gets a **Withdrawn** tab beside the published list, showing what was taken out, by whom, when and why, with Restore for a publisher and Purge only when it is enabled.
### 16.11 API surface — taking documents out
| Method | Path | Group | Purpose |
|---|---|---|---|
| `POST` | `/docs/withdrawals` | ✔ | Body: a selector (`doc_number` + `revision`, or `source_file`) and a reason. Flips `superseded` in the request, queues the file move. Returns the chunk count affected. |
| `POST` | `/docs/withdrawals/{action_id}/restore` | ✔ | Queued to the worker. 409 if another revision of the same document is live, naming it. |
| `POST` | `/docs/purges` | ✔ | Requires `ALLOW_PURGE=true`, a reason, and `confirm_doc_number` matching the target exactly. 403 when disabled — never a silent no-op. |
| `GET` | `/docs/withdrawn` | — | `withdrawn_documents`. Readable by anyone: "why is that not in there any more" should not need a publisher. |
A selector that matches nothing is a **404 with the selector echoed back**, not a 200 with `chunks_affected: 0`. "It worked, zero rows" is how somebody comes away believing they withdrew a procedure they did not.
### 16.12 UI
A second view in `ai-web`**Documents** — beside the question box:
- **Upload**: file picker, `doc_type` select, note field, and a plain statement that the document will not be used in answers until it is reviewed. An operator who thinks they have just fixed the assistant, and has not, is the failure mode worth spending a sentence on.
- **Queue**: rows with status, uploader, age. Everyone sees it; only publishers get the action buttons.
- **Review**: as section 16.7. The supersede checkbox sits next to the list of revisions it withdraws, not in a separate confirmation dialog.
- **Published**: `live_documents`, so "is the new SCADA design doc in there?" is answerable without asking anyone. Each row carries a **Withdraw** action for a publisher — the same list that shows what is citable is the right place to stop citing it.
- **Withdrawn**: `withdrawn_documents` — what was taken out, by whom, when and why, with Restore for a publisher and Purge only when it is enabled. A file move still pending is shown as pending, not as done.
- **Pool**: `pool_documents` with an in/out toggle per document and per `doc_type`, the saved profiles, and `documents_in_pool / documents_live` shown as a count. When the pool is short of whole, that count is visible on the **question** screen too, not only here — see section 16.14.
Hiding the buttons is a courtesy. The 403 is the control, and the gate tests it directly against the API.
### 16.13 Curating the retrieval pool
**Does 16.116.12 already cover this? Half of it.** Withdraw and restore are a per-document on/off switch, so mechanically a publisher can already take a document out of the pool and put it back. But `superseded` is document-control state — it means *withdrawn or replaced*, it is safety-meaningful, restore is deliberately refused while another revision is live, and every flip demands a written reason and moves the file out of the document tree. That is the right amount of friction for "this procedure has been cancelled". It is the wrong mechanism entirely for "run with twelve documents instead of forty-seven", and using it that way fills the audit trail with `reason: 'demo'` and leaves documents looking withdrawn to everyone else on a shared live host.
So the pool gets its own dimension, orthogonal to the safety one:
| Flag | Means | Set by | Retrieval |
|---|---|---|---|
| `superseded` | The document is withdrawn or replaced. A claim about the **document**. | withdraw / restore (16.10) | must be `FALSE` |
| `pool_enabled` | The document is part of the set we are running with. A claim about the **corpus**. | pool curation, this section | must be `TRUE` |
Retrieval requires both, which gives the property worth having: **re-enabling a withdrawn document in the pool does not make it citable.** Somebody curating the corpus cannot resurrect a withdrawn procedure by accident. If the two flags were ever collapsed into one, a demo that trimmed the corpus would be indistinguishable from a document withdrawn on purpose.
**Curation** — `pool_documents` lists every live document with its in/out state; a superuser toggles them individually or by `doc_type`, and each toggle writes a `pool_disable` / `pool_enable` row to `doc_actions`. Unlike `superseded`, the web-facing role may set `pool_enabled` in **both** directions: being in the pool is not a claim that a document is current, so there is nothing here to fail safe against. Nothing moves on disk, and the change is live on the next question.
**Who.** `DOC_ADMIN_GROUP`, defaulting to `AI_DocPublishers` — the people who already decide what is citable are the obvious people to decide what is in the pool. Point it at a separate AD group if demo control should be narrower; that is a config change, not a code change, and the direct-membership trap applies to any new group.
### 16.14 Demonstrating coverage against answer quality
"Feed it three documents, then forty-seven, and show the difference" is a genuinely good demonstration of this system, and it needs to not be dangerous on a live shared host. Three things make it safe:
**1. Prefer the per-request override to global state.** `POST /ask` accepts an optional `pool_profile`, and the retrieval filter is narrowed for that request only. Nothing stored changes. Nobody else's answer changes. There is nothing to remember to undo afterwards, which matters because the thing that will actually go wrong is a demo ending in a hurry with the pool left trimmed, and an operator asking a real question against it a week later. A **named profile** (`doc_pool_profiles`) is how "three documents" is one selection rather than forty-four clicks, and how the same comparison is reproducible next month.
Global `pool_enabled` curation stays for the operational job — keeping the pool current — where the change *should* persist and *should* be audited.
**2. A reduced pool is visible in the answer, always.** `BaseAnswer` gains `pool_scope`: the profile name, the document count, and the total. The UI renders a banner whenever the count is short of the total, in exactly the place and style the fixture-data banner already occupies — and for the same reason `used_fixture_data` carries the instruction *"never suppress it to make a demo cleaner"*. An answer produced from a deliberately trimmed corpus is indistinguishable from a complete one otherwise, and that is the failure this feature introduces. The banner is the whole mitigation.
This matters most on the Procedural path. With the governing procedure out of the pool, "how do I lift the interlock on Pump 02" returns *no procedure found* — which is correct, and looks exactly like the procedure not existing. It must read **"no governing procedure found in the active document set (12 of 47 documents)"**. That sentence is the demo.
**3. Traces and evals record the pool.** Every Langfuse trace carries the profile and the document count. `eval/run_eval.py` records it in the scorecard and **refuses to run a gate against a non-`full` pool** — the Phase 8 gate is ≥85% over 62 cases, and a run against a trimmed corpus produces a scorecard that looks like a regression and is nothing of the sort.
**What the demo should actually show.** The intuitive story is "more documents, better answers", and it is the weaker half. The stronger half is what the assistant does when the evidence is not there: with the relevant documents out of the pool it says *no records found* and *no governing procedure in the active set* — it does not degrade into a plausible answer. Set the comparison up that way and the coverage demo doubles as the safety demo, which is the one worth the room's attention.
> **One technical trap, and it will bite exactly in this demo.** The HNSW index covers every embedding, disabled rows included; the filter is applied after the approximate scan. Disable most of the corpus and the scan can return almost nothing even though relevant enabled documents exist — retrieval appears to collapse, in a demo whose entire subject is corpus size. With a few thousand chunks the fix is cheap: raise `hnsw.ef_search`, or drop to an exact scan below a threshold of enabled documents. Details and both statements are at the top of `db/006_doc_pool.sql`. **Rehearse the demo with the trimmed profile before showing it.**
### 16.15 API surface — the pool
| Method | Path | Group | Purpose |
|---|---|---|---|
| `GET` | `/docs/pool` | — | `pool_status` + `pool_documents`. Readable by anyone: what the assistant is running with is not privileged information. |
| `POST` | `/docs/pool/toggle` | ✔ admin | `{source_files: [...], enabled: bool, reason}`. Writes `doc_actions`. |
| `GET` | `/docs/pool/profiles` | — | Named selections, with member counts. |
| `POST` | `/docs/pool/profiles` | ✔ admin | Create or replace a named selection. `full` is protected. |
| `POST` | `/ask` | — | Optional `pool_profile`. Narrows retrieval **for that request only**; changes nothing stored. Unknown profile is a 400, never a silent fall back to `full`. |
### 16.16 What this deliberately does not do
- **It is not integrated with document control.** It records what a named person asserted. That is an improvement on a terminal prompt that recorded nothing, and it is not the same as knowing the current revision. Section 14 keeps the shortcut.
- **It does not update plant reference data.** Tags, aliases, setpoints and Cube models are still changed in Git and deployed. See `reference_data_checked`.
- **It does not scan for malware.** There is no ClamAV on this host. Files are type-checked and size-capped, and land on a shared live host that also runs the demo PLC. New entry in section 14; a `clamav` sidecar the worker calls before the move is the production answer.
- **It does not delete documents by default.** Withdrawal keeps the chunks and stops them being cited (section 16.10). Purge exists, deletes, and is off unless somebody turns it on and types the document number.
- **It does not notify anyone.** An upload sits in the queue until a publisher looks. If the gap between "document issued" and "assistant knows" matters operationally — and it is the reason this phase exists — that is an argument for an email hook later, not for automatic approval now.

View file

@ -49,6 +49,10 @@ the right number by hand, prove retrieval finds the right procedure by hand, *th
- **Do not invent schema.** `imh` is pending — inspect it, or ask. Fixtures are marked as fixtures. - **Do not invent schema.** `imh` is pending — inspect it, or ask. Fixtures are marked as fixtures.
- Store UTC. Convert to `SITE_TIMEZONE` exactly once, in Cube. Never do timezone maths in a prompt. - Store UTC. Convert to `SITE_TIMEZONE` exactly once, in Cube. Never do timezone maths in a prompt.
- Fix failures in the classifier, Cube or ingestion — **not by adding instructions to the prompt.** - Fix failures in the classifier, Cube or ingestion — **not by adding instructions to the prompt.**
- **A document becomes citable only after a human confirms its number, revision and effective date**
`confirm_header()` at a terminal, or the Phase 9 review screen. Never add a path that ingests an
unconfirmed header. The web-facing role may only make a document **less** citable (withdraw);
publishing and restoring go through `ai-docs-worker`, which has no HTTP surface.
- When something fails, add the failing case to `eval/testset.jsonl` *before* fixing it. - When something fails, add the failing case to `eval/testset.jsonl` *before* fixing it.
- Small commits, one concern each. If a change alters an accepted phase, re-run that phase's gate. - Small commits, one concern each. If a change alters an accepted phase, re-run that phase's gate.

View file

@ -110,6 +110,7 @@ equipment/tag reference data.
| `ai-api` | Python 3.12 + FastAPI | `ai-internal` + `proxy` | `api.yokogawa.tech` | | `ai-api` | Python 3.12 + FastAPI | `ai-internal` + `proxy` | `api.yokogawa.tech` |
| `ai-web` | Vite build → `nginx:alpine` | `proxy` | `ai.yokogawa.tech` | | `ai-web` | Vite build → `nginx:alpine` | `proxy` | `ai.yokogawa.tech` |
| `ai-ingest` | Python 3.12, on demand | `ai-internal` | none | | `ai-ingest` | Python 3.12, on demand | `ai-internal` | none |
| `ai-docs-worker` | same image, long-running (Phase 9) | `ai-internal` | none |
| `langfuse` + `lf-db` | official images | `ai-internal` + `proxy` | `lf.yokogawa.tech` | | `langfuse` + `lf-db` | official images | `ai-internal` + `proxy` | `lf.yokogawa.tech` |
--- ---
@ -181,6 +182,12 @@ safety issue, not a data-quality one. When a new revision lands:
docker compose -f ~/ai-compose.yml run --rm ai-ingest --supersede WRPS-OPS-014 4 docker compose -f ~/ai-compose.yml run --rm ai-ingest --supersede WRPS-OPS-014 4
``` ```
This is the SSH path, and it stays. Phase 9 adds the same thing as a screen, so
that an operator who is issued a new document when the PLC logic changes does
not have to find someone with a host login. It does not remove the header
confirmation or the supersede decision — it puts them in front of a named
person and records the answer. See step 8 below.
### 5. Phase 4 — `imh` ⚠ PENDING ### 5. Phase 4 — `imh` ⚠ PENDING
**The only true blocker.** Start the conversation now; do not wait for Phase 3. **The only true blocker.** Start the conversation now; do not wait for Phase 3.
@ -223,6 +230,89 @@ any of those is missed. It also marks Historical and Advisory cases
`needs_review`: whether "6" is the *right* number is a judgement for an `needs_review`: whether "6" is the *right* number is a judgement for an
engineer with access to `imh`, not something this script can decide. engineer with access to `imh`, not something this script can decide.
### 8. Phase 9 — operator document upload
**After Phase 8 passes, not before.** When the PLC logic or the SCADA program
changes, a new document is issued and the assistant is wrong about the plant
until it is ingested. Today that needs SSH to a live shared host. Phase 9 puts
it behind the UI:
```
upload ─► pre-scan ─► review ─► approve ─► published
(anyone (worker, (a named (header (chunks
with 2FA) Docling) publisher) confirmed) citable)
```
Nothing is citable until a named person in `AI_DocPublishers` has confirmed the
document number, revision and effective date, and decided what it supersedes —
the same questions `ingest.py` asks at a terminal, asked on a screen and, unlike
the terminal, recorded. The database refuses an approved row without them.
```bash
psql -h pg-ai -U postgres -d plant -f db/004_doc_uploads.sql
psql -h pg-ai -U postgres -d plant -f db/005_doc_actions.sql
psql -h pg-ai -U postgres -d plant -f db/006_doc_pool.sql
sudo install -d -o 10002 -g 10002 /datadisk/ai-docs-inbox # check df -h first
sudo install -d -o 10002 -g 10002 /datadisk/ai-docs-withdrawn
docker compose -f ~/ai-compose.yml up -d ai-docs-worker
```
**Taking documents out is the other half**, and today there is no way to do it:
`--supersede` needs a revision to *keep*, so a cancelled procedure or a manual
for equipment that has been removed cannot be withdrawn at all. Phase 9 adds
**withdraw** (immediate, reversible, keeps the chunks and the audit trail — this
is what "remove it" almost always means), **restore**, and **purge** (deletes,
irreversible, off unless `ALLOW_PURGE=true` and the publisher types the document
number). All three need the publisher group and a written reason, and all three
are recorded in `doc_actions`, which nothing can delete from.
Withdrawal also **moves the file out of `/datadisk/ai-docs`**. That is not
tidying: `ingest_file()` inserts every chunk with `superseded = FALSE`, so a
withdrawn document left in the tree comes back live on the next
`ai-ingest --all`. The same is true of `--supersede` today — fix both together.
Then the manual steps: the `copy_headers` change on the `api.yokogawa.tech`
Caddy block, the `^/docs/.*` Authelia rule **above** the general one, and
`AI_DocPublishers` in AD with **direct** membership.
Design and gate: [`BUILD-AI-CONTAINERS.md`](BUILD-AI-CONTAINERS.md) §16 and
Phase 9. Two gate items matter most. A user who is authenticated but not a
publisher must get a **403 from the API**, tested by calling `api.yokogawa.tech`
directly — the button being hidden proves nothing. And after withdrawing a
document, `ai-ingest --all` must **not** bring it back; run it and check, because
that is the failure that puts a withdrawn procedure back in front of an
operator.
**Choosing what is in the pool** is a third, separate thing, and it is separate
on purpose. `superseded` says *this document is withdrawn or replaced* — a claim
about the document, with a reason and an audit row behind it. `pool_enabled`
says *this document is part of the set we are running with* — a claim about the
corpus, and no comment on whether the document is current. Retrieval requires
both, so putting a withdrawn document back in the pool does **not** make it
citable. A superuser curates the pool to keep it current; the same screen saves
named profiles.
**For demos**, `POST /ask` takes an optional `pool_profile` that narrows
retrieval **for that one request** and changes nothing stored — so "three
documents versus forty-seven" needs nothing undone afterwards on a host other
people are using. Every answer from a reduced pool carries a banner with the
document count, in the same place and for the same reason as the fixture-data
banner: an answer from a trimmed corpus is otherwise indistinguishable from a
complete one. The demo worth showing is not "more documents, better answers" —
it is that with the evidence removed the assistant says *no governing procedure
in the active document set*, rather than degrading into something plausible.
Read the HNSW note at the top of [`db/006_doc_pool.sql`](db/006_doc_pool.sql)
**before** rehearsing that demo. The index covers every embedding and filters
afterwards, so a heavily trimmed pool can appear to collapse retrieval entirely.
**What none of it does:** update `tags.csv`, the Cube models or any
setpoint. A new design document changes what the assistant can *cite*; the
numbers behind Historical and Advisory answers still come from reference data
that is changed in Git and deployed. The review screen asks the reviewer to
confirm they know that, because a document going live while the tag metadata
behind it has not is a gap that is only visible at that moment.
--- ---
## Working on it ## Working on it
@ -261,7 +351,8 @@ authelia/access-rules.md the rule additions as text — never the real confi
db/ schema, roles, fixtures, and the alias seed CSVs db/ schema, roles, fixtures, and the alias seed CSVs
cube/model/ alarms, process values, operations, equipment cube/model/ alarms, process values, operations, equipment
api/ FastAPI, classifier, agent, contracts, guardrails api/ FastAPI, classifier, agent, contracts, guardrails
ingest/ Docling → chunk → embed → pg-ai ingest/ Docling → chunk → embed → pg-ai, plus the
Phase 9 upload worker
web/ React + Vite operator UI web/ React + Vite operator UI
eval/ 62-case test set and the scorecard runner eval/ 62-case test set and the scorecard runner
scripts/ deploy.sh, verify.sh scripts/ deploy.sh, verify.sh
@ -283,7 +374,9 @@ Deliberate, documented, and not to be shipped. Full list in
estate and the simulated plant's PLC estate and the simulated plant's PLC
- No automated backup — `pg-ai` needs adding to whatever backup exists - No automated backup — `pg-ai` needs adding to whatever backup exists
- Document revision metadata entered semi-manually, not integrated with - Document revision metadata entered semi-manually, not integrated with
document control document control — Phase 9 records *who* asserted a revision, which is not the
same as knowing what the current one is
- Uploaded documents are not malware-scanned; type and size checks only
**Section 2 of the build spec must be reviewed with an OT/safety representative **Section 2 of the build spec must be reviewed with an OT/safety representative
before any operator sees a demo.** before any operator sees a demo.**

View file

@ -18,6 +18,9 @@ Four hostnames join the existing `HTTPS_UserAccess` `two_factor` rule in
| `api.yokogawa.tech` | 6 | `ai-api` FastAPI | | `api.yokogawa.tech` | 6 | `ai-api` FastAPI |
| `ai.yokogawa.tech` | 7 | `ai-web` operator UI | | `ai.yokogawa.tech` | 7 | `ai-web` operator UI |
Phase 9 adds no hostname. It adds one **path-scoped rule** on an existing one —
see "Phase 9: the document publisher rule" below.
The shape of the addition — the domain list on the existing trailing rule gains The shape of the addition — the domain list on the existing trailing rule gains
these entries, the policy and subject stay exactly as they already are: these entries, the policy and subject stay exactly as they already are:
@ -40,6 +43,49 @@ Add each hostname at the phase that needs it. Every domain added here must also
have a Caddyfile block with `import authelia` (`caddy/ai-routes.caddy`), and have a Caddyfile block with `import authelia` (`caddy/ai-routes.caddy`), and
every Caddyfile block must have a rule here. One without the other is a hole. every Caddyfile block must have a rule here. One without the other is a hole.
## Phase 9: the document publisher rule
Operator document upload splits the API in two. Anyone in `HTTPS_UserAccess`
may ask a question and upload a document for review. **Approving** a document —
which is what makes it citable, and what decides whether a superseded revision
stops being citable — needs a second group, `AI_DocPublishers`.
```yaml
access_control:
rules:
# ... existing rules unchanged ...
# MUST come BEFORE the general api.yokogawa.tech rule. Authelia applies the
# FIRST matching rule and stops. Below it, this rule is dead and every
# authenticated user can approve a procedure revision.
- domain: api.yokogawa.tech
resources:
- '^/docs/.*'
policy: two_factor
subject:
- group:AI_DocPublishers # added <date>, AI PoC Phase 9
# ... the existing trailing rule, unchanged, still carries
# api.yokogawa.tech for everyone in HTTPS_UserAccess ...
```
Three things about this rule specifically:
- **`AI_DocPublishers` must be created in AD with DIRECT membership.** The same
trap as `HTTPS_UserAccess`: a user inside a nested group is silently denied,
with no useful log line. `svc-authelia` is read-only and cannot fix it.
- **It is not the only check.** `ai-api` re-reads `Remote-Groups` and returns
403 on approve, reject and supersede. This rule protects the whole `/docs`
path; the API protects the three operations that matter, and survives this
rule being reordered or dropped in a future edit of a file nobody diffs.
- **Verify it by calling the API directly**, as a user who is authenticated but
not a publisher. The UI hides the buttons from that user, which proves
nothing at all:
```bash
curl -si https://api.yokogawa.tech/docs/uploads/<id>/approve -X POST -H 'Content-Type: application/json' -d '{}' # expect 403
```
## How to apply it ## How to apply it
```bash ```bash
@ -68,6 +114,9 @@ docker logs --tail 50 authelia
line. Before Phase 7, confirm the demo operator account is a direct member of line. Before Phase 7, confirm the demo operator account is a direct member of
`HTTPS_UserAccess` and is Duo-enrolled. `svc-authelia` is read-only and `HTTPS_UserAccess` and is Duo-enrolled. `svc-authelia` is read-only and
cannot fix membership for you. cannot fix membership for you.
- **Rule order is a security control, not a style choice.** First match wins.
A path-scoped rule placed after the domain rule it narrows is inert, and
nothing warns you — the service keeps working, for everybody.
- **A missing rule fails open at the wrong layer.** Caddy will happily serve a - **A missing rule fails open at the wrong layer.** Caddy will happily serve a
hostname that has `import authelia` before the rule exists — Authelia then hostname that has `import authelia` before the rule exists — Authelia then
applies its default policy. Add the rule in the same change as the Caddyfile applies its default policy. Add the rule in the same change as the Caddyfile

View file

@ -43,6 +43,43 @@ api.yokogawa.tech {
reverse_proxy ai-api:8000 reverse_proxy ai-api:8000
} }
# --- Phase 9 -----------------------------------------------------------------
# Document upload needs two changes to the block above. No new hostname: the
# upload endpoints live under /docs on the existing API.
#
# 1. ai-api must learn WHO is calling. Authelia returns Remote-User,
# Remote-Name, Remote-Email and Remote-Groups from the forward-auth
# subrequest; Caddy only passes them upstream if it is told to.
#
# CHECK ~/Caddyfile's shared `authelia` snippet FIRST. If it already sets
# copy_headers, this is done and duplicating it is a no-op at best. Do NOT
# edit the shared snippet to add it - every other service on the host
# imports it, and this is not the change to make on their behalf.
#
# If the snippet does not copy them, replace the Phase 6 block with:
#
# api.yokogawa.tech {
# forward_auth authelia:9091 {
# uri /api/verify?rd=https://auth.yokogawa.tech
# copy_headers Remote-User Remote-Name Remote-Email Remote-Groups
# }
# request_body {
# max_size 50MB # match MAX_UPLOAD_MB; Caddy refuses larger
# }
# reverse_proxy ai-api:8000
# }
#
# - matching the shared snippet's own forward_auth arguments, which must be
# read off the host rather than assumed from this comment.
#
# 2. ai-api trusts those headers only because nothing outside the proxy network
# can reach it. If anything here ever gains a published port, that trust is
# gone and the /docs endpoints are open to whoever can reach the port.
#
# The group restriction is NOT expressed here. It goes in the Authelia rule for
# ^/docs/.* (authelia/access-rules.md), and is re-checked in ai-api. A Caddy
# matcher would be a third place to keep in step, and the first to be forgotten.
# --- Phase 7 ----------------------------------------------------------------- # --- Phase 7 -----------------------------------------------------------------
ai.yokogawa.tech { ai.yokogawa.tech {
import authelia import authelia

View file

@ -93,6 +93,11 @@ services:
networks: [ai-internal, proxy] networks: [ai-internal, proxy]
env_file: env_file:
- /home/azureuser/ai/api.env # 0600, not in Git - /home/azureuser/ai/api.env # 0600, not in Git
volumes:
# Phase 9 - the upload inbox, and the ONLY writable path this container
# has. Deliberately not /datadisk/ai-docs: a file that has been uploaded
# but not yet approved must not be visible to `ai-ingest --all`.
- /datadisk/ai-docs-inbox:/inbox
healthcheck: healthcheck:
test: ["CMD", "python", "-m", "app_healthcheck"] test: ["CMD", "python", "-m", "app_healthcheck"]
interval: 30s interval: 30s
@ -140,6 +145,53 @@ services:
driver: json-file driver: json-file
options: { max-size: "10m", max-file: "3" } options: { max-size: "10m", max-file: "3" }
# ---------------------------------------------------------------------------
# ai-docs-worker - Phase 9. The ai-ingest IMAGE with worker.py as entrypoint,
# so an uploaded document is parsed and chunked by exactly the same code as a
# file ingested from the command line - by construction, not by discipline.
#
# Two jobs: pre-scan `uploaded` rows for a header proposal, and publish
# `approved` ones. It is the only container that can write doc_chunks
# (ingest_rw), and it has no HTTP surface and no place on the proxy network.
#
# /docs is READ-WRITE here, unlike the ai-ingest CLI service above, because
# publishing moves the approved file into the folder that determines its
# doc_type. That is the one write, and it happens only after a human has
# confirmed the header.
#
# Both mounts must be writable by the image's uid 10002 (ingestuser):
# sudo install -d -o 10002 -g 10002 /datadisk/ai-docs-inbox
# sudo chown -R 10002:10002 /datadisk/ai-docs
# ai-api writes the inbox as its own non-root uid - give the inbox group
# write and put both uids in the group rather than making it world-writable.
# ---------------------------------------------------------------------------
ai-docs-worker:
build:
context: /home/azureuser/ai/ingest
dockerfile: Dockerfile
image: yau/ai-ingest:local
container_name: ai-docs-worker
restart: unless-stopped
depends_on:
pg-ai:
condition: service_healthy
networks: [ai-internal]
env_file:
- /home/azureuser/ai/api.env # 0600, not in Git
entrypoint: ["python", "worker.py"]
command: []
volumes:
- /datadisk/ai-docs-inbox:/inbox
- /datadisk/ai-docs:/docs # rw - see above
# Withdrawn documents are MOVED here, not deleted. It is outside the four
# doc_type folders on purpose: ingest_file() inserts every chunk with
# superseded = FALSE, so a withdrawn file left in /docs comes back LIVE on
# the next `ai-ingest --all`.
- /datadisk/ai-docs-withdrawn:/withdrawn
logging:
driver: json-file
options: { max-size: "10m", max-file: "3" }
networks: networks:
ai-internal: ai-internal:
driver: bridge driver: bridge

269
db/004_doc_uploads.sql Normal file
View file

@ -0,0 +1,269 @@
-- =============================================================================
-- 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.
-- =============================================================================

180
db/005_doc_actions.sql Normal file
View file

@ -0,0 +1,180 @@
-- =============================================================================
-- 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.
-- =============================================================================

202
db/006_doc_pool.sql Normal file
View file

@ -0,0 +1,202 @@
-- =============================================================================
-- 006_doc_pool.sql — which documents are in the retrieval pool.
--
-- psql -h pg-ai -U postgres -d plant -f 006_doc_pool.sql
--
-- Applied at Phase 9, after 005. Additive: `pool_enabled` defaults TRUE, so
-- every document already ingested stays exactly where it was.
--
-- TWO REASONS A CHUNK IS NOT RETRIEVABLE, AND THEY MUST NOT BE CONFLATED:
--
-- superseded = TRUE A statement ABOUT THE DOCUMENT. It is withdrawn, or
-- a newer revision replaced it. Document-control state.
-- Safety-meaningful. Changing it is an operational act
-- with a reason and an audit row (005).
--
-- pool_enabled = FALSE A statement ABOUT THE CORPUS. This document is not
-- part of the set we are running with. It says nothing
-- about whether the document is valid or current.
-- Curation, and — see section 16.13 — demonstration.
--
-- Retrieval requires BOTH: superseded = FALSE AND pool_enabled. That is what
-- makes them orthogonal and safe to expose separately. Re-enabling a withdrawn
-- document in the pool does NOT make it citable again; only a restore does.
-- Somebody curating the pool cannot accidentally resurrect a withdrawn
-- procedure, which is the mistake this separation is here to make impossible.
--
-- If these two ever collapse into one flag, a demo that trimmed the corpus
-- becomes indistinguishable from a document that was withdrawn on purpose.
-- =============================================================================
ALTER TABLE doc_chunks
ADD COLUMN IF NOT EXISTS pool_enabled BOOLEAN NOT NULL DEFAULT TRUE;
-- The retrieval predicate, as an index. Replaces doc_chunks_live_type as the
-- filter that matches what tools/retrieval.py actually asks for.
CREATE INDEX IF NOT EXISTS doc_chunks_retrievable
ON doc_chunks (doc_type)
WHERE superseded = FALSE AND pool_enabled;
-- -----------------------------------------------------------------------------
-- READ THIS BEFORE DISABLING MOST OF THE CORPUS.
--
-- The HNSW index is built over EVERY embedding, superseded and disabled rows
-- included. An approximate scan finds the k nearest vectors and the WHERE
-- clause filters afterwards, so if 90% of the pool is disabled — exactly what a
-- coverage demo does — the scan can come back with almost nothing even though
-- relevant enabled documents exist. The failure looks like "retrieval got
-- worse", which in a demo about corpus size is the single most misleading
-- result available.
--
-- With a corpus this small (thousands of chunks, not millions) the fix is
-- cheap. Either raise the candidate list for the query:
--
-- SET LOCAL hnsw.ef_search = 200;
--
-- or, when the enabled fraction is low, drop to an exact scan for that query:
--
-- SET LOCAL enable_indexscan = off; -- sequential + exact, milliseconds here
--
-- tools/retrieval.py should do the second automatically below a threshold
-- (POOL_EXACT_SCAN_BELOW_PCT). Measure it before the demo, not during.
-- -----------------------------------------------------------------------------
-- -----------------------------------------------------------------------------
-- doc_pool_profiles — a named set of documents, applied and reverted as one.
--
-- "Show them the answer with three documents, then with all forty-seven" is not
-- forty-four checkbox clicks, and it must be revertible in one action, in front
-- of an audience, without anyone wondering afterwards whether the pool was left
-- trimmed. A profile is a saved selection, not a copy of the documents.
--
-- Profiles are also how a demo avoids touching global state at all: the
-- intended path is a PER-REQUEST override naming a profile (section 16.14),
-- which changes nothing stored and leaks nothing to the operator asking a real
-- question on the same shared host at the same time.
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS doc_pool_profiles (
profile_id UUID PRIMARY KEY,
name TEXT NOT NULL UNIQUE, -- 'full', 'procedures-only', 'minimal-3'
description TEXT,
is_builtin BOOLEAN NOT NULL DEFAULT FALSE,
created_by TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS doc_pool_profile_members (
profile_id UUID NOT NULL REFERENCES doc_pool_profiles(profile_id) ON DELETE CASCADE,
source_file TEXT NOT NULL,
PRIMARY KEY (profile_id, source_file)
);
CREATE INDEX IF NOT EXISTS doc_pool_members_file
ON doc_pool_profile_members (source_file);
-- 'full' is the only profile with no members: it means "no restriction", not
-- "no documents". Special-cased in one place in the API and nowhere else.
INSERT INTO doc_pool_profiles (profile_id, name, description, is_builtin)
VALUES ('00000000-0000-0000-0000-000000000001',
'full',
'Every enabled, live document. The operational default.',
TRUE)
ON CONFLICT (name) DO NOTHING;
-- -----------------------------------------------------------------------------
-- Curating the pool is an audited act, like withdrawing.
-- 005 already holds the trail; it gains two more verbs.
-- -----------------------------------------------------------------------------
ALTER TABLE doc_actions DROP CONSTRAINT IF EXISTS doc_actions_action_ck;
ALTER TABLE doc_actions ADD CONSTRAINT doc_actions_action_ck
CHECK (action IN ('withdraw','restore','purge','pool_disable','pool_enable'));
-- -----------------------------------------------------------------------------
-- pool_status — what the assistant is actually running with right now.
--
-- The number in the corner of the screen. An operator should be able to see
-- that the pool is not whole without asking anybody, and a demo should not be
-- able to hide it.
-- -----------------------------------------------------------------------------
CREATE OR REPLACE VIEW pool_status AS
SELECT count(DISTINCT source_file) FILTER (
WHERE superseded = FALSE AND pool_enabled) AS documents_in_pool,
count(DISTINCT source_file) FILTER (WHERE superseded = FALSE) AS documents_live,
count(*) FILTER (WHERE superseded = FALSE AND pool_enabled) AS chunks_in_pool,
count(*) FILTER (WHERE superseded = FALSE) AS chunks_live
FROM doc_chunks;
-- -----------------------------------------------------------------------------
-- pool_documents — the curation screen's list. Every live document, in or out.
-- -----------------------------------------------------------------------------
CREATE OR REPLACE VIEW pool_documents AS
SELECT source_file,
max(doc_number) AS doc_number,
max(revision) AS revision,
max(doc_type) AS doc_type,
max(effective_date) AS effective_date,
count(*) AS chunk_count,
bool_and(pool_enabled) AS pool_enabled,
max(created_at) AS ingested_at
FROM doc_chunks
WHERE superseded = FALSE
GROUP BY source_file;
-- =============================================================================
-- Grants.
--
-- pool_enabled is NOT a safety flag, so uploads_rw may set it in BOTH
-- directions - unlike `superseded`, where the trigger in 005 allows only
-- withdrawal. Being in the pool is not a claim that a document is current;
-- `superseded` is still the only thing that says that, and it is still the only
-- thing the web-facing role cannot undo.
-- =============================================================================
GRANT UPDATE (pool_enabled) ON doc_chunks TO uploads_rw;
GRANT SELECT, INSERT, UPDATE, DELETE ON doc_pool_profiles TO uploads_rw;
GRANT SELECT, INSERT, UPDATE, DELETE ON doc_pool_profile_members TO uploads_rw;
GRANT SELECT ON doc_pool_profiles, doc_pool_profile_members TO agent_ro, ingest_rw;
GRANT SELECT ON pool_status, pool_documents TO agent_ro, uploads_rw, ingest_rw;
-- A profile is a selection, so deleting one destroys no documents. `full` is
-- protected because a demo that deletes the way back to the operational pool is
-- not a recoverable position in front of an audience.
CREATE OR REPLACE FUNCTION doc_pool_profiles_protect() RETURNS TRIGGER AS $fn$
BEGIN
IF OLD.is_builtin THEN
RAISE EXCEPTION 'the % profile is built in and cannot be % ',
OLD.name, TG_OP;
END IF;
RETURN OLD;
END;
$fn$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS doc_pool_profiles_protect_tr ON doc_pool_profiles;
CREATE TRIGGER doc_pool_profiles_protect_tr BEFORE DELETE OR UPDATE
ON doc_pool_profiles
FOR EACH ROW EXECUTE FUNCTION doc_pool_profiles_protect();
-- =============================================================================
-- Phase 9 gate — the pool half.
--
-- Orthogonality, which is the property the whole file exists for:
-- UPDATE doc_chunks SET pool_enabled = TRUE
-- WHERE superseded = TRUE; -- allowed, and changes NOTHING:
-- -- the withdrawn document must still not be retrievable. Ask the question
-- -- that used to cite it and confirm it is not cited.
--
-- As uploads_rw:
-- UPDATE doc_chunks SET pool_enabled = FALSE WHERE ...; -- must work
-- UPDATE doc_chunks SET pool_enabled = TRUE WHERE ...; -- must work
-- UPDATE doc_chunks SET superseded = FALSE WHERE ...; -- must be REJECTED
-- DELETE FROM doc_pool_profiles WHERE name = 'full'; -- must be REJECTED
--
-- Retrieval, with most of the corpus disabled - the demo case:
-- Disable 90% of documents, then ask a question whose answer is in one of
-- the remaining 10%. It MUST still be found. If it is not, the HNSW note at
-- the top of this file is why, and the fix is there, not in the prompt.
--
-- The banner, which is the part that protects everyone else on this host:
-- With any document out of the pool, EVERY answer must carry the reduced-
-- pool banner, exactly as fixture data does. An answer from a trimmed
-- corpus that looks like an answer from the whole corpus is the failure
-- this feature introduces, and the banner is the whole mitigation.
-- =============================================================================