ADR 0050: A tag's declared engineering range is enforced on every write path, by refusal rather than by clamping¶
Status: Accepted Date: 2026-08-11 Issue: #1439
Context¶
A tag may declare engMin and engMax. Every consumer of them in the
tree was display or simulation: the gateway's tag DTO carried them as
rangeMin/rangeMax so the HMI could scale a bar, the ControlModule
controller copied them into status.effectiveTags, and the simulator
scaled generated values with them. No write path consulted them. A tag
could state its engineering ceiling and still accept any value from a
recipe, from an operator, or from an integrator over the API.
The cost of that is not cosmetic, and the Getting Started flagship shoot
found out how. A CIP phase wrote recirc_pump.SPEED_SP from a parameter
declaring defaultValue: 120 on a 60-220 RPM range. The pump is a VFD
whose max_speed is 100.0 in percent, so the template's clamp_speed
MIN block and the AO's outMax held the drive at 100 while the setpoint
tag kept the raw 120. On camera the faceplate showed SPEED_SP 120 %
above SPEED 100 %, permanently, and three takes and two reviewers read
past it.
The clamp was silent, and that silence is what makes the shape expensive.
The chart guards its circulate transition on
READ('recirc_pump.SPEED') >= recircSpeed * 0.8. SPEED cannot
exceed the clamp at 100, so the guard is satisfiable only while
recircSpeed <= 125. Every value from 126 to 220 — all of them legal
against the parameter's own declared range — makes it unsatisfiable, and
the step wedges until its 60 s timeout with nothing anywhere naming the
reason. The shipped default sat five points under that cliff.
Generalised: any guard that waits for a feedback to reach a setpoint is a trap wherever the setpoint can exceed what the device will do. The guard is not the defect. The defect is that nothing refused the setpoint.
Decision¶
A numeric write outside a tag's declared engineering range is refused.
The refusal is enforced in adapter.WriteTag, which is the one point
every spelling of a write reaches: the ST WRITE builtin arrives there
over the runtime's HTTP API, and so do the gateway's ControlModule and
Unit tag routes, the WebSocket write frame, and the HMI faceplate. That
is the same placement ADR 0024's access-level barrier settled on in
#1277,
for the same reason: a barrier applied to one spelling of a write is
bypassable by another. The gateway applies the check again on each of its
three write paths, so a caller gets a 400 naming the tag and the range
instead of a proxied runtime error.
The declared range travels with the tag into the unit controller's tag
map, already resolved: pkg/templatecompiler turns
engMax: "max_speed_limit.value" into the literal the instance's
parameters produce, so the runtime enforces the ceiling the instance
actually has rather than the template default. The judgement itself lives
in pkg/tagrange, shared by the runtime and the gateway, so the refusal
is worded once.
The check judges only what it can. A tag that declares no bound, a bound that does not parse as a number, and a value that is not numeric all pass through. This sits on the control path, and a check that guessed at any of them would refuse legitimate writes. An unparseable bound in particular is a template defect, and punishing the command for it would be the wrong end.
Only the tag's own declaration is enforced. The gateway falls back to
the ControlModule-level eng_min/eng_max parameters when a tag declares
no range of its own, for display. That fallback does not extend to the
write barrier: those parameters describe the module's analog process
value, and applying them to every writable tag on the module is precisely
the inference ADR 0016 keeps out of this product. A tag that should be
bounded declares engMin/engMax.
make lint-tag-range catches the mismatch at author time. It resolves
every WRITE in every chart through the real binding chain — the phase's
cmRoles, a Unit's cmRoles, the module's compiled template — and holds the
declared range against the values the write can produce: a literal, or the
declared minValue/maxValue/defaultValue span of a phase parameter
written through unchanged. It runs the real compiler, so it cannot drift
from what the runtime enforces.
Alternatives Considered¶
Clamp the write and annunciate it. Rejected, and the reason is
decisive rather than a matter of taste: clamping does not fix the wedge.
At recircSpeed: 220, clamping SPEED_SP to 100 still leaves the guard
waiting for 176, and the step still dies on its timeout. It converts a
silent mismatch into a second silent mismatch one layer higher. Refusal is
also the behaviour the sibling barriers on this path already have — a
read-only tag and a module in Automatic mode both refuse — so clamping
would have made range the one declaration the platform negotiates with.
Leave the write path alone and ship only the author-time gate.
Rejected. The gate reads examples/, and a customer's recipes are not in
examples/. It catches this class of defect in the fixtures the product
ships and nowhere else, which is worth having and is not enforcement.
Enforce in the gateway only. Rejected: it leaves the recipe path
unguarded, which is the path this issue was filed from. An ST WRITE
never touches the gateway.
Enforce on read as well. Rejected. A field value outside its declared range is a real reading, and refusing to report it would hide exactly the condition an operator needs to see. The HMI marks it instead.
Consequences¶
A recipe that commands a setpoint outside a device's declared range now holds the phase at the scan it happens, naming the tag and the range, rather than wedging a step until its timeout with no cause recorded. That is a behaviour change for any deployment whose recipes already do this, and it is deliberately loud: the write was never reaching the device anyway, so the only thing lost is the appearance of working.
The blast radius is bounded by declaration. A tag with no engMin/engMax
behaves exactly as before, so nothing in a tree that never declared a
range changes.
engMax stops being free to write down. A template author declaring a
ceiling is now constraining what recipes may command through that tag, not
just what the HMI will draw, and widening it is a deliberate act rather
than a display tweak.
The HMI bounds its setpoint input by the declared range and marks a live value that sits outside one. Before this the bars clamped their fill to the gauge, so an over-range value painted a full bar and read as normal — which is how the frame this issue was found in survived three takes.