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; amended so the health indication grades a server on two questions rather than one, because serverNodeHealth reads readiness and pressure alone and a node the product itself had cordoned or drained stayed Ready throughout, so an eighteen-hour maintenance with its overrun alarm standing unacknowledged read healthy on the only hardware readout an operator has — an open maintenance is quiet for exactly its ADR 0036 shelve window and degrades the chip once that window closes, and a cordon no maintenance of ours accounts for degrades it at once, because nothing in the product asked for that one or will return it 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; amended by #1719, which splits the retained cascade by protocol, because losing the Controller determines the device only for protocol: simulation 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 | | 0072 | A manual override releases through the block that tracked it — pid-loop/pid-cascade's CV was declared read-only and the FB runtime had no mode-awareness at all, so the commissioning workflow the docs already taught ("switch to Manual, step CV directly") could not be performed; CV becomes readwrite, gated by the existing #1255 equipment-mode barrier alone, and PID detects a manual override on its own OUT port via a new FBContext.SelfOverridden hook, folding it into the same TRK/TRK_VAL back-calculation ADR 0007 already gives a device-forced value, so bumpless transfer needs zero new PID logic; release on return to Automatic is a new ManualOverridable marker interface rather than a CRD field, scoped so a plain readwrite constant like SP (bound to a REAL_CONST that computes nothing) is never swept up | Accepted | | 0073 | A batch does not command equipment an operator holds — a phase's WRITE reached Adapter.WriteTag directly and never the gateway, so the only ISA-88 mode barrier in the tree (#1255) sat on a road the SFC engine does not travel and a batch drove a ControlModule an operator held in Manual, silently; the refusal goes on the bridge as a controller-supplied WriteGuardFunc, because the runtime pod has no apiserver client by ADR 0006 design and because mode judges the WRITER, not the write — the gateway bars an operator unless the module IS Manual and this bars a phase when it is, two directions that cannot both be enforced on the one route they share; barred for the action and restarting charts (production commanding) and exempt for holding, stopping, aborting and resetting, whose refusal would trade a dual-writer hazard for equipment left where a failed phase abandoned it; an action refusal self-holds and a restarting refusal holds rather than aborting the lot | Accepted | | 0074 | A reading is published only if the block stands behind it — an analog channel that stopped carrying its signal read as a valid 0.009 %, the loop computed a 60 % error against it and wound its output to 100 % on a dead input, because the Modbus driver stamps Good on every successful read, the AI block never looked at tv.Quality and ctx.Bus("PV") hands the PID a bare float; AI now holds OUT at its last trusted value, raises PV_BAD and faults the block so the program reports Degraded, while PID gains a PV_BAD input that reuses the ADR 0007 back-calculation for a bumpless hold; the driver keeps saying Good because a successful read is a successful read and the judgement belongs to the block that knows the raw span, and an opt-in rawFailLow/rawFailHigh band is the only detection available on a driver with no quality channel — deliberately not documented as the general answer, since a WAGO 4-20 mA card maps 4 mA onto raw zero and normalises the live zero away before the driver sees it | Accepted | | 0075 | A health verdict counts only drivers that reach the plant — every unit-runtime pod is created with --protocol simulation and a simulation driver reports connected from Connect until shutdown, so the default entry it publishes made all drivers down unreachable on every deployment ever created: the watchdog Hold never fired, the 60-second NOT_SERVING grace never expired, dcs_runtime_healthy was pinned at 1, and #1735's clearing edge fired against plants whose I/O was still gone; driver.IsFieldProtocol is now the one definition and it names the protocol rather than the entry, because a simulation IOModule driver carries the identical defect one layer down, while a unit with NO field drivers abstains rather than concluding loss, and the driver table itself stays unfiltered | Accepted |

| 0076 | An I/O address names the driver that answers it — the runtime routes an address on its <iomodule>: prefix and serves one WITHOUT a prefix from its own primary driver, which every unit-runtime pod starts as an unconfigured simulation driver that answers an address it has never heard of with 0.0 at quality Good and no error, so twenty shipped bindings and nine block addresses read a plausible number no instrument stands behind with the module reporting healthy; the gateway now refuses a ControlModule whose address names no IOModule at all (a create outright, an update only for what it writes), while a prefix naming a module that does not exist stays a runtime refusal because it is loud where it lands and refusing it at save time would make authoring order load-bearing, and make lint-example-bindings holds the corpus to the stricter rule that the address is a channel the named module declares — reporting a channel-less module unanalysed rather than passing it; amended by #1750, which moves that stricter rule into pkg/bindingcheck and ships it as dcs lint bindings <dir>, an offline check over a directory of documents, because the defect class belongs to anyone authoring ControlModules by hand and hack/ ships in nothing | Accepted | | 0077 | Persisted state has a writer, or it has no meaning — the unit runtime kept two copies of its deployed-program state and only one was alive: NetworkManager writes networks/all.json on every deploy and RestoreAll redeploys from it at startup through the Adapter, while ReplayLastNetwork read a last-program.json whose only writer was deleted with the legacy oneshot route in the Phase 3 cutover, and would have run it four scans against the unconfigured primary driver had the file ever existed; nothing went red because the loader answered (nil, nil) and the log line said no persisted network found, which is also what a healthy first boot says, so the dead path is deleted rather than routed and make lint-runtime-state-files now pairs every data-directory read against a writer, with external and gap the only verdicts and an unresolvable path reported rather than skipped | Accepted | | 0078 | A partial loss of field I/O is annunciated like a total one — the watchdog asked whether EVERY field driver was down, so a unit that lost one of two buses kept Running with runtimeReady true, no condition and no alarm, while it wrote to the channels that still answered and failed silently on the rest; a failed field WRITE reached driver health, the CM health topic, dcs_cm_program_degraded_total and the HMI and reached no alarm at all, so the only signal from the bench outage was a step timeout on a guard two steps downstream that named a process fault; partial loss now Holds and raises High naming the drivers that went (unit granularity IS reference granularity, because a runtime's driver list is built from the IOModules its own ControlModules reference), the two extents raise under separate prefixes so each can clear the other rather than dedupe against it, dcs_runtime_healthy moves to "every field driver connected" so the alert cannot read all-clear over a Held unit, and ControlProgram.status.faultedBlocks carries the names a new reconciler raises a High equipment alarm from — with no batch effect, because a degraded program is still regulating every healthy output (ADR 0009) | Accepted | | 0079 | The retained alarm event carries the alarm as it stands — AlarmReconciler published on four edges of its own and on nothing else, so the six sites outside the alarm package that raise an alarm got one publish at activation and never another: the clear clearUnitAlarmsByPrefix writes onto the status and the message annunciateDriverLoss rewrites under a standing alarm (#1760) both woke the controller and both fell past every branch, leaving the retained topic saying ActiveUnacknowledged for an alarm that had returned to normal; the HMI hid it by replacing its working set from a 15-second REST poll, while the historian recorded the raise and never the end and a subscriber connecting later replayed an alarm that had cleared; a status.publishedRevision fingerprint taken over the payload itself — spec and status together, because a clear bumps no metadata.generation — turns "the change reached the controller" into "the change reached the broker", written only after the broker accepts the publish so a marker that is missing or behind always means the event is owed, and recorded inside the two publish helpers the in-package generators already call so their own transitions are not published twice | Accepted | | 0080 | A restarting runtime restores what it was commanded — RestoreAll redeployed the programs and nothing else, so the first scan after any restart wrote a compile-time default to the field: the bench cut a field bus, the coupler correctly held 60 % and 50 % for 138 seconds, and the first write to land at the replug was zero on both channels, while four documents (ADR 0006, ADR 0008, the failover runbook and the HA failure-mode table, whose claim is also #1283's whole rationale for not safing a redeploy) published that outputs hold last value across a pod restart; commanded values and block operating points now travel in networks/state.json and are installed between Load and the scan goroutine, with fbruntime.StatefulBlock on PID, AO, DO and AI — AI because since #1738 it HOLDS a last trusted reading, so a restart under a dead instrument republishes zero as a measurement and an unwired loop rails on it; snapshot age governs the annunciation and never the restore, because the only device declaration a remembered value can step is clear with recovery: resume and withholding the restore there substitutes a guaranteed step for a conditional one; and IsConnected is now lock-free on every driver, because a health RPC that waited on the Modbus five-second I/O lock could not answer a one-second liveness probe, which is why the pod was killed on a timeout instead of degrading through the NOT_SERVING its 60-second grace was designed to deliver | Accepted | | 0081 | A refusal is not a lost connection — replayQueue read every publish failure as a dropped link, so one message the broker refused by configuration held 8404 behind it for the life of the pod while live telemetry ran at 1481 messages every 15 seconds, and the four hold-lifecycle events from a #942 partition drill never reached the control plane at all; a PUBACK cannot arrive over a link that is down, so PublishRaw now returns the reason code paho was discarding, a refusal the broker will repeat is dead-lettered to {DataDir}/queue/deadletter.jsonl and advanced over rather than held, a refusal that may be answered differently later (0x91, 0x97) is left in place, an unrecognised code resolves towards letting the queue drain, and the replay worker runs on a 30-second timer as well as on reconnect because a healthy link produces no reconnect to wait for; dcs_runtime_mqtt_queue_blocked exists because depth and the replay counter are both flat whether a queue is held or idle | Accepted | | 0082 | A promoted runtime resumes from the plant — the bench cut power to the active node under a running batch and the coupler correctly held both outputs at Good quality for the whole 52-second dead-node window, then the standby was promoted and both stepped to zero about four seconds before the unit reported itself controlling, on all three valid reps; ADR 0080 cannot reach that path and its own argument says why, because the reason it restores a snapshot however old is that the device is still holding exactly that value, which is true of a restart in place and false the moment the node changes — so the second half of the defect was already on main and undescribed, a standby that has hosted the unit before finding its own leftover state.json and re-driving a four-hour-old 20 % onto a plant held at 50 %, annunciated as a successful continuity restore because age was the only thing recorded and provenance was the thing that mattered; a promotion now adopts what the DEVICE is holding through the IOChannel.ReadbackAddress the product has carried since #1689 and only where a channel declares one (an undeclared output address answers from the coupler's INPUT process image at Good quality with another channel's number), and where the device cannot answer the block writes nothing at all, which is what ADR 0006, ADR 0008, the failover runbook and both HA node rows have published all along; there are three output phases and not two, because a block that adopted 60 % and then passed IN through would write a constant's compile-time zero on its very next scan; a loop is initialised onto the adopted value through the ADR 0007 back-calculation, armed only where TRK_VAL is wired to an output that actually adopted; an interlock trip and the fail state are never withheld; and the binding epoch, stamped into state.json and now passed to every unit-runtime pod rather than only a leased one, is what separates a promotion from the restart ADR 0080 still owns | Accepted | | 0083 | A historian row is stamped with the observation, not the arrival — mqtt.Message carried a Timestamp from the first commit that Subscribe never set and nothing else in the tree wrote, so it was the zero time on every message ever delivered and all four historian ingest paths fell through to time.Now(); a promoted runtime draining an 8,781-message store-and-forward queue therefore wrote a two-hour-old run into the minute of the promotion, and the record for that minute says the loop sat at a 60 % setpoint with its output railed at 100 % while the unit was Idle and the io-probe read the same coupler at zero throughout, and every historian restart re-ingested each retained alarm still standing and dated it at the restart; the event time was never missing — driver.TagValue.Timestamp is the ADR 0027 observation time, the alarm and state payloads carry timestamp, CM health carries since — so the field is deleted rather than filled with receive time, which is the number the consumers already have and would only turn every fallback into dead code, and each path reads its own payload through one stampAt; a declared instant more than 30 s AHEAD is refused as a clock fault because migration 001 cannot drop a chunk whose range_end is in the future, while an old one is never refused because that is the replay this exists to honour; the fallback is counted per type and reason rather than silent, dcs_historian_ingest_lag_seconds becomes the true lag its Help text always claimed and is re-bucketed to ~1.8 h to measure a backlog, and the state and alarm publishers gain sub-second precision so reading the wire does not collapse the order of two transitions inside one second | Accepted | | 0084 | A live stream holds the newest observation — the gateway TagBus and the OMF egress read the same mqtt.Message.Timestamp ADR 0083 deleted, so both stamped their own arrival time and both went on doing it in the open once the dead IsZero() branch was gone; on the gateway that is the false "fresh" ADR 0025 names as the more dangerous of its two failures, because the HMI subtracts the frame's timestamp from now and a tag whose runtime stopped observing it half an hour ago rendered current for as long as anything kept republishing it; the event time is now read off the payload by all three consumers through one pkg/eventtime, which carries the rules rather than three copies of the reasoning behind them, while each consumer keeps its own counter because a historian row dated at ingest, a PI sample in the wrong minute and an HMI element that reads fresh are three findings with one cause; the egress is a record and filters nothing, and the gateway is a control surface and therefore holds the NEWEST observation of each subject, dropping a strictly older frame so a runtime draining 8,781 queued messages does not animate two hours of plant history across a mimic that answers the present — bounded to the two subjects a runtime can replay, with an undated frame neither withheld nor recorded and an equal instant admitted; and both HMI live-update paths stop treating an arriving frame as evidence of freshness, asking ADR 0025's one resolver instead of clearing the cue | Accepted | | 0085 | A write posture is declared, and refused where the write happens — the product asserted on every buyer-facing surface that it sits above the OEM islands and that each island keeps its own control loops, and nothing in the software refused a write, so a read-mostly installation on somebody else's line was a modelling convention in which we declared the channels as inputs and then behaved; spec.writePosture on Unit and IOModule now takes Observed, Supervisory or Regulating, composing by the MORE restrictive of the two so a module can narrow what its unit permits and never widen it, with unset meaning Regulating because defaulting the other way would stop a running plant's outputs on upgrade (ADR 0068's call on the same fork) and WritePostureDeclared carrying the effective posture in its message either way; Observed refuses every WRITE at Adapter.WriteValue and Prober.WriteValue, the two points every road reaches — which is also where the accessLevel: read bypass is closed, since TagMap.CheckAccess answers about a tag PATH and the FB scan loop writes by ADDRESS, so a read-only tag was refused to an operator at the HMI and not to the control program driving the same channel at scan rate; Supervisory refuses the NETWORK at load instead, because whether an output is computed from a measurement is a property of the wiring and no single write carries it, and the walk propagates only across a destination port whose declared type is not BOOL — every BOOL input in the catalog is a permission, enable, selector, latch, edge or count pulse, so a fault bit ANDed into a run command stops at AND.IN1 while a PV wired to PID.PV does not, and refusing the first would have obliged a supervisory deployment to strip the fault interlocks examples/templates/vfd.yaml ships; a boolean chain to a DIGITAL output is a permissive and is also on-off control and nothing in the document separates them, so it is disclosed as a Gate that carries a written verdict rather than guessed at; one judgement in pkg/loopcheck serves the runtime, the gateway save path and make lint-write-posture, and examples/supervised-skid/ is the corpus that keeps the gate from reporting all-clear about nothing | Accepted | | 0088 | A service fault is an answer, and the OPC UA client library is carried on a fork until upstream takes the fix — gopcua's client read every response header that was not Good as a loss of the channel, its dispatcher handing the status to the reconnect monitor before it handed the response to the caller, so a Bad_TooManyOperations or a Bad_ServiceUnsupported on an open channel closed the socket, paused and republished every subscription, flipped the state the ADR 0075 health verdict reads, and handed the caller EOF about one run in a hundred and fifty where the fault itself was the answer; the per-call sessions in pkg/opcuaclient now pass reconnect OFF explicitly (gopcua's default is on, so a false never passed was never off), with dcs opcua subscribe the one session that keeps it on because the monitor's exit also ends the publish loop, and the long-lived driver takes a dispatcher fix in gopcua itself that forwards a header status to the monitor only when it names the channel or session as gone; that fix is carried on github.com/cloud-native-dcs/opcua as v0.8.0 plus one commit tagged v0.8.0-cndcs.1 behind a go.mod replace, proposed upstream as gopcua/opcua#904, with Dependabot told to leave the require line alone because a bump there would be overridden by the replace in silence | Accepted | | 0087 | A session credential is named by the record and read where the session opens — an OPC UA IOModule held its password in spec.options, readable by anyone with list on the kind and copied verbatim into the unit's ConfigMap, and named certificate files at paths nothing mounted, so the only endpoint the unit runtime could open was None/None anonymous, which a certificate-provisioned CODESYS controller does not offer at all; spec.security is now the same OPCUASecurityConfig a Unit's serviceBinding carries, the physical operator projects the named Secret into the unit-runtime and io-probe pods under /etc/dcs/opcua-credentials/<secret>/ and the iomodules.json carries the Secret's NAME and never its contents, the driver reads the projection at construction and refuses a Secret it cannot parse with a sentence naming the Secret and the key, and on the typed road discovers the server's endpoints, opens against the one advertising exactly the requested tuple and checks the pin before a session exists; the deprecated keys stay accepted because a module authored on them is a running plant, reported by CredentialsSecured=False/PlaintextInSpec on every surface and refused by the driver only where the deployment declares ioSecurity.refusePlaintextCredentials, the ADR 0085 shape; the gateway never serves the password in either direction and carries the existing one forward on an update that does not name it, so a client that never saw it cannot strip it by handing back what it was served; the runtime pod is recreated when its Secret set changes and the probe is NOT, on the #1762 ruling, reporting ProbeNotProjected and naming the restart instead | Accepted | | 0086 | PLC source is Structured Text, and the project file is a build artefact — the CC100 application #941 needed was generated by a script that embedded its ST in Python string literals, so an edit made in the IDE was destroyed on the next regeneration and no reviewer could diff a POU; the .st files, project.json and iomap.csv under plc/<controller>/ are now authoritative and the .project is a gitignored artefact, with a drift check that exports the built project back out before regenerating and REFUSES rather than overwriting, normalising the one volatile line (creationDateTime, stamped to the tick, so a naive diff would go red every run and be read as noise within a week); PLCopen XML lost the decision on evidence rather than on taste, because a default export of the SCALE GVL kept the comment attached to a declaration and silently dropped the twelve-line block standing on its own that explains why the analog input and output full-scale counts are two different constants — it returns only under the 3S proprietary declarations_as_plaintext, and a GVL is itself carried entirely inside another proprietary addData, so "the exported XML is the source of truth" resolves to either a format that drops the reasoning or ST embedded XML-escaped inside XML; IEC 61131-10 was evaluated as #1892 asked and is broken headlessly on 3.5.21.50, having no ScriptEngine API at all and reachable only as a UI command whose no-argument form blocked a --noUI process until it was killed at 240 s, whose destination form returns None, files no message, creates the file at zero bytes, holds it there through 8 s of polling and flushes 3075 bytes at project close that end mid-element and are not well-formed, and whose -f form writes a complete, valid, entirely empty document — a fourth call in this toolchain reporting success over failure, and the one that would let a gate read an empty export as a clean one; the loop is plc-build/plc-download/plc-verify over five gates run cheapest first, of which the hermetic IOModule parity gate is ADR 0076's shape one layer out (a tag names a symbol the controller actually publishes, and an input channel must publish read because an input the DCS could write is the DCS writing the process image behind the field signal); the expected symbol set is parsed out of the pragmas rather than listed beside them, because the copy that drifts silently is the one nobody edits; ST-only is accepted deliberately and a graphical-language requirement reopens this ADR rather than being worked around; and the workflow is bench-only until #1896, because the programming channel carrying the application and the device password is unencrypted while the OPC UA path beside it refuses anything less than Basic256Sha256/SignAndEncrypt | Accepted | | 0089 | A display name is rendered verbatim — the gateway drew one identity four ways, stripping a trailing parenthetical off a ChangeRequest signer and a recipe approver, keeping it whole on a recipe's withdrawnBy and rejectedBy subject and all, and keeping it in the top bar's full label behind a 260px cap that then ellipsised it, so the #2087 re-shoot came back with Samuel Okafor (Production Supervisor) · Sup… above an e-signature reading Priya Nair against a stored Priya Nair (Process Engineer); the regex was written for an IdP display name carrying its own subject and was never that narrow, and this product's own capture directory ships FUXA SCADA (control) and FUXA SCADA (read-only) as a deliberate pair that it collapsed to one name — on surfaces where the printed name is what §11.50(a)(1) regulates and the raw value sat in a title a screenshot, a print and an exported batch record all lose; a display name is drawn verbatim now and principalCell takes the display name and the subject as the two fields every DTO already carries, with one exception that is the inverse of a known Sprintf rather than a guess: recipe_approval.go composes <display name> (<subject>) into four MasterRecipe status fields, so splitComposedPrincipal takes back the last parenthetical there and a name carrying one of its own survives it; userInitials keeps dropping every parenthetical because it builds initials rather than a name and never offers what it builds as the identity; and the docs-shots seeds that promoted bds-v2 with Priya Nair (Process Engineer) and relied on the strip to draw Priya Nair write the bare name they meant | Accepted | | 0090 | A historian holds an outage in time, and says what it lost — on 2026-09-09 the bench's historian lost its database for thirty-seven minutes, twenty-five of them because the recreated CNPG Cluster minted a new application password that the pod had read into an environment variable at start, and through that window its buffer was capped at ten flush buffers, about two minutes of the bench's arrivals, so it dropped 45,371 records while the gateway's card said degraded and database unavailable, the same sentence it says while a slow flush catches up; a record now waits for the database for a configured retention (an hour by default, sized to the outage on record) behind a per-type record ceiling that is the memory bound, judged on how long the historian has held it and never on its own timestamp so a replayed backlog is not thrown away on arrival; every buffer keeps a loss account that GET /api/v1/historian/ingest reads from memory during the outage and the system card reads beside /readyz, so the card says whether data is being lost or merely held and by how much, and a closed episode is written to the ingest_gaps table on the first successful flush after it and served by GET /api/v1/historian/gaps, the durable record ADR 0063 asks a log line to have; and the credential reaches the pod as a whole-volume Secret file the kubelet rewrites, re-read before every dial, so a rotated or re-minted password is followed with no hand on the pod, with a subPath mount refused by the chart test because it would undo the whole of it | 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.