Rewrite CLAUDE.md as an index, and fix what the review found

CLAUDE.md was 120 lines and had become a detailed document. Its job is to
be the first thing another team's Claude Code reads: an index that says
where everything is, plus the few rules that must be known before acting.
Detail is better maintained next to the thing it describes, so it moved
there - the four namespaces and the PS_ ban to db/seed/scada-source, the
ai-docs-worker divergence to api/documents.py's docstring, the plant
description to README.md. The file now says so, and tells the next editor
to keep it that way.

Then a sentence-by-sentence review, which did not go the way the premise
suggested. Of seventeen sentences flagged as possibly unnecessary, one was
padding - and it had been introduced ten minutes earlier. The rest were
carrying real information in the wrong shape, or were wrong, or were
hiding a gap:

  WRONG. "Details: api/contracts.py" followed the classification
  precedence rule, which is implemented in classifier.py. "Prove each
  layer before wiring the agent" instructed a build phase that finished
  weeks ago.

  MISSING. Neither table pointed at retrieval, the schema and migrations,
  compose, or the operator's web app - about a third of the repository's
  changeable surface, absent from an index whose only job is to point at
  it. The retrieval gap surfaced because "the document library" was
  ambiguous: it names the SCREENS in documents.py, not the corpus, and the
  corpus had no row at all.

  CUT BY MISTAKE, in the rewrite this commit also contains. The trigger
  for writing to OPEN-ISSUES.md, and the .bak-<purpose>-<date> convention
  which is the only concrete form of "snapshot before editing" - the rule
  that made running migration 008 on a live database safe.

Two rules are new because the audit kept finding the same failures.
"Never quote a count in prose" generalises three separate staleness
defects - container counts across four documents, the eval-set size across
five, the env-file count. "Pin it before you fix it" now names both
destinations, eval/testset.jsonl and api/tests/, and admits that
deployment and migration defects have neither.

The host's size is recorded for the first time: 2 vCPU and 3 GiB, verified
live. Build elsewhere and ship the image where possible; where not, check
the host is quiet and announce it. deploy.sh builds on the host today,
which is why the rule is a judgement rather than an absolute.

86 lines became 91 during a review meant to shorten it. That is the
finding: the file was not too long, it was pointing at the wrong things.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Claude 2026-09-01 21:37:41 +10:00
parent e5c5ff8088
commit b08fe60772

186
CLAUDE.md
View file

@ -1,120 +1,90 @@
# CLAUDE.md — rules to keep front of mind
# WRPS Plant Assistant — an index to this repository, and rules that are not advisory
Plant Operations Assistant for the **Waterloo Road Pump Station (WRPS)**, deployed onto an
existing, live, shared Docker host. Full detail: `spec/BUILD-AI-CONTAINERS.md` (the build spec) and
`spec/YAU_Linux_Host_Onboarding.md` (the host brief). **Where the two conflict, the host brief wins.**
A proof-of-concept assistant that answers an operator's plain-English questions from plant
data and controlled documents. It runs on `lin001`, an existing **live, shared** Docker host
that is not ours alone.
This file holds only what you must know *before* you act; everything else is a pointer.
Keep it that way: put detail next to the thing it describes, not here.
## Where everything is
| To understand | Read |
|---|---|
| What this is, and how to get oriented | `README.md` |
| Why it is built this way, and the accepted shortcuts (§14) | `spec/BUILD-AI-CONTAINERS.md` |
| The host, its history and its rules — **wins on any conflict** | `spec/YAU_Linux_Host_Onboarding.md` |
| How to deploy or rebuild it | `spec/REBUILD.md` |
| What is running right now | `status/current-state.html` |
| What is blocked, and on whom | `status/REQUESTS.md` |
| What is broken or unfinished, and ours to fix | `status/OPEN-ISSUES.md` |
| To change | Go to |
|---|---|
| The safety contracts, one per answer class | `api/contracts.py` |
| Which class a question gets | `api/classifier.py` |
| The SQL allow-list and query caps | `api/guardrails.py` |
| The document library screens — its docstring records how the build diverges from the design | `api/documents.py` |
| How a document becomes searchable, and how it is found and cited | `ingest/ingest.py`, `api/tools/retrieval.py` |
| How a question becomes a number | `cube/model/` |
| Retention, windows and history measures | `api/tools/metrics.py` |
| Plant facts, tags and equipment | `db/seed/tags.csv` |
| The schema, roles and migrations | `db/*.sql` |
| How a container is defined — networks, volumes, env files | `compose/ai-compose.yml` |
| The operator's screen | `web/` |
| SCADA namespaces and the delivered CI Server config | `db/seed/scada-source/README.md` |
| The stand-in historian — some assumptions fail silently, not loudly | `db/README-standin-historian.md` |
| Who can reach what | `caddy/ai-routes.caddy`, `authelia/access-rules.md` |
| The eval set and its scorecard | `eval/` |
## The three lines this system does not cross
1. **No instructions for safety-critical actions.** Procedural questions get document identity,
1. **No instructions for safety-critical actions.** Procedural answers give document identity,
revision, effective date and verbatim prerequisites — never synthesised steps.
2. **No recommended setpoints or operating parameters.** Advisory questions get evidence, ranges,
outcomes, documented limits, then an explicit deferral. Never a number presented as the answer.
3. **No answers outside the evidence.** Zero rows means "no records found", never a plausible figure.
2. **No recommended setpoints or operating parameters.** Advisory answers give evidence and
documented limits, then defer. Never a number presented as the answer.
3. **No answers outside the evidence.** Zero rows is "no records found", never a plausible figure.
These are **code paths, not prompt instructions.** A safety rule living only in a prompt is not
implemented. The classifier runs first; each class has a Pydantic contract validated in Python
after generation and before returning. Contract failure -> regenerate once -> error. Never return.
**These are code paths, not prompt instructions.** A safety rule living only in a prompt is not
implemented. Fix failures in the classifier, Cube or ingestion — never by adding to the prompt.
When a class is uncertain, choose the **more restrictive** one.
When class is uncertain, choose the **more restrictive** class. Procedural beats Reference.
Advisory beats Historical. Partly-advisory is advisory.
## Do not break these — a live host, and a live PLC
## Host rules — inherited, non-negotiable
- Growing data goes on **`/datadisk`**, never `/`. Root is 62 GB and has hit 100% before.
- **No published host ports for anything we build.** Join the external `proxy` network, reach it
through Caddy. Some existing containers do publish (`caddy`, `wireguard`, `mosquitto`,
`chirpstack-gateway-bridge`, `openplc-runtime`) — they carry non-HTTP protocols. We do not.
- **`openplc-runtime` is live control.** Never restart, update or reconfigure it as a side effect
of AI work, and never move its `10.0.0.17` binding to `0.0.0.0` — that binding is the only thing
keeping unauthenticated Modbus off the internet.
- **Never install on, write to or restart `cicore1`.** Never exceed read-only on `imh`.
- **Never omit `import authelia`** from a Caddyfile block — it silently makes a service public.
One deliberate exception, applied 2026-08-28 at the customer's direction: `ai.yokogawa.tech`
admits `remote_ip 10.0.0.21` (`cicore1`) unauthenticated and 403s everything else. It does
not generalise — do not copy it, and do not widen it. `api.yokogawa.tech` stays gated.
- `~/authelia/configuration.yml` is **root-owned**: `sudo`, back up as `.bak-<purpose>-<date>`.
Restarting Authelia **logs out every active user** — announce it first.
- AD group membership must be **DIRECT**. Nested membership silently fails.
- `pg-ai` and `cube` are pinned — **do not add them to Watchtower's update list**.
- **`openplc-runtime` is live control for this demo.** Never restart, update or reconfigure it as
a side effect of AI work. Do not copy its published-port pattern, and never change its
`10.0.0.17` binding to `0.0.0.0` — that binding is what keeps unauthenticated Modbus off the
internet.
- Never install on, write to or restart `cicore1`. Never exceed read-only on `imh`.
One deliberate exception exists for `ai.yokogawa.tech`; do not copy or widen it.
- **Restarting Authelia or Caddy logs out every active user.** Announce it first.
- **Growing data goes on `/datadisk`, never `/`.** Root is 62 GB and has hit 100% before.
- **Publish no host ports.** Join the `proxy` network and go through Caddy.
- **No secrets in Git, compose files, logs or error messages.** `0600` env files under `~/ai/`.
- **`lin001` has 2 vCPU and 3 GiB. Do not build images on it if you can build elsewhere**
and ship the image. When you cannot, check the host is quiet first — `uptime`, and who
else is mid-demo — and say so before you start. `deploy.sh` builds on the host today,
which is why this is a judgement and not an absolute.
## Verification
## How to work here
`docker ps` showing "Up" is **not** proof. `curl -sI` the public URL and read the container
logs. **Expect `302` to the auth portal from `api`, `cube` and `lf` — but `403` from `ai`**,
which is Authelia-free and IP-matched (see the exception above). A `302` on `ai` means the
SCADA-only block was reverted; a `200` means the matcher is restricting nothing. `verify.sh`
treats the `403` as the PASS. Prove each layer without the LLM first: prove Cube returns
the right number by hand, prove retrieval finds the right procedure by hand, *then* wire the agent.
## Working conventions
- Prefer additive changes. Snapshot config before editing.
- **Do not invent schema.** `imh` is pending — inspect it, or ask. Fixtures are marked as fixtures.
Before connecting it, work through `db/README-standin-historian.md` — the stand-in's shape is
reasoned from the SCADA config, but **every SQL Server detail in it is a guess**, and two of the
twelve listed assumptions fail silently rather than erroring.
- 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.**
- **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).
**The design puts publishing and restoring behind `ai-docs-worker`, which has no HTTP surface.
That is not what is built.** `ai-docs-worker` was never deployed: `api/documents.py` serves
`POST /documents/review/{id}/approve` and `POST /documents/restore` directly on `ai-api`. The
boundary still exists, but it moved — it is enforced by `db/005`'s trigger and by which
database role each function opens its connection with, not by deployment. Read the docstring
at the top of `api/documents.py` before touching that path, and do not assume an HTTP request
cannot publish.
- When something fails, add the failing case to `eval/testset.jsonl` *before* fixing it.
- A defect you are **not** fixing now goes in `status/OPEN-ISSUES.md`, not in a comment.
Read its header first — it says what belongs there and what belongs in `REQUESTS.md` or
in §14 of the build spec, which is closed by decision and must not be re-raised.
- Small commits, one concern each. If a change alters an accepted phase, re-run that phase's gate.
## The plant
Waterloo Road Pump Station: a three-pump wastewater station. Wet well `WW-101` (07000 mm,
120 m³/m), duty/assist/assist pumps `PU-301/302/303` on a common VSD speed reference, discharging
through manifold `MAN-301` against 22 m static lift. Spill weir at 6000 mm, `LSHH-102` at 5500 mm.
Control runs on `openplc-runtime`; Yokogawa CI Server on `cicore1` polls it over Modbus TCP and
historises the result.
**Four namespaces name the same measurement. Know which one you are holding.**
| Layer | Example | Source of truth |
|---|---|---|
| Instrument tag | `LIT-101` | `WRPS/01-design-doc` |
| PLC symbol + address | `%QW0` | `WRPS/04-plc/register-map.csv` |
| CI Server point | `STN_LEVEL`, on Modbus station `WRPS_PLC` | `WRPS/05-scada/modbus/wrps_modbus_point_df.qli` |
| **CI Server item** | **`AID.WRPS.STN.LEVEL`** | `WRPS/05-scada/modbus/wrps_item_df.qli` |
The point layer is written `WRPS_PLC:STN_LEVEL` in the `.qli` export: the Modbus **station**
name, a colon, then the point name. There are 49 points and 49 items, one to one here, but
that is a property of this small station and not a rule.
**`PS_*` names are not a namespace.** An earlier delivery of the point list carried
`PS_STN_WET_WELL_LEVEL` and its like where the item name belongs. They were a proposal
derived from the PLC register map that CI Server never adopted, and they were removed
from this repository on 2026-09-01: the tag seed, the alarm bits, the fixtures, the Cube
models and the exam are all keyed on the item now. **Do not reintroduce them.** The only
legitimate `PS_` names are the four Modbus poll groups — `PS_STATUS_BITS`, `PS_PUBLISHED`,
`PS_SETPOINTS`, `PS_SIM_CONTROL` — which are live configuration and appear only in a
`poll_group` column. The delivered CI Server configuration lives in
`db/seed/scada-source/`; read its README before touching any of this.
**The historian is keyed on the item, and only the item.** Modbus carries register numbers,
not names, so the point layer and the item layer are free to differ — and they do. Keying
history on a point name is what produced all three Phase 5 findings. `db/seed/tags.csv` owns
plant facts; `db/seed/historian_items.csv` owns the item-to-tag mapping and is **generated**
by `scripts/gen_historian_items.py` — never hand-edited.
- **Equipment is asserted in exactly one place: `tags.equipment_id`.** Nothing in the
history carries an equipment column. CI Server's section tree stops at the station and the
three pumps; it has no wet well, weir, manifold or switchboard.
- **The historian retains seven days.** Zero rows outside that window means "the historian
does not go back that far", never "nothing happened". `metrics.HISTORY_RETENTION_DAYS`.
- **Never hardcode a sample interval.** Read `scan_interval_seconds` from the item. The
analogue groups run at 5 s and 30 s; a measure that assumed 60 s was wrong by twelvefold.
- **`docker ps` showing "Up" is not proof.** Run `scripts/verify.sh` and read the container logs.
- **When an answer is wrong, prove Cube and retrieval by hand before touching the agent**
otherwise a wrong answer has four possible causes.
- **Do not invent schema.** `imh` is still pending — inspect it, or ask. Fixtures are marked.
- **Do not hand-edit generated files.** `db/seed/historian_items.csv` comes from
`scripts/gen_historian_items.py`.
- **Store UTC. Convert to local exactly once, in Cube.** Never do timezone maths in a prompt.
- **A document is citable only after a human confirms its number, revision and effective date.**
Never add a path that ingests an unconfirmed header.
- **When something fails, pin it before you fix it.** A question answered wrongly goes in
`eval/testset.jsonl`; anything provable without a model goes in `api/tests/`. Deployment and
migration defects have no test home yet, so they go in `status/OPEN-ISSUES.md`.
- **A defect you are not fixing now goes in `status/OPEN-ISSUES.md`** — read its header first;
it says what belongs there and what belongs elsewhere.
- **Never quote a count in prose.** Point at the file. Container counts, eval-set sizes and
assumption counts have all gone stale in this repo; the file never does.
- **Small commits, one concern each.**
- **If a change alters an accepted phase, re-run that phase's gate.**
- **Snapshot before you edit anything on the host** — config, database or seed.
Name back-ups `.bak-<purpose>-<date>`.