Skip to content

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.