The three open Phase 5 findings were one defect: the stand-in was keyed on
CI Server POINT names (PS_STN_WET_WELL_LEVEL) when the historian is keyed on
CI Server ITEM names (AID.WRPS.STN.LEVEL). Modbus carries register numbers,
not names, so those two layers are free to differ - and do. Reconciling
against the register map, as planned, would only have proved the first three
namespaces agreed with each other.
Rebuilt from WRPS/05-scada/modbus, so item names, sample rates, retention and
timestamp semantics come from the machine rather than from a guess.
(a) Level tag does not join. PS_STN_WET_WELL_LEVEL becomes a tag row in its
own right; LIT-101 is marked NOT HISTORISED - a field input on %IW0 that
never reaches SCADA. It was the only seed row carrying two addresses.
public.historian_items holds the item-to-tag mapping, generated by
scripts/gen_historian_items.py and enforced non-empty at generate, at
deploy and at verify.
(b) first_alarm/last_alarm returned UTC. Converted inside the measure, so it
stays in Cube and happens once. Aggregate first, convert after - the other
order picks the wrong row across a DST fall-back. Returned as a formatted
string with a companion site_timezone measure. Storage being UTC is now
confirmed, not assumed: all 49 points carry TIME_ZONE "Date+time GMT" and
every history group CORRECT_DAYLIGHT=0. This answers Phase 4 task 4.
(c) High level alarm filed against the wrong equipment. Both sides were right
about different things; the defect was asserting equipment twice. The
history now carries no equipment column at all - faithful, since CI
Server's section tree stops at the station and three pumps. Equipment is
reached bit -> tag -> equipment via public.alarm_bits.
Alarms are derived, not stored: CI Server's ALARM_HISTORY group is empty
because every item imports with alarming off. Decomposing the alarm word needs
no configuration that does not exist.
Three things the SCADA config changed that were never filed as faults:
- retention is 7 days, not 30. The advisory path was reporting a month of
evidence drawn from a week of data
- the analogue rate is 5 s, not 60. Two measures multiplied sample counts by
a hardcoded 60 - a twelvefold overstatement that read as plausible
- the deadband warning in process_values.yml was wrong and was steering
people away from the correct measure
db/002_fixtures.sql now asserts its own counts at load and cross-checks the
alarm derivation against two independent signals. Those prove the pipeline,
not the plant.
db/README-standin-historian.md documents removal: the seam between generation
and contract, and twelve assumptions about imh that are NOT confirmed. Two of
them fail silently.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6.2 KiB
CLAUDE.md — rules to keep front of mind
Plant Operations Assistant for the Waterloo Road Pump Station (WRPS), deployed onto an
existing, live, shared Docker host. Full detail: BUILD-AI-CONTAINERS.md (the build spec) and
YAU_Linux_Host_Onboarding.md (the host brief). Where the two conflict, the host brief wins.
The three lines this system does not cross
- No instructions for safety-critical actions. Procedural questions get document identity, revision, effective date and verbatim prerequisites — never synthesised steps.
- 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.
- No answers outside the evidence. Zero rows means "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.
When class is uncertain, choose the more restrictive class. Procedural beats Reference. Advisory beats Historical. Partly-advisory is advisory.
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
proxynetwork, 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. - Never omit
import autheliafrom a Caddyfile block — it silently makes a service public. One deliberate exception, applied 2026-08-28 at the customer's direction:ai.yokogawa.techadmitsremote_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.techstays gated. ~/authelia/configuration.ymlis 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-aiandcubeare pinned — do not add them to Watchtower's update list.openplc-runtimeis 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 its10.0.0.17binding to0.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 onimh. - No secrets in Git, compose files, logs or error messages.
0600env files under~/ai/.
Verification
docker ps showing "Up" is not proof. curl -sI the public URL, expect 302 to the auth
portal, and read the container logs. 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.
imhis pending — inspect it, or ask. Fixtures are marked as fixtures. Before connecting it, work throughdb/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_TIMEZONEexactly 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); publishing and restoring go throughai-docs-worker, which has no HTTP surface. - When something fails, add the failing case to
eval/testset.jsonlbefore fixing it. - 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 (0–7000 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 | PS_STN_WET_WELL_LEVEL |
WRPS/05-scada/modbus/scada-points.csv |
| CI Server item | AID.WRPS.STN.LEVEL |
WRPS/05-scada/modbus/wrps_item_df.qli |
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_secondsfrom the item. The analogue groups run at 5 s and 30 s; a measure that assumed 60 s was wrong by twelvefold.