ContractViolation takes (rule, detail, offending_output=""). The guard
passed a single string, so every retired name raised
TypeError: ContractViolation.__init__() missing 1 required positional
argument: 'detail'
instead of the clean error it was written to produce - a guard that
crashes the request it was meant to explain.
It was not caught before deployment because this machine has neither
fastapi nor psycopg, so the guard could not be imported. It was tested by
lifting its source out of the file and exercising it against a stubbed
ContractViolation - a plain Exception, which accepts one argument. The
stub is what made the test pass. Running it in the built image found it in
one command, which is the argument for running the suite where the
dependencies are rather than reasoning about it where they are not.
Also records what the error actually does: main.py logs the rule, returns
422, and deliberately keeps the detail out of the operator-facing message,
so the explanation lands in the log and in Langfuse rather than on screen.
That is the right trade - an operator asking in plain English never types
one of these names, so the audience is a developer or a stale integration.
Verified in the running container: the retired names and their lowercase
forms are rejected with rule "retired_tag_name"; the poll groups, the item
names, the instrument tags and plain English all pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With the seed rekeyed, a question asking after PS_STN_WET_WELL_LEVEL now
matches nothing - and the assistant answers "no records found". An
operator reads that as "the plant recorded nothing", not as "you asked
with a name this system retired". That is precisely the confusion the four
namespaces exist to prevent, and an empty result is the wrong shape of
answer for it.
equipment.resolve() now rejects those names up front, raising
ContractViolation, which main.py already turns into an error rather than
an answer. The message says what the name was, why it is not a tag, and
what to use instead.
The four Modbus poll groups keep the prefix legitimately - they are
groups, not names, and nothing resolves an operator term onto one - so
PS_STATUS_BITS, PS_PUBLISHED, PS_SETPOINTS and PS_SIM_CONTROL pass.
Scope, deliberately small: this covers equipment.resolve(), the path an
operator's words take. Calling metrics directly with a retired id is not
covered. For a demo that is the right trade; for production it is not.
Verified by lifting the guard's own source out of the file and exercising
it - upper case, lower case, surrounding whitespace, the _SP variant, all
four poll groups, and the item, instrument and plain-English forms. The
module itself could not be imported here: no psycopg on this machine.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Build spec and host brief carried in from C:\Claude and WRPS/02-env; the
plant model (equipment, tags, alarm bitmask, enums, unit conversions) is
derived from WRPS/04-plc/register-map.csv, WRPS/05-scada/modbus/scada-points.csv
and WRPS-CTL-003.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>