Skip to content

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/fbblocktypes carries full CRUD, with functionblocktype:* in the action catalog,
  • GET /api/v1/blocktypes merges composites into the block catalog with isComposite: 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.resolveCompositeTypes walks a program's blocks, resolves each composite to depth 8, bundles the definitions into the deploy request as compositeTypes and hashes them into specHash,
  • the hot-swap gate defers PUT and DELETE on a block type on the stated grounds that editing one changes the content of every program resolving it,
  • docs/architecture.md documented the kind and published compositeTypes as 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 is block.port and ApplyOverrides recovers the block name by splitting on the FIRST dot. With a dot, an override carried across a hot swap for loop.ctrl.CV would resolve its block name to loop, 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_IN and __SELF_OUT are pseudo-blocks, never instantiated. A wire from __SELF_IN routes a declared dataInput to a block inside. A wire to __SELF_OUT drives a declared dataOutput. 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 ControlProgramReconciler already 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 value Init would 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.