04 — The Write Path and Safety Model#
Who this is for. Anyone who touches anything under Ring/Services/PLC/, and
anyone reviewing a change that does. Read this before you write code, not after.
What you'll learn. Every gate between an operator gesture and a byte on the wire, with the code that implements it; where the gates sit in the order the code actually runs them (which is not uniform across rails); the commissioning holds, including the two that are hard-closed on purpose; the setpoint write queue and its deliberate non-contract; the evidence journal and exactly what it covers; the write-surface register that keeps the whole surface enumerable; and what a new writer owes before it is allowed to exist.
Ring writes to a live controller. From the 2026-09-08 cutover those writes drive a running plant. A bad write does not corrupt a record, it moves a valve. Every rule below exists because breaking it would cost something real.
If you're reviewing a write-path PR, you need these four things#
This chapter is long — it is the safety chapter and the reason the rest of the book exists — and most of it you read once. This box is for the second and third time, when you are re-opening it mid-review to check one gate. It restates nothing; every line links to the section that is the actual source of truth, so this box cannot drift out of sync with the chapter under it.
- The six gates, in the order
PlcTagWriter.WriteCoreactually runs them: ReadOnly → Demo (live re-check) → Endpoint → Heartbeat → wire. Per-rail order differs from this — see the verified table in §4.7 before you assume a new rail matches it. - Which flag gates this write family, and its default. Eight
per-feature
Enable*flags plusAllowLoopbackPlcWrites; seven defaultfalse, one (EnableProcessorTimeDateSync) defaultstrue. OnlyEnableHmiAgitatorCycleships in the JSON — everything else is the C# default. §4.2. - Is this tank or tag one of the two hard-closed holds?
MainTvcWriteAuthorization.IsAuthorizedandTankTempModeWriteAuthorization.AuthorizedTankTempModeTanksare a hard-codedfalseand an empty list, respectively — do not populate either without a controls decision. §4.3, "The two that are hard-closed on purpose". - What the new writer owes, before it is allowed to exist: a write-surface register row with the count floor raised in the same commit; a seam test pinning tag, value, count and order; bench evidence for a UDT member or a BOOL bit; an independent review. §4.12.
4.0 The idea, in plain language, before the detail#
Ring can change three kinds of thing on the controller:
- A setpoint — a target value that stays where you put it (a temperature, a requested level, a recipe number). Level-valued: writing it twice is harmless.
- A command bit — a single bit the controller reacts to. Most are momentary: Ring sets it TRUE, waits, and sets it FALSE, and the controller acts on the transition. Leaving one TRUE is a fault, not a no-op.
- A block of configuration — an array such as a recipe bank, rewritten as a unit.
Each of those can hurt a plant in a different way, so Ring does not have one "write" function with one check. It has a stack of independent gates, each answering a different question, each failing closed, and each cheap enough to run on every write:
| Gate | The question it answers |
|---|---|
| Read-only guard | Is this station allowed to write at all, in this session? |
Per-feature Enable* flag |
Has this whole family of writes been commissioned? |
| Commissioning hold | Has this tag, on this tank, with this value, been signed off? |
| Endpoint guard | Does the configured address actually name a plant controller? |
| Heartbeat gate | Is the controller's program demonstrably still running? |
| Demo re-check at the wire | Is this session pretending, right now? |
"Fails closed" means: when a gate cannot establish that a write is safe, it
refuses. Not "assumes yes"; not "throws". Almost every ambiguous case in this
chapter — an unreadable roster, an unread capability bit, an unbuilt DI
container where the answer is a permission (PlcWriteEndpointGuard.LoopbackWritesAuthorized,
§4.4), a null batch-step read — resolves to refuse. The one gate where an
unbuilt container resolves the other way is the demo re-check
(DemoModeData.Enabled() returns false on any failure, DemoModeData.cs:29-39)
— see the note in §4.6 for why that is still the safe direction.
The rest of this chapter is those gates in the code that implements them.
4.0.1 The shape of the argument#
The stack is six layers, cheapest-and-most-global first:
PlcWriteGuard.IsReadOnly— global kill switch- per-feature
Enable*master flags - bespoke commissioning holds
PlcWriteEndpointGuard— the write whose target must be the plant controllerPlcHeartbeatConnectionTracker.AllowPlcWrites()- the live demo re-check at the wire
This funnel is the conceptual six layers, in the order this section names
them — it is not any single rail's actual order. §4.7's table gives that,
per rail, and the note below explains why PlcTagWriter.WriteCore itself
runs Endpoint before Heartbeat.
That is now the count in README.md and
ARCHITECTURE.md
too — both once said five, omitting the endpoint guard, and both have been
corrected. PlcWriteEndpointGuard
(Ring/Services/PLC/PlcWriteEndpointGuard.cs) refuses a write whose target
cannot be the plant controller. It is wired into every write rail and has its
own row in the write-surface register
(Ring.Tests/WriteSurfaceRegisterTests.cs:130). The register's own count-floor
comment records it as the one genuinely new write-surface file added in the
2026-08-26 amendment (WriteSurfaceRegisterTests.cs:500-502). It is not
optional and it is not a subset of any other layer.
The order is per-rail, not global. The six-layer list above is conceptual,
useful for talking about the stack — it is not the order any single rail
actually runs them in. Each writer
runs them in the order that made sense for its own diagnostics, and they differ.
Inside the shared funnel PlcTagWriter.WriteCore
(Ring/Services/PLC/PlcTagWriter.cs:135-220) the actual order is:
ReadOnly → Demo (live re-check) → Endpoint → Heartbeat → wire
Note that this contradicts PlcWriteEndpointGuard's own class doc
(PlcWriteEndpointGuard.cs:41-44), which describes itself as sitting after
the heartbeat gate and after the demo re-check. In PlcTagWriter it sits
before the heartbeat gate, and the reason is written at the call site: on an
unconfigured station the link is Disconnected as well, and "PLC link
unavailable" would send the operator to the network instead of to
appsettings.json (PlcTagWriter.cs:164-171). The doc comment is stale about
ordering; the set of gates it names is right. Ordering among gates that all
fail closed is a diagnostics decision, not a safety one — but do not quote the
doc comment as the order.
§4.7 gives the verified per-rail table.
4.1 Layer 1 — PlcWriteGuard.IsReadOnly#
Ring/Services/PLC/PlcWriteGuard.cs. A static, process-wide gate.
private static volatile bool _readOnly = false; // :25
private static volatile bool _forcedReadOnly = false; // :30
public static void Configure(bool readOnly) // :37-40
=> _readOnly = readOnly || _forcedReadOnly;
public static void ForceReadOnlyForSession() // :49-53
{ _forcedReadOnly = true; _readOnly = true; }
public static bool ResolveConfiguredReadOnly(AppSettings settings) // :75-76
=> settings?.PlcSettings?.ReadOnlyMode ?? true;
Four properties, each load-bearing:
It is configured exactly once, before anything can write. App.OnStartup
calls Configure(ResolveConfiguredReadOnly(settings)) inside
RunStartupConfigurationValidation() (Ring/Views/App.xaml.cs:1115-1116),
which is phase 9 — before DatabaseInitializer, before any background service,
before MainWindow.
The resolver fails safe. A null AppSettings or a null PlcSettings both
resolve to true. The fallback was extracted into a static specifically so the
safety default is unit-testable (PlcWriteGuard.cs:67-74).
ForceReadOnlyForSession is a one-way latch. Configure ORs
_forcedReadOnly in, so anything can tighten the gate and nothing can loosen
it short of a restart. StartupDegradedState.ApplyDatabaseInitOutcome(false, …)
pulls it when database initialization fails
(Ring/Services/StartupDegradedState.cs) — see
chapter 02 §2.4.
ResetForcedReadOnlyForTests() (:62-65) exists solely so a test that
exercised the forced path does not leak read-only into the rest of a
-parallel none run; production never calls it.
The field defaults to false, not true. That is intentional: headless
tests never call Configure and must keep writing to their fakes
(PlcWriteGuard.cs:23-24). In the app, Configure always runs. The only other
caller under Ring/ is the setup wizard's read-only step
(Ring/Views/Wizard/Steps/ReadOnlySafetyStep.xaml.cs, register row at
WriteSurfaceRegisterTests.cs:198) and it can only turn the gate on.
What a blocked write returns is not uniform#
This matters more than it looks, because a caller that reads the return value as "it landed" will lie to an operator.
| Path | Behaviour when read-only |
|---|---|
PlcTagWriter.WriteCore |
logs the suppression, writes a comm-log row marked "ReadOnly suppressed", and returns true (PlcTagWriter.cs:143-148) |
PcWriteInteger30BatchControlPlcService.PulseBitSerialized |
returns BatchControlWriteResult.BlockedReadOnly (:190) |
ShiftControlPlcService pulse |
logs and returns true (ShiftControlPlcService.cs:292) |
ProcessorTimeDatePlcService.WriteProcessorDateTime |
returns ProcessorClockVerifyResult.Held (:359) |
BatchFormulaPresetPlcWriter |
returns FormulaBankWriteOutcome.SuppressedReadOnly (FormulaBankWriteInterlock.cs:22) |
TVCControlPlcService, UseTankControlPlcWriter, BatchStartTankPlcWriter, InventoryAmountPlcWriter |
return false — explicitly so a suppressed write is never reported as a landed one |
The "return true" cases are the legacy fake-success idiom: they exist so the
operator flow (batch start, formula select, ack, silence) continues normally in
read-only and demo, and the operator is told separately that nothing was sent.
FormulaBankWriteOutcome documents the reasoning at length
(Ring/Services/PLC/FormulaBankWriteInterlock.cs:16-93), including why a held
capability is treated exactly like read-only rather than as a failure —
SuppressedFeatureHeld (:83) was added because a Blocked outcome aborted
the whole commit and "formula editing for formulas 1-6 stopped working on
cutover day".
How to read a return value on this path, in one rule: a true from a write
API in Ring means "the caller's flow may continue", not "the controller
accepted a value". Only PlcWriteStatus.WrittenAndVerified claims a read-back;
WrittenUnverified claims only that the writer reported success. If you are
adding a caller that will tell an operator something landed, you must consult
the typed outcome, not the boolean — and if your rail only has a boolean, that
is a reason to give it a typed outcome rather than a reason to trust the bool.
The convergence of these return shapes onto one PlcWriteResult type is
designed but deliberately deferred past the cutover
(docs/production-readiness/PLC_WRITE_RESULT_MIGRATION_2026-08-02.md;
Ring/Services/PLC/PlcWriteResult.cs already exists and is used by the evidence
journal).
4.2 Layer 2 — the per-feature Enable* master flags#
Eight keys on PlcSettings (Ring/Infrastructure/Configuration/AppSettings.cs),
each gating one family of writes. Seven default false; one defaults true.
| Key | Default | Line | Gates |
|---|---|---|---|
EnableInventorySnapshotPlcAcknowledge |
false |
:403 |
the PC_Write_Integer[27].4 ack pulse |
EnableInventoryAmountWrite |
false |
:412 |
Inventory_Amount[0..13] bulk REAL write |
EnableProcessorTimeDateSync |
true |
:432 |
controller wall-clock write + [30].10 commit pulse |
EnableShiftControlPlcWrites |
false |
:442 |
shift pulses on PC_Write_Integer[44/45/48/49] |
EnableUseTankFormulaWrite |
false |
:477 |
Tanks[slot].Formula_number from the Use Tank windows |
EnableHmiAgitatorCycle |
false |
:505 |
the HMI-side Tank_IO[N].O_Agitat auto-cycle |
EnableFormulaBankWrite |
false |
:529 |
destructive Formula_0N_Preset_* bank rewrite |
EnableTankNamePlcNameSync |
false |
:542 |
HMI_Tank_Name[] / HMI_Group_Name[] STRING sync |
Two more Enable* keys on PlcSettings are not write gates and are worth
distinguishing so nobody "hardens" them by mistake:
EnableBatchStartPolling (:380, default true) and
EnableInventorySnapshotCapture (:386, default true) gate reads, and
EnableLiveViscometerFeed (:453, default false) gates a recorder that
writes SQLite and never the PLC.
The C# defaults are the contract. Of the eight, only EnableHmiAgitatorCycle
appears in the shipped Ring/Config/appsettings.json (verified: that file's
PlcSettings node contains ReadOnlyMode, the five timeout/retry values
(ConnectionTimeout, ReadTimeout, WriteTimeout, RetryAttempts,
RetryDelay), DefaultIpAddress, DefaultPath, DefaultPort, Protocol,
EnableBatchStartPolling, EnableInventorySnapshotCapture,
EnableHmiAgitatorCycle — and nothing else). The other seven bind to the C#
default, which is exactly why WriteSurfaceRegisterTests pins them:
[Fact]
public void Hardware_signoff_write_capabilities_remain_fail_closed_in_the_shipped_settings()
{
var plc = new PlcSettings();
Assert.False(plc.EnableInventoryAmountWrite);
Assert.False(plc.EnableInventorySnapshotPlcAcknowledge);
Assert.False(plc.EnableShiftControlPlcWrites);
Assert.False(plc.EnableUseTankFormulaWrite);
Assert.False(plc.EnableFormulaBankWrite);
Assert.False(plc.EnableTankNamePlcNameSync);
Assert.False(plc.EnableHmiAgitatorCycle);
Assert.False(plc.AllowLoopbackPlcWrites);
...
}
(Ring.Tests/WriteSurfaceRegisterTests.cs:1053-1126.) That test pins seven
of the eight flags — EnableProcessorTimeDateSync is not covered, because
it ships true. It also pins AllowLoopbackPlcWrites false, pins
AlarmSettings.EnableCustomSilenceLatchTag false and
SilenceLatchTagName == "PC_Write_Integer[30].3", and re-checks all of that
against both shipped JSON files (Ring/Config/appsettings.json and
Ring/appsettings.production.template.json) so a JSON override cannot flip the
runtime value with the C# assertions still green. The test states its own scope
limit explicitly: the deployed Config\appsettings.local.json overlay is a
field file that does not live in this repository and is checked by
scripts/Preflight-FieldKit.ps1 and the cutover config read-back instead.
AllowLoopbackPlcWrites — the ninth flag#
PlcSettings.AllowLoopbackPlcWrites (AppSettings.cs:351, default false) is
not a feature gate; it is the deliberate opt-in that re-permits a
loopback/unconfigured write target for bench work against a local simulator.
Nothing in the shipped configuration sets it. See §4.4.
Each flag's rationale is in its doc comment — read it before flipping one#
These are not stylistic comments. EnableHmiAgitatorCycle
(AppSettings.cs:479-505) records that the ladder owns Tank_IO[N].O_Agitat
— ST_ROUTINE Agitator drives it on a 25 ms task that round-robins twelve tanks
— so the HMI cycler would be a second, unsynchronised cycler contending for a
motor the controller already sequences. Its comment lists three preconditions
before anyone turns it on, including "confirm MainTask InhibitTask = No on the
live controller" and "add a per-tank capability gate — live enabl_agt is true
for tanks 1, 2, 8 and 9 only".
EnableFormulaBankWrite (:507-529) records that the rewrite replaces every
one of the 31 cells of a live recipe bank, single-pass, and that the
Preset_Amount engineering unit per operation is still provisional.
4.3 Layer 3 — bespoke commissioning holds#
These are per-subsystem authorizations that answer a question the global flags cannot: is this specific write, to this specific tank, with this specific value, something a controls engineer has signed for?
| Hold | File | State in the code today | What it gates |
|---|---|---|---|
MainTvcWriteAuthorization |
MainTvcWriteAuthorization.cs:15 |
IsAuthorized => false, hard-coded |
the shared main-TVC command surface (TVC_Ctrl[N].enabled, TVC[N].Temp_mode) |
TankTempModeWriteAuthorization |
TankTempModeWriteAuthorization.cs:62 |
authorized-tank array is empty | Tanks[N].Temp_mode on every tank; also restricts the value vocabulary |
TvcCoolingWriteAuthorization |
TvcCoolingWriteAuthorization.cs:54 |
authorized tanks { 1, 2, 4 } — not empty |
TVC[N].Temp_mode = 2 (cooling) |
TankInstalledWriteGate |
TankInstalledWriteGate.cs |
data-driven from controller bits; Unknown refuses | every operator write landing on a Tanks[N] member |
RosterWriteIndex locks |
RosterWriteIndex.cs |
sticky, fail-closed | every storage-tank write that must resolve a tank index |
FormulaBankWriteInterlock |
FormulaBankWriteInterlock.cs |
decision function | the destructive formula-bank rewrite |
The two that are hard-closed on purpose#
MainTvcWriteAuthorization.IsAuthorized is the literal false. Not a
config read, not a list — a compiled constant
(Ring/Services/PLC/MainTvcWriteAuthorization.cs:15). The rationale is in the
class doc: the controller maps a tank through Tanks_c[N].TVC_tank, while the
retired writer addressed TVC[N] directly and also changed a ladder-computed
TVC_Ctrl bit; that plant contract is not verified.
The refusal is enforced twice, and the second one is stronger:
TVCControlPlcService.SetMainTVCControlModeAsync consults the flag
(TVCControlPlcService.cs:278-281), but SetMainTVCPresetAsync refuses
unconditionally, without even consulting it (TVCControlPlcService.cs:266).
TankTempModeWriteAuthorization.AuthorizedTankTempModeTanks is { }. Empty
by design, and the doc comment says so in capitals: "EMPTY BY DESIGN — this is
the inert half of a two-phase fix, not an oversight… Do NOT populate this list
to 'make the screen work'"
(Ring/Services/PLC/TankTempModeWriteAuthorization.cs:55-62). Phase 1 (the
retarget away from the wrong shared-unit tags) has shipped; phase 2 (permission
to write the new per-tank tag) is one edit here after controls sign off row
F058-T1.
That class also carries a value vocabulary: TempModeOff = 0,
TempModeHeat = 1, TempModeCool = 2, and IsWritableValue returns false for
3 (:97-98). The ladder reads four states; value 3 (heat and cool) is a
legal ladder value Ring may read and display but must never write. A controller
already sitting on 3 is displayed as "no option selected" with an explanation
rather than coerced into an item that would write 1 or 2 over it.
CONTRIBUTING.md§4 andCLAUDE.mdboth say the same thing, and this book repeats it: do not populate either of these without a controls decision. The status of every held item isdocs/production-readiness/CONTROLS_SIGNOFF_RECORD.md; the design rationale is in the 2026-07-09 and 2026-07-13 held-item packages.WriteSurfaceRegisterTestsasserts both states (WriteSurfaceRegisterTests.cs:1074-1075), so populating one turns the suite red.
The one that is narrowly open#
TvcCoolingWriteAuthorization authorizes tanks {1, 2, 4} and excludes tank 3
(the low-mid screen). Its doc comment is the clearest example in the codebase of
why a hold is where it is: the F058 UI convergence gave tank 3 the ability to
send TVC[3].Temp_mode = 2 for the first time — a new setpoint value on a
live temperature-control path, not a suppression and not a tightening. The
authorized set is "exactly the set of tanks that could ALREADY command cooling
before the convergence", so the hold only narrows: nothing that used to be
writable stopped being writable, and the one thing the wave added that nobody
signed for is refused (TvcCoolingWriteAuthorization.cs:33-42).
IsModeAuthorized(tank, cooling) is !cooling || IsCoolingAuthorized(tank)
(:70-71) — heating and off are unaffected on every tank. This can only ever
refuse the cooling selection.
The commissioning gate driven by the controller itself#
TankInstalledWriteGate (Ring/Services/PLC/TankInstalledWriteGate.cs) answers
"does the controller's own configuration say this tank (and this additive)
exists?" from Tanks_c[N] bits, read through the A-B hidden-parent aliases:
| Bit | Alias / bit | L5K line |
|---|---|---|
tank_installed |
ZZZZZZZZZZTank_c17 bit 0 |
:454 |
Liquid_1_installed |
ZZZZZZZZZZTank_c44 bit 5 |
:486 |
Liquid_2_installed |
ZZZZZZZZZZTank_c44 bit 6 |
:487 |
Liquid_3_installed |
ZZZZZZZZZZTank_c44 bit 7 (the SINT sign bit — masks applied to a widened int) |
:488 |
Two policies, deliberately different and pinned on both sides:
- The UI fails OPEN on
Unknown— a link blip must not lock an operator out of a working tank. - The wire fails CLOSED on
Unknown— an unread capability is not permission to command a live starch tank.
Its known limit is stated in the class doc: state is keyed by PLC tank index and nothing evicts it when Setup re-pins a window's roster entry, so a capability can be up to one 60 s re-validation old. The index itself is still resolved per gesture, so the gate never answers for a tank the screen is not aimed at.
The roster locks — three failure modes, three answers#
RosterWriteIndex (Ring/Services/PLC/RosterWriteIndex.cs) is the single owner
of "which physical tank does this UI slot write to?" — the write-side twin
of RosterPollPlan.
It exists because of a specific, real defect class: the Wave 2 cardinality lift
made every per-tank read take its PLC index from the roster slot, while the
batch-start writer still computed uiSlot + 1. Setting storage slot 1's
PlcIndex to 5 therefore made the Batch Start screen read Tanks[5] and
write Tanks[1] — a formula and level committed to the wrong physical tank
while the screen displayed another tank's data (RosterWriteIndex.cs:11-21).
Three distinct failures, and the NoWrite = -1 sentinel alone covers only the
first:
- Unmapped / disabled / out-of-range slot →
StoragePlcIndexreturnsNoWrite(:174-190). Also returned for aLegacyWindowKeythat no slot claims or that more than one slot claims (:245-253) — "with two candidates there is no honest answer to which physical tank is this screen showing?". - Mid-session roster change →
StorageWritesLockedByRosterChange(:76-91), which readsTankRosterState.RestartRequired. Needed because the pollers pin their roster at construction whileCurrentStorageSlots()resolves live on every write, so after a Setup save the two are different physical tanks. Sticky; clears only on restart. - Roster file present but unusable →
StorageWritesLockedByRosterLoadFailure(:124-139), readingTankRosterState.LoadFailed. This one is subtle and the doc comment explains why the other two cannot see it: the substituted fallback roster is valid-looking.StoragePlcIndexreturns an ordinary 1..4, so theNoWritebranch never trips; and…ByRosterChangeis false because noReplaceever ran — the divergence came from disk, not from an operator edit.
Every catch in this file returns the fail-closed answer. Refusing can
never arm the wrong physical tank; guessing can.
StorageWriteIndexForLegacyWindow(key) (:269-274) is the whole write-side
decision in one call — both sticky locks first, then the lookup — and is what
the four TVC control screens use so their thirteen setpoint calls and the
agitator motor command all take the same resolved index.
StorageWriteRefusal(key, out resourceKey, out englishFallback) (:298-314)
resolves the localized key and its English fallback together, from one read
of both locks. The comment records why: the screens used to render one generic
key and pass the cause-specific sentence as its English fallback, so the cause
was named only on a station whose dictionary had lost the key — i.e. never.
ResolveStorageWriteTank (:208-212) exists for display text only and
returns uiSlot + 1 on failure; the comment is explicit that this "deliberately
produces a tank number that may name no configured tank" and that the write
itself is still refused by BatchStartTankPlcWriter, which resolves the index
for real.
The active-batch interlock#
FormulaBankWriteInterlock.Decide(...)
(Ring/Services/PLC/FormulaBankWriteInterlock.cs:153-179) is a pure function
guarding the destructive Formula_XX_Preset_* rewrite. Three rules:
- A
nullcurrent_stepread isBlockUnknown, never idle (:162-163). Unreadable ⇒ blocked. - An active step is
BlockActiveBatchviaBatchStepsPoller.IsActiveStep, using the configurablePlcSettings.BatchIdleStepValuesentinel. - A fresh cached snapshot that still says "active" wins over a single idle
read, guarding a one-tick flap to the idle sentinel mid-batch — but only
inside
SnapshotVetoWindow = 10 s(:143), so a stale "active" snapshot can never wedge the recipe editor shut forever.
The primary signal is a fresh direct read, not the cached snapshot, and the
comment explains why: BatchStepsPoller deliberately does not republish on
steady idle, so snapshot age is not a usable liveness signal.
It is bank-agnostic on purpose — it refuses while any batch runs, not only when the running batch consumes the bank being edited, because Ring cannot prove which bank a running batch is bound to. "Prefer refusing too often over refusing too rarely."
4.4 The endpoint gate#
Ring/Services/PLC/PlcWriteEndpointGuard.cs closes a hole created by a
deliberate decision elsewhere.
PlcConnectionConfig.GetPlcIp() does not throw when no IP is configured; it
warns once and returns 127.0.0.1, so an unset IP cannot trap the operator
before the startup wizard runs
(Ring/Infrastructure/Configuration/PlcConnectionConfig.cs:93-121). That is
right for reads: loopback simply never answers and Ring shows its normal
disconnected banner. It is wrong for writes — anything listening on the
loopback interface (tools/ab_server, a replay harness) answers them, so an
unconfigured station would "arm a batch", "silence an alarm" or "set the
controller clock" against a simulator while every gate and the write journal
report success. And the shipped Ring/Config/appsettings.json ships
DefaultIpAddress: "", so the fall-through is the shipped state, not a corner
case.
The guard classifies a gateway string with no network contact at all — no DNS lookup, because a lookup is itself network contact and a name that does not resolve fails the connection rather than reaching the wrong plant:
| Class | Meaning | Written to? |
|---|---|---|
Routable |
a non-loopback IP literal, or a syntactically valid RFC-1123 host name | yes |
Loopback |
127.0.0.0/8, ::1, localhost, *.localhost, localhost.localdomain |
only with the opt-in |
Unconfigured |
empty/whitespace, or the unspecified address 0.0.0.0 / :: |
only with the opt-in |
Unresolvable |
neither a valid IP literal nor a valid host name | never — no flag makes it a valid target |
(PlcWriteEndpointGuard.Classify, :70-113; AllowsWriteTo, :128-152.)
Classify tolerates an explicit ":port" suffix, but only strips it when the
remainder is a plain number, so an IPv6 literal is left intact (:80-86).
The opt-in is PlcSettings.AllowLoopbackPlcWrites, read through
LoopbackWritesAuthorized() (:190-201) — and an unbuilt DI container means
NOT authorized, which is the fail-closed direction.
RefusalReason (:155-177) produces operator- and log-facing text that names
the target, the class, and the remedy, including the sentence "Anything
answering there is a local simulator, so the write would report success against
synthetic data."
This does not replace ConfigurationValidator's production loopback abort:
that aborts boot on a real install; this refuses individual writes on a
station that booted anyway.
4.5 The heartbeat gate#
PlcHeartbeatConnectionTracker.AllowPlcWrites() returns true only in the
Connected state (Ring/Services/PLC/PlcHeartbeatConnectionTracker.cs:143-150)
— strictly stricter than AllowHeavyPlcPolling(), which also accepts Stale.
The reasoning is in the doc comment: Stale (4–12 s without heartbeat motion)
is fine for read pollers but "is not proof the controller scan is advancing, so
writes wait."
The state machine, its thresholds, and the fact that all elapsed math is monotonic are covered in chapter 03 §3.6.
Two things to keep in mind here:
- Setting the
HeartbeatLinkLogicEnabledconst tofalse(PlcHeartbeatConnectionTracker.cs:33) makesAllowPlcWrites()returntrueunconditionally. It is a documented bench toggle for a controller withMainTaskinhibited. It disables a safety gate; it must never reach a release. - Most write rails check the heartbeat twice — once at the service (an outer
gate, so a pulse cannot start on a degraded link) and once inside
PlcTagWriter(the inner backstop).InventoryEventCaptureServiceis the documented exception: it has no outer gate (PlcTagWriter.cs:126-128).
4.6 The demo re-check at the wire, and the F013 de-assert exemption#

Demo is re-checked live, on every write#
if (DemoModeData.Enabled()) // PlcTagWriter.cs:156
{
var demoWriter = _demoWriter ??= new DemoPlcTagWriter(_tagName, _type, _logger);
...
}
The check deliberately ignores the flag captured when the writer was
constructed, and lazily creates the no-op writer if demo was off at construction
time (PlcTagWriter.cs:57-58, 150-162). The reason: the Plant Profile screen
can flip demo mid-session, and "a writer built before the flip must still
suppress". Note the asymmetry with the read path, where PlcTagReader
captures demo once at construction so a mid-run flip cannot make one tag
half-substitute (PlcTagReader.cs:24-27). Both choices are right for their
side: on the read path a half-substituted screen is the hazard; on the write
path a live writer surviving a flip to Demo is.
DemoPlcTagWriter (Ring/Services/PLC/DemoPlcTagWriter.cs) is deliberately
trivial — it logs the write in the same style as PlcTagWriter and returns
true, "so operator flow (batch start, formula select, ack, silence) continues
normally during training without touching a real PLC". Note that the comm log is
still written from WriteCore (PlcTagWriter.cs:160), so
DisplayCommunicationForm shows demo traffic as activity. That is the same
"fake success" idiom as the read-only path, for the same reason, and it is why
FormulaBankWriteOutcome gives demo its own value (SuppressedDemo) rather
than reusing a generic success.
The demo check is also present at several service rails above
PlcTagWriter (see the table in §4.7). Those are not redundant: they let a
service skip work it would otherwise do — a read-back, a verification loop, a
database commit — rather than performing it against nothing.
A one-gate exception to "unbuilt container resolves to refuse."
DemoModeData.Enabled() catches any ServiceLocator failure and returns
false — "not in demo" — which lets a write continue toward the wire rather
than refusing it (DemoModeData.cs:29-39). That is the opposite resolution
from PlcWriteEndpointGuard.LoopbackWritesAuthorized() (§4.4), which treats
the same failure as NOT authorized. Both are correct for what they gate: demo
is a suppression, not a permission, so failing to detect it can never arm a
write that was not already armed by every other gate in the stack. Do not
generalise "unbuilt container fails closed" to this one case.
F013 — the de-assert exemption#
PlcTagWriter.WriteClear (:133) is identical to Write except that a
degraded heartbeat does not refuse it: it logs a warning and attempts the wire
anyway (PlcTagWriter.cs:188-198).
The ReadOnly gate, the live demo re-check and the endpoint gate remain
unconditional for a de-assert. F013 relaxes the heartbeat gate and nothing
else. The code says so at both the doc comment (:122-125) and the three
call-site comments.
The justification is a rung-level census, quoted verbatim in the source: every
instruction touching PC_Write_Integer in
scripts/RS_3000_2024_APR_22.L5K is {XIC ×20, XIO ×5, MOV ×6}, and all six
MOVs use the array as a source (L5K:12852). There is not one
OTU/OTL/CLR/COP/FLL/CPS destination. The PC is the sole clearer, so a
gate-refused clear leaves the bit TRUE on the controller and the ladder will
never take it down.
The consequence ranking is not uniform, and MomentaryPulse's class doc is
careful about it (MomentaryPulse.cs:100-133):
| Bit | Shape | Cost of a stuck TRUE |
|---|---|---|
[30].1 Hold |
level-sensitive on a sealed coil (L5K:12072) | keeps commanding Hold; the Resume rung cannot break the seal, so Resume becomes impossible from any source until someone clears the bit from Studio 5000 |
[30].0 Resume, [30].2 Reset, [30].3 Silence |
ONS-gated | does not repeat the command; holds the one-shot input high so the next command cannot fire — and because each rung ORs the PanelView, the alarm path and the hardwired inputs into one shared ONS, it blocks that command from every station, not just Ring |
[30].10 clock sync |
ONS-gated SSV |
stamps once, then latches ons1.0 so no later clock sync can ever fire |
[27].4 inventory ack |
generic confirm rule, PC_HandShake_Word = 12 |
the rule re-zeroes the notify mirror every scan, so the controller can never raise a new inventory notification |
"Paying a libplctag timeout beats latching that."
MomentaryPulse — the shared clear#
Ring/Services/PLC/MomentaryPulse.cs is the de-assert half of every momentary
pulse Ring issues, extracted from per-site duplicated loops.
ClearWithRetry (:167-216):
ClearAttempts = 3by default, spaced byClearDelayMs = 250and never before the first attempt. Spacing is the point — the pre-F013 loop retried instantly, so all three attempts landed inside the same instant of degradation and were refused together.- A throwing attempt does not abort the remaining attempts (
:192-199) — that was the failure the duplicated loops had. - On total failure it logs
STUCK TRUEnaming the tag, and only ifArmBackgroundReclearis explicitly on, arms one detached re-clear.
ArmBackgroundReclear is off by default (MomentaryPulse.cs:44, in the
MomentaryPulseOptions class that lives in the same file) and every site opts
in explicitly. The default-off is load-bearing for
the test suite: a detached task re-invoking a caller's clear delegate would
mutate test doubles after the test finished asserting, in a suite that runs with
no parallelism.
The armed re-clear (:227-...) attempts the wire on every tick and does not
wait for the heartbeat gate, and the comment explains the bug that taught
this: it used to poll AllowPlcWrites(), which requires the heartbeat to be
advancing — precisely the condition that is false on the degraded link the
mitigation exists for. It therefore "waited out its whole cap and gave up
WITHOUT EVER TRYING THE WIRE, throwing away the heartbeat exemption F013
bought." It is still bounded: one task per failed pulse, one wall-clock budget
(ReclearMaxWaitMs), one attempt ceiling (ReclearMaxAttempts = 120), because
the controller caps EIP sessions per source IP.
For the PC_Write_Integer[30] family the budget is configurable:
PlcSettings.CommandBitReclearSeconds (AppSettings.cs:370), defaulting to
PcWriteInteger30BatchControlPlcService.DefaultCommandBitReclearSeconds = 1800
(30 minutes) and clamped to 60 s .. 4 h (:126-132, :120-121). The reason it
is measured in tens of minutes: the 2026-08-20 site visit produced a read stall
of roughly 25 minutes, and a re-clear that gives up inside that stall leaves
Hold latched.
The set of files allowed to call WriteClear is pinned by a test.
Ring.Tests/MomentaryPulseClearGateTests.cs:752-777 scans every .cs under
Ring/ for .WriteClear( and fails on anything outside the approved set:
PlcTagWriter.cs (the definition),
PcWriteInteger30BatchControlPlcService.cs, ProcessorTimeDatePlcService.cs,
ShiftControlPlcService.cs, InventoryEventCaptureService.cs,
AlarmSilencePlcService.cs. Its message: "never widen this list to make a
build green."
A sibling test in the same file pins that PlcTagWriter.WireWriteHookForTests
— the test seam that stands in for the libplctag call, deliberately placed
after every gate (PlcTagWriter.cs:91-100, 207-208) — is never assigned by
production code (MomentaryPulseClearGateTests.cs:779-793).
Word-30 serialization#
PcWriteInteger30BatchControlPlcService holds a private static
Word30PulseGate lock (:40) and takes it for the whole pulse
(PulseBit → PulseBitSerialized, :182-186), including the clear
(SerializedClear, :294-298). Monitor is re-entrant, so when
MomentaryPulse later invokes the same delegate from a background re-clear it
reacquires the same gate and cannot interleave with a newer sibling pulse.
RunUnderWord30Gate<T> (:329-333) lets ProcessorTimeDatePlcService — which
also writes into word 30, at bit 10 — share it.
WriteSurfaceRegisterTests.cs:1129-1143 asserts, by source scan, that the gate,
the lock and SerializedClear are all still present.
One more behaviour in that service worth knowing, because it looks wrong until
you read the comment: a failed assert still runs the clear
(:227-247). PlcTagWriter.WriteInternal swallows every libplctag exception
and returns false, so a timeout or a lost CIP reply is indistinguishable from
"the write was refused". If the request did land, the bit is TRUE while Ring
reports Failed. The clear is cheap, idempotent, and owned by Ring —
PC_Write_Integer[30] is PC1's block, so writing False can never stomp
another station's command bit.
4.7 The verified per-rail gate order#
Each row is what the source file actually does, in order, for its main write
entry point. PlcTagWriter gates (marked ▸) run inside the funnel and
therefore apply to every rail that uses it.
| Rail (file) | Order of gates as coded |
|---|---|
PlcTagWriter.WriteCore |
▸ ReadOnly → ▸ Demo → ▸ Endpoint → ▸ Heartbeat → wire (:143-208) |
PlcTagWriter.WriteClear |
▸ ReadOnly → ▸ Demo → ▸ Endpoint → heartbeat warns only → wire |
BatchStartTankPlcWriter.TryWriteMember |
ReadOnly (:74) → roster-change lock (:93) → roster-load-failure lock (:107) → StoragePlcIndex/NoWrite (:118) → Heartbeat (:163) → Demo (:174) → Endpoint (:202) → inline libplctag |
TVCControlPlcService (per method) |
ReadOnly (:189, 206, 225, 243, 260, 272, 343) → MainTvcWriteAuthorization (:266, :278) → TvcCoolingWriteAuthorization (:292) → TankInstalledWriteGate (:420) → Heartbeat (:195, 212, 231, 249, 298, 369) → Demo (:506) → Endpoint (:517) |
UseTankControlPlcWriter |
ReadOnly (:137, 156, 175, 197) → Heartbeat (paired per method, :143-147, 162-166, 181-185, 203-207) → TankInstalledWriteGate.AllowsAdditiveWrite (:224) → Demo (:320, returns false) → Endpoint (:331); inline libplctag |
UseTankFormulaPlcWriter |
EnableUseTankFormulaWrite (:117) → ReadOnly (:148, 251) → Heartbeat (:176) → Demo (:187, 264) → Endpoint (:275) |
TankAgitatorPlcWriter |
EnableHmiAgitatorCycle (:72) → ReadOnly (:162) → Heartbeat (:181, 292) → Demo (:191) → Endpoint (:216); index via RosterWriteIndex.StorageWriteIndexForLegacyWindow (:126) |
InventoryAmountPlcWriter |
ReadOnly (:280) → Heartbeat (:290) → Demo (:299) → Endpoint (:315); affordance additionally requires EnableInventoryAmountWrite (:71-77) |
BatchFormulaPresetPlcWriter |
ReadOnly (:396) → Demo (:482) → EnableFormulaBankWrite (:505) → Heartbeat (:535) → Endpoint (:544 calls _io.TryPrepare, which calls PlcWriteEndpointGuard.AllowsWriteTo at :811) → active-batch interlock (:553-586) |
PcWriteInteger30BatchControlPlcService |
word-30 lock (:184) → ReadOnly (:190) → Heartbeat (:192) → endpoint resolve → PlcTagWriter ▸gates → assert → clear |
ShiftControlPlcService |
EnableShiftControlPlcWrites (:159) → ReadOnly (:292) → Heartbeat (:303) → PlcTagWriter ▸gates |
ProcessorTimeDatePlcService |
EnableProcessorTimeDateSync → ReadOnly (:359) → Demo (:369) → Heartbeat (:378) → PlcTagWriter ▸gates; clear under the shared word-30 gate (:613) |
AlarmSilencePlcService |
ReadOnly (:66) → custom-tag authorization EnableCustomSilenceLatchTag (:73-77) → bit-address validation (:101) → Heartbeat (:105) → PlcTagWriter ▸gates |
InventoryEventCaptureService |
Demo (:223) → ReadOnly and EnableInventorySnapshotPlcAcknowledge (:372, :426) → PlcTagWriter ▸gates (no outer heartbeat gate — documented) |
TankNamePlcWriter |
EnableTankNamePlcNameSync → ReadOnly (:130) → Heartbeat (:178) → Demo (:188, 275) → Endpoint (:285) |
The BatchFormulaPresetPlcWriter row runs through EvaluateGates, which also
checks the formula-number range (:412) and validates the payload
(:428-475) between ReadOnly and Demo — omitted from the row above because
neither is a write-path safety gate. :505 is where FeatureEnabledProvider()
is actually invoked; :211 (DefaultFeatureEnabled()) is only the default
provider's definition, not a call site on the write path.
The UseTankControlPlcWriter row is one of the "return false on suppression"
writers named in §4.1 — its
DefaultInt16Writer (:314-336) is not the fake-success idiom: a demo
suppression or an endpoint refusal both return false, same as read-only.
The AlarmSilencePlcService row deserves a note: AlarmSettings.SilenceLatchTagName
defaults to "PC_Write_Integer[30].3" and EnableCustomSilenceLatchTag
defaults false (AppSettings.cs:626, 634). Retargeting the silence pulse is
gated because "the only override anyone reaches for is B3_bit[15], which is
NOT a legacy PC write at all: it is the ladder's own OTL latch (Alarm/Main
L5K:9735), consumed and self-cleared by ST_ROUTINE Alarm_CTRL (L5K:9344).
Writing it would produce a partial, horn-still-ringing silence."
(WriteSurfaceRegisterTests.cs:1077-1083.)
4.8 The setpoint write queue#
Ring/Services/PLC/PlcSetpointWriteQueue.cs serializes operator setpoint
writes. It is dispatch, not safety — and that is the single most important
thing to know about it.
The defect it closes#
Every spinner click used to queue its own detached Task.Run and the services
wrapped the wire call in a second one, so nothing serialized two writes to the
same tag. With libplctag timeouts of 3–5 s, two in-flight EIP writes can
complete in the reverse order they were issued and the controller settles on an
intermediate value the screen no longer shows. On Tank_IO[N] it is worse than
ordering: that write is a read-modify-write of a SINT carrying five other
physical output bits, so two concurrent commands lose an update on
O_Fill_valv / O_Heat / O_Cool / O_Resin_*
(PlcSetpointWriteQueue.cs:79-88).
The contract#
- Serialized per tag key. One tag never has two writes in flight; different tags proceed concurrently, so a slow tank cannot stall another screen.
- Within a tag, a burst from one control collapses to its LAST value after a
debounce —
SpinnerDebounceMs = 300(:118),NoDebounceMs = 0for single-shot commands (:121). - Coalescing is scoped to the COALESCE KEY (the source control), never to the
tag. Two different controls that share a tag — the Main-Heating and
Main-Cooling spinners both write
TVC[N].Preset_Temp— keep their own pending entries, so neither operator action is silently deleted. They are merely prevented from overlapping. - The newest submission for a control is dispatched last, so the controller
settles on the operator's most recent intent (
:222-226).
The non-contract, stated in the source#
"This class is PURE DISPATCH and holds no safety gate of its own. It never consults
PlcWriteGuard,PlcHeartbeatConnectionTrackerorDemoModeData." —PlcSetpointWriteQueue.cs:100-107
Adding a copy of those checks here would either double-gate (drifting out of sync with the originals) or, in the read-only case, break the deliberate suppression contract — a suppressed operation is not a successful write. The checks live at the bridge that enqueues and at the wire.
It is not for momentary/pulse bits. Latest-value-wins on an assert/clear
pair would delete the clear half. Only level-valued setpoints and level-valued
commands belong here. Tank_IO[N].O_Agitat qualifies precisely because the
ladder drives the same bit every ~300 ms, so collapsing a burst to its final
state cannot strand the output.
Two engineering details worth preserving#
The exit race. The emptiness check and the Pumping = false flip must happen
in the same lock acquisition. Split them and a Submit that sees
Pumping == true after the pump has decided to quit would buffer the operator's
final value and start no pump — "a silently dropped setpoint, which is worse
than the ordering bug" (:302-310).
The write runs outside the lock, always (:330-346) — it blocks for up to
the libplctag timeout, and holding Gate across it would stall every Submit
on that tag and deadlock any write submitted from its own completion. A throwing
write is caught and logged: one bad write must never kill the pump.
Tag keys and coalesce keys#
Ring/Services/PLC/PlcWriteTagKeys.cs builds the canonical serialization keys
so two different code paths writing the same physical tag land on the same slot
— concretely, the TVC bridge and the Use Tank bridge both write
Tanks[N].Agt_on_pre through different services (:9-11).
| Builder | Produces |
|---|---|
TankMember(n, member) |
Tanks[n].<member> |
TvcMember(n, member) |
TVC[n].<member> |
TvcControlPair(n) |
TVC[n].Temp_mode — a named key kept distinct from the tank-side Tanks[N].Temp_mode gesture so the queue can never coalesce the two (TankTempModeWriteTargetTests pins that they differ) |
TankIoOutputByte(n) |
Tank_IO[n].ZZZZZZZZZZTank_I_O9 — the discrete-output SINT carrying O_Agitat at bit 1 |
Coalesce(tagKey, controlId, callerContext) |
the source-control identity |
TargetsTank(tagKey, n) |
matches the bracketed index, so Tanks[1] does not match Tanks[11] |
TvcControlPair used to be a composite TVC_Ctrl[N].enabled+TVC[N].Temp_mode
because the gesture was a three-step read-modify-write. That two-step is gone:
the ladder recomputes TVC_Ctrl[n].enabled every scan, and the recompute
precedes the gate that consumes it in the same pass, so a value Ring wrote was
never observed by the control logic (PlcWriteTagKeys.cs:32-39).
SetpointEchoGuard — "is this a genuine operator change?"#
Ring/Services/PLC/SetpointEchoGuard.cs is a small pure class with an outsized
job. It remembers the value each writable control was seeded with (read back
from the controller) and, afterwards, the value it last successfully queued.
It suppresses three failure paths that all shipped as real write-on-open bugs:
- XAML-init-time handler fires (
IsSelected="True"/Text="10"attributes raiseSelectionChangedduringInitializeComponent); - programmatic seed-time fires (assigning
SelectedIndex/Textre-enters the same handler — the fix itself would otherwise become a guaranteed write-on-open); - unchanged-value focus-out (tab straight through a
TextBoxand theLostFocushandler writes the hardcoded XAML default over the real setpoint).
Three deliberate behaviours:
- A key that was never seeded is NOT writable.
ShouldWritereturns false for an untracked key (:76-81). No read-back ⇒ no write. - Recording is explicit and happens only after the write was accepted for
dispatch (
:56-60), so a rejected write leaves the baseline at the last known-good value and the next identical attempt still goes out. - Values compare as trimmed ordinal strings, so
"10"and" 10 "are the same setpoint.
It is one instance per window, never static — and because views are cached
singletons (NavBar.GetOrCreateView), instance state survives navigate-away, so
every window must call Reset() at the top of its Loaded handler and
re-seed or it will compare against an hours-old baseline (:32-35).
SetpointRefreshCoordinator (Ring/Services/PLC/SetpointRefreshCoordinator.cs)
turns a settled write into a re-read of the screen's setpoints, hanging off
the queue's WriteCompleted and WriteSubmitted events — the latter because a
read already in flight when the operator changed something may predate that
change, and applying its result would repaint the edit away
(PlcSetpointWriteQueue.cs:161-171).
SetpointSeedContext and TankControlSetpointReader are the read-back seam
that fills the guard's baselines.
4.9 The write evidence journal — and exactly what it covers#
Ring/Services/PLC/PlcWriteEvidenceJournal.cs is an append-only JSONL journal
of PlcWriteResult records, at
%ProgramData%\Ringwood\Ring\plc-write-evidence.jsonl (:75-79), UTF-8 without
BOM, size-rotated at DefaultMaxBytes = 10 MB with
DefaultRetainedArchives = 5 (:17-18, 57-73), serialized under a lock.
Its class doc states the safety property precisely: "This is evidence only: it exposes no replay API, so restart can never resend a dangerous command automatically."
Scope — be exact about this. The journal is not wired into every write
path. A grep for PlcWriteEvidenceJournal under Ring/ returns four
production references: its own file, PlcSetpointWriteQueue
(:124, :130, :147), Services/Audit/IncidentExportBuilder.cs (which reads
it back for an incident export, :144, :158), and a doc-comment cross-
reference in Services/Audit/UiAuditJournal.cs:98. Only writes that go
through PlcSetpointWriteQueue produce evidence lines. Batch-start writes,
the PC_Write_Integer[30] pulses, the formula-bank rewrite and the inventory
writes do not — their record is the application log, the comm log, and the
per-writer outcome types.
Each queued write produces two lines, in order:
| When | Status | Operation |
|---|---|---|
| accepted for dispatch | PendingDispatch |
"Queued" |
| settled | WrittenUnverified or Failed |
"Settled" |
The pending record is written before the write becomes visible to a pump,
because the pump starts inside the slot lock and could otherwise journal the
settled record first, inverting the evidence order
(PlcSetpointWriteQueue.cs:196-201).
The settled detail is deliberately modest: "Writer reported success; controller
readback not proven by this queue" (:349-352). WrittenUnverified means
exactly that — see PlcWriteStatus (Ring/Services/PLC/PlcWriteResult.cs:10-21),
where WrittenAndVerified is a separate value and
ControllerAccepted/Verified are separate predicates (:41-42).
Each record carries a WriteId — a GUID unique per Submit call — because the
audit trail must join on the submission, not the coalesce bucket: a control
that writes twelve times in a shift has one coalesce key and twelve writes, and
correlating on the bucket would report a dozen operator actions as one
(PlcSetpointWriteQueue.cs:29-38).
The journal is not covered by any backup. See
docs/CONFIG_AND_STARTUP.md
and chapter 05.
4.10 The write-surface register — how the surface stays enumerable#
Ring.Tests/WriteSurfaceRegisterTests.cs is, in its own words, "the CENSUS
TRIPWIRE… Every other test in this lane proves something about a write path that
is KNOWN. This one exists so a write path cannot become unknown."
It is the single most valuable safety artifact in this repository. Treat it as part of the write-path contract, not as test hygiene.
The register#
A Dictionary<string, WriteClass> (:93-199) with one row per file that can
write to a controller, keyed by a forward-slashed path relative to Ring/.
Six classes, and the class is not decorative — the proof matrix's
BENCH-OUTSTANDING column is generated from it:
WriteClass |
Meaning | Wire proof |
|---|---|---|
UdtMember |
dotted UDT-member writes (Tanks[N].X, TVC[N].X, Tank_IO[N].X) |
locally provable only up to the wire seam; controller acceptance is bench-only |
FlatTag |
flat scalar/array tags | the only class the local simulator can serve |
BitPulse |
BOOL bits on PC_Write_Integer[N].b |
the simulator refuses these (ErrorUnsupported); bench-only |
Infrastructure |
gates, queues, coordinators, the guard itself | no tag of its own |
UiDispatch |
code-behind that turns a gesture into one of the writers | — |
DeadCode |
reachable from no call site; retained only because deletion needs a csproj edit | — |
The five discovery signals#
The census is the union of five independent textual signals, and its history
is instructive: it originally recognised a write path only by the presence of
the gate itself, so "the one file shape it most needed to catch — a new writer
that builds its own libplctag Tag and never consults the guard — matched
neither alternative and was invisible to every assertion here" (:19-25).
| # | Signal | Regex / mechanism |
|---|---|---|
| 1 | names the gate | \bPlcWriteGuard\b (:234) |
| 2 | builds the funnel | new\s+PlcTagWriter\s*\( (:237) |
| 3 | submits to the queue | the literal PlcSetpointWriteQueue.Instance.Submit( (:257-258) |
| 4 | writes a raw libplctag tag — gate-independent | binds an identifier to any writable libplctag type, then calls .Write( / ?.Write( / .WriteAsync( on that identifier (:292-341) |
| 5 | declares or calls a writer-service entry point | a name-bound alternation of the actual public Pulse*/Write* vocabulary (:401-409) |
Each signal documents its own limits, and you should read those before treating a green run as "there are no other write paths":
- Signal 3 is spelling-bound. A file that took the queue as a constructor
dependency and called
_queue.Submit(would not be discovered — and nothing else would necessarily catch it. That is the known residual gap, bounded today only because the queue is a singleton with no DI registration (:239-255). - Signal 4 is a single-file textual scan, not a call graph. It cannot see a
tag constructed in one file and written by a helper in another, a tag handed
to a method as a parameter, a write reached through an interface/delegate/
reflection, or a tag type libplctag adds after 1.5.2 (
:314-319). Identifier-scoping the.Write(is load-bearing, not fussiness: 22 files underRing/construct a raw tag and most are pure readers that nonetheless call.Write(on loggers and streams (:320-325). - Signal 5 is a maintained list, not a derivation. Adding an entry point to
a writer service without adding it here leaves its callers invisible again
(
:396-399).
Signal 5 exists because a call-site sweep on 2026-08-25 found eight files
dispatching PLC writes that no signal saw and no register row named — and the
tripwire passed green throughout, because those files were invisible in both
directions: absent from the discovered set (so not "unregistered") and absent
from the register (so not "stale") (:359-374).
The assertions#
| Test | What it catches |
|---|---|
Every_file_on_the_PLC_write_surface_is_in_the_pinned_register |
a new writer, or a screen that starts dispatching writes |
The_register_contains_no_file_that_has_left_the_write_surface |
a stale row describing code that no longer exists |
The_register_is_not_silently_shrinking |
bulk row deletion — a blunt Register.Count >= floor, set to the exact current count on purpose, zero slack. Read the file for the number; if you add a row, raise it in the same commit (:486-511) |
Every_file_that_writes_a_raw_libplctag_tag_is_in_the_pinned_register |
a wire-level writer, found without reference to the gate |
Every_file_that_writes_a_raw_libplctag_tag_also_consults_the_write_guard |
the safety property. A raw writer that never names PlcWriteGuard writes to the live plant in read-only mode. Note the scope: this is file-scoped, not method-scoped — a file with one gated write method and a second ungated one passes (:549-554) |
The_raw_writer_detector_still_detects |
anti-blindness. Exercises the predicate against twelve synthetic writer shapes the codebase does not yet contain, asserts two non-matches, and floors the live population — because the two tests above pass vacuously the moment the detector stops matching |
The_dispatcher_entry_point_detector_still_detects |
the same anti-blindness for signal 5, plus a drift check: it reads the public Pulse*/Write* declarations back out of the four momentary-pulse services and requires the signal to match a call to each |
Every_writer_that_needs_bench_evidence_is_classified_as_needing_it |
a mis-filed WriteClass that would turn a bench-only claim into a "proven" one |
No_UI_file_writes_a_controller_tag_without_going_through_a_writer_service |
a code-behind that news up its own tag, bypassing ReadOnly, the heartbeat gate, the demo seam and the queue in one move |
The_writers_that_reach_libplctag_without_a_test_seam_are_named_and_bounded |
an honesty pin. Exactly three writers build their Tag inline with no injectable seam, so no local test can observe what they put on the wire: BatchStartTankPlcWriter, InventoryAmountPlcWriter, TankAgitatorPlcWriter. Their guard chains are proven; the wire shape is not. This test stops that list growing (:887-931) |
Every_UI_dispatch_screen_on_the_write_surface_can_actually_be_reached |
a registered write path hanging off a screen nothing constructs — which would inflate the apparent reviewed write surface with paths no operator can trigger |
Hardware_signoff_write_capabilities_remain_fail_closed_in_the_shipped_settings |
see §4.2 |
Batch_start_verification_seams_are_test_only_and_word30_pulses_keep_one_shared_gate |
production assigning BatchStartPlcWriteHelper.WriteMember/ReadMember; loss of the word-30 lock |
Two details in Every_UI_dispatch_screen_on_the_write_surface_can_actually_be_reached
are worth carrying into any similar test you write:
- It resolves a screen's type name from the paired XAML's
x:Class, not from the file name, because those differ in this repo —Views/Shifts/ShiftControl.xamldeclaresRing.Views.Shifts.ShiftControlView. Deriving from the file name reported that screen as an orphan, and the only ways to clear a false orphan are an exemption or a deletion, either of which "would have taken the shift family's ONLY operator gesture off the reviewed surface" (:1003-1009). - Its
ApplicationDefinitionexclusion is derived from the csproj rather than hardcoded, so it cannot drift (:977-984). The comment records the lesson: beforeRing/Program.cswas deleted, the only thing that looked like a construction site forAppwas a commented-outvar app = new App();inside that dead file — dead code can prop up a reachability claim.
The UnreachableUiDispatchExemptions dictionary (:940-947) is currently
empty, and worth keeping that way. The one entry it ever held —
Views/UseTanks/UseTank1Window.xaml.cs, an unreachable clone of MF1Window —
was removed when that screen and its register row were deleted, which is the
intended way an entry leaves the list.
Worth restating here:
Ring/Views/UseTanks/contains three windows —MF1Window,UseTank2Window,UseTank3Window. A fourth,UseTank1Window, was an unreachable clone ofMF1Windowand was deleted along with its register row; the only remaining references to it anywhere in the tree are the two historical comments inWriteSurfaceRegisterTests.csrecording its deletion.
4.11 Two writes, end to end#
Reading the gates one at a time makes them look like a checklist. They are not; they are a sequence with different shapes on different rails. These two walkthroughs are the whole model in motion.
(a) An operator presses Hold#
- The gesture.
NavBar.HandleButtonClickreceivesHoldButton. Because that name is in theisNonNavActionset, the unsaved-changes guard is skipped — a dirty editor must never be able to abort a safety command (Ring/Views/UserControls/NavBar.xaml.cs:437-444). Control reachesOnProcessHoldClicked()(:513-516). - Serialization.
PcWriteInteger30BatchControlPlcService.PulseBittakes the process-wideWord30PulseGatelock for the whole pulse, assert and clear (:182-186). No other word-30 command can interleave. - Gate 1 — read-only.
PlcWriteGuard.IsReadOnly→LogBlockedand returnBatchControlWriteResult.BlockedReadOnly(:190). Pre-cutover, the walk ends here, and the operator is told the controller was not updated. - Gate — heartbeat (outer).
AllowPlcWrites()must beConnected, so a pulse cannot start on a degraded link (:192-196). - Endpoint resolution.
GetPlcIp()/GetPlcPath(), each re-read from the config file; a throw here returnsFailed(:198-208). - The assert.
new PlcTagWriter("PC_Write_Integer[30].1", ip, BOOL, path)thenwriter.Write("True")→WriteCoreruns ▸ReadOnly, ▸Demo (live re-check), ▸Endpoint, ▸Heartbeat, then the wire. - If the assert failed — the clear still runs.
Writereturning false does not mean the request never reached the controller:WriteInternalswallows every libplctag exception, so a timeout or a lost CIP reply looks identical to a refusal (:227-247). Skipping the clear here is what would strand Hold TRUE. - The pulse.
Thread.Sleep(PulseDurationMs)inside atry, with the clear in thefinallyso it runs even on an exception. - The clear.
MomentaryPulse.ClearWithRetry(() => SerializedClear(writer), …)→ three spaced attempts (250 ms apart, never before the first), each throughPlcTagWriter.WriteClear, which is heartbeat-exempt but still ReadOnly-, Demo- and Endpoint-gated.SerializedClearre-takes the word-30 lock (Monitoris re-entrant) so a background re-clear can never interleave with a newer sibling pulse (:294-298). - If every attempt failed.
STUCK TRUEis logged naming the tag, and — because this service opts in (DefaultClearOptions(),:67-74, setsArmBackgroundReclear = true) — one detached re-clear is armed, which attempts the wire on every tick regardless of the heartbeat, bounded byCommandBitReclearSeconds(default 1800 s, clamped 60 s..4 h) andReclearMaxAttempts = 120. - The result.
Writtenonly when both halves succeeded;PulseHoldResumeResetBoolcollapses the tri-state so that onlyWrittenmaps totrue(:179-180).
Count the gates that stood between the button and the bit: read-only, outer heartbeat, demo, endpoint, inner heartbeat — plus a lock, a retry policy and a stuck-bit mitigation. That is the shape every command write should have.
(b) An operator nudges a TVC preset spinner#

- The gesture. The spinner's handler in a
Views/TVCcontrolwindow calls intoTVCWindowPlcBridge. - Echo guard.
SetpointEchoGuard.ShouldWrite(key, value)— false unless the control has a read-back baseline and the value differs from it (SetpointEchoGuard.cs:76-81). This is what stops an init-time or seed-time handler fire, and a tab-throughLostFocus, from writing the XAML default over the real setpoint. - Index resolution. For a storage screen the PLC index comes from
RosterWriteIndex.StorageWriteIndexForLegacyWindow(key), which consults both sticky roster locks before the lookup and returnsNoWriteif either is latched (RosterWriteIndex.cs:269-274). - Enqueue-time heartbeat check at the bridge, then
PlcSetpointWriteQueue.Instance.Submit(tagKey, coalesceKey, 300, write). The tag key comes fromPlcWriteTagKeys, so two screens writing one physical tag share one slot. - The queue journals
PendingDispatchbefore the pump can see the write (PlcSetpointWriteQueue.cs:196-201), coalesces this control's earlier pending value if any, debounces ~300 ms, and dispatches outside every lock. - The write delegate runs the real gates —
TVCControlPlcServicechecks read-only, the applicable commissioning holds (MainTvcWriteAuthorization,TvcCoolingWriteAuthorization,TankInstalledWriteGate), the heartbeat, demo and the endpoint. The queue itself checked nothing. - Settlement.
WriteCompletedfires on the pump thread; the queue journalsWrittenUnverifiedorFailed;SetpointRefreshCoordinatorre-reads the screen's setpoints so the display shows controller truth rather than the operator's optimism; and only now does the screen callSetpointEchoGuard.RecordWritten— so a rejected write leaves the baseline at the last known-good value and the identical retry still goes out.
4.12 What a new writer owes#
If you add a write path, all of the following, in the same change:
Route it through the gate stack.
PlcWriteGuard→ the relevantEnable*flag → any applicable commissioning hold →PlcHeartbeatConnectionTracker.AllowPlcWrites()→ the live demo re-check andPlcWriteEndpointGuardat the wire. Prefer funnelling throughPlcTagWriter, which gives you four of those for free. If you must build a rawTag, use the prologue shape fromBatchStartTankPlcWriter.cs:74-81:if (PlcWriteGuard.IsReadOnly) { PlcWriteGuard.LogBlocked(op, tag, value, logger); return true; // or the honest outcome for your rail — see §4.1 }Add its register row to
WriteSurfaceRegisterTests.Registerwith a truthfulWriteClass, and raise the count floor in the same commit.Add a seam test pinning tag, value, count and order.
For a UDT member or a BOOL bit: add bench evidence — a bench round-trip in
Ring.Tests/BenchPlcIntegrationTests.csand a row indocs/production-readiness/WRITE_PATH_PROOF_2026-07-30.md. The commissioning evidence required per write family isdocs/production-readiness/PLC_WRITE_COMMISSIONING_REGISTER_2026-08-02.csv, verified byscripts/Test-PlcCommissioningEvidence.ps1. Remember that the bench-gated tests early-return a vacuous pass unlessBENCH_PLC_AVAILABLE=1; the gate summary recordsbenchGatedTestCountandbenchHardwareWasAvailablefor exactly that reason (scripts/Invoke-ProductionGate.ps1:157-186).Get an independent review. A write-path change is reviewed by someone who did not write it (
CONTRIBUTING.md).
And the two standing prohibitions:
- Do not populate
MainTvcWriteAuthorizationorTankTempModeWriteAuthorizationwithout a controls decision. - Never put a UI guard or an early
returnin front of a shared handler that also dispatches PLC-write buttons. A dirty-check or confirmation added to a handler shared by Hold / Resume / Reset can silently swallow a safety command. The live example isNavBar.HandleButtonClick(Ring/Views/UserControls/NavBar.xaml.cs:415-444), where the unsaved-changes guard is skipped for an explicitisNonNavActionset that includesHoldButton,ProcessResumeButtonandProcessResetButton— because the guard "would pop a misleading 'discard?' prompt and, if the operator keeps editing, ABORT a process command (e.g. a live Hold)". The roster-generated per-tank menus are built with a dedicated click handler for the same reason (NavBar.xaml.cs:124-129). Enumerate every button routed to a handler before you add a guard to it.
The step-by-step version of this, with the file edits in order, is chapter 10 §10.6.
Next: 05 — Data and Persistence.
Verified against#
Every claim in this chapter was read out of these files on 2026-09-01:
Ring/Services/PLC/ — PlcWriteGuard.cs, PlcTagWriter.cs,
PlcWriteEndpointGuard.cs, PlcHeartbeatConnectionTracker.cs,
MainTvcWriteAuthorization.cs, TankTempModeWriteAuthorization.cs,
TvcCoolingWriteAuthorization.cs, TankInstalledWriteGate.cs,
RosterWriteIndex.cs, FormulaBankWriteInterlock.cs, MomentaryPulse.cs,
PlcSetpointWriteQueue.cs, PlcWriteTagKeys.cs, PlcWriteResult.cs,
PlcWriteEvidenceJournal.cs, SetpointEchoGuard.cs,
SetpointRefreshCoordinator.cs, TVCControlPlcService.cs,
BatchStartTankPlcWriter.cs, UseTankControlPlcWriter.cs,
UseTankFormulaPlcWriter.cs, TankAgitatorPlcWriter.cs,
InventoryAmountPlcWriter.cs, BatchFormulaPresetPlcWriter.cs,
PcWriteInteger30BatchControlPlcService.cs, ShiftControlPlcService.cs,
ProcessorTimeDatePlcService.cs, InventoryEventCaptureService.cs,
TankNamePlcWriter.cs, DemoPlcTagWriter.cs, DemoModeData.cs ·
Ring/Services/Alarms/AlarmSilencePlcService.cs ·
Ring/Infrastructure/Configuration/AppSettings.cs ·
Ring/Infrastructure/Configuration/PlcConnectionConfig.cs ·
Ring/Config/appsettings.json · Ring/Views/App.xaml.cs ·
Ring/Views/UserControls/NavBar.xaml.cs ·
Ring.Tests/WriteSurfaceRegisterTests.cs ·
Ring.Tests/MomentaryPulseClearGateTests.cs ·
scripts/Invoke-ProductionGate.ps1 · docs/PLC_FACTS.md · ARCHITECTURE.md ·
CONTRIBUTING.md