10 — Extending Ring#
Who this is for. Anyone adding something. Each recipe is the shortest change that is complete — the file list where a missing step fails silently, or fails on the plant floor rather than on your machine.
What you'll learn. How to add a screen, a service, a localized string, a config key, a database table — and, most importantly, the only sanctioned way to add a PLC writer.
Before every recipe: this is a non-SDK project. Every new .cs, .xaml and
image must be hand-registered in Ring/Ring.csproj or
Ring.Tests/Ring.Tests.csproj, or ProjectMembershipGuardTests fails
(chapter 08 §8.2).
That step is assumed in every list below, but it is the one people forget.
10.1 Adding a screen#
Choose the tier. Live PLC data → a ViewModel with
INotifyPropertyChangedand real bindings (tier 1). A report or setup screen → match the surrounding code-behind style (tier 2). Do not invent a third pattern (chapter 06 §6.1).Create the XAML + code-behind under the right area of
Ring/Views/. Register both inRing/Ring.csproj:<Page Include="Views\Setup\MyNewScreen.xaml"> <Generator>MSBuild:Compile</Generator><SubType>Designer</SubType> </Page> <Compile Include="Views\Setup\MyNewScreen.xaml.cs"> <DependentUpon>MyNewScreen.xaml</DependentUpon> </Compile>Follow the canonical
Loadedshape for tier 2: constructor callsInitializeComponent()and nothing else;Loadedresolves dependencies once viaServiceLocatorinside atry/catch, holds the references, and populates. Do not sprinkleServiceLocatorcalls through the file.If it is a hosted screen, do not give it a title row.
ReportHostViewpaints the header. Twenty-three files carry a comment recording that their banner was deleted to fix a double header, and nothing enforces it (chapter 06 §6.2).Wire navigation through
NavBar, usingReportHostView.ShowHostedLocalized(context, titleKey, subtitleKey, content)for a localized header. If the screen is cached, useNavBar.GetOrCreateView<T>()and remember the instance is retained — reset per-session state inLoaded.FitHost? Only for structural screens (cards, mimics, forms). Never wrap a virtualizedDataGridor a long list — measuring at unbounded height realizes every row. Data-table screens keep inner scroll.Strings, sizes, touch. Every user-visible string is a resource key in all thirteen dictionaries (§10.3). Use the type-scale styles and tokens from
Ring/Resources/Tokens.xaml, and respectMinTouchTarget = 44.Watch the rename freeze.
UiScreenResolverwritesGetType().Nameinto the hashed UI-audit payload, andMainWindow.NavigateBack()branches on theRing.Views.BatchStartnamespace string (ARCHITECTURE.md).If the screen dispatches a PLC write, it needs a write-surface register row — go to §10.6 instead of stopping here.
10.2 Adding a service#
Put the logic in a WPF-free, I/O-free type if you possibly can. That is the house pattern and the reason so much of
Ring/Services/is pure calculators and pure decision functions. A calculation embedded in a code-behind is a calculation with no test (chapter 07 §7.12).Design for the missing input. Return
Unknown/NotConfigured/Unavailablerather than a confident zero.MaintenanceStatus.Unknown,GiveawayCostState.NotConfigured,ReportDataUnavailableNotice,TankCapability.Unknown,HasAnyDataon the snapshots — all of these exist for that reason. Follow them.Register it in
AddRingServices(Ring/Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs:21), as a singleton unless you have a reason. Repositories are registered there with a factory that pulls the connection string fromConfigurationService; copy the surrounding shape.Inject through the constructor wherever there is one. From XAML-constructed code-behind, resolve once in
LoadedviaServiceLocator.Do not add a new static
*Stateclass or a newInstancesingleton. That is the pattern being retired, and new instances make the retirement harder (chapter 02 §2.6).If it owns a timer, follow the poller conventions: a
System.Threading.Timer(not aDispatcherTimer) off the UI thread, anInterlockedbusy flag so a slow cycle skips rather than stacks, and an explicitStart/Stop.If it writes SQLite on a timer, add it to
DatabaseWriterQuiesce. That type exists because a duplicated, incomplete list of writers left four services running through a database restore (chapter 05 §5.5).If it reads the PLC on a timer, know that you are adding to the set of timers outside the polling coordinator — and that the controller caps EIP sessions per source IP. Prefer consuming an existing snapshot holder;
ProcessHistorianServiceis the model (60 s, snapshot-only, heartbeat-gated).Register the
.csin the csproj; add tests.
10.3 Adding a localized string#
Add the key to all thirteen dictionaries under
Ring/Resources/Strings/, in one commit.en,nl,de,fr,es,it,pt,tr,pl,ko,ja,zh-Hans,zh-Hant. A per-locale parity test fails otherwise, and it checks placeholder parity ({0}/{1}) as well as key parity.Never create a duplicate
x:Key. WPF throws while loading the dictionary — before the first frame — so the app dies at startup with no window and a bare crash log. This is the single most expensive mistake in this recipe.Do not change the merge order in
App.xaml. English is merged last as the permanent base fallback.Save UTF-8 without BOM. If the mojibake test fires, repair the encoding (read the bytes back through the cp1252 round-trip) — do not retype the character, which leaves the underlying double-encoding in place.
For a string composed in C#, remember XAML cannot repaint it: subscribe to
LocalizationService.LanguageChanged, or use the houseLocalizedOrFallback(key, englishFallback)pattern (ConfigurationValidator.cs:517-524) which readsApplication.Current?.TryFindResource(key)and falls back to the English literal so a test host with noApplicationstill works.For an operator-facing refusal message on a safety path, follow the convention used by the commissioning holds: expose a
…ResourceKeyand a…Fallbackconstant side by side, and resolve the two together so they can never name different causes (RosterWriteIndex.StorageWriteRefusal,RosterWriteIndex.cs:298-314, is the worked example — and the comment there records the bug that made it necessary).
Alarm operator text is the known exception: it lives in SQLite in English only, and localizing it is a schema change, not a dictionary edit.
10.4 Adding a config key#
Add the property to the right settings class in
Ring/Infrastructure/Configuration/AppSettings.cs, with a C# default that is the safe value. For anything touching the PLC that means fail-closed. Write a real doc comment: what it gates, why the default is what it is, and what must be true before someone changes it. TheEnable*comments inPlcSettingsare the standard to match.Decide whether it ships in
Ring/Config/appsettings.json. Leaving it out is a deliberate choice that makes the C# default the contract — and that is what the shipped file does for seven of the eight write-gate flags (chapter 04 §4.2). If you do ship it, remember theCopyAppSettingsToOutputtarget overwrites the output copy after every build.Add validation in
ConfigurationValidatorif a wrong value is dangerous or merely confusing. Errors are fatal (IsFatal => Errors.Count > 0,:23) and block boot forever; warnings go to the 60-second countdown dialog. Choose deliberately — a warning that should have been an error boots a plant with a bad setting at 3 a.m.Beware the array index-merge.
Microsoft.Extensions.Configurationmerges JSON arrays by index. If your key is an array and an overlay is expected to replace it, add it toConfigurationService.ApplyLocalArrayOverridesalongsideAlarmEscalation.Smtp.ToandAlarmEscalation.Webhook.AllowedHosts.If it is a write gate, pin it. Add an assertion to
Hardware_signoff_write_capabilities_remain_fail_closed_in_the_shipped_settings(Ring.Tests/WriteSurfaceRegisterTests.cs:1053) — both the compiled default and an absent-or-false check against both shipped JSON files.Document it in
docs/reference/plc/APPSETTINGS_REFERENCE.md— type, default, plant-specific or not, and what breaks if it is wrong. That file is the authority for "what does this key do"; this book is not.Config is restart-scoped.
reloadOnChangeisfalseon both files, and nothing watches them. The exception, worth knowing rather than copying, isPlcConnectionConfig, which re-reads the file per call (chapter 02 §2.3).
10.5 Adding a database table — the complete checklist#
This is the recipe with the most places a partial change silently succeeds, so
it is given in full here rather than by reference. Every step was verified
against Ring/Database/DatabaseInitializer.cs and the tests named.
1. Write the interface and the repository.
Ring/Database/Interfaces/IXRepository.cs and
Ring/Database/Repositories/XRepository.cs. Copy the shape of an existing pair:
no base class, a connection opened per call, and
RingwoodDbAccess.ApplyConcurrencyPragmas(connection) immediately after
Open().
2. Expose the schema as a static, and have the instance use it.
public static void ApplySchema(SQLiteConnection connection) { /* CREATE TABLE IF NOT EXISTS … */ }
public void EnsureTable() => /* … */ ApplySchema(connection);
That static is the single source of DDL truth, shared between first-run creation and migration. If the two ever diverge, a fresh database and an upgraded one end up with different schemas.
3. Append a MigrationStep at the END of GetMigrationSteps()
(DatabaseInitializer.cs:361). The shape is
new MigrationStep(version, "description", conn => XRepository.ApplySchema(conn))
(:331-340). Steps run inside a SAVEPOINT and the version is stamped
atomically, so the step body must be idempotent —
CREATE TABLE IF NOT EXISTS, guarded ALTER.
4. Bump CurrentSchemaVersion (DatabaseInitializer.cs:743) to your new
step's version.
The trap, and it is worth reading twice. If you author the step as
new MigrationStep(CurrentSchemaVersion, …)and forget to bump the constant, you get two steps with the same version. Every test still passes — a fresh database runs both steps in order — but a plant database that is already at that version silently skips your migration, because migrations only apply wherestep.Version > user_version. Check the number by eye; nothing catches this one.
Three tests do catch a forgotten bump in the ordinary case:
DatabaseSchemaMigrationTests.CurrentSchemaVersion_Matches_HighestMigrationStep
(:47), DatabaseExpectedTablesDriftTests.CurrentSchemaVersion_MatchesInitializerLatest
(:120), and DatabaseSchemaMigrationTests.Wave6Migrations_CreateBothTables_AndStampCurrent,
which asserts the version as a literal (DatabaseSchemaMigrationTests.cs:139-140)
— so that literal has to move with the constant. Expect to edit it.
5. Add the table name to ExpectedTables in
Ring/Services/DatabaseBackupService.cs:43. This is the restore contract: a
backup stamped at CurrentSchemaVersion or newer must contain the full table
set before it is allowed to overwrite a live database (:496). Miss this and a
valid backup is rejected at restore time — on a plant, under pressure.
Ring.Tests/DatabaseExpectedTablesDriftTests.ExpectedTablesExactlyMatchesWhatInitializeActuallyCreates
(:93) runs the real initializer against a temp database, reads
sqlite_master, and asserts a symmetric set-difference — so it fails in
both directions if this step and step 3 disagree. Do not "fix" a red run by
editing only one side.
You do not normally add to CoreTables (DatabaseBackupService.cs:144),
which is the smaller must-have set applied to older backups.
6. Mirror the name in the AllTables literal in
Ring.Tests/DatabaseBackupServiceTests.cs:38, which builds the full fixture
database.
7. Register the repository in AddRingServices
(Ring/Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs),
following the surrounding factory shape that pulls the connection string from
ConfigurationService.
8. Register both new .cs files in Ring/Ring.csproj, or
ProjectMembershipGuardTests fails — and until it does, the compiler never sees
them.
9. Consider the failure semantics. If the repository returns lists, its catch
blocks should call RepositoryReadScope.ReportFailure so a failed read can be
told apart from "no rows"
(chapter 05 §5.2).
A report that renders a failed read as a clean zero is a defect this codebase has
already paid for once.
10. If a background timer will write this table, add it to
DatabaseWriterQuiesce so a database restore or factory reset cannot race a
tick into the file being replaced
(chapter 05 §5.5).
10.6 Adding a PLC writer — the only sanctioned route#
Stop here if you have not read chapter 04. This recipe is a summary of that chapter, not a substitute for it. From the 2026-09-08 cutover, a writer added wrongly moves a valve.
Step 0 — establish that the write is allowed at all#
Before any code: is this tag, on this tank, with this value, something a controls
engineer has signed for? The record is
docs/production-readiness/CONTROLS_SIGNOFF_RECORD.md;
open questions are in
docs/production-readiness/CONTROLS_QUESTIONS_2026-07-28.md
and its 2026-08-26 successor. If the answer is "not yet", the correct outcome is
a fail-closed authorization class like
TvcCoolingWriteAuthorization — shipped inert, with the screen rendering an
honest explanation — not a writer that works and a flag you plan to add later.
Also verify the tag itself against
docs/PLC_FACTS.md before you trust any string:
- UDT members are read/written by byte offset, and bit-packed SINT members
are exposed under generated
ZZZZZZZZZZ…alias names. Grep the L5KDATATYPEblock for the verbatim name. - Per-tank capability is real:
enabl_agt/enabl_tvcare true for tanks 1, 2, 8 and 9 only, so a tag can exist on both controllers and be inert on one. Tank_IO[N].O_Agitatis not a command bit on the doser tanks —ST_ROUTINE UD_Ctrloverwrites it from a remote-I/O status word within about four scans.- Bench evidence does not automatically transfer to the plant.
Step 1 — route it through the gate stack#
Prefer funnelling through PlcTagWriter, which gives you ReadOnly, the live demo
re-check, the endpoint gate and the heartbeat gate for free
(Ring/Services/PLC/PlcTagWriter.cs:135-220). Add your own outer heartbeat
check at the service so a pulse cannot start on a degraded link.
If you must build a raw libplctag Tag, the prologue shape is
BatchStartTankPlcWriter.cs:74-81:
if (PlcWriteGuard.IsReadOnly)
{
PlcWriteGuard.LogBlocked(op, tagName, value, logger);
return /* the honest outcome for your rail */;
}
…and you must then add the demo, endpoint and heartbeat checks yourself, in that
file, before the Tag is constructed.
Choose the return shape deliberately. A suppressed write is not a successful
one. Return false, or a typed outcome, unless you have the same reason the
"fake success" paths have — that the operator flow must continue and the operator
is told separately that nothing was sent
(chapter 04 §4.1).
If it is a momentary bit: assert with Write, clear with
MomentaryPulse.ClearWithRetry(() => writer.WriteClear("False"), …), and add
your file to the approved WriteClear call-site list in
Ring.Tests/MomentaryPulseClearGateTests.cs:757-765 — deliberately, never to
make a build green. If the bit shares PC_Write_Integer[30], take the shared
word gate via PcWriteInteger30BatchControlPlcService.RunUnderWord30Gate.
If it is a level-valued setpoint from a screen: dispatch through
PlcSetpointWriteQueue.Instance.Submit(...) with a key built by
PlcWriteTagKeys, and seed/guard the control with SetpointEchoGuard
(Reset() in Loaded, then re-seed). Never put a momentary/pulse bit on the
queue — latest-value-wins would delete the clear half.
If it targets a Tanks[N] member on a storage tank: resolve the index
through RosterWriteIndex, below any test seam, and refuse on NoWrite and on
both sticky locks. Never compute uiSlot + 1.
If it targets a per-tank member at all: consult TankInstalledWriteGate,
which fails closed on Unknown.
Step 2 — add a per-feature Enable* flag#
New write families ship off. Add a bool to PlcSettings defaulting
false, with a doc comment stating what evidence would justify turning it on
(§10.4 step 1), and leave it out of the shipped JSON so the C# default is the
contract.
Step 3 — add the write-surface register row#
In Ring.Tests/WriteSurfaceRegisterTests.cs:
{ "Services/PLC/MyNewWriter.cs", WriteClass.UdtMember }, // Tanks[N].My_Member
- Path is relative to
Ring/, forward-slashed. - Pick the truthful
WriteClass— it generates the proof matrix's BENCH-OUTSTANDING column, so a mis-filed row turns a bench-only claim into a "proven" one. - Raise the count floor in
The_register_is_not_silently_shrinkingin the same commit. The floor is set to the exact current count with zero slack on purpose. - If your writer is a UI dispatcher that only calls a service, and it is
invisible to the five signals, widen signal 5's alternation rather than
planting a decorative
PlcWriteGuardreference — and add the corresponding case toThe_dispatcher_entry_point_detector_still_detects.
Step 4 — add a seam test#
A test that pins tag, value, count and order for your writer. Give the writer
an injectable seam (Func<string, string, bool>,
Func<string, short, bool>, or an I…PlcIo interface) so the test can observe
the wire without a controller. If you do not, your writer joins the three named,
bounded, seam-less writers and
The_writers_that_reach_libplctag_without_a_test_seam_are_named_and_bounded
will fail until someone consciously adds it to that list
(WriteSurfaceRegisterTests.cs:887-931) — which is the point.
Step 5 — bench evidence, for a UDT member or a BOOL bit#
- A bench round-trip in
Ring.Tests/BenchPlcIntegrationTests.cs(orBenchPlcWriteRoundTripTests.cs). - A row in
docs/production-readiness/WRITE_PATH_PROOF_2026-07-30.md. - The commissioning evidence line in
docs/production-readiness/PLC_WRITE_COMMISSIONING_REGISTER_2026-08-02.csv, whichscripts/Test-PlcCommissioningEvidence.ps1verifies.
Remember the bench tests pass vacuously unless BENCH_PLC_AVAILABLE=1; run
them with the variable set against the bench twin at 192.168.202.15, and cite
benchHardwareWasAvailable from the gate summary when you claim they ran.
Step 6 — review and gate#
- Independent review. A write-path change is reviewed by someone who did not
write it. Do not self-review
(
CONTRIBUTING.md). - Run
scripts/Invoke-ProductionGate.ps1before asking for a merge. - Expect the PLC Tag Audit workflow to notice a new read/write against a tag
with no ladder writer in the parsed 2024 export. Read
docs/PLC_FACTS.mdbefore dismissing it — and before believing it.
The two prohibitions#
- Do not populate
MainTvcWriteAuthorization.IsAuthorizedorTankTempModeWriteAuthorization.AuthorizedTankTempModeTanks. Both are hard-closed pending a controls decision; both are asserted byWriteSurfaceRegisterTests; both say so in capitals in their own source. "Making the screen work" is not a reason. - Never add a UI guard or an early
returnin front of a shared handler that also dispatches PLC-write buttons. Enumerate every button routed to the handler first.NavBar.HandleButtonClick(Ring/Views/UserControls/NavBar.xaml.cs:415-444) is the live example of how to do it correctly — two explicit exclusion sets, and a dedicated handler for the roster-generated tank buttons so they never travel through it at all.
10.7 Adding a document#
Small, but it is how this book stays worth reading.
- Check
docs/INDEX.mdfirst. If a document already owns the question, add to it rather than forking it. One authority per question. - Cite the file you read. A claim without a path is a claim nobody can check, and this repository has been burned by exactly that.
- Never copy a volatile number into prose. Test totals, tag counts, table
counts, dead-read counts belong in
artifacts/production-gate/production-gate-summary.jsonandscripts/code-tag-audit.md. Link, do not quote. - Say "unknown" when it is. A document that says "I could not verify this"
is worth more here than one that guesses
(
CONTRIBUTING.md). docs/archive/**is evidence, not instruction. Never quote its status claims as current.- If the code contradicts a document, fix the document in the same pass — or archive it.
- Generated files are never hand-edited:
scripts/live-tags.{md,json},scripts/code-tag-audit.{md,json}. The gate diffs them and fails on drift.
10.8 A short checklist for any change#
- ☐ New files registered in the csproj
- ☐ CRLF preserved; csproj/sln still BOM-free; diffed before commit
- ☐ New user-visible strings in all thirteen dictionaries, no duplicate
x:Key - ☐ Tests added; suite run with
-parallel none - ☐
scripts/Invoke-ProductionGate.ps1green - ☐ Any PLC write path: gate stack, register row + floor, seam test, bench evidence, independent review
- ☐ Working in a worktree; branch verified after committing
- ☐ Documents cite the files they were read from; no volatile numbers in prose
Back to: book index · chapter 04, the one to re-read
Verified against#
Every claim in this chapter was read out of these files on 2026-09-01:
Ring/Ring.csproj ·
Ring/Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs ·
Ring/Infrastructure/Configuration/AppSettings.cs,
ConfigurationService.cs, ConfigurationValidator.cs ·
Ring/Database/DatabaseInitializer.cs, RingwoodDbAccess.cs,
RepositoryReadScope.cs · Ring/Services/DatabaseBackupService.cs ·
Ring/Services/DatabaseWriterQuiesce.cs ·
Ring/Services/PLC/PlcWriteGuard.cs, PlcTagWriter.cs,
BatchStartTankPlcWriter.cs, MomentaryPulse.cs,
PcWriteInteger30BatchControlPlcService.cs, PlcSetpointWriteQueue.cs,
PlcWriteTagKeys.cs, SetpointEchoGuard.cs, RosterWriteIndex.cs,
TankInstalledWriteGate.cs ·
Ring/Views/UserControls/NavBar.xaml.cs ·
Ring/Views/Reports/ReportHostView.xaml.cs ·
Ring.Tests/WriteSurfaceRegisterTests.cs,
MomentaryPulseClearGateTests.cs, ProjectMembershipGuardTests.cs,
DatabaseSchemaMigrationTests.cs, DatabaseExpectedTablesDriftTests.cs,
DatabaseBackupServiceTests.cs · docs/PLC_FACTS.md · docs/INDEX.md ·
CONTRIBUTING.md