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, whereNNNNis the next sequential four-digit number.0000-template.mdis reserved. - A number belongs to exactly one ADR, and the H1 carries it too. Pick
the next number against
origin/mainimmediately 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-adrfails 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) andcategory: explanation, perdocs/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 toSuperseded 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.