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.