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.

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.ToandAlarmEscalation.Webhook.AllowedHosts. Every other array in the file is still index-merged. NormalizeConnectionStringDbPathrewrites a relativeData 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. - Warnings →
StartupWarningDialog.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 withReadOnlyModetrue 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:
- Calls
PlcWriteGuard.ForceReadOnlyForSession()— the one-way latch. Once pulled,Configurecomputes_readOnly = readOnly || _forcedReadOnly(Ring/Services/PLC/PlcWriteGuard.cs:37-40), so nothing in the app can re-arm writes short of a restart. - Records the reason, first-detail-wins, so
MainWindowcan 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:
- The DI container —
AddRingServices(Ring/Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs) is the real composition root; it is built once inApp.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). 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-constructedUserControlhas no constructor-injection point.- Ambient static
*Stateclasses — e.g.TankRosterState,GroupHardwareSetupState,ProcessHoldState,StartupDegradedState. - Process-wide
Instancesingletons — 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.
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.ClearWithRetry → PlcTagWriter.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