Skip to content

Architecture Decision Records

ADRs capture load-bearing design decisions for Cloud-Native DCS so that future contributors — and bidirectional tooling that reasons over the repo — can recover the why behind a choice, not just the resulting code.

When to write an ADR

Write one when any of the following applies:

  • The decision changes a CRD shape, an API contract, or a compliance posture (ISA-88, IEC 61131-3, 21 CFR Part 11, IEC 62443).
  • More than one credible alternative was on the table and the reasoning for picking one is non-obvious from the diff.
  • The choice has downstream blocking effects on other issues, milestones, or repos.
  • Reversing the choice later would require migrations, rewrites, or customer-visible behavior change.

If a PR description is enough to explain the change, you do not need an ADR. If a contributor a year from now would have to read commits to recover the rationale, you do.

docs/design/ is for longer-form RFCs and implementation notes. ADRs are the decision record — short, dated, citable. The two can cross-link.

Conventions

  • Filename: NNNN-short-kebab-title.md, where NNNN is the next sequential four-digit number. 0000-template.md is reserved.
  • A number belongs to exactly one ADR, and the H1 carries it too. Pick the next number against origin/main immediately before committing, not when you start writing — parallel sessions each picking "the next number" from a stale checkout is how the two ADRs numbered 0024 collided (#962/#991). make lint-docs-adr fails on a duplicate.
  • Every ADR gets exactly one row in the index below, and the row's [NNNN] label must match the number of the file it links to. When renumbering, move the file, the H1, and the index label together — a half-done renumber leaves a [0024](0026-….md) row that reads as correct and cites the wrong decision (#1005).
  • Sections (in order, exact headings): Context, Decision, Alternatives Considered, Consequences.
  • Front matter: YAML block with at least title (matching the H1) and category: explanation, per docs/STYLE-GUIDE.md.
  • Status line: one of Proposed, Accepted, Deprecated, Superseded by ADR NNNN. Write it as **Status:** <value> on its own line near the top.
  • Link the source issue near the top so the ADR is reachable from the issue tracker.
  • ADRs are immutable once Accepted. Corrections that change the decision live in a new ADR that supersedes the old one; the old ADR's status changes to Superseded by ADR NNNN.

Index

ADR Title Status
0000 Template n/a
0001 Dissolve Asset CRD into ControlModule + Unit pattern Accepted
0002 Alarms reaction-time stance — BPCS poll cadence, phase guards for fast interlocks Accepted (amended by 0007)
0003 docs/api-reference.md is the contract; CI fails on drift Accepted
0004 Node join is a deployment-layer concern — retire in-product k3s join brokering Accepted
0005 Role definitions are deployment configuration; the permission vocabulary is the product contract Accepted
0006 Edge-runtime redundancy is hold-then-resume failover to a designated standby node — no hot-standby state replication Accepted
0007 Device-level interlocks run in the FB scan on the edge — three-layer protection model Accepted
0008 Edge-local holding logic — partition-triggered safe-state sequencing at the unit runtime Accepted
0009 Fail-safe output behavior on program halt — per-block fault tolerance, safe-state write-out on removal, device watchdog backstop Accepted
0010 First-class device-interlock bypass — permission-gated, time-boxed, annunciated, edge-enforced Accepted
0011 The BPCS/SIS boundary is a documentation and PHA-time concern, not an operator-HMI banner Accepted
0012 Control-module logic has one canonical form — the function-block network; ST and FBD are projections of it Accepted
0013 One blessed golden path for change authoring; the speculative surface around it is demoted, not advertised Accepted
0014 Alarm shelving suppresses annunciation via the retained alarm event; consumer contract, not a new topic Accepted
0015 One canonical resource address across the gateway apps; the hierarchy tree becomes a shared component Accepted
0016 Tag/module semantics are declared in the schema (role, deviceClass) and merely rendered — the HMI infers nothing from names, with no defaulting anywhere Accepted
0017 Typed operator prompts — acknowledge, choice, and bounded value Accepted
0018 Device state is a first-class derived tag — synthesized in the FB network, rendered verbatim by the HMI Accepted
0019 FBD layout is presentation, not configuration — never persisted, fully re-laid-out on any network change Accepted
0020 OPC UA discovery classifies each node onto a control plane — field devices become ControlModule instances, modules become Units Accepted
0021 IOModule.controllerRef is optional for network protocols — controller-less network I/O is monitored by a namespace-shared probe Accepted
0022 Device discovery classifies on the type-definition namespace — a recognized PA-DIM / OPC UA-DI type is a data-plane signal Accepted
0023 Live-feed freshness and cluster health are separate indications — one status dot for the cluster, a ticking age readout for the feed, banner on fault Accepted
0024 Action-level authorization policies refine the permission tiers — per-role allow/deny over a product-owned action catalog, exact names, per-role precedence Accepted
0025 Tag freshness is judged against a declared publish mode — an onChange tag's unbounded age is not staleness; it falls back to feed liveness Accepted
0026 Link redundancy is the standard controller network posture; zero-gap continuous control is deferred to bench measurement Accepted
0027 A tag timestamp is observation time, not last-change time — authoritative in-memory stores stamp read time, OPC UA keeps its source timestamp Accepted
0028 Feed freshness is answered by the live-data path that paints the mounted view — the WebSocket clock on process displays, each dashboard view's own poll clock on batch and alarms Accepted
0029 Alarm annunciation is backed by the 15s REST poll with the WebSocket push as an accelerator, so a broker outage degrades the cues in cadence rather than freezing them Accepted
0030 Animated conceptual diagrams are deterministic frame-stepped captures of committed brand-tokened HTML/SVG sources, riding the clip pipeline's spec/lint/publish/embed machinery unchanged Accepted
0031 Device nodes are tainted unit-runtime-only — the deployment layer applies dcs.io/role=device-node:NoSchedule, and only the control-zone pods the physical operator creates tolerate it Accepted
0032 Server degradation annunciates as an ISA-18.2 alarm raised by an operator in every site namespace Accepted
0033 The asset tree is a site-scoped Infrastructure branch beside the ISA-88 process tree; a thing enters it only when the product operates it, and gets a record of its own only when no Kubernetes object is already authoritative Accepted
0034 Infrastructure actions are operator-executed requests recorded on a cluster-scoped NodeMaintenance object, the gateway gains no node write, and shutdown is offered only by a power channel that can also power on Accepted
0035 A foreign control device is an IOModule carrying an optional field-device record; no new kind is minted, and a vendor skid's asset position stays with its Unit Accepted
0036 Planned maintenance shelves the server alarms of the node it services on a fixed clock from the moment it started, and an overrun annunciates in its own right Accepted
0037 A server alarm states its consequence, its per-site unit impact and its operator response before any engineering detail; the health indication covers the hardware, and the HMI gains no infrastructure surface Accepted
0038 A site outage is one sequenced act on its own object, quorum is broken deliberately at the end or not at all, and shutdown without a power-on channel is available only in an attended form that says so Accepted
0039 The docs toolchain stays on MkDocs, the dependency closure is pinned by hash so a fork cannot arrive transitively, and adoption of ProperDocs is a decision we make deliberately or not at all Accepted
0040 Product-authored attributes of a machine live on one cluster-scoped NodeIdentity per node, named for the node; a controller-bound node is named through its Controller and a NodeIdentity for it is refused Accepted
0041 Redundant-collector sourcing answers the controller-failover gap; hot-standby FB-state replication is deferred behind a written requirement Accepted
0042 Network io-probe placement is declared per IOModule as a nodeSelector over deployment-applied field-reach labels; the shared probe partitions into one pod per distinct selector, the empty selector keeping the unconstrained ADR 0021 default Accepted
0043 NE 107 device health is a declared address on the ControlModule, polled by the io-probe into status.deviceHealth (Unknown always carries a reason; absence is not a state) and annunciated only through declared AlarmDefinitions; the IEC 61987 instrument nameplate lands as ControlModule.spec.fieldDevice Accepted
0044 The canonical northbound namespace is the declared equipment tree from enterprise to tag with batch, procedural, and alarm context; the OPC UA address space and the Sparkplug B metric tree are projections of it, the internal JSON topics stay a parallel surface, and publication is per-site opt-in on the Site CR Accepted
0045 An MTP service is recognized by its ServiceControl variable shape and modeled with the VDI 2658-4 vocabulary; ISA-88 presentation is a declared correspondence, and no MTP conformance is claimed Accepted
0046 The supported viewport floor is 1280x720; above it an operator surface may never require a horizontal scroll, while vertical scrolling is normal, and width reductions key on the pane through container queries rather than on the window Accepted
0047 The action chart's position belongs to the action chart alone, a transitional chart may read it through STEP_ACTIVE() but never resumes from it, and a resumed step still never re-runs its action Accepted
0048 A holding action deferred by a dead runtime is commanded automatically on the first reconnect and the phase chart re-armed at the edge, driven from a named sub-state of Held (status.holdingActionDeferredSince) rather than from Held itself, because ISA-88 makes HELD a waiting state that directs no actions Accepted
0049 An external quality system gates a change our own ceremony already signed, the loop is polled with the record's state read fail-closed, and any close that is not an approval ends the ChangeRequest as Rejected Accepted
0050 A tag's declared engMin/engMax is enforced on every write path — the runtime's adapter.WriteTag plus each gateway route — by refusing the write rather than clamping it, because a clamp leaves a guard that waits for the feedback to reach the setpoint unsatisfiable and the step wedges Accepted
0051 spec.batchID identifies exactly one Batch in a site, enforced in the Batch admission webhook because the audit correlation and the batch-id label depend on it for every writer; a repeat work order with an identical body is answered with the existing order and 200, a differing one is refused with 409, and reads resolve an already-forked pair to the oldest Accepted
0052 A dcs.io/command Start is the only thing that starts a batch; spec.scheduledStartTime is the planned start — recorded, reported, exported as the B2MML ProductionRequest StartTime — and it neither starts a batch nor defers an operator who starts one earlier Accepted
0053 An IOModule whose io-probe did not answer reads Unknown, not Offline — Offline is kept for the cases the DCS determined, so losing the probe no longer claims a healthy device is dead and no longer auto-HOLDs a running batch, and the lost visibility is annunciated in its own words Accepted
0054 A command an interlock refused is not a device fault: MISMATCH is measured against the position the output block actually drove, a defeated command annunciates on its own CMD_BLOCKED tag, and a refusal rule carries no exceptionAction — because the holding chart it would invoke drives a safe state free to clear the very interlock that refused the command Accepted
0055 A measurement produced outside the control system reaches a running phase through AWAIT_RESULT and an API-key delivery endpoint that records provenance — the delivering system, the credential, the sample — and no electronic signature, because a machine identity cannot sign; it lands in its own batchRecord.spec.externalResults list rather than in operatorActions, so the record can never assert that a person acknowledged a number no person saw Accepted
0056 The OMF projection of the ADR 0044 namespace names every AF element after the CR it comes from, identifies every element and stream by the canonical path, and carries all values on three immutable dynamic types whose per-tag engineering metadata rides on the container; a name PI cannot carry is refused at the declaration that produced it, and nothing the projection creates is ever renamed, moved, or deleted Accepted
0057 An OPC UA securityMode or securityPolicy we cannot resolve refuses the driver at construction rather than falling back to a default or reaching a library that maps it to Invalid, because a typo in the field that decides whether traffic is encrypted otherwise produces a working connection and no complaint; unset stays unset, and the accepted policy set is read from the client library's own table Accepted
0058 Inline expansion is the default container for detail views, entry forms and plain confirmations — views re-render an expanded region from state via the shared DcsExpand component, which owns the toggle, Escape, focus movement, ARIA wiring and keyboard operation; a modal is a per-dialog founder ruling (#1545), and DcsModal remains only for the ceremonies so ruled Accepted
0059 Interrupt to prevent, expand to attest — the founder's per-dialog ceremony rulings: authored-content delete, node/outage verbs, interlock bypass, failover, hot-swap and the re-auth prompt stay modal because their interruption is the protection, while terminal-batch delete (bare confirm), batch commands, CR sign, recipe lifecycle, Review & Sign, operator prompt respond and Promote migrate to inline expansion so the thing being signed or reviewed stays visible; every rung survives each migration and the status quo governs until it lands Accepted
0060 A mutating endpoint declares what it refuses — every DELETE and PUT gateway route carries a row in scripts/.mutation-preconditions.tsv saying whether execution state can make the action unsafe: guarded names the test asserting the refusal, stateless carries the written reason, gap names an open issue, and there is no allowlist because not asking is what let a running batch be deletable for the product's whole life Accepted
0061 An MQTT rotation window is two ACCOUNTS rather than two passwords — mosquitto keys credentials by username, so each client identity gets credential slots a (its base name) and b (a -b suffix) carrying the same ACL rules from one list, and a rotation opens the standby slot, flips activeSlot, then retires the slot it left; slot b exists only while a rotation runs through it, the broker image is pinned to a release, and the proof is a broker started against the file the chart writes rather than a grep over the manifest Accepted
0062 The read tier is confined to reads — the shipped table carries dcs-viewer (read alone) so least privilege for a SIEM collector is a group assignment, the OPC UA discovery routes move to engineer because an outbound session to a caller-named endpoint is not a read, and every non-GET route left at PermRead carries a dry-run / handler-gated / flag-gated row in scripts/.read-tier-writes.tsv with its evidence Accepted
0063 One logging configuration for every binary — all twelve log through a single pkg/dcslog constructor at the console encoder, Info level, no sampling and stack traces only for DPanic and above, because the Diagnose Logs tab serves stdout as raw text and Debug put 95 request lines into its 2000-line window on one page load; Development stays true only because false turns on zap's per-message sampler with no way back, and the chart's release-wide logging block reaches unit-runtime and io-probe through the physical operator so the override cannot rebuild the split Accepted
0064 A dangling block reference is refused where it is written — a ControlProgram create refuses any variableBindings entry whose blockRef names no block in the same spec, an update refuses only a binding that request strands and carries an inherited one through, and POST /api/v1/apply stays exempt so a backup remains restorable; the asymmetry exists because the FB editor carries bindings without displaying them, so a PUT refusing what it inherited would leave a stranded program uneditable with no move available inside the editor Accepted

| 0065 | A composite block is flattened at load — fbruntime.Flatten splices a composite type's inner blocks into the program at the instance's position under a /-joined path name, rewrites the connections through the __SELF_IN/__SELF_OUT boundary and repoints the variable bindings, so a composite is never registered as a block type and every downstream surface works unaware; the gateway calls the same function to refuse a program where it is written; amended by #1660, which gives a type a declared parameter interface an inner block reads as {{.params.<name>}} | Accepted | | 0066 | A read-back is declared on the output it reads — a ControlModuleTemplate output port carrying readBack: true says the device point it drives can also be read, which licenses an input block whose address is written as {{.outputs.<name>}} and nothing else, so one point keeps one binding and an instance that aliases an independent input port onto the driven address is still refused; the licence is granted per block on the pre-substitution expression, because after substitution an address the author wrote as an output reference and one an instance bound to the same string are indistinguishable | Accepted | | 0068 | A device fail-safe is declared and never defaulted — a rack cut mid-batch left a coupler holding 13.58 mA with every controller dark, and neither the armed hold nor the halt selector can reach that case because both need a live process; spec.failSafe on IOModule now declares hold or clear with a timeout and a recovery posture, applied to the device by the one io-probe that serves it, while an UNSET field deliberately writes nothing and is reported through FailSafeDeclared instead, because defaulting would change what a running plant does on upgrade and a cleared 4-20 mA output sits at 4 mA where nothing downstream can see it; amended by ADR 0071, which gives the timeout a floor | Accepted | | 0067 | A dwell counts only time the control system was present — a step's elapsed time is wall clock from an activation that survives a restart, so an interval in which no scan ran counted as dwell time and an eleven-minute power loss satisfied a 120-second hold in two scans; the engine now stamps each scan into the persisted position, a resuming run measures the distance before re-entering the chart, and steps[].onControlGap carries the verdict — Hold by default because it is the only one that decides nothing about the lot, then Extend, Fail and Count | Accepted | | 0069 | An interlock guard is authored where equipment is driven — transitions[].interlock marks a hazardous-condition jump to a safe-hold step, and the SFC editor offered it at every chart level the panel draws, so a recipe procedure save sent a field RecipeSFCTransition cannot hold and an operation chart stored a safety claim no webhook validates and no engine reads; ISA-88 Clause 5.2.1 puts interlocking inside equipment control and a step above the phase names a child procedural element rather than driving tags, so the control renders on phase charts alone and the recipe projection sends no such field | Accepted | | 0070 | A step above the phase names the child it stands for — ValidateSFCChart reported all 33 shipped above-phase charts clean while three of them could not be instantiated at all, because the executor reads step names and template references while the SFC diagram and the change-control diff read the initialStep, transitions and divergences it never looks at; ValidateAbovePhaseChart runs the structural checks in full and adds the one the corpus was failing, refused at admission on CREATE and UPDATE and at the gateway save path because every engineering stack runs with webhooks off, and held by the batch instantiator itself rather than by a second copy of the rule | Accepted | | 0071 | A watchdog deadline is measured against our own heartbeat — spec.failSafe.timeout had no floor, so 100ms was admitted and the WAGO register accepted it exactly; the io-probe reads every device it serves once every 15s and any telegram feeds the watchdog, so a deadline under that cadence detects our absence no sooner and only adds a trip when one read runs late, which makes the floor three cadences and a property of the product rather than of the plant; a reconcile-time FailSafeTimeoutMargin condition then measures the gaps each device really sees, because the serial probe loop pays a dial timeout for every unreachable module and the cadence the floor assumes stops being true under load | Accepted |

Cross-linking

  • The source issue should link back to the ADR once merged.
  • Implementation PRs reference the ADR in the description so the decision travels with the code.
  • Compliance pages under docs/compliance/ cite the ADR when the decision moves a standards boundary.