07 — Services Catalog#
Who this is for. Anyone looking for "where does X live?" or "is there already something that does this?" before writing a new service.
What you'll learn. A guided index to everything under Ring/Services/ —
each entry a short, verified statement of responsibility and its key
collaborators, with its file path. Entries are grouped by concern rather than by
directory where that reads better; the directory is always given.
How to read this. Each summary was taken from the type's own doc comment in the cited file. Where a service's own comment states a limit, a rationale or a warning, this catalog carries it — those are the parts that stop you reintroducing a fixed bug. Where a service is large enough to need its own explanation, this chapter points at the chapter that gives it.
Coverage. Every .cs file directly under Ring/Services/ and in each of its
fifteen subdirectories is named somewhere below. Interfaces and their default
implementations are grouped together; small companion types (enums, records,
option objects) are named with the service that owns them rather than given
their own row. Nothing under Ring/Services/ is silently omitted — if you
cannot find a file here, that is a defect in this chapter. This catalog is
Services-only; the equivalent catalog for Ring/ViewModels/ is
chapter 06 §6.1a.
The PLC services are catalogued here only briefly; chapters 03 and 04 are their real documentation.

7.1 PLC — Ring/Services/PLC/#
The largest subfolder and essentially flat. Grouped by role:
Gates and write infrastructure#
| File | Responsibility |
|---|---|
PlcWriteGuard.cs |
The global read-only gate and its one-way session latch. §4.1 |
PlcWriteEndpointGuard.cs |
Refuses a write whose target cannot be the plant controller. §4.4 |
PlcHeartbeatConnectionTracker.cs |
Link state machine; AllowPlcWrites() / AllowHeavyPlcPolling(). §3.6 |
PlcConnectionState.cs |
The six-state enum, with Starved documented as distinct from Disconnected |
MainTvcWriteAuthorization.cs |
Hard commissioning hold — IsAuthorized => false |
TankTempModeWriteAuthorization.cs |
Hard commissioning hold — empty authorized-tank list; also owns the 0/1/2 value vocabulary |
TvcCoolingWriteAuthorization.cs |
Per-tank cooling authorization {1,2,4}; can only ever refuse the cooling selection |
TankInstalledWriteGate.cs |
Fail-closed commissioning gate from Tanks_c[N] install bits; UI fails open on Unknown, the wire fails closed |
RosterWriteIndex.cs |
Single owner of "which physical tank does this UI slot write to?"; three sticky fail-closed locks |
FormulaBankWriteInterlock.cs |
Active-batch interlock for the destructive bank rewrite, plus the FormulaBankWriteOutcome vocabulary |
MomentaryPulse.cs |
The shared de-assert half of every momentary pulse: spaced retries, STUCK TRUE, opt-in background re-clear |
PlcSetpointWriteQueue.cs |
Per-tag write serializer with per-control coalescing. Pure dispatch; holds no safety gate |
PlcWriteTagKeys.cs |
Canonical serialization keys so two paths writing one physical tag share one queue slot |
PlcWriteResult.cs |
The unambiguous PlcWriteStatus outcome vocabulary (blocked ≠ simulated ≠ success) |
PlcWriteEvidenceJournal.cs |
Append-only JSONL evidence for queued writes. Evidence only — no replay API |
SetpointEchoGuard.cs |
"Is this a genuine operator change?" — no read-back baseline ⇒ no write |
SetpointSeedContext.cs |
Per-window state for the seed-before-writable contract. Pure, no WPF types |
SetpointRefreshCoordinator.cs |
Turns a settled write into a re-read, off the queue's events |
TankControlSetpointReader.cs / TankControlSetpoints.cs |
The one-shot read-back seam that fills those baselines |
Writers#
| File | Writes |
|---|---|
PlcTagWriter.cs |
The shared write funnel; Write / WriteClear; four gates inline |
BatchStartTankPlcWriter.cs + BatchStartPlcWriteHelper.cs |
Tanks[N] batch-start quintet (Formula_number, Request_level, Splitting_A/B, Start_Type last); the helper is the ordering seam |
TVCControlPlcService.cs (+ ITVCControlPlcService.cs) |
Tanks[N].Preset_Temp, TVC[N].Preset_Temp / Temp_mode, TVC_Ctrl[N].enabled |
UseTankControlPlcWriter.cs |
Tanks[N].Agitator_mode, Agt_on_pre, Agt_off_pre, Liq_add_1..3_mode |
UseTankFormulaPlcWriter.cs |
Tanks[slot].Formula_number from the Use Tank windows — gated groundwork |
TankAgitatorPlcWriter.cs |
Tank_IO[N].ZZZZZZZZZZTank_I_O9 bit 1 (O_Agitat), read-modify-write |
BatchFormulaPresetPlcWriter.cs + FormulaBankCommitCoordinator.cs + FormulaPresetCodec.cs + FormulaBankPresetPlcReader.cs |
The Formula_01..06_Preset_{Operation,Amount,Mix}[0..30] bank; the coordinator drives the commit through an IFormulaBankPlcCommitTarget seam; the codec is the pure array shaping |
InventoryAmountPlcWriter.cs |
Inventory_Amount[0..13] REAL, one bulk array write |
PcWriteInteger30BatchControlPlcService.cs |
[30].0 Resume / .1 Hold / .2 Reset / .3 Silence, under a shared word-30 lock |
ShiftControlPlcService.cs |
PC_Write_Integer[44/45/48/49].(shift-1) momentary pulses |
ProcessorTimeDatePlcService.cs |
Controller wall-clock words [34..39] + the [30].10 commit pulse |
InventoryEventCaptureService.cs |
The PC_Write_Integer[27].4 acknowledge — and the capture that precedes it |
TankNamePlcWriter.cs / TankNamePlcSyncService.cs / TankNamePlcCodec.cs / TankNamePlcReader.cs / TankNamePlcNameHydrator.cs / TankNamePlcProbeState.cs / TankNameMergeRules.cs |
The provisional HMI_Tank_Name / HMI_Group_Name STRING family. No such tags exist on the plant today; inert on three independent fail-safes |
Pollers, snapshots and caches#
PlcPollingCoordinator.cs owns seven pollers, each publishing an immutable
snapshot into a static holder or cache:
| Poller | Publishes into |
|---|---|
PlcSnapshotPoller |
PcReadFloatSnapshot, MakeReadyTankPlcData, PcDisplayOutputsCache |
PcReadIntegerPoller |
PcReadIntegerCache — a heartbeat-word tick and a full [0..479] block tick |
BatchStepsPoller |
BatchStepsSnapshotHolder (BatchStepsSnapshot) |
StorageTankGroupPoller |
StorageTanksSnapshotHolder (StorageTanksSnapshot) |
UseTankGroupPoller |
UseTanksSnapshotHolder (UseTanksSnapshot) |
TVCPoller |
TVCSnapshotHolder (TVCSnapshot) |
BatchStartPoller |
BatchStartSnapshotHolder (BatchStartSnapshot) |
RosterPollPlan.cs is the pure roster→read-target translation. Cadences and
gotchas: §3.2.
Readers and decoding#
PlcTagReader.cs (+ IPlcTagReader.cs, PlcDataType.cs) is the per-tag
reader; UdtTankReader.cs + UdtTankLayout.cs are the byte-offset UDT path.
Per-subsystem readers: StorageTankTagReaderService, UseTankTagReaderService,
TVCTagReaderService, BatchTagReaderService, InventoryStampsPlcReader.
Index maps: PcReadIndexes.cs, PcWriteIndexes.cs. Decoders:
SystemStatusWord.cs (the PC_Read_Integer[9] hold/running word and the
Process-menu command policy), BatchOperationMapper.cs (operation code →
description), IngredientFamilyMapper.cs (operation codes → coarse ingredient
families), BatchStamps.cs (element indexes inside Batch_Current_Stamps),
AgitatorPresetBands.cs (the single definition of the agitator preset minute
bands).
Diagnostics and demo#
| File | Responsibility |
|---|---|
PlcCommunicationLogService.cs |
RCS-128: in-memory TX/RX ring buffer + optional per-day CSV, feeding the live DisplayCommunicationForm tail. §7.13 |
PlcCommEventLogService.cs + PlcCommEventType.cs |
Not the row above, despite the near-identical name: persists PLC connection-lifecycle events (Connected/Disconnected/HeartbeatStalled/reprobe/session/write outcomes) to the SQL PlcCommunicationLog table (schema v32) for after-the-fact auditability. Bounded queue, single batched writer, CommLogRetentionDays default 90 d. ch. 03 §3.7 |
PlcClockSkewMonitor.cs |
Read-only DST/skew sentinel |
PcWriteInteger30StrandedBitMonitor.cs |
Read-only sentinel for a stranded command bit in PC_Write_Integer[30] |
TagManifestService.cs |
Loads scripts/live-tags.json; its Live/Dead verdicts are 2024-sourced and non-authoritative |
TagSuggestionCatalog.cs |
Tag Inspector autocomplete |
TrendingDataService.cs |
2 s Trending screen feed — builds its own readers |
ViscometerLiveRecorder.cs |
5 s; reads snapshots only, writes SQLite |
ProcessHoldState.cs |
Live mixer HOLD from the polled status word |
DemoModeData.cs |
Demo-mode substitute for pollers that bypass PlcTagReader |
DemoPlcTagReader.cs |
Demo-mode substitute seam inside PlcTagReader |
DemoPlcTagWriter.cs |
Demo-mode substitute seam inside PlcTagWriter |
7.2 Alarms — Ring/Services/Alarms/#
An alarm, controller → operator#
The pieces below are each independently correct but never sequenced elsewhere in this book. This is the path a single alarm actually takes, verified against the sources:
- Acquisition. Two independent 2 s timers reach the same call —
MainWindow's_plcUpdateTimerDispatcherTimerand a backgroundSystem.Threading.Timeralarm pump (PlcAlarmBackgroundPumpInterval,MainWindow.xaml.cs:170) — both routed throughRequestPlcAlarmPollAndPopups(), which throttles the actual PLC read to no more than once perPlcAlarmPollMinGap = 8 s(MainWindow.xaml.cs:164) and busy-gates the two callers so they never race. That reachesAlarmAlarmNumberPlcService.PollAlarmsForPopupsAndCache()(:578-629), which bulk-readsAlarm_Alarm_NumberasINT[40]in one PLC read (:162-171), falling back to 40 individual indexed reads only if the bulk decode fails (:211-244) — the point being to avoid a 40× timeout storm on the common path. - Decode.
AlarmNumberDecoder.DecodeBaseAlarmNumber(:101-112) strips the band offset to get the catalog number;DecodeState(:114-128) classifies the raw value as Active (1–999), a silenced fault still present (1000–1999) or a resolved, cleared fault (2000–2999). - Describe.
AlarmDescriptionBuilder.BuildDescriptionOrError(:49-63) looks the catalog number up in theAlarmDefinitionstable, substituting a computed tank number where the definition'sHasTankIndexsays to; an uncatalogued number gets a localizable "Alarm {N}" placeholder rather than a blank (:71-75). - Timestamp, where legacy PLC date parts are used.
AlarmLegacyDateTime.FromPlcParts(:27-65) reconstructs a fullDateTimefrom the PLC's month/day/hour/minute/second (it supplies no year, so the year is inferred from the PC clock with a rollover rule);AlarmLifecycleTimeParser.TryParseDisplayToUtcIso(:11-44) is the inverse, parsing a grid-displayed string back to UTC ISO for storage. - Persist and time-track.
AlarmAlarmNumberPlcService.RecordPlcAlarmEventsIfEnabled(:768-938) reconciles the whole snapshot into the lifecycle repository and inserts onePlcAlarmEventsrow per identity transition.PlcAlarmUiTimingStore.ApplySnapshot(:129-200) separately tracks each slot's raised/acknowledged/resolved times in memory — carrying a base alarm's original raise time across the PLC's own shift-insert queue moving it to a different slot — and rehydrates from persisted events at startup (HydrateFromEvents,:212-258) so a restart cannot fake-reset an alarm's displayed age. - Present.
AlarmSummaryComposer.Compose(:44-74) splits rows into shelved (suppressed) versus visible and sorts both by severity then recency for the ISA-101 Alarm Summary screen — display-only, and never a factor in detection, logging or escalation (:9-16).AlarmScreenLinkResolver.Resolve(:106-130) maps the catalog number to a deep link into the relevant equipment screen; a plant-wide alarm deliberately resolves toNonebecause no single screen owns it.PlaybookService.GetByAlarmNumber(:105-147) returns the first-responseMeaning/FirstCheck/WhoToCallentry, optionally overridden per alarm from the database. - Escalate. A newly-raised alarm fires
IAlarmEscalator.NotifyAsync(AlarmAlarmNumberPlcService.cs:1090-1125). The default isNoopAlarmEscalator, which only logs a "would notify" line — active untilAlarmEscalation.Modeis switched to"Smtp"or"Webhook". Independently of escalation, rule-driven alarm emails are gated per rule byAlarmEmailStormControl.Register(:55-88), which returnsSendSingle,SuppressedByCooldownorDigestso a flood collapses into one email rather than one per event; a severity-aware check againstSmtpAlarmEscalator.WouldSendstops the rule engine and the SMTP escalator from both emailing the same raised alarm. - Silence — the one write in this pipeline. Everything above is reads and local state. Silencing an alarm is an operator-initiated write, covered on the write path in chapter 04 §4.7, not repeated here.
The catalog, by role#
Acquisition and decoding.
| File | Responsibility |
|---|---|
AlarmAlarmNumberPlcService.cs |
Reads Alarm_Alarm_Number as INT[40] in one PLC read, no per-element sequential fallback on the common path |
AlarmNumberDecoder.cs |
Splits a raw value into base catalog number + band offset (Active/Silenced/Resolved) |
AlarmDescriptionBuilder.cs |
Looks the base number up in the AlarmDefinitions table; unknown numbers get a placeholder, never a blank |
AlarmLegacyDateTime.cs |
Reconstructs alarm/ack timestamps from the PLC's month/day/hour/minute/second fields (no year supplied) |
AlarmLifecycleTimeParser.cs |
Parses grid display strings back to UTC ISO for storage |
PlcAlarmUiTimingStore.cs |
Tracks raised/ack/silence times per slot; survives the PLC's own shift-insert queue and a Ring restart |
The silence write.
| File | Responsibility |
|---|---|
AlarmSilencePlcService.cs |
Writes the silence latch — PC_Write_Integer[30].3 by default, with a configured legacy level tag behind EnableCustomSilenceLatchTag. Its outcome type distinguishes a real write from a suppression. §4.7 |
Presentation and triage.
| File | Responsibility |
|---|---|
AlarmStatusDisplay.cs |
Operator-facing rendering of a lifecycle status pair |
AlarmSummaryComposer.cs |
Pure shelf-filter + priority sort for the ISA-101 summary — display-only |
AlarmScreenLinkResolver.cs |
Alarm → screen deep link; plant-wide alarms deliberately resolve to None |
PlaybookService.cs + AlarmPlaybookSeedService.cs |
Operator first-response playbooks, keyed by catalog number, DB-overridable |
AlarmLegendService.cs + AlarmLegendReportDocument.cs |
The bilingual wall chart from Resources/data/alarm-legend.csv |
AlarmHistoryReportDocument.cs |
The printable/exportable alarm history report |
Notification and escalation.
| File | Responsibility |
|---|---|
IAlarmNotifier / AlarmNotifier.cs |
In-cab sound + flash |
IAlarmEscalator |
The off-site escalation interface (NotifyAsync) |
NoopAlarmEscalator.cs |
The default. Logs a "would notify" line; no network call |
SmtpAlarmEscalator.cs |
Sends the alarm email; WouldSend(severity) is the single authority for whether it will actually send |
WebhookAlarmEscalator.cs + WebhookCardFormatter.cs |
Posts an alarm card to a configured webhook (Teams/Slack auto-detected); SSRF-checked via WebhookUrlValidator |
AlarmEmailService.cs + AlarmEmailComposer.cs + AlarmContextProvider.cs |
Composes the rule-driven alarm email; AlarmContextProvider stamps the running-batch context onto it |
AlarmEmailStormControl.cs |
Per-rule storm suppression — one instance per rule Id, defensively locked because the alarm hook fires from a background path |
Analytics.
| File | Responsibility |
|---|---|
AlarmAnalyticsCalculator.cs |
Pure, over a window of lifecycle rows |
AlarmAnalyticsQueryService.cs |
The thin read-only wrapper that supplies it |
7.3 Audit — Ring/Services/Audit/#
| File | Responsibility |
|---|---|
UiAuditJournal.cs |
The hash-chained, append-only operator trail plus its tail anchor and VerifyChain. Tamper-evident, not tamper-proof (unkeyed SHA-256, stated in its own doc). §5.8 |
UiActionAuditor.cs |
App-wide, observe-only capture of operator interactions into the journal |
UiActionRecord.cs |
The record shape and the UiActionKinds vocabulary |
UiAuditQueryService.cs |
Pure projection over the journal: parse lines, keep the readable ones, apply the operator's filters. No WPF, no clock, no I/O beyond the files it is handed — so the screen's filter semantics are provable in a unit test |
UiScreenResolver.cs |
Answers "which screen was the operator looking at?" — writes GetType().Name into the hashed payload, hence the rename freeze |
IncidentExportBuilder.cs |
Reads both JSONL journals back for an incident export |
7.4 Batch — Ring/Services/Batch/#
| File | Responsibility |
|---|---|
BatchLifecycleRecorder.cs |
The recorder driven by BatchStepsPoller edges; on batch end calls BatchRepository.CompleteWithUsage so completion and usage commit in one transaction |
BatchProgressMath.cs |
Pure progress math over the step snapshot |
BatchStampsBackfillService.cs |
Startup catch-up for controller verdicts missed while Ring was down — bulk-only by design |
BatchStampsMatcher.cs |
Decides which controller stamps slot belongs to a given Ring batch |
BatchHistoryBackfillService.cs |
Preview-first importer for the legacy six-month batch history |
BadBatchEarlyWarningService.cs |
Pure warning engine for live bad-batch indicators. "It never owns plant assumptions: every warning requires caller-supplied baselines and thresholds" |
BatchCompletionCelebrationService.cs + BatchCompletionScorecardComposer.cs + ICompletionChimePlayer / CompletionChimePlayer.cs |
The "batch klaar" moment: a chime deliberately different from the alarm sound, plus a composed toast line |
Batch policy types (eligibility, preconditions, split mapping) live with the
screens in Ring/Views/BatchStart/ — see
chapter 06 §6.3.
7.5 Display — Ring/Services/Display/#
| File | Responsibility |
|---|---|
LocalizationService.cs |
Thirteen-locale dictionary swapping; never touches CurrentCulture/CurrentUICulture. §6.5 |
DisplayUnitService.cs |
Owns the station's chosen display units. Display only |
UnitSystem.cs / PerQuantityUnits.cs / UnitDisplay.cs |
The unit family, the per-quantity volume unit, and the unit-aware layer around the formatter — all explicitly display only |
PlcDisplayFormatter.cs |
Single source of truth for PLC-value display formatting |
LocalizedInput.cs / OperatorNumberInput.cs |
Locale-safe and locale-tolerant parsing of operator-typed numbers. The distinction matters: operator input accepts both conventions; wire values never do (see PlcTagWriter.TryParseRealInvariant) |
DataFreshnessClassifier.cs |
How old is this PLC-sourced value, and may it be shown? Feeds the freshness converters |
Ring/Services/PlantNativeUnits.cs (top level) is the single declaration of this
site's native recipe/batch weight unit and the display seam every consumer
goes through — read it together with
docs/production-readiness/RECIPE_UNITS_VERDICT_2026-07-31.md,
which settled the question: pounds.
7.6 Reports — Ring/Services/Reports/#
The largest non-PLC subfolder. Three layers, and it is worth keeping them separate when you add one.
Pure calculators (no I/O, no PLC, no UI, fully testable):
| File | Responsibility |
|---|---|
BatchCostMath.cs |
Per-batch cost math — ingredient cost and leak detection |
BatchCompletionEtaCalculator.cs |
Batch-completion ETA math |
DowntimeOeeCalculator.cs |
Downtime/OEE rollup over operator-captured downtime events plus caller-supplied economics |
DrySolidsMath.cs |
Converts a delivered batch volume (US gal) to dry-starch pounds; feeds glue-usage, dry-lbs/sqft and giveaway-$ analytics |
DryLbsPerSqFtCalculator.cs |
Dry-lbs-per-square-foot ratio; an explicit no-data outcome distinct from zero when solids or completed batches are missing |
GiveawayCostCalculator.cs |
Prices giveaway volume; returns NotConfigured rather than $0 when no marginal starch rate is set |
YieldScorecardCalculator.cs |
End-of-batch yield and quality scorecard math |
YieldScorecardAggregator.cs |
Windowed yield/giveaway aggregation — distribution counts and a signed mean giveaway |
ReportScheduleCalculator.cs |
Pure cadence due-math for scheduled report emails (next-due, monthly rules) |
ReportDateRange.cs |
Canonical half-open [StartInclusive, EndExclusive) date range shared by the report screens |
ReportPageNumbering.cs |
The page-count stamp in a report's page header |
ProcessHistoryChartProjection.cs |
Turns queried ProcessHistorySample rows into summary statistics and a gap-aware chart series for the Tank History report |
ShiftConsumptionBreakdown.cs |
Per-operation ingredient breakdown for one Shift Consumption occurrence |
InventoryInitiatorMapper.cs |
Decodes Inventory_Stamps[0] ("Update Initiator") to display text, mirroring the legacy switch |
Read-only query services (assemble a payload from persisted data only):
| File | Responsibility |
|---|---|
CostsQueryService.cs |
Assembles the Costs report / dashboard "Spend" payload from Batch + IngredientUsage + FormulaSteps + effective-dated IngredientCost |
GlueUsageQueryService.cs |
Sums delivered glue volume and derived dry-starch pounds over a date range |
YieldGiveawayQueryService.cs |
Yield/Giveaway Tracker query, using only persisted RequestLevel/ActualVolume batch fields |
ShiftConsumptionQueryService.cs |
Joins finalized batch/ingredient-usage rows to operator-defined shift windows |
ShiftHandoverQueryService.cs |
"What happened on my shift" aggregation for one shift occurrence |
DryLbsPerSqFtQueryService.cs |
The join that pairs each shift occurrence with DryLbsPerSqFtCalculator's pure ratio |
BatchEtaQueryService.cs |
Thin read-only layer feeding BatchCompletionEtaCalculator |
AlarmBatchCorrelationService.cs |
Correlates alarms with the batch(es) running when each alarm went active |
DowntimeDollarCounterService.cs |
Aggregates alarm lifecycle durations by alarm number/description and applies caller-supplied economics |
FormulaCycleBaselineService.cs |
Per-formula, per-step median cycle-time baselines from observed completed batch-step timings |
Document builders (FlowDocument for on-screen preview and PrintDialog
output — each pairs with the report screen of the same name in
chapter 06 §6.3):
BatchReportDocument, BatchHistoryReportDocument,
BatchHistoryUsageReportDocument, UsageReportDocument, CostsReportDocument,
FormulaReportDocument, GlueUsageReportDocument,
InventorySnapshotReportDocument, ShiftHandoverReportDocument,
YieldGiveawayReportDocument, DataEntryReportDocument. PdfExportService
renders any of them to PDF.
Scheduling: ReportSchedulerService (1-minute timer over subscriptions) +
ScheduledReportRenderer.
And one small type that carries a contract:
ReportDataUnavailableNotice.cs — the single wording used everywhere a report
surface has to say "this is not a zero, the data could not be read". It is the
user-facing half of RepositoryReadScope
(§5.2).
7.7 Email and notifications#
Ring/Services/Email/ — IEmailDeliveryService / EmailDeliveryService.cs
is the shared SMTP transport for the whole app: Enqueue drops the message on
an in-memory BlockingCollection and returns immediately, so a dead or slow
SMTP server can never block the UI thread; a background worker sends with a
3-attempt retry (1s/2s/4s backoff).
Durable-by-construction queue. Enqueue writes a Status='Pending'
EmailLog row (IEmailLogRepository) synchronously, before the message
ever touches the in-memory queue. The worker updates that same row to Sent
or Failed when the send finishes, with the full exception text on failure.
This means a dead SMTP server degrades to visible log rows, never a frozen
HMI or a silently vanished message.
Crash / power-cut recovery — and why it never auto-resends. A process
death after the pending row is written but before the worker finishes leaves
that row stuck at Status='Pending'. On the next startup,
RecoverPendingFromPriorRun sweeps every such row and marks it Failed with
reason "recovered after restart…" — it is deliberately never resent,
because this service cannot tell whether the original attempt actually left
the wire, and a duplicate alarm/report email is judged a worse failure mode
than a documented gap the operator can act on. Documented residual: the
one window this doesn't cover is a crash during the few seconds
DatabaseWriterQuiesce/SuspendDelivery holds the database file for a
restore or factory reset — a message queued in that narrow window has no
durable row to recover, so it degrades to in-memory-only for that one
message. Outside a crash, SuspendDelivery/ResumeDelivery itself does not
drop mail: queued messages stay in the in-memory queue and drain on resume,
and their log writes are buffered and replayed, not lost.
EmailAddressValidator.cs is the single "is this a usable address" check for
the UI; EmailSeedService.cs is the idempotent, self-trapping seeder for
out-of-the-box defaults; BatchCompletionEmailService.cs composes the
completion mail.
Ring/Services/Notifications/ — ToastService.cs (app-wide non-blocking
toasts; singleton because a single ToastHost overlay is hosted once),
ToastItem.cs, ToastSeverity.cs, ToastBrushPalette.cs (severity → semantic
design-system accent brush).
7.8 Predictive — Ring/Services/Predictive/#
All five are pure or read-only.
| File | Responsibility |
|---|---|
FeedRateMonitorService.cs |
Pure, fully-testable predictive-failure math. No DB calls |
EquipmentHealthService.cs |
Evaluates drift rows plus the two facts that qualify them |
TankTemperatureStabilityCalculator.cs |
One tank's cook-temperature stability over a window: mean and variability |
GlueLineRunDryCalculator.cs |
Run-dry projection for the mix tank feeding the corrugating glue line |
IPredictiveEventSource.cs |
The hand-off point from the predictive monitor to the alarm-email rule engine |
Related top-level services in the same spirit:
GoldenBatchDeviationCalculator.cs (temperature only — not viscosity, and
the file says so), IngredientStockForecastService.cs (pure stock-runway math),
MaintenanceDueCalculator.cs / MaintenanceReminderEvaluator.cs (which return
Unknown with a null percent and an em-dash rather than inventing a number when
there is no usable interval), ScaleCalibrationOverdueEvaluator.cs,
TankCipDueCalculator.cs, ReceivingVarianceMath.cs (whose honesty rule is
that with no configured tolerance an event is never flagged short).
7.9 Roster and group setup#
Ring/Services/Roster/ — TankRoster.cs (the ordered site-configurable slot
list, Default() and IndianaPreset()), TankRosterSlot.cs, TankRole.cs,
TankRosterState.cs (the process-wide holder over
%LocalAppData%\Ring\tank_roster.json, with Changed, RestartRequired,
LoadFailed and CaptureSessionBaseline), StorageTankScope.cs (the storage
tank numbers an analytics surface must cover, taken from the roster).
Everything downstream of the roster is safety-relevant: see chapter 01 §1.2 and chapter 04 §4.3.
Ring/Services/GroupSetup/ — GroupHardwareSetupState.cs, the legacy
Setup → Groups hardware options (tank count, display style, screen tank mapping,
cooling/discharge) over %LocalAppData%\Ring\group_hardware_setup.json.
7.10 Operators, configuration, documentation, export#
| Directory / file | Responsibility |
|---|---|
Operators/IOperatorSessionService.cs, OperatorSessionService.cs |
"Who is operating right now" — identity only, not authentication; best-effort persisted last choice in %LocalAppData%\Ring\operator-session.json |
Configuration/IConfigChangeNotifier.cs, ConfigChangeNotifier.cs |
Notifies consumer screens when a supervisor edits a category of configuration |
Documentation/ManualPageLauncher.cs |
Opens the shipped all-in-one RS-3000 operators manual at a specific page |
Documentation/ScreenHelpService.cs |
One per-screen deep link into the manuals library |
Documentation/MarkdownToHtml.cs |
Minimal dependency-free Markdown → HTML for the operator-facing docs |
Export/DatabaseExportSchema.cs |
The whitelist of exportable tables and columns |
Export/DatabaseExportService.cs |
Streams a whitelisted table to CSV |
Authentication proper lives outside Services/, in
Ring/Infrastructure/Security/ — AdminCredentialGate.cs,
CredentialRotationService.cs (which writes appsettings.local.json),
LoginAttemptTracker.cs.
Two more small, easily-missed files sit beside the settings writers in
Ring/Infrastructure/Configuration/, not in Security/:
SmtpPasswordProtector.cs (DPAPI-protects the SMTP password stored in
configuration) and WebhookUrlValidator.cs (the SSRF guard consulted by
WebhookAlarmEscalator before it POSTs to an operator-configured URL).
7.11 Top-level services — Ring/Services/*.cs#
Logging, crash and health#
| File | Responsibility |
|---|---|
ILogger.cs / Logger.cs |
The logging abstraction and its file/ring-buffer implementation (%ProgramData%\Ring\logs\application.log) |
CrashLogger.cs |
Global last-chance crash logger, wiring three escape paths to one file-per-crash under <exe dir>\logs\crashes\ |
UiHangDetector.cs |
Classifies the UI thread's state from a single sentinel tick (paired with MainWindow's 5 s ping/pong) |
UnbiasedClock.cs |
A monotonic millisecond clock that does not advance while the system is asleep |
DiagnosticBundleService.cs |
Builds a one-click support zip |
Database lifetime#
DatabaseBackupService.cs, DatabaseHealthService.cs,
DatabaseWriterQuiesce.cs, DatabaseMaintenanceLatch.cs — all covered in
chapter 05.
Startup and process#
| File | Responsibility |
|---|---|
StartupDegradedState.cs |
Records the session-degrading condition (failed DB init) and forces PlcWriteGuard read-only for the session |
SecondLaunchAdvice.cs |
Decides what to tell a second launch. Two independent brakes: an explicit boot marker (authoritative — the other process says it is still starting) and a sustained re-probe for the post-boot case |
NameTablesStartupHydrator.cs |
Hydrates the three name tables at startup |
NavHistoryDisposer.cs |
Pure decision logic for back-navigation history: when a retained screen may be disposed |
Ambient name state#
MakeReadyTankSupervisorNamesState.cs and
StorageTankGroupSupervisorNamesState.cs — in-memory supervisor-entered names
(and, for the former, derived inventory line titles), updated on Apply from the
matching Setup screen, with a NamesChanged event the UI subscribes to. Both
are examples of the ambient-static pattern that is being retired
(chapter 02 §2.6).
Formula and recipe#
| File | Responsibility |
|---|---|
FormulaNameResolver.cs |
App-wide display-name lookups for formula numbers and storage tank numbers |
FormulaSolidsMath.cs |
Dry-solids percentage from recipe rows. Display/report only — never touches a PLC. Its two oracles (the plant's own decoded recipe CSV and the legacy step-seed variant) are pinned forever by golden parity tests |
FormulaIngredientAmountLimits.cs |
Min/max/default amount (lb) per formula operation, aligned with the legacy panel limits |
PlantNativeUnits.cs |
The single declaration of the site's native weight unit |
Maintenance and calibration#
MaintenanceTaskSeeder.cs (seeds sensible defaults on first run),
MaintenancePmSeedData.cs (the one replaceable file holding the provisional
PM register), MaintenancePlantPmSeedData.cs (the real plant PM register,
seeded from the site's own PM forms), MaintenanceDueCalculator.cs,
MaintenanceReminderEvaluator.cs, ScaleCalibrationOverdueEvaluator.cs,
TankCipDueCalculator.cs.
Analytics and history#
ProcessHistorianService.cs, RuntimeAccumulatorService.cs,
TankLevelTrendService.cs, ShiftDryLbsService.cs (one shift's derived dry-lbs
total together with the population it was summed over — the pairing is the
point), IngredientStockForecastService.cs,
GoldenBatchDeviationCalculator.cs, ReceivingVarianceMath.cs,
ReceivingVerificationService.cs (manual-first bulk-receiving verification).
Commissioning and legacy#
PlantProfileService.cs (builds a versioned PlantProfile JSON commissioning
export), LegacyRs360ImportService.cs (imports legacy tank names, make-ready
ingredient names and custom data), DashboardService.cs (coordinates dashboard
data from the existing ViewModels — and is on the write-surface register as
Infrastructure because it names PlcWriteGuard).
7.12 A note on where not to put things#
Two conventions this catalog makes visible and that are worth preserving:
- Pure math lives in its own type, WPF-free and I/O-free. Almost every
calculator above says so in its own doc comment, and the reason is stated in
CONTRIBUTING.md: tests prefer WPF-free logic objects over constructingUserControls. A calculation embedded in a code-behind is a calculation with no test. - "Honesty" types are a pattern here, not an accident.
ReportDataUnavailableNotice,RepositoryReadScope,MaintenanceStatus.Unknown,GiveawayCostState.NotConfigured,TankCapability.Unknown,PlcWriteStatus.WrittenUnverified,HasAnyDataon the snapshots — each exists so a missing input renders as missing, not as a confident zero. Follow the pattern rather than defaulting.
7.13 Where the evidence lives#
Every subsystem above is described mechanically somewhere in this book, but nothing collects "here is the file you open" in one place. This table does.
| Artefact | Path | Written by | Rotation |
|---|---|---|---|
| Application log | %ProgramData%\Ring\logs\application.log |
Ring/Services/Logger.cs |
Size-rotated, MaxLogFileBytes = 10 MB (:35), MaxBackupCount = 5 (:36) → .log.1...log.5 |
| PLC comm log (in-memory) | n/a — a 500-event ring buffer | PlcCommunicationLogService.cs (MaxBufferedEvents = 500, :32) |
Overwrites oldest on overflow |
| PLC comm log (optional per-day CSV) | %ProgramData%\Ring\logs\plc-comm-YYYY-MM-DD.csv (PlcCommunicationLogService.cs:74) — the same folder as application.log, not the install directory |
Same service, gated by its own DiskLoggingEnabled runtime property (:62) — a UI toggle in DisplayCommunicationForm / the NavBar comm-log button, not an AppSettings key |
RetentionDays = 30 (:68), also a runtime property |
| PLC comm log (SQL table) | PlcCommunicationLog table inside the database (schema v32), queryable via Setup → Database Export |
PlcCommEventLogService.cs — see ch. 03 §3.7, not the CSV row above |
PlcSettings.CommLogRetentionDays, default 90 d, pruned on startup + daily |
| Write evidence journal | %ProgramData%\Ringwood\Ring\plc-write-evidence.jsonl |
PlcWriteEvidenceJournal.cs:75-79 |
Size-rotated at 10 MB, 5 retained archives (ch. 04 §4.9) |
| Operator audit journal | %ProgramData%\Ringwood\Ring\ui-audit\ui-audit.jsonl |
UiAuditJournal.cs:1040-1041 |
Append-only, hash-chained (ch. 05 §5.8) |
| Production gate summary | artifacts/production-gate/production-gate-summary.json |
scripts/Invoke-ProductionGate.ps1 (:190) |
Generated, not committed — regenerated every gate run |
| The database | <exe dir>\RingwoodDatabase.db (+ -wal/-shm) |
Repositories, via RingwoodDbAccess |
See ch. 05 §5.1 |
Note that the two %ProgramData% roots are not the same directory —
Ring\logs\ for the application log versus Ringwood\Ring\ for both JSONL
journals. This is a naming accident worth knowing before you go looking for
one and find the other empty.
A gate-blocked write, as it appears in the log. PlcWriteGuard.LogBlocked
(PlcWriteGuard.cs:97) emits one line per suppressed write:
[WARN] [ReadOnly] PlcTagWriter.Write: PLC write to 'Tanks[3].Preset_Temp'=75 SUPPRESSED (ReadOnlyMode ON — nothing sent to the controller).
[WARN] [ReadOnly] TVCControlPlcService.SetAgitatorModeAsync: PLC write to 'Tanks[2].Agitator_mode'=Auto SUPPRESSED (ReadOnlyMode ON — nothing sent to the controller).
The source half of the message (PlcTagWriter.Write,
TVCControlPlcService.SetAgitatorModeAsync) is whatever string the caller
passed — it names the method, not a fixed vocabulary, so grepping for
SUPPRESSED is more reliable than grepping for one source name.
A healthy boot, roughly in order. Reconstructed from the call order in
App.xaml.cs → ConfigurationService.cs → DatabaseInitializer.cs →
DatabaseHealthService.cs → MainWindow.xaml.cs; treat this as
high-confidence, not an exhaustively traced guarantee — a real boot has more
conditional branches than this list:
"Configuration loaded successfully from: {path}"(ConfigurationService.cs)"[DatabaseInitializer] Boot sample: Path={path}, FileExistedAtBoot={bool}""[DatabaseInitializer] Starting. Path={path}, FileExistedAtStartup={bool}""Database initialized (first run) at {path}"or"Database OK at {path}""[DatabaseHealthService] PRAGMA integrity_check = ok ({path})""[DatabaseHealthService] Schema check OK ({path})""[DatabaseHealthService] Online backup OK → {backupPath} ({bytes} bytes)"- Background services (
TankNamePlcNameHydrator,PcWriteInteger30StrandedBitMonitor,ReportSchedulerService,ProcessHistorianService,RuntimeAccumulatorService) are silent on success — they only log on failure, so their absence from the log is itself the healthy signal. MainWindow's background reachability probe:"[PlcMonitoring] TryStartPlcMonitoringIfReachable: resolved PLC IP = {ip}", then"[PlcMonitoring] PLC reachable at {ip}:44818 on attempt {n}/3 — starting monitoring.", then theStartPlcMonitoringsequence ending in"[PlcMonitoring] StartPlcMonitoring: started OK (background poll 500ms, UI 1000ms)."(Demo mode replaces the probe with"[PlcMonitoring] Demo mode enabled — skipping TCP reachability probe, starting synthetic pollers directly.")
A caution the log itself creates. At the shipped Debug log level the
application log has been observed to grow at roughly 32 MB/h; with the 10 MB ×
5 rotation ring that is only about 1.6 hours of retained history
(docs/production-readiness/WRITE_ENABLE_READINESS_2026-07-26.md:441-444,
which recommends shipping Information for production or enlarging the ring).
If you are trying to find what happened more than two hours ago on a
Debug-level station, the log may simply no longer have it.
Next: 08 — Testing and Quality.
Verified against#
Every claim in this chapter was read out of these files on 2026-09-01:
Every .cs file under Ring/Services/ and its fifteen subdirectories
(Alarms, Audit, Batch, Configuration, Display, Documentation,
Email, Export, GroupSetup, Notifications, Operators, PLC,
Predictive, Reports, Roster) — summaries taken from each type's own doc
comment · Ring/Infrastructure/Security/ (listing) ·
Ring/Infrastructure/Configuration/AlarmEscalationConfigWriter.cs,
AnalyticsSettingsConfigWriter.cs, CostSettingsConfigWriter.cs,
AppSettingsSectionWriter.cs, SmtpPasswordProtector.cs,
WebhookUrlValidator.cs ·
Ring/Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs ·
Ring/Services/PLC/PlcWriteGuard.cs, PlcCommunicationLogService.cs,
PlcCommEventLogService.cs, PlcCommEventType.cs ·
Ring/Services/Email/EmailDeliveryService.cs (RecoverPendingFromPriorRun,
RecoveredAfterRestartError, SuspendDelivery/ResumeDelivery,
CompletePendingSafe) ·
Ring/Database/Interfaces/IEmailLogRepository.cs,
Ring/Database/Repositories/EmailLogRepository.cs ·
Ring/Database/Repositories/PlcCommunicationLogRepository.cs ·
Ring/Services/PLC/UdtTankReader.cs ·
scripts/Invoke-ProductionGate.ps1 ·
docs/production-readiness/WRITE_ENABLE_READINESS_2026-07-26.md ·
CONTRIBUTING.md