RINGby Ringwood

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.

  1. The six gates, in the order PlcTagWriter.WriteCore actually 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.
  2. Which flag gates this write family, and its default. Eight per-feature Enable* flags plus AllowLoopbackPlcWrites; seven default false, one (EnableProcessorTimeDateSync) defaults true. Only EnableHmiAgitatorCycle ships in the JSON — everything else is the C# default. §4.2.
  3. Is this tank or tag one of the two hard-closed holds? MainTvcWriteAuthorization.IsAuthorized and TankTempModeWriteAuthorization.AuthorizedTankTempModeTanks are a hard-coded false and an empty list, respectively — do not populate either without a controls decision. §4.3, "The two that are hard-closed on purpose".
  4. 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:

  1. PlcWriteGuard.IsReadOnly — global kill switch
  2. per-feature Enable* master flags
  3. bespoke commissioning holds
  4. PlcWriteEndpointGuard — the write whose target must be the plant controller
  5. PlcHeartbeatConnectionTracker.AllowPlcWrites()
  6. the live demo re-check at the wire
1. ReadOnly guard Is this station allowed to write at all? 2. Per-feature Enable* flag Has this write family been commissioned? 3. Commissioning hold Signed off for this tag, tank and value? 4. Endpoint guard Does the address actually name the plant? 5. Heartbeat gate Is the controller's program still running? 6. Demo re-check (live) Is this session pretending, right now? the wire CompactLogix controller any gate refusing stops the write here — never a throw, always a refusal

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_AgitatST_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 and CLAUDE.md both 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 is docs/production-readiness/CONTROLS_SIGNOFF_RECORD.md; the design rationale is in the 2026-07-09 and 2026-07-13 held-item packages. WriteSurfaceRegisterTests asserts 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:

  1. Unmapped / disabled / out-of-range slotStoragePlcIndex returns NoWrite (:174-190). Also returned for a LegacyWindowKey that 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?".
  2. Mid-session roster changeStorageWritesLockedByRosterChange (:76-91), which reads TankRosterState.RestartRequired. Needed because the pollers pin their roster at construction while CurrentStorageSlots() resolves live on every write, so after a Setup save the two are different physical tanks. Sticky; clears only on restart.
  3. Roster file present but unusableStorageWritesLockedByRosterLoadFailure (:124-139), reading TankRosterState.LoadFailed. This one is subtle and the doc comment explains why the other two cannot see it: the substituted fallback roster is valid-looking. StoragePlcIndex returns an ordinary 1..4, so the NoWrite branch never trips; and …ByRosterChange is false because no Replace ever 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 null current_step read is BlockUnknown, never idle (:162-163). Unreadable ⇒ blocked.
  • An active step is BlockActiveBatch via BatchStepsPoller.IsActiveStep, using the configurable PlcSettings.BatchIdleStepValue sentinel.
  • 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 HeartbeatLinkLogicEnabled const to false (PlcHeartbeatConnectionTracker.cs:33) makes AllowPlcWrites() return true unconditionally. It is a documented bench toggle for a controller with MainTask inhibited. 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). InventoryEventCaptureService is 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#

The Plant Profile setup screen
The Plant Profile screen — the one surface that can flip Demo Mode mid-session, which is the reason demo is re-checked live at every write rather than captured once at construction.

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 = 3 by default, spaced by ClearDelayMs = 250 and 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 TRUE naming the tag, and only if ArmBackgroundReclear is 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 (PulseBitPulseBitSerialized, :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 = 0 for 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, PlcHeartbeatConnectionTracker or DemoModeData." — 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:

  1. XAML-init-time handler fires (IsSelected="True" / Text="10" attributes raise SelectionChanged during InitializeComponent);
  2. programmatic seed-time fires (assigning SelectedIndex/Text re-enters the same handler — the fix itself would otherwise become a guaranteed write-on-open);
  3. unchanged-value focus-out (tab straight through a TextBox and the LostFocus handler writes the hardcoded XAML default over the real setpoint).

Three deliberate behaviours:

  • A key that was never seeded is NOT writable. ShouldWrite returns 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 under Ring/ 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.xaml declares Ring.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 ApplicationDefinition exclusion is derived from the csproj rather than hardcoded, so it cannot drift (:977-984). The comment records the lesson: before Ring/Program.cs was deleted, the only thing that looked like a construction site for App was a commented-out var 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 of MF1Window and was deleted along with its register row; the only remaining references to it anywhere in the tree are the two historical comments in WriteSurfaceRegisterTests.cs recording 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#

  1. The gesture. NavBar.HandleButtonClick receives HoldButton. Because that name is in the isNonNavAction set, 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 reaches OnProcessHoldClicked() (:513-516).
  2. Serialization. PcWriteInteger30BatchControlPlcService.PulseBit takes the process-wide Word30PulseGate lock for the whole pulse, assert and clear (:182-186). No other word-30 command can interleave.
  3. Gate 1 — read-only. PlcWriteGuard.IsReadOnlyLogBlocked and return BatchControlWriteResult.BlockedReadOnly (:190). Pre-cutover, the walk ends here, and the operator is told the controller was not updated.
  4. Gate — heartbeat (outer). AllowPlcWrites() must be Connected, so a pulse cannot start on a degraded link (:192-196).
  5. Endpoint resolution. GetPlcIp() / GetPlcPath(), each re-read from the config file; a throw here returns Failed (:198-208).
  6. The assert. new PlcTagWriter("PC_Write_Integer[30].1", ip, BOOL, path) then writer.Write("True")WriteCore runs ▸ReadOnly, ▸Demo (live re-check), ▸Endpoint, ▸Heartbeat, then the wire.
  7. If the assert failed — the clear still runs. Write returning false does not mean the request never reached the controller: WriteInternal swallows 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.
  8. The pulse. Thread.Sleep(PulseDurationMs) inside a try, with the clear in the finally so it runs even on an exception.
  9. The clear. MomentaryPulse.ClearWithRetry(() => SerializedClear(writer), …) → three spaced attempts (250 ms apart, never before the first), each through PlcTagWriter.WriteClear, which is heartbeat-exempt but still ReadOnly-, Demo- and Endpoint-gated. SerializedClear re-takes the word-30 lock (Monitor is re-entrant) so a background re-clear can never interleave with a newer sibling pulse (:294-298).
  10. If every attempt failed. STUCK TRUE is logged naming the tag, and — because this service opts in (DefaultClearOptions(), :67-74, sets ArmBackgroundReclear = true) — one detached re-clear is armed, which attempts the wire on every tick regardless of the heartbeat, bounded by CommandBitReclearSeconds (default 1800 s, clamped 60 s..4 h) and ReclearMaxAttempts = 120.
  11. The result. Written only when both halves succeeded; PulseHoldResumeResetBool collapses the tri-state so that only Written maps to true (: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#

TVC control window for one tank, showing heating and cooling setpoint spinners and live status
The gesture this walkthrough traces: a preset spinner on a TVC control window. Everything from here to the wire is the setpoint write queue, the echo guard, and six gates.
  1. The gesture. The spinner's handler in a Views/TVCcontrol window calls into TVCWindowPlcBridge.
  2. 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-through LostFocus, from writing the XAML default over the real setpoint.
  3. Index resolution. For a storage screen the PLC index comes from RosterWriteIndex.StorageWriteIndexForLegacyWindow(key), which consults both sticky roster locks before the lookup and returns NoWrite if either is latched (RosterWriteIndex.cs:269-274).
  4. Enqueue-time heartbeat check at the bridge, then PlcSetpointWriteQueue.Instance.Submit(tagKey, coalesceKey, 300, write). The tag key comes from PlcWriteTagKeys, so two screens writing one physical tag share one slot.
  5. The queue journals PendingDispatch before 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.
  6. The write delegate runs the real gatesTVCControlPlcService checks read-only, the applicable commissioning holds (MainTvcWriteAuthorization, TvcCoolingWriteAuthorization, TankInstalledWriteGate), the heartbeat, demo and the endpoint. The queue itself checked nothing.
  7. Settlement. WriteCompleted fires on the pump thread; the queue journals WrittenUnverified or Failed; SetpointRefreshCoordinator re-reads the screen's setpoints so the display shows controller truth rather than the operator's optimism; and only now does the screen call SetpointEchoGuard.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:

  1. Route it through the gate stack. PlcWriteGuard → the relevant Enable* flag → any applicable commissioning hold → PlcHeartbeatConnectionTracker.AllowPlcWrites() → the live demo re-check and PlcWriteEndpointGuard at the wire. Prefer funnelling through PlcTagWriter, which gives you four of those for free. If you must build a raw Tag, use the prologue shape from BatchStartTankPlcWriter.cs:74-81:

    if (PlcWriteGuard.IsReadOnly)
    {
        PlcWriteGuard.LogBlocked(op, tag, value, logger);
        return true;   // or the honest outcome for your rail — see §4.1
    }
    
  2. Add its register row to WriteSurfaceRegisterTests.Register with a truthful WriteClass, and raise the count floor in the same commit.

  3. Add a seam test pinning tag, value, count and order.

  4. For a UDT member or a BOOL bit: add bench evidence — a bench round-trip in Ring.Tests/BenchPlcIntegrationTests.cs and a row in docs/production-readiness/WRITE_PATH_PROOF_2026-07-30.md. The commissioning evidence required per write family is docs/production-readiness/PLC_WRITE_COMMISSIONING_REGISTER_2026-08-02.csv, verified by scripts/Test-PlcCommissioningEvidence.ps1. Remember that the bench-gated tests early-return a vacuous pass unless BENCH_PLC_AVAILABLE=1; the gate summary records benchGatedTestCount and benchHardwareWasAvailable for exactly that reason (scripts/Invoke-ProductionGate.ps1:157-186).

  5. 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 MainTvcWriteAuthorization or TankTempModeWriteAuthorization without a controls decision.
  • Never put a UI guard or an early return in 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 is NavBar.HandleButtonClick (Ring/Views/UserControls/NavBar.xaml.cs:415-444), where the unsaved-changes guard is skipped for an explicit isNonNavAction set that includes HoldButton, ProcessResumeButton and ProcessResetButton — 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

Generated from the docs/manual/engineering book in the Ring repository — the markdown there is the source of truth. Paths shown in code like this point into the Ring source repository, which is private to Ringwood — they are not links.