RINGby Ringwood

02 — Runtime Architecture#

Who this is for. Anyone changing startup, configuration, threading, or the lifetime of a background service — and anyone debugging "it works on my machine but not on the LCP".

What you'll learn. How Ring boots and which parts of that order are load-bearing; where configuration comes from and the two ways it surprises you; what "degraded startup" means and what it takes away; the threading model, which is not one model but four; and how the subsystems actually hand data to each other.

This chapter complements ARCHITECTURE.md and docs/CONFIG_AND_STARTUP.md. Where those are complete — the ordered phase table, the runtime file set, the paste-ready config profiles — this chapter links rather than duplicates, and goes deeper where they are terse.


Ring's dashboard screen with KPI tiles, trend chart and status strip
What the boot sequence in §2.1–2.2 is racing to put on screen — the dashboard is the default first view after a successful launch.

2.1 The entry point, precisely#

There is no Program.cs and no Main you can read. Ring/Ring.csproj:218 declares:

<ApplicationDefinition Include="Views\App.xaml">

so PresentationBuildTasks generates the Main into obj/. The first code of yours that runs is App.OnStartup in Ring/Views/App.xaml.cs.

Worth carrying precisely: the application class is Ring.App, not Ring.Views.App. The file lives at Ring/Views/App.xaml.cs but declares namespace Ring (Ring/Views/App.xaml.cs:14), matching x:Class="Ring.App" in Ring/Views/App.xaml:1. This is not pedantry — the generated-Main detail is exactly what a reachability scan has to special-case, and WriteSurfaceRegisterTests derives the ApplicationDefinition path from the csproj for that reason (Ring.Tests/WriteSurfaceRegisterTests.cs:977-984).

The phase list#

The full ordered phase table — twenty-two phases, each with its failure mode — is in docs/CONFIG_AND_STARTUP.md and is not reproduced here. What this chapter adds is why four of those orderings cannot move.


2.2 The four load-bearing orderings#

These four orderings compile fine in any order. Three of them fail only on a plant — the compiler cannot catch a startup-sequence bug that only shows up against a real database, a real controller, or an operator who used the first-run wizard.

Ordering What must come first What breaks if you swap them Code reference
(1) First-run detection SampleDbFileExistence() before any repository is touched Every later "was this a first run?" decision reads a stale answer — a database created by an incidental read looks pre-existing App.xaml.cs:190
(2) The write gate PlcWriteGuard.Configure(...) before any poller, window or background service exists A window in which a write could run before the gate is configured — currently impossible only because this ordering holds App.xaml.cs:1115-1116
(3) Schema before seeding DatabaseInitializer.Initialize() before the seed services (alarm playbooks, maintenance tasks, email defaults) The seeds run against tables that do not exist yet — though note the seeds also re-run and fail again, logged, if initialization itself failed docs/CONFIG_AND_STARTUP.md §1 phase 15
(4) Roster baseline after the wizard TankRosterState.CaptureSessionBaseline() after the first-run wizard has had its chance to edit the roster A roster the operator just configured registers as a mid-session roster change, which locks every storage-tank write for the session until a restart App.xaml.cs:706; RosterWriteIndex.cs:76-91

The detail behind each row:

(1) SampleDbFileExistence() before anything opens the database#

Ring/Views/App.xaml.cs:190 calls ServiceLocator.GetService<DatabaseInitializer>()?.SampleDbFileExistence() immediately after DI is built and before any repository is touched.

The reason is that opening a SQLite connection creates the file. Every later "was this a first run?" decision — whether to show the setup wizard, whether the pre-migration backup is of a real database or of a freshly created empty one stamped user_version = 0 — reads the latch this call sets (Ring/Database/DatabaseInitializer.cs:153-171). Sample it late and the answer is always "not a first run".

This is a trap with a wide blast radius, because a read is enough to trip it: every repository call begins with EnsureTable(), which issues CREATE TABLE / CREATE INDEX IF NOT EXISTS. DatabaseMaintenanceLatch (Ring/Services/DatabaseMaintenanceLatch.cs) exists because a dashboard refresh tick running inside a modal message box's nested dispatcher pump could recreate the file mid-factory-reset — see chapter 05.

(2) PlcWriteGuard.Configure before any poller or window exists#

RunStartupConfigurationValidation() is phase 9, and the first thing it does after resolving the logger and settings is:

Ring.Services.PLC.PlcWriteGuard.Configure(
    Ring.Services.PLC.PlcWriteGuard.ResolveConfiguredReadOnly(settings));

(Ring/Views/App.xaml.cs:1115-1116.) It runs before DatabaseInitializer, before any background service, before MainWindow. There is therefore no window in which a write could precede the gate. Note the ordering inside the method too: the guard is configured before the config-load-error branch and before validation runs, so even a boot that is about to abort has already closed the gate.

(3) DatabaseInitializer.Initialize() before the seed services#

Alarm playbooks, maintenance tasks and email defaults are seeded after the tables exist. A subtlety worth knowing: the seeds also run when database initialization failed — they simply fail again and log (docs/CONFIG_AND_STARTUP.md §1, phase 15).

(4) TankRosterState.CaptureSessionBaseline() after the wizard#

Ring/Views/App.xaml.cs:706 captures the roster baseline after the first-run wizard has had its chance to edit it. Capture it earlier and a roster the operator just configured registers as a mid-session roster change, which latches RosterWriteIndex.StorageWritesLockedByRosterChange (Ring/Services/PLC/RosterWriteIndex.cs:76-91) and blocks every storage-tank write for the session. The recovery is a restart; the cause would be invisible.


2.3 Configuration#

Load order#

Ring/Infrastructure/Configuration/ConfigurationService.cs builds:

<exe dir>\Config\appsettings.json        required   (optional: false)
<exe dir>\Config\appsettings.local.json  optional overlay, gitignored

both with reloadOnChange: false — deliberately, because a file watcher rethrowing a FormatException on a thread-pool thread would kill the process (docs/CONFIG_AND_STARTUP.md §2).

Two behaviours from that file are worth internalising:

  • Arrays merge by index, not by replacement. Override one SMTP recipient in the overlay and you get your one plus the base file's second and third. Two keys are re-overridden wholesale to defuse this (ConfigurationService.ApplyLocalArrayOverrides): AlarmEscalation.Smtp.To and AlarmEscalation.Webhook.AllowedHosts. Every other array in the file is still index-merged.
  • NormalizeConnectionStringDbPath rewrites a relative Data Source= to <exe dir>\<name> at load time, because repositories open the connection string verbatim and a launch from a different working directory would silently create a second database. Absolute paths and :memory: are untouched.

The PLC IP is not restart-only — and that is deliberate#

The blanket statement "config changes need a restart" is true of the DI-bound AppSettings object. It is not true of the two PLC connection values, and the difference is easy to trip over.

PlcConnectionConfig.GetPlcIp() (Ring/Infrastructure/Configuration/PlcConnectionConfig.cs:25-121) builds a fresh ConfigurationBuilder over Config\appsettings.json + Config\appsettings.local.json on every call, and only falls back to the DI-resolved AppSettings if the file yielded nothing. GetPlcPath() mirrors the same pattern (PlcConnectionConfig.cs:127-205). Since every libplctag reader and writer resolves the endpoint through these, a change to PlcSettings:DefaultIpAddress on disk takes effect on the next tag construction, without a restart. The class comment states the intent explicitly: "Reads from file first so mode-switch popup and new readers always see current config."

Because GetPlcIp() is on the hot polling path, its loopback-fallback warning is latched to once per process via Interlocked.Exchange (PlcConnectionConfig.cs:110-120). Do not read a single warning line as "this happened once".

PlcSettings.DefaultPath binds to nothing#

The shipped Ring/Config/appsettings.json contains "DefaultPath": "1,0", but Ring/Infrastructure/Configuration/AppSettings.cs declares no DefaultPath property on PlcSettings (verified: the only code references to that key are inside PlcConnectionConfig.cs). The key is consumed exclusively by PlcConnectionConfig.GetPlcPath(), which reads it as a raw configuration key with a compiled "1,0" default (PlcConnectionConfig.cs:152-179). If you are looking for a strongly-typed settings.PlcSettings.DefaultPath, it is not there and adding one would create a second, divergent source of truth.

Configuration is written back into the build output#

Ring persists operator-entered settings to <exe dir>\Config\appsettings.json — that is, into bin\<cfg>\. Four classes under Ring/Infrastructure/Configuration/ touch it — AlarmEscalationConfigWriter, AnalyticsSettingsConfigWriter, CostSettingsConfigWriter and AppSettingsSectionWriter — plus Infrastructure/Security/CredentialRotationService, which writes the .local.json overlay the same way. There is exactly **one** atomic-write implementation among them, not four or five: AlarmEscalationConfigWriter.WriteAtomically (AlarmEscalationConfigWriter.cs:88-116, the File.Replace call at :108) does the temp-file + File.Replace + one-deep .bak dance, and AnalyticsSettingsConfigWriter.cs:61, CostSettingsConfigWriter.cs:50 and AppSettingsSectionWriter.cs:65, 103 all call into that same static method rather than repeating it.

The trap is the build, not the write: Ring/Ring.csproj:1985 declares

<Target Name="CopyAppSettingsToOutput" AfterTargets="Build">

which copies Ring/Config/appsettings.json over the output copy after every build. PLC IP, ReadOnlyMode, SMTP credentials and cost rates an operator typed in are reverted by the next build. The .local.json overlay is copied only for Debug and only if present in Ring/Config/ (Ring/Ring.csproj:1881); a Release build deliberately keeps an existing overlay in the output and emits a warning, with /p:PurgeLocalConfig=true as the opt-in that deletes it (Ring/Ring.csproj:1995-1999). A release package can therefore silently carry a developer's bench PLC IP if nobody reads the warning — which is why Invoke-ProductionGate.ps1 passes /p:SuppressLocalConfigWarning=true only for its own isolated build (scripts/Invoke-ProductionGate.ps1:70) and why scripts/Preflight-FieldKit.ps1 re-checks the deployed copy.

The complete "everything Ring stores on disk" table — ten stores across three roots, and which single one the backup covers — is docs/CONFIG_AND_STARTUP.md.

Startup validation and the countdown dialog#

ConfigurationValidator.Validate returns errors and warnings; Result.IsFatal => Errors.Count > 0 (Ring/Infrastructure/Configuration/ConfigurationValidator.cs:23).

  • Fatal → message box, Shutdown(1). Blocks forever, by design.
  • WarningsStartupWarningDialog.ShowWarning(..., timeoutSeconds: 60) (Ring/Views/App.xaml.cs:1168-1170) — a countdown that self-continues so a 3 a.m. unattended restart is never parked on a human click, while a present operator can still choose Quit.
  • Suppressed only under --isolated-sim-harness, which itself only applies with ReadOnlyMode true and a loopback IP.

A write-enabled station always produces at least one warning: any configuration with writes enabled adds the single-writer advisory (ConfigurationValidator.cs:281, SingleWriterAdvisory() at :507). Expect the countdown dialog on every post-cutover boot; its absence is the anomaly.

Production mode — the strict profile#

Everything in the previous subsection describes ConfigurationValidator's advisory mode, which is what a normal Ring/Config/appsettings.json install runs under. There is a second, stricter mode that this book did not previously name: AppSettings.Production (ProductionSettings, Ring/Infrastructure/Configuration/AppSettings.cs:119-131), whose Enabled flag defaults false. Setting it true — as the signed Ring/appsettings.production.template.json profile does — switches ConfigurationValidator.ValidateProduction (ConfigurationValidator.cs:67-114) from a no-op into nine additional fatal checks, each a message box and Shutdown(1) if it fails:

Check Line
Production.SiteId is set (not blank, not a placeholder) :73
Production.CommissioningApprovalId is set :74
Production.PlantProfileSha256 is a valid SHA-256 :75
Production.ControllerProjectSha256 is a valid SHA-256 :76
Production.LegacyConfigurationSha256 is a valid SHA-256 :77
PlcSettings.DefaultIpAddress is a parseable, non-loopback literal IP — blank, unparseable, or loopback all fail :83-88
Production.RequireWriteEnabled and PlcSettings.ReadOnlyMode are not both true at once (an inconsistent signed profile) :90-91
DemoMode.Enabled and Authentication.EnableDemoMode are both off :93-94
Supervisor and administrator credentials are not blank or a known placeholder (the legacy "9999" default, or a REPLACE_/<...> template value) :96-102
Database.ConnectionString's Data Source is an absolute path :104-106
DatabaseMaintenance.BackupDirectory is set and absolute :108-113

That is ten checks, not nine, once the SHA-256 triad is counted individually — the point to take away is that every one of them is dormant until Production.Enabled is true. Production.RequireWriteEnabled is the signed-instant-cutover companion flag: it errors (:90-91) if PlcSettings.ReadOnlyMode is still true, so a profile cannot claim write-enabled and read-only at once. CUTOVER_RUNBOOK.md walks the team through this distinction explicitly: the ordinary cutover step installs appsettings.local.json with ReadOnlyMode still true and Production.Enabled not yet set, so these fatal checks are not yet armed and DemoMode.Enabled has to be verified by hand; only the later, separately supervised write-enable step installs the signed profile with Production.Enabled: true and Production.RequireWriteEnabled: true together with ReadOnlyMode: false (CUTOVER_RUNBOOK.md:88-109, 241-253; see STARCH_SYSTEM_CUTOVER_RUNBOOK.md §2.1 "Enable writes" for that step). Fill in Ring/appsettings.production.template.json without also setting Production.Enabled and you get none of these protections and no warning that they are missing — the template does not switch itself on.

The single-writer problem, stated honestly#

Every gate in the write stack reasons about this process only, and the controller arbitrates nothing. Two write-enabled Rings would drive the same setpoints against each other with no detection anywhere.

The single-instance mutex does not close this. SingleInstanceMutexName is "RS360-RingwoodApp-SingleInstance" with **no Global\ prefix** (Ring/Views/App.xaml.cs:21), so Windows resolves it in the per-session Local\ namespace: a second Ring launched under a different login on the same LCP — an engineer signing in while the kiosk account runs Ring, or a MeshCentral/RDP seat — creates its own mutex and boots all the way through. Because none of it is detectable, ConfigurationValidator states it as a warning rather than enforcing it. Keeping one write-enabled Ring per controller is a procedural rule. (Full framing: docs/CONFIG_AND_STARTUP.md.)


2.4 Degraded startup#

"Degraded" has one precise meaning in this codebase: database initialization failed, so batch history, the audit trail and the alarm log are unreliable for this session.

The response is not a dialog you dismiss. StartupDegradedState.ApplyDatabaseInitOutcome(false, detail) (Ring/Services/StartupDegradedState.cs) does two things:

  1. Calls PlcWriteGuard.ForceReadOnlyForSession() — the one-way latch. Once pulled, Configure computes _readOnly = readOnly || _forcedReadOnly (Ring/Services/PLC/PlcWriteGuard.cs:37-40), so nothing in the app can re-arm writes short of a restart.
  2. Records the reason, first-detail-wins, so MainWindow can paint a persistent banner for as long as the app runs.

The rationale is stated in the class doc and is worth repeating: a Ring that cannot record what it did is not allowed to do anything. A successful init changes nothing at all — the configured ReadOnlyMode keeps deciding.

A second, milder degradation: if the tank roster fails to load, startup falls back to the legacy 4-tank default, warns, and latches RosterWriteIndex.StorageWritesLockedByRosterLoadFailure, which blocks storage-tank writes even though the substituted roster looks perfectly valid (Ring/Services/PLC/RosterWriteIndex.cs:98-139). Chapter 04 explains why that needs its own lock.


2.5 Threading and the dispatcher#

Ring does not have one concurrency model; it has four, and they meet at well-defined places.

Model Where Rules
WPF dispatcher (UI thread) Every screen, MainWindow, NavBar, all DispatcherTimer ticks Anything touching a Control or a DispatcherTimer must be here
System.Threading.Timer background pollers The seven pollers under Ring/Services/PLC/, ProcessHistorianService, ReportSchedulerService, the alarm background pump Explicitly "never runs on UI thread; no Dispatcher" (e.g. Ring/Services/PLC/PlcSnapshotPoller.cs class doc). Each guards re-entrancy with an Interlocked/CompareExchange busy flag so a slow cycle skips a tick rather than stacking
Thread-pool tasks PlcSetpointWriteQueue pumps, MomentaryPulse.ArmBackgroundReclear, startup batch reconcile Detached; must never assume UI affinity
Process-wide static state under a lock PlcHeartbeatConnectionTracker, PcReadIntegerCache, the snapshot holders, TankInstalledWriteGate, PlcWriteGuard Readable from any thread; the gates use volatile / Volatile / lock so a reader can never see a torn value

Three concrete patterns you will meet:

Screens pull, they are not pushed. Every PLC-facing screen owns a DispatcherTimer (typically 1000 ms) that reads a static snapshot holder and assigns values into named controls. There is no event bus from poller to screen. The end-to-end latency for a value is therefore poller cadence + screen cadence. Chapter 03 gives the per-poller numbers.

Blocking I/O never runs on the dispatcher. The startup batch reconcile is the canonical example: it does a blocking libplctag read of current_step (up to PlcSettings.ReadTimeout per attempt, retried three times ~500 ms apart), so PlcPollingCoordinator.Start() defers it to Task.Run (Ring/Services/PLC/PlcPollingCoordinator.cs:165-180) after an earlier version froze the UI for seconds on a reachable-but-slow controller.

Marshalling back is explicit. MainWindow.ProbeAndStartPlcMonitoring marshals StartPlcMonitoring() onto the dispatcher because that method touches a DispatcherTimer and, on failure, a MessageBox (Ring/Views/MainWindow.xaml.cs:828).

Timers owned by MainWindow#

MainWindow is not just chrome; it owns three lifetimes:

Timer Cadence Purpose
_plcUpdateTimer (DispatcherTimer) 2000 ms The UI-side PLC tick; also calls PlcHeartbeatConnectionTracker.EvaluateWatchdog() (MainWindow.xaml.cs:1287)
PLC alarm background pump (System.Threading.Timer) 2 s (PlcAlarmBackgroundPumpInterval, MainWindow.xaml.cs:170) Drives AlarmAlarmNumberPlcService independently of the dispatcher, so a frozen UI thread cannot leave alarms frozen-but-green
PLC auto-reprobe (System.Threading.Timer) 30 s (PlcReprobeIntervalMs, MainWindow.xaml.cs:62) Re-runs the reachability probe and restarts monitoring when the controller comes back

Plus a UI-hang sentinel: a background timer pings and expects a dispatcher "pong" every 5 s (UiHangPingIntervalMs, MainWindow.xaml.cs:122), feeding Ring/Services/UiHangDetector.cs.

The dual-drive on the alarm pump is deliberate and has a matching hazard: the same method is pumped from both the UI timer and the background timer, so a CompareExchange busy gate stops them doubling up (ARCHITECTURE.md).


2.6 Dependency resolution — four mechanisms, one recommendation#

Ring resolves dependencies four different ways and all four are live. This is the largest structural inconsistency in the codebase and ARCHITECTURE.md is the authority on it. In brief:

  1. The DI containerAddRingServices (Ring/Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs) is the real composition root; it is built once in App.OnStartup (Ring/Views/App.xaml.cs:1184-1197) and holds on the order of a hundred registrations (read the file for the current count — do not quote one).
  2. ServiceLocator — a three-method static wrapper over that container (Ring/Infrastructure/DependencyInjection/ServiceLocator.cs). Its own summary says "This is a temporary solution. In production, prefer constructor injection." It exists because a XAML-constructed UserControl has no constructor-injection point.
  3. Ambient static *State classes — e.g. TankRosterState, GroupHardwareSetupState, ProcessHoldState, StartupDegradedState.
  4. Process-wide Instance singletons — e.g. ToastService.Instance, PlaybookService.Instance, PlcSetpointWriteQueue.Instance (Ring/Services/PLC/PlcSetpointWriteQueue.cs:124).

What new code should do: register in AddRingServices and inject through the constructor wherever there is one. From XAML-constructed code-behind, resolve once in Loaded and hold the reference. Do not add a new static *State class or a new Instance singleton — that is the pattern being retired.

One important asymmetry to be aware of when you are tempted to make a gate injectable: the gates are static on purpose. PlcWriteGuard.LogBlocked explicitly avoids reaching into ServiceLocator and constructs its own Logger as a last resort (Ring/Services/PLC/PlcWriteGuard.cs:86-103) so the safety path never depends on the container being built. PlcTagWriter, PlcTagReader and PlcWriteEndpointGuard all wrap their ServiceLocator lookups in try/catch and treat an unbuilt container as the fail-closed answer (PlcWriteEndpointGuard.LoopbackWritesAuthorized, PlcWriteEndpointGuard.cs:190-201).


2.7 How the subsystems fit together#

The three narratives below share one shape: reads flow one direction, through snapshots, and writes take a separate, narrower path that never touches them.

CompactLogix controller EtherNet/IP via libplctag Seven pollers System.Threading.Timer, background thread, 500 ms–5 s Snapshot holders & caches static, immutable, CapturedUtc-stamped Tag services / ViewModels read the holder on the UI thread Screens DispatcherTimer paints named controls, ~1000 ms poll, publish pull, never pushed pull paint or refuse (freshness) Six-gate write stack (chapter 04) 1. ReadOnly guard 2. Enable* flag 3. Commissioning hold 4. Endpoint guard 5. Heartbeat gate 6. Demo re-check each fails closed independently operator gesture write, if every gate agrees

A single narrative of one value's journey, and one command's:

A tank temperature, controller → screen. PlcPollingCoordinator.Start() constructs StorageTankGroupPoller with the roster's storage slots snapshotted at construction (Ring/Services/PLC/StorageTankGroupPoller.cs). Every 2 s its System.Threading.Timer callback — if AllowHeavyPlcPolling() says the link is Connected or Stale — calls StorageTankTagReaderService for each mapped, enabled slot, builds an immutable StorageTanksSnapshot stamped with CapturedUtc, and publishes it into the static StorageTanksSnapshotHolder. The Storage Tank Group screen's own DispatcherTimer reads Holder.Latest, classifies its freshness (Ring/Services/Display/DataFreshnessClassifier.cs) and either paints the value or refuses to. Nothing pushed; nothing bound to a poller.

A Hold command, operator → controller. NavBar's shared button handler routes HoldButton to OnProcessHoldClicked() (Ring/Views/UserControls/NavBar.xaml.cs:513-516), which reaches PcWriteInteger30BatchControlPlcService. That service checks its gates, asserts PC_Write_Integer[30].1 through PlcTagWriter.Write, sleeps, then clears it through MomentaryPulse.ClearWithRetryPlcTagWriter.WriteClear. Each of those hops is a gate; chapter 04 walks every one.

A batch, controller → SQLite. BatchStepsPoller observes current_step edges and drives Ring/Services/Batch/BatchLifecycleRecorder.cs, which on batch end calls BatchRepository.CompleteWithUsage. Completion and every IngredientUsage row commit in one transaction, so a failed usage insert leaves the batch Running rather than completing with missing usage (ARCHITECTURE.md).


Next: 03 — PLC Communications.


Verified against#

Every claim in this chapter was read out of these files on 2026-09-01:

Ring/Views/App.xaml.cs · Ring/Views/App.xaml · Ring/Ring.csproj · Ring/Infrastructure/Configuration/ConfigurationService.cs · Ring/Infrastructure/Configuration/ConfigurationValidator.cs · Ring/Infrastructure/Configuration/PlcConnectionConfig.cs · Ring/Infrastructure/Configuration/AppSettings.cs · Ring/Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs · Ring/Infrastructure/DependencyInjection/ServiceLocator.cs · Ring/Services/StartupDegradedState.cs · Ring/Services/PLC/PlcWriteGuard.cs · Ring/Services/PLC/PlcPollingCoordinator.cs · Ring/Services/PLC/PlcSetpointWriteQueue.cs · Ring/Services/PLC/RosterWriteIndex.cs · Ring/Services/PLC/StorageTankGroupPoller.cs · Ring/Views/MainWindow.xaml.cs · Ring/Views/UserControls/NavBar.xaml.cs · Ring/Database/DatabaseInitializer.cs · Ring/Infrastructure/Configuration/AlarmEscalationConfigWriter.cs, AnalyticsSettingsConfigWriter.cs, CostSettingsConfigWriter.cs, AppSettingsSectionWriter.cs · Infrastructure/Security/CredentialRotationService.cs · docs/CONFIG_AND_STARTUP.md · CUTOVER_RUNBOOK.md · ARCHITECTURE.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.