Skip to content

ADR 0064: A dangling block reference is refused where it is written

Status: Accepted Date: 2026-08-17 Issue: #1649 Related: #1645 (the save that erased the interface, whose fix exposed this), #1439 and #1620 (the same shape in the example corpus)

Context

A ControlProgram binds a POU variable to a function block output through spec.variableBindings, whose entries are {variable, blockRef, portRef}. blockRef is the plain text of a spec.blocks[].name. Nothing in the document links the two, and nothing outside the runtime resolves them.

The runtime builds its variable table in pkg/fbruntime/runtime.go with if val, ok := outputs[blockRef+"."+portRef]; ok. A binding whose block is not there is skipped. It is not faulted. The variable stops appearing in the table, the tag bound to it reads empty, and the program keeps reporting Running. Nothing is logged, no condition is set, and no metric moves. This is the quietest failure in the control path, because a tag that reads empty looks like a tag that has not been written yet.

The FB editor renames and deletes blocks. updateBlockName writes block.name after a uniqueness check, and a delete drops the block from editorState.blocks. Connections survive both, because the editor holds them by its own numeric block id and re-derives the names when it builds the payload. A binding is a string, and there is nothing for the editor to re-derive it from.

#1645 is what brought this into view. Before it, every FB editor save erased spec.variableBindings outright, because the read DTO did not carry the field and the PUT replaces the whole spec. Its fix has the editor carry the list from the GET to the PUT verbatim. That is correct, since the editor authors no variable and must not rewrite one. Carrying the list is also what lets a rename strand a binding. The same rename used to delete it. The erasure was the worse failure and it is closed. The rename hazard is what carrying the list exposed.

The route is not the only road to a stranded binding. A kubectl edit that renames a block has always been able to produce one. So has POST /api/v1/apply, which decodes the spec itself and runs none of the REST validators.

The rule this record settles already exists one layer up. pkg/templatecompiler refuses both halves of it for a ControlModuleTemplate. An explicit network binding naming an unknown block fails with "variable binding %q references unknown block %q", and a template tag naming one fails with "template tag %q references unknown block %q". Those two are the only other road a binding is authored on, since the ControlModule controller compiles the template into the generated program. The two REST routes are the surface that never asked.

The tree already answers this class of question the same way elsewhere. #1439 and #1620 both found a reference that resolved to nothing and was silent about it. Both landed as a refusal. Neither landed as a warning.

Decision

A block reference that resolves to nothing is refused by the endpoint that writes it, and tolerated by the endpoint that merely carries it.

A create refuses any dangling binding. POST /api/v1/sites/{site}/fbnetworks has no prior document, so every binding in the request was written by that request. A blockRef naming no block in the same spec is a 400. An empty blockRef is included, because it names no block either.

An update refuses a binding it strands and carries one it inherited. PUT /api/v1/sites/{site}/fbnetworks/{name} compares the incoming spec against the stored one. A binding that resolved before the request and does not resolve after it is refused, which is exactly the rename and the delete. A binding that was already stranded when the request arrived passes through untouched. The comparison keys on the whole {variable, blockRef, portRef} triple, so a second binding written onto a block name that was already missing is still refused for the entry the request wrote.

That asymmetry is the load-bearing part of this decision, and § Alternatives Considered records why.

POST /api/v1/apply stays exempt, for the reason validateScanInterval already exempts it. That route is the road dcs restore crds takes, and a backup written before this check has to remain restorable.

A binding is never rewritten. Re-pointing one at the renamed block would be the editor authoring a POU interface it has no user interface for, and a block delete has no correct rewrite at all.

The check reads blockRef and not portRef. A wrong port strands a binding the same way, and answering it needs the block type's output ports. For a composite type that means resolving an FBType the gateway would have to fetch, and a type it could not resolve would refuse a document that runs. The reference this record closes is the one the editor moves.

The refusal names the variable first and the block second, because the variable is what stops working and the block is what the engineer moved. The FB editor shows no variable and edits none, so a message naming only the field would describe something the engineer has never seen.

internal/gateway/control_binding_guard.go holds both guards, and internal/gateway/control_binding_guard_test.go holds every case above, including the exemption.

Alternatives Considered

Refuse a dangling binding on update unconditionally. This is the candidate the issue weighed first, and it is simpler by a whole comparison. Rejected because it turns every road that can already produce a stranded document into a road to an uneditable one. kubectl and the apply route both write bindings without passing this check, and the apply exemption above is deliberate. A program arriving stranded is therefore a reachable state. The surface that would meet the refusal is the FB editor. That editor carries bindings and does not author them, so no move would be available to an engineer inside it. That is the #1569 rule, which is that a refusal has to leave the endpoint's purpose reachable. Refusing what the request breaks and tolerating what it inherited does both. Adding the missing block back still heals the document.

Warn in the FB editor at save. Cheap, and it reaches the surface where the rename happens. Rejected as the whole answer, because it is client-side only, and kubectl and the CLI would keep writing what the editor had learned to warn about. The editor gets the refusal's own text through the toast that api() already raises, which is the same warning with a server behind it.

Write nothing down and leave the behaviour as it is. A defensible answer, and the one the product had. Rejected on the evidence that the same rule is already enforced twice in pkg/templatecompiler, so leaving it unenforced here is not a policy but an inconsistency nobody had noticed.

Have the editor re-point the binding at the renamed block. Rejected outright. A rename is the only case where a rewrite is even definable, a delete has no answer at all, and an editor that quietly edits a POU interface it does not display is a worse defect than the one being fixed.

Add a corpus lint over examples/, matching #1439 and #1620. Rejected because there is nothing for it to guard. No example carries a ControlProgram variableBindings list at all. The nine ControlModuleTemplates in examples/ carry 36 tag block references and 59 data connections between them, every one resolves, and pkg/templatecompiler already refuses the tag half on every reconcile. A gate over a corpus it cannot fail on reports success in exactly the way a broken one would.

Consequences

A client that renames or deletes a block while carrying a binding to it now receives a 400 naming the variable and the block, where it previously received a 200 and a program with one fewer working tag. In the FB editor that arrives as a toast at save, and the way forward is to restore the block name or to repair the binding outside the editor.

A ControlProgram that reaches the cluster with a stranded binding, by kubectl or through the apply route, stays editable through the gateway. The stranded binding is carried through each save, and adding a block of that name clears it. The product still says nothing about that program on its own initiative, which is the remaining half of the silence and is not closed here.

spec.connections is out of scope here. #1650 settled it. The paragraph this record originally carried about that field was wrong on the facts. See the amendment below.

The rule generalises past this field, and the shape is worth naming. Where a reference is authored, a target that resolves to nothing is refused. Where a reference is only carried, it is passed through. The two clauses hold each other up, because the second is only safe while some road can still write a document the first would have refused, and the first is only humane while the second leaves a stranded document editable.

Amendment: the connection hazard lives on the port (#1650)

Date: 2026-08-17 Issue: #1650

This record's original Consequences section said that the runtime skips a data connection whose source block is missing "by the identical mechanism", and that nothing refuses one. Both halves are false. The paragraph above has been corrected.

fbruntime.Load has validated both endpoints of every data connection since the runtime was written. A ControlProgram naming a block its own spec does not declare is refused at deploy. ControlProgramReconciler records that refusal as Deployed=False with reason DeployFailed, carries the runtime's message on the condition, increments dcs_reconcile_total{result="error"} and retries every ten seconds. That is loud. What made it look silent from the outside is where it happens. The refusal lands on the device node after a deploy round trip. The resource therefore exists, lists normally, and never runs.

The mechanism the issue described is real, and it lives on the port.

A connection is read twice per scan, and the two reads disagree about what a miss costs. executeScanCycle builds status.Inputs by looking the source up in the collected outputs and skipping what it cannot find. A miss there drops one row from a status map. That is the loop the issue quoted.

The Bus closure in makeFBContext is the one the blocks pull through. A source port matching no entry in the block's Outputs() returns nil there. CoerceToFloat64 reads that nil as 0, and CoerceToBool reads it as false.

The consequence is worse than the binding case this record closed. A stranded binding empties a tag. A stranded source port drives a live analog output. An AO wired from a misspelled port writes 0 to its device address every scan, clamped to outMin, and publishes it at Good quality. The program reports Running with no faulted block, no condition and no log line. Probed against the shipped AO block, a valve commanded to 75 sits at 0 with nothing anywhere saying so.

What changed

fbruntime.Load validates the source port against the instantiated block's Outputs(). This record scoped portRef out of the gateway guard because answering it needs the block type's output ports, and a composite type the gateway could not resolve would false-refuse a document that runs. That reasoning is right for the gateway, and it does not carry to the runtime. The runtime holds the instantiated block. Load already ran GetBlockFactory and Init on every one of them, so a type that does not resolve has failed the load before the question is asked. Asking a block what it outputs is a method call. The runtime is the layer that can answer what the gateway cannot, so the check belongs there.

The destination port is deliberately not checked, because nothing can answer it. FunctionBlock declares Outputs and no counterpart for inputs. A block pulls the inputs it wants by name through Bus at execute time, so there is no declared input set to compare against. A dangling destination port is inert. The Bus loop matches on block and port together, so it never fires. The destination then reads nil for its real input, exactly as it would with no connection drawn. It is still a wire the engineer drew that carries nothing. Closing it means declaring input ports on all 33 block types, which is tracked separately. That is #1655, and the second amendment below is what it settled.

pkg/templatecompiler refuses a data connection naming an unknown block. That is the asymmetry the issue named. The compiler already refused a template tag and an explicit variable binding for the same reference, and it copied a data connection through unchecked. The runtime would have refused the generated program anyway, so this is not a new verdict. It moves one template edit ahead of N undeployable ControlPrograms, on the reconcile path where the sibling checks already live.

No gateway guard was added for spec.connections. The runtime already refuses the block half, and loudly. A second refusal in front of the API would duplicate it. The port half cannot be answered there, for the reason above. A guard covering only the half that is already covered, on a field whose real hazard is one attribute over, would read as closing the connection hazard while leaving it open. That is the shape #1475 names. The general rule at the top of this record is unchanged. What changed is which layer counts as the endpoint that writes this particular reference.

What the check found on its way in

A refusal is only safe to add once you know what it would refuse. Asking that question turned up a live defect in the ST projection.

An unqualified reference to a block in Structured Text resolves to that block's primary output port. pkg/cmlogic inferred the name of that port instead of looking it up. It returned "Q" for a block carrying multiOut entries, which is the timers and the counters. Everything else got "OUT". That is wrong for four types. SR and RS output only Q1. R_TRIG and F_TRIG output only Q. So this program

latched := SR(S1 := set_pb, R := rst_pb);
DO('00001', latched);

compiled to a connection reading latched.OUT, a port SR does not have. The coil then read false for as long as the program ran, whatever the latch did, and the program reported Running throughout. A set/reset interlock latch that never energises anything is the worst instance of this hazard found anywhere in the sweep, and it was reachable by writing ordinary ST.

Nothing compared the two names. stBlockMeta records only the ports the ST projection needs a variable for, so it cannot answer whether a port exists. Its own comment says as much. The block catalog can answer it, and primaryPortFor reads the catalog now. A block type the catalog does not know keeps OUT, which matches what blockHasOutputPort already does for a custom function block.

The catalog is load-bearing for more than that, and it was being trusted without being checked. TestCatalogMatchesRegistry compared block type names and stopped there. The catalog's declared ports and the runtime blocks' own Outputs() had therefore never been held together. Both are consumed as truth by different halves of the product. The FB editor and blockHasOutputPort offer and validate ports from the catalog, and the runtime now refuses from its registry. Drift between them is a wire the editor offers and the deploy refuses. That is the two-declarations-agreeing-by-assumption shape of #1454. TestCatalogOutputPortsMatchRuntime compares them. They agree today on every type but one legacy event source, whose only port is an event output. The catalog files that port as event metadata, and the runtime surfaces it through Outputs(). The test names that difference, and does not skip it. The difference also runs in the permissive direction, so nothing the editor offers is refused.

The corpus

This record rejected a corpus lint on the finding that every endpoint in examples/ resolved. The survey behind that finding compared blocks and never compared ports. Re-run against port sets, the shipped pid-loop FunctionBlockType in examples/newark-plant/14-fbblocktype.yaml exported an ERR output wired from pid.ERR. PID has no such port, so that output was permanently nil. The declaration and the connection are removed.

Every data connection in the deployable corpus resolves on both block and port. Those are the ControlModuleTemplates and the ControlPrograms, and fbruntime.Load now covers them on every deploy. A composite FunctionBlockType is not covered, because its blocks never reach that registry.

#1653 is what that last sentence was pointing at. The same pid-loop composite wired two of its three blocks to SCALE and CLAMP. Neither is registered anywhere, so the document could never have been instantiated at all. The file is deleted and make lint-example-blocktypes resolves every block type in the corpus against fbruntime.GetBlockFactory. Asking why a composite's blocks never reach that registry turned up the wider answer: #1658 records that nothing has executed a composite since the IEC 61499 → 61131-3 cutover removed the registration path, while every layer above the runtime still offers one.

Amendment: a block declares what it reads (#1655)

Date: 2026-08-17 Issue: #1655

The first amendment left the destination port unchecked and said why. The reason was that nothing could answer the question, and that reason was accurate rather than final. FunctionBlock now declares InputPorts, and fbruntime.Load refuses a data connection whose destPort names no input on the block it drives.

What the silence cost

Less than the source half. It is also a different kind of failure. The Bus closure matches on DestBlock and DestPort together, so a connection whose destination port matches nothing never fires at all. No wrong value reaches anything. What is lost is the wire itself.

The destination block reads nil for its real input, exactly as it would with no connection drawn, and it runs on its default for as long as the program runs. The FB editor offered the port. executeScanCycle even records a row for it in status.Inputs, because that loop keys on a source hit and never consults the destination. The program reports Running with no faulted block. The only thing that would surface it is someone noticing the plant behaving as though a wire they can see on screen were not there.

Why the interface had to change

The asymmetry between the two halves is the whole reason this took a second issue. A block holds its outputs, so Outputs() could always be asked. A block does not hold its inputs. It pulls each one by name inside Execute and lets it go. Before this the input set therefore existed only as string literals scattered through 33 Execute bodies, and nothing enumerated it.

pkg/blockcatalog already declared DataInputs per block type, and checking against that would have been 33 edits cheaper. It was rejected. DataInputs had never been compared to anything for the life of the package, while the FB editor offered every entry in it as a wireable port. Validating the runtime against a list nothing had checked is #1454's shape with the checking relocated. Verifying the catalog first is most of the work of declaring the ports, with none of the type safety.

What holds the declaration honest

A declaration is not the thing it declares, so the interface method needed a reader of its own. Three checks hold the chain, and each compares two things that were previously assumed to agree.

TestInputPortsAreWhatExecutePulls drives every registered block with a bus that records the port names it is asked for, and requires the recorded set to be exactly what InputPorts declares. It runs each block twice, answering true and then false, because SEL is the one shipped block that chooses which port to read from a value it read first. Equality is asserted in both directions on purpose. A port Execute pulls and InputPorts omits makes Load refuse a connection the program needs, which is loud. A port InputPorts declares and Execute never pulls is the silent one. That is the exact failure this amendment exists to close, and declaring it one layer up would reintroduce it.

TestCatalogInputPortsMatchRuntime holds blockcatalog's DataInputs against the runtime declaration, which is the check that was missing when this question was first asked. The output direction needed a named exemption for one legacy event source. The input direction needs none, because that block reads no data inputs at all.

TestSTWiredInputsMatchTheCatalog covers the third declaration, and § What the check found on its way in below records why it was needed.

What the check found on its way in

Asking what a refusal would refuse turned up two defects, on the same pattern the first amendment recorded.

The ST projection could generate a dangling destination port from ordinary source. stBlockMeta.inputs is the named-argument list of a block type's ST call, and IEC 61131-3 spells a timer preset and a counter preset as arguments beside the wired ones. This runtime reads PT and PV once at Init and never from the bus. Nothing distinguished the two kinds of argument, so a literal became a param and a variable became a connection. This compiled with no diagnostic at all:

dwell := AI('30001');
q := TON(IN := run, PT := dwell);

The wire into tmr.PT carried nothing, the block was left with no pt param, and the timer therefore held a preset of zero. A timer asked to wait fired on the scan it was enabled, and the program reported Running. stBlockMeta was a fourth declaration of a block type's ports, after the runtime's own reads, the catalog and the composite CRD, and nothing compared it to any of them. It carries a paramArgs carve-out now, a variable bound to one of those arguments is refused where it is written, and TestSTWiredInputsMatchTheCatalog holds the wired remainder against the catalog.

The corpus carried the same shape as the pid.ERR output the first amendment removed, one attribute over. The pid-loop FunctionBlockType declared KP, KI and KD as composite data inputs and wired all three into pid.KP, pid.KI and pid.KD. PID reads none of them, because its gains are params read once at Init. Every instance of that type would have run on PID's default gains whatever a caller passed.

There was nothing left to repair by the time this landed. #1653 landed first and deleted the file, on the wider ground that two of its three member types were registered nowhere and that no composite reaches the runtime at all. The three wires were found independently and by a different route, which is worth knowing: two of the file's three defects were invisible to a survey that compared block names, and each needed a different question asked of the same document.

Scope

The refusal lands where the block is instantiated. It therefore covers the deployable corpus and not a composite FunctionBlockType, whose members never reach the runtime registry. That boundary is the same one the first amendment recorded, and this record is the corpus half of it.

No gateway guard was added, for the reason the first amendment gives. The runtime is the layer holding the instantiated block, and the FB editor already offers destination ports from the catalog that is now pinned to it.

Amendment: a malformed parameter value refuses the load (#1663)

Date: 2026-08-17 Issue: #1663

The two amendments above are about a reference that resolves to nothing. This one is about a value that means nothing. It lands in the same place for the same reason, which is that the runtime holds the instantiated block and so is the layer that can ask.

parseFloatParam answered the same number to two different questions. A parameter the engineer did not set returned the block's documented default, which is correct. A parameter the engineer set to a value that does not parse returned that default as well, and nothing recorded that it had happened. There was no log line, no condition, no faulted block and no error out of Init. The program reported Running and the block ran on a number nobody wrote.

It had done so for a year across thirteen call sites. Those are AI's rawMin, rawMax, engMin and engMax, AO's outMin, outMax, rawMin and rawMax, and PID's kp, ki, kd, outMin and outMax.

Decision

A parameter is absent when the key is missing or when its value is empty. A present non-empty value that does not parse refuses the load. The refusal names the block type and the parameter.

Empty means absent deliberately, and that is the half that would have broken the corpus if it had gone the other way. The shipped ControlModuleTemplates declare safeValue: "", failState: "" and interlockAddress: "" so the UI can surface them for an instance to fill in. Those empty defaults resolve to an empty string that reaches the block. pkg/cmlogic's ST projection drops them for the same reason, in ioConfigArgs.

An unresolved template expression is a third case. It never arrives here at all. substituteAll drops the parameter, so the block sees the key missing. That is the contract the auto-discovery branches were written against.

Why refusing, against ADR 0009

The counter-argument is the fail-safe one. Refusing the load leaves the unit with no logic, where defaulting leaves it with wrong logic, and NetworkManager.Deploy stops the running program before it loads the replacement. Three things settle it.

Every other parameter in the package already refuses. The timers' pt, the counters' pv, BOOL_CONST and REAL_CONST's value, AO and DO's safeValue, failState and interlockInvert each return an Init error. interlockInvert's own comment says that silently defaulting the sense of a trip signal is not acceptable for a protection parameter. The engineering ranges and the gains were the outliers here, and the refusal is the precedent.

A malformed value cannot arrive from the plant. It arrives from a document an engineer wrote, so the refusal is deterministic and it lands at deploy instead of mid-batch. The way out of it is the edit that was going to be needed anyway.

The refusal is also loud where the default is silent. Deploy reports CMStateCompileError carrying the message, and the outputs of the program it stopped went to their configured fail state. That value is predetermined rather than computed, which is the state ADR 0009 exists to reach. It beats an output driven from a gain nobody wrote.

What the two ranges reach past their own block

AI's engMin and engMax are the declared engineering range ADR 0050 enforces on every write. make lint-tag-range reads that same chain to decide whether a phase may command a value at all. A silently defaulted bound therefore leaves the gate and the runtime answering about different intervals, with the gate reading the document while the runtime runs the fallback.

AO's outMin is the default for safeValue, and [outMin, outMax] is the interval safeValue is then validated against. A defaulted bound moves the goalposts of that check. The check still passes, because it is checking against the defaulted numbers.

A range is declared whole or not at all

The same six lines carried a second disagreement. AI reads an absent range as an instruction to auto-discover from the driver. One bound alone fell through to the else and invented the other from the block's fallback, so a document declaring engMin: "50" got engMax = 100.

That is refused now, and so is rawMin without rawMax. AO already applied that rule to the same pair in the other direction, for the reason #1362 wrote down: the span has no sane default, and a defaulted one inverts the conversion while clearing the equality guard beside it.

Discovering only the missing bound from the driver was the other candidate. It was rejected because the resulting interval is declared by neither source. The document names one bound and the driver names the other, so the scaling that comes out is nobody's stated intent.

What holds it

TestRealParam_MalformedValueRefusesInit and TestRealParam_AbsentAndEmptyTakeTheDefault (pkg/fbruntime/blocks/params_test.go) drive every swept site through all three cases. The malformed table asserts that its baseline loads first, so a row cannot pass because the block refused the document for some other reason. TestAI_HalfDeclaredRangeIsRefused covers both ranges in both directions.

TestCatalogParamsRefuseMalformedValues (pkg/blockcatalog/param_refusal_test.go) then drives every typed parameter the block catalog declares through the block that reads it, across REAL, INT, BOOL and DURATION, and requires the refusal to name both. The catalog is the right side to sweep from, because it is what the FB editor offers an engineer to type into.

It is not the whole surface. Nothing has ever held the catalog's Params against the params a block reads, which is the gap the port amendment above closed one field over. The catalog is missing three of them today: AO's rawMin and rawMax, and failState on AO and DO. So that gate's coverage claim is about the declaration. The per-site table in pkg/fbruntime/blocks is the one about the blocks.

Writing that sweep found a smaller defect of the same kind. REAL_CONST, BOOL_CONST, CTU and CTD refused a malformed value by returning the bare strconv error, and Load prefixes only the block name from the document. The engineer was therefore told that a value did not parse without being told which parameter of which block type. Those four name both now.

Where this meets the composite parameter interface

#1660 landed a dataType check on a composite instance's parameter values, in pkg/fbruntime/composite_params.go, and parseParamValue there says it uses the same parser the blocks use "so the flattener cannot accept a value Init would reject". That sentence describes the tree only with this record in it. Before it, Init accepted every malformed REAL the flattener would have refused, and answered the block's default. The two halves cover different documents: the flattener holds a parameter declared on a composite type, and this holds the same parameter written directly on a block, which is every document in the corpus today.

Scope

This covers the runtime's own parameters. pkg/driver/simulation carries the same helper verbatim, across 34 call sites that read a simulated tag's behaviour config. Its NewBehaviorFromConfig already returns an error, so the mechanical fix there is cheap.

It is a separate verdict because the trade-off is different. A sim tag that refuses to start takes a demo or a capture stack down with it, and one of those sites, rawParam, documents its swallowing as deliberate. That belongs in its own issue. The amendment below is that issue's answer.

Amendment: the simulation driver refuses too, and at the document (#1665)

Date: 2026-08-17 Issue: #1665

pkg/driver/simulation carried the same swallowing helper, verbatim, for a different document. Thirty-four call sites read a simulated tag's behaviour configuration off an IOModule's spec.simulation block or a SimulationPreset. A malformed center, amplitude, periodSec, initial, stepSize, min, max, engMin, engMax, capacity, gain, driftRate or stuckValue silently became the helper's default, and nothing recorded that it had happened.

The verdict is the same, and the neighbours had already made it

The amendment above settled the question one package over by finding that every other parameter in pkg/fbruntime/blocks already refused. The same grep answers it here, and it does not have to leave the package. parseOptions (pkg/driver/simulation/options.go) refuses a tickRate that is not a duration, a seed that is not a 64-bit integer, an init. key naming no address, and any option key the driver does not implement. Its own comment says why. That sentence is about the whole class and not only about options: every failure there is a static authoring mistake. It cannot become correct by retrying. So it surfaces when the driver is built. The alternative is a run that is quietly not the run that was asked for.

NewBehaviorFromConfig had already made the same call twice inside its own body. An expr program that does not parse refuses. So does a behaviour type the driver does not implement. One function was therefore refusing a malformed program and defaulting a malformed number beside it, out of the same map.

The swallowing on raw was documented as deliberate, and is re-decided

rawParam said in its doc comment that a missing or invalid value means engineering units. That is a written decision and not an oversight. The issue therefore asked for it to be re-decided, and sweeping it would have been the wrong answer.

It refuses now. The parameter selects the units the tag is stored in. A typo therefore scales a 45 degree reading across the full 0 to 65535 ADC span and hands an AI block 0.07 degrees, which is the consequence a wrong engMin already has. The value arrives from the same place too. It comes out of a document an engineer wrote. No path exists by which the plant can produce one. strconv.ParseBool accepts eight spellings of each answer, so what is refused is a typo and not an author.

The blast radius, which is what made this a separate issue

The concern was that a refusal takes a demo or a capture stack down with it. It does not, and the reason is worse than the concern.

configureSimIOModule (internal/adapter/ioconfig.go) logs the error and returns nothing. The pod stays up and the module runs with no behaviours at all. IODriverStatus carries three fields, name, protocol and connected. So the Diagnose panel's I/O Drivers table paints a healthy row for a module whose profile was thrown away. No status, condition or metric records it. A malformed expr already lands there, so the refusal added here changes no failure mode.

It does mean that refusing at the runtime alone trades a wrong number for a dead module. That trade is silent, and it is not an improvement an engineer can act on. So the refusal is placed where this ADR's title already puts it, at the document. internal/gateway/simulation_param_guard.go refuses a SimulationPreset or an IOModule whose parameters the driver would not load. It does so through simulation.ValidateBehaviorConfig, which is the driver's own construction path. A second list of parameter names would let the two come to disagree about which documents are good.

The SimulationPreset form is why this is not theoretical. It takes behaviour parameters as one free-text key=val,key=val field. So center=5O with the letter is a keystroke away, and every layer under that field used to accept it.

The create-refuses, update-carries split is unchanged. An update is refused only when the request rewrites the behaviours. One that edits a description alone is not refused over stored content it did not write, so a document that is already unloadable stays reachable by the edit that fixes it (#1569).

Empty means absent, again

A parameter is absent when the key is missing or when its value is empty. stringParam had already made that call in the same file, for the address parameters sitting beside these. The preset form also emits key= for a value it read as empty, so a document round-tripped through it must not start being refused.

Two carve-outs, both written down

The Expr program is not checked at the gateway. An inline behaviour on an IOModule writes its tag references with the module prefix, as in fermenter-sim:analog.6, and the expression grammar has no character for the colon. ConfigureFromProfile strips that prefix before anything parses the string, and preset expansion rewrites generic names through the AddressMap first. The program is therefore parseable only once it reaches the driver. Ten shipped examples are in exactly that state, and the corpus test found them on its first run. The expression's refusal stays at the runtime, which is the layer holding the context that resolves it.

An init.<address> value is left alone. It is stored as a float when it parses and as the string it is when it does not. That is correct and not inherited: an address may hold either, so such a value is a type and not a malformed number.

A refusal belongs to the site that reads the parameter

SineWave writes engineering units directly and reads neither an engineering range nor raw. A malformed one of those on a SineWave is therefore not refused. This is the per-site scope the amendment above already settled, and it needed one edit to hold. registerBehaviorMeta read raw once at the top of its switch, including in the branches that ignore it.

What holds it

TestBehaviorParam_MalformedValueRefusesLoad and TestBehaviorParam_AbsentAndEmptyTakeTheDefault (pkg/driver/simulation/params_test.go) drive every swept site through all three cases. The malformed table asserts that its baseline loads first, so a row cannot pass because the config was refused for some other reason. Removing the refusal turns 33 subtests red. TestBehaviorParam_EngineeringRangeReachesTagMetadata covers the bound that reaches furthest, including that a refused behaviour leaves no partial range behind for an AI block to discover. TestConfigureFromProfile_RefusalInstallsNothing and its fault twin pin the blast radius. A refused profile installs no behaviours and injects no faults, so it cannot leave half of each in place.

TestShippedExamplesSimulationParamsLoad (internal/gateway) walks the corpus, 108 behaviours and 1 fault across 22 documents. TestBuiltinPresetsLoad covers the 15 behaviours in the five built-in presets a demo seeds itself from. Both fail when they find nothing, because every check they make is a comparison and a scan that reached no document would exit clean looking exactly like a clean corpus.

The corpus check earned its place immediately. It is what found the Expr carve-out above, which the first implementation had wrong.

Amendment: the runtime says which module it refused (#1669)

Date: 2026-08-17 Issue: #1669

The amendment above says of configureSimIOModule that no status, condition or metric records a refused profile. That was true when it was written and is what made the placement question look hard, so it is worth being explicit that the sentence no longer describes the product. The runtime reports the refusal now. Nothing about where a document is judged changes: the gateway still refuses an authored document, for the reason the amendment gives, and this closes the backstop path a kubectl apply or a restore takes.

What the silence was

configureSimIOModule returned nothing, so both of its failure paths ended at a logger.Error line in one pod. Three surfaces above it reported health. IODriverStatus carried name, protocol and connected. A simulation driver satisfies all three whether or not its profile took. POST /api/v1/ioconfig answered {"success":true} unconditionally, so the unit controller was told the push landed. dcs_runtime_driver_connected read 1. The plant presented as a set of tags that had stopped moving, and nothing named the module or the reason.

The shape of the fix

configureSimIOModule returns its error. The adapter stores it beside the driver, in ioConfigErrs. That map is swapped copy-on-write with ioDrivers on the #877 hot-reload path. A repaired profile therefore clears the verdict. A newly broken one raises it.

IODriverStatus and DriverHealth each grew a configError. It reaches the Diagnose panel's I/O Drivers table, dcs get runtime, and the unit controller's watchdog.

The field is separate from connected, and that is the decision. Folding a configuration refusal into connectivity would have been one line and is wrong for the reason determineState already writes down about an unreadable io-probe: it would assert a comms fault on evidence of something else, and send the diagnosis at the network and the hardware while both are healthy. HealthStatus() is unchanged for the same reason. It summarises connectivity, and a runtime reporting degraded here would be claiming a fault it cannot see.

dcs_runtime_driver_config_valid is the machine half, set by the watchdog from the same status read that sets dcs_runtime_driver_connected. Every driver gets a 1 on every poll, and not only the failing ones a 0, because a series that first appears on its own failure is indistinguishable from one nobody is scraping (#1646). The message itself is not a label. It is served by GET /api/v1/diagnostics, which is where an engineer reads which behaviour was rejected.

Two questions the issue asked and this answers

Whether the IOModule controller should mirror the refusal into a condition. Not here. The obstacle is the cardinality. A ControlModule in any Unit may reference an IOModule, resolveIOModules compiles the module's config into every referencing Unit's runtime, and each runtime holds its own driver instance and applies the profile independently. So the refusal is a fact about a (module, unit-runtime) pair and an IOModule condition would collapse N of them into one boolean. IODriverStatus is already at the right granularity. The controller also has no route to a unit runtime for a simulation module, since determineState returns Online for one without consulting any probe. The mirror is not a small change dressed as a large one.

Whether one failed behaviour should cost the whole module's profile. It still does, unchanged. ConfigureFromProfile refuses the profile whole, a malformed expr has always landed there, and the amendment above kept the new parameter refusals alike deliberately. A per-behaviour skip would convert one loud absence into several quiet ones, and it needs a surface saying WHICH tags went inert before it is an improvement. That surface is per behaviour and this one is per module.

The third item was smaller and is fixed with the rest: dcs get runtime printed an ADDRESS column read from a key IODriverStatus has never emitted, so the column was blank for the life of the command. The struct carries the module's address now.

Amendment: the catalog is held against the params a block reads (#1666)

Date: 2026-08-17 Issue: #1666

The amendments above decided what the runtime refuses and how it says so. This one is about what the product never offered in the first place.

blockcatalog.BlockTypeMeta.Params is the FB editor's property form. The editor renders one row per entry and offers nothing else, so a param that is not in the catalog cannot be configured from the editor at all. Nothing had ever held that declaration against the params a block reads in Init, which is the gap the second amendment closed for ports one field over. It was out by four entries.

AO reads rawMin and rawMax, and they are the engineering-to-raw conversion applied at the write. An engineer who placed an AO from the palette could set outMin and outMax and could not reach either raw bound, so the only route to the conversion was hand-written YAML or the ST projection. That conversion is the one #271 shipped without, where a CV of 80 per cent reached a WAGO 750-554 as 80 raw counts and the loop ran at a gain of one part in 328. Both output blocks read failState. That parameter is the ADR 0009 and IEC 62443 SR 3.6 selector between driving safeValue on a program halt and holding the last commanded value. Neither block offered a field for it either.

Both omissions are silent in the direction that looks fine. The form renders, every field in it works, and nothing says a param is missing.

Deciding the oracle was most of the issue

The obvious repair is four catalog rows, and it leaves the same silence in place for the next param. The opposite drift is real too. A catalog entry no block reads is a field that collects a value and delivers it nowhere, which is #1639 one kind over.

The port version of this check works because a block's input set could be declared on the interface and then held against a recording bus. That oracle does not transfer. The reason is a property of the language. A bus is a function value, so substituting a recorder for it is an ordinary substitution. A param set arrives as a map[string]string. A map is not an interface and carries no hook, so nothing in Go can report that a block looked up a key. A Params() method with nothing holding it honest would be a third declaration agreeing with the catalog by assumption, which is the shape this whole file exists to refuse.

The two directions therefore take different oracles, each the strongest one available for the question it answers. A param the block reads and the catalog omits is answered by reading the source of every Init, which is what found all four of the entries above. A param the catalog declares and no block reads is answered by running the block, because a key an Init fetches and then ignores is written exactly like a key it uses.

What the scan has to resolve, and what it refuses to guess

A param key is a literal at the call and an identifier at the read. AO hands "outMin" to realParam. That shared helper indexes params[key]. failState is read inside parseFailState. That one takes no key argument at all. So the scan binds a callee's parameters positionally and follows the map one call deep at a time. Anything it cannot resolve is reported and not skipped, because a scanner that quietly under-reads reports a clean tree.

DEVSTATE is the one block whose key set is computed. It ranges over the map and accepts default and state1 through state8, so no literal set in its Init describes what it reads. What makes its catalog entry checkable anyway is that it refuses anything else, which its own comment says is deliberate. That refusal is the enumeration. The rule for a computed-key block is therefore that it must refuse an unknown key. A future one that does not is a failure and never a skip. Every declared param ending in a digit is also probed one number higher, since a computed set is one constant away from growing and raising devStateMaxInputs is exactly how it would.

The form now reads the type it has always declared

The catalog declares a dataType beside every param and the form rendered all of them as the same free-text box. A closed domain gets a picker now. That set is every BOOL. It also covers any STRING whose domain the catalog closes with the new enum field. failState is the shipped case. The choice between the two fail states is now made from a list. Everything else keeps its text box and gains a check on the literal in it.

Three properties of that check are load-bearing. It is advisory and never blocks a save. It errs permissive, because a false warning about a value the runtime accepts is worse than the silence it replaces. And it runs on a literal only, since a param value is legitimately an expression in template-editor mode and the tunable toggle writes one itself.

TestCatalogDeclaresEveryParamTheBlockReads, TestCatalogParamsAreReadByTheBlock and TestComputedParamBlocksRefuseUnknownKeys (pkg/blockcatalog) are the three gates. TestParamScanResolvesTheSharedReaders pins what the scan resolves. A change to how a block reads its params then fails on the reader itself. Without it, the failure would surface on a catalog entry three files away. On the browser side, test/js/fb-editor-param-types.test.js reads the shipped catalog out of its Go source and requires the check to flag nothing the product itself declares, which is the failure that would put a red field on every AI block in the plant.