ADR 0065: A composite block is flattened at load¶
Status: Accepted Date: 2026-08-17 Issue: #1658 Related: #1653 (the example that surfaced it), #1650 and #1649 (a reference that resolves to nothing, refused where it is written), #1660 (the parameter interface, amended in below)
Context¶
A FunctionBlockType is a cluster-scoped CRD describing a reusable
sub-network of function blocks behind declared data ports. Nothing executed
one between commit c2910088 and this decision.
That commit was the IEC 61499 to 61131-3 cutover. Its own message says
"Remove composite/ECC type registration", and it deleted
registerCompositeTypes, registerCompositeType, registerBasicType and
fbCompositeTypeDef from internal/adapter, along with
fbruntime.CompositeBlockDef and RegisterCompositeBlock. Composite
execution was an IEC 61499 capability and the cutover dropped it.
Nothing else was dropped with it. Every layer above the runtime went on offering the shape:
- the CRD ships, cluster-scoped,
/api/v1/fbblocktypescarries full CRUD, withfunctionblocktype:*in the action catalog,GET /api/v1/blocktypesmerges composites into the block catalog withisComposite: true,- the FB editor reads that flag into a Composite palette category, draws such a block with a distinct stroke, and lets an engineer place one,
ControlProgramReconciler.resolveCompositeTypeswalks a program's blocks, resolves each composite to depth 8, bundles the definitions into the deploy request ascompositeTypesand hashes them intospecHash,- the hot-swap gate defers
PUTandDELETEon a block type on the stated grounds that editing one changes the content of every program resolving it, docs/architecture.mddocumented the kind and publishedcompositeTypesas a field of the deploy request body.
fbDeployRequest in internal/adapter declared no compositeTypes field and
decoded with a plain json.Decoder, so the bundle was discarded without a
word. grep -r compositeTypes found one producer and no consumer. An engineer
who placed a composite from the editor's own palette got a saved
ControlProgram, a Deployed=False condition with reason DeployFailed,
dcs_reconcile_total{result="error"} climbing, and a retry every ten seconds.
The same walk found a second defect in the same feature.
FunctionBlockTypeDTO and FunctionBlockTypeCreateRequest carried
eventInputs, eventOutputs, eventConnections and outputMappings, none of
which FunctionBlockTypeSpec declares. The Define Composite Block Type panel
asked the engineer for Event Inputs and Event Outputs, defaulted them to REQ
and CNF, sent them, and the handler dropped both. That is
#1639's
shape, in a kind that sweep did not reach.
Decision¶
A composite function block is flattened when the program is loaded, before any block is instantiated. It is never registered as a block type.
The reasoning for not registering one is worth stating, because registration
is the shape the deleted code had. A composite is not a FunctionBlock. It
has no Execute of its own, and the scan's contract has no meaning for a
container: one Execute per block per cycle, in declared order, with a fault
confined to the block that raised it. A synthetic block running a nested scan
would own a second scan order, a second fault policy and a second output
namespace, none of which any existing surface can address.
fbruntime.Flatten is the one implementation. It takes a ProgramDef
carrying the composite definitions and returns an ordinary one:
- Scan order. A composite's inner blocks are spliced in at the position the instance occupied, in the composite's own declared order. The scan executes in declared order, so an engineer who placed a composite between two blocks means its contents to run between them.
- Naming. An inner block takes a path name,
loop/ctrl, joined by/. The separator is not.because a status key isblock.portandApplyOverridesrecovers the block name by splitting on the FIRST dot. With a dot, an override carried across a hot swap forloop.ctrl.CVwould resolve its block name toloop, match nothing, and be dropped. The drop is silent, because dropping an unknown block is what that carry-over does by design. An operator-commanded value vanishing on a redeploy is what the carry-over exists to prevent. - Boundary.
__SELF_INand__SELF_OUTare pseudo-blocks, never instantiated. A wire from__SELF_INroutes a declareddataInputto a block inside. A wire to__SELF_OUTdrives a declareddataOutput. An input port may fan out to several inner readers, so one wire drawn into an instance becomes several. An output port takes exactly one source, because two blocks driving one value is a conflict the scan cannot arbitrate. - Nesting recurses to the cap
ControlProgramReconcileralready applies when it decides what to bundle. The count runs the same way there, so a bundle the reconciler was willing to send is never refused here for depth. A composite that contains itself is refused, and the message names the cycle. - Variable bindings are repointed. A binding to a composite's declared output port resolves to the inner block driving it, because the status map is keyed on the flattened name and a binding left naming the instance would resolve to nothing. That is the stranded reference ADR 0064 closed, arriving by a different road.
An output block may sit inside a composite. WriteSafeOutputs and the
interlock bypass both address a block by name. After flattening, a nested AO
is an ordinary entry in the block map under its path name. It therefore keeps every ADR 0009 and
ADR 0010 guarantee an unwrapped output block has.
A composite type declares its parameters, and an inner block references one where it is instantiated. This replaces the blanket refusal the ADR shipped with, which is recorded in the amendment below along with why the refusal was the right first answer.
The gateway refuses a program the runtime could not load, by calling the
same Flatten. ADR 0064 scoped portRef out of the binding guard on the
grounds that a composite type the gateway would have to fetch and could not
resolve would false-refuse a document that runs. That premise is gone: a
composite is one Get away and Flatten is the whole verdict. The create
and update routes for a ControlProgram resolve the composites the program
names and run it. The refusal therefore lands on the request that wrote the
document. Before this it landed on the device node, after a deploy round
trip.
The create-versus-update asymmetry is ADR 0064's, unchanged and for its
reason. A create authored every block, so an unresolvable one is refused
outright. An update carries an already-unresolvable program through, because
such a program is undeployable and the only way out of that state is an edit.
A PUT refusing what it inherited would leave the broken program uneditable
in the one surface an engineer would fix it from.
POST /api/v1/apply stays exempt so dcs restore crds can restore a backup
written before this existed.
The dead event-* fields are removed from both DTOs in the same commit as
the panel that sent them, per #1639's rule that a removal is not done until
the readers are gone.
Alternatives Considered¶
Withdraw the surface. Remove the palette category, the catalog merge,
resolveCompositeTypes, the compositeTypes field and the documentation
claims, then keep or retire the CRD. This is the honest small answer if
composites are not coming back, and it was rejected because reusable blocks
are table stakes for a distributed control system and because it would have
deleted the Define Composite Block Type panel built days earlier under
ADR 0058.
Refuse at the gateway and nothing else. A small guard, no runtime change. Rejected on its own: it turns "offered everywhere, executed nowhere" into "offered everywhere, refused on save", which is worse than withdrawing the surface. It is the correct second half of this decision, and it is included above.
Register a composite as a synthetic block type, which is the shape the deleted IEC 61499 code had. Rejected for the reasons in the Decision. It needs a second scan order, a second fault policy and a second output namespace, and none of the name-addressed surfaces could reach inside it.
Consequences¶
A composite executes. make lint-example-blocktypes resolves one against the
composites the corpus declares. It also runs Flatten over every program and
over a synthetic instantiation of every declared type, so a composite no
example uses is still checked. The corpus carries a working one again:
deviation-monitor in examples/newark-plant/14-fbblocktype.yaml,
instantiated by jacket-temp-deviation beside it.
The flattened name is what an engineer sees. Program status keys, tag
overrides, interlock bypass and safe-state all address band/over rather
than band. That is the cost of not registering a composite as a block, and
it is the honest reading: those surfaces address a block that executes, and
the block that executes is the inner one.
A composite may wrap I/O since the amendment below. regulated-valve in
examples/newark-plant/14-fbblocktype.yaml is a PID loop around one modulating
valve, instantiated twice by coolant-valves in the file beside it, and the two
instances differ only in their device addresses and one gain.
TestFBHandler_CompositeTypeBundleIsDropped asserted the gap and was written
to fail when execution returned. It did, and
TestFBHandler_CompositeTypeBundleIsExecuted replaces it.
Amendment: a composite type declares its parameters (#1660)¶
Date: 2026-08-17 Issue: #1660
The decision above originally refused a parameter on a composite instance outright. That was the conservative half of #1658 and it was the right first answer, because the two alternatives available at the time were both silent: drop the parameter, which is the defect #1658 closed, or push it into every inner block, which sets a parameter on blocks that never declared one. Refusing failed loudly and left the interface open.
It also left the feature far smaller than it looked. A block whose behaviour
comes from a parameter was fixed for every instance of the type. So an AI
inside a composite carried one device address, and a PID carried one set of
gains. What remained usable was composites built entirely from blocks that take
everything through their input ports. That excludes every composite wrapping
I/O, which is the kind an engineer reaches for first.
The interface¶
FunctionBlockTypeSpec.parameters declares a list of {name, dataType,
description, defaultValue, required}. The field set is the block catalog's
parameter metadata for a built-in, so the FB editor's properties panel renders an
instance's parameters with no new concept. The catalog's binding field is
deliberately not mirrored: it names the ControlModuleTemplate namespace a
built-in's param binds to, and nothing here would honour it, which is the
declared-field-nobody-reads shape of #1646 and #1639.
An inner block references a parameter in its own params as
{{.params.<name>}}, and fbruntime.Flatten substitutes the instance's value.
The spelling is borrowed and the failure mode is not¶
pkg/templatecompiler already resolves a block parameter reference for a
ControlModuleTemplate, spelled exactly that way, and make lint-tag-range reads
that chain. Matching it is worth more than inventing a second syntax for the same
idea.
The two disagree on a miss, deliberately. substituteAll DROPS a parameter
whose reference did not resolve. That is correct there, because a block reads an
absent param as "use driver defaults or auto-discover". A template that supplied
no value is therefore asking for the default. Here an unresolved reference is
refused. A reference that reached a block intact would arrive as a device address
made of template syntax, or as a gain PID silently replaces with its own
default (#1663).
Anyone who finds both substitution paths should find this paragraph with them.
Where a reference is answered¶
Where the block is instantiated, which is ADR 0064's rule one layer up.
Substitution is threaded per scope. It is not applied once over the whole
program. An inner block's
params resolve against the scope of the composite that CONTAINS it, and an
instance's own param values resolve in the scope the instance sits in. That
ordering is what gives a nested composite a defined answer. Where outer
declares addr and instantiates inner with address: "{{.params.addr}}", the
reference belongs to outer's scope and is answered before inner's scope exists.
The top level is not in scope, and that is load-bearing¶
A block at the top level of a network has its params passed through untouched.
The reason is not symmetry: the top level of a document Flatten is handed may be
a ControlModuleTemplate network, whose params carry {{.inputs.x}} and
{{.params.x}} for pkg/templatecompiler to resolve on the reconcile path.
Flatten sees such a document unresolved in exactly one place,
make lint-example-blocktypes, which reads the corpus as authored. The first
implementation refused any expression it could not answer and that gate reported
six shipped templates as broken, which is how the scope was settled.
Inside a composite type body nothing else resolves anything, so the refusal belongs there and only there.
The two resolvers compose in the useful direction. A ControlModuleTemplate may
instantiate a composite and pass {{.inputs.pv}} as a parameter value: the
template compiler resolves it to a real address on the reconcile path, and the
flattener then substitutes that address into the inner AI.
Six refusals¶
Each one is a value that would otherwise reach a block, or a declaration that would accept a value and deliver it nowhere.
- An instance sets a parameter the type does not declare. This is the original refusal, narrowed and not deleted, because the value still reaches no block.
- An inner block references a parameter the type does not declare. This is a property of the type alone, so it is checked when the bundle is indexed and a type nothing instantiates is still refused. A misspelling lands here.
- A required parameter with no default that the instance does not set.
- A value that is not an instance of the declared
dataType. A declared default is held to the same standard. The parser is the one the blocks use, so the flattener cannot accept a valueInitwould reject. - A template expression inside a composite type body that is not a parameter reference, since nothing would ever answer it.
- A parameter the type declares that no inner block references. An inner block param is the only place a composite parameter can be consumed, so "referenced by none" means "reaches nothing": the CRD would accept a value from an engineer and deliver it nowhere. That is #1646's dead declaration and #1639's dropped field at once, and this repo has ruled the same way on both.
The last one was the only judgement call, and the objection to it is that it makes a type unwritable in the intermediate state where somebody declares the interface first and wires the body second. It does not survive contact with the product. The Define Composite Block Type panel holds an unsaved draft across a collapse by design (#1536), and it creates the type from the CANVAS. There is no state in that flow where an interface exists and a body does not. An intermediate authoring state is a reason for the editor to hold a draft, which it already does. It is not a reason for a CRD to hold a promise the runtime does not keep.
Consequences¶
make lint-example-blocktypes supplies each declared parameter when it
instantiates a type for its synthetic probe, using the declared default where
there is one. Without that, a type requiring a parameter would be refused by a
probe that sets nothing, which is a false red on a correct corpus.
The refusals reach the request that wrote the document, because
internal/gateway/control_blocktype_guard.go calls the same Flatten. What that
guard can still get wrong is the bundle it assembles: resolveCompositeBundle
copies the type's fields one by one, and a forgotten parameters would make the
gateway refuse programs the runtime accepts.
TestCompositeBundleCarriesEveryDeclaredField holds the reconciler's own
projection against the CRD structurally. The next field added to the spec
therefore fails a test. It does not go missing at deploy.