Merge branch 'fix/r3-02-stale-good-family' — R3-02: one quality contract at four seams — comms loss marks raw+UNS value variables BadNoCommunication (WriteStatuses sink member, recovery audit), Calculation + VT hold on Bad / worst-of sub-code / (value,status) dedup, MQTT initial push + ConnectionLost stale, Sparkplug birth replay (archreview 03/S15, 05/STAB-19, 05/STAB-18, 02/S14)

This commit is contained in:
Joseph Doherty
2026-08-20 11:49:06 -04:00
46 changed files with 4340 additions and 129 deletions
@@ -580,3 +580,331 @@ T17 anytime; T18 after all; T19 last
```
Overall effort: **~2 dev-days code+unit, plus the rig session**. The three HIGH-RISK data-plane slices (T3 MQTT stale, T8 value-node Bad, T13/T16 hold-on-Bad) each land behind their full suites; the two recovery contract tests (T10, T16's identical-value case) must each be seen to FAIL once against a reverted dedup before merge — that is the whole reason this family re-appeared.
---
## Execution notes
Branch `fix/r3-02-stale-good-family` (worktree `.claude/worktrees/agent-a696a39981023fa0b`), off master
`72329eab`. Executed in plan block order T1, B (T2–T5), C (T6–T10), D (T12–T14), E (T15–T16), T17, T18.
**T11 and T19 are deliberately NOT in this branch** — T11 waits for the sibling R3-03 branch to merge
(it is the only edit that would touch `DriverInstanceActor` lifecycle alongside R3-03's restart work),
and T19 is the operator rig gate.
Red-before observations are recorded per task below: what was run, and the failure text that proves the
test could only pass once the production change landed.
### T1 — `InputQuality.WorstOf`
- RED: `dotnet test tests/Core/…Core.Abstractions.Tests --filter "FullyQualifiedName~InputQualityTests"`
⇒ **build failure**, 8 × `error CS0103: The name 'InputQuality' does not exist in the current context`.
- GREEN: 13 passed. `Core.ScriptedAlarms.Tests` 94/94 green with `WorstInputStatus` re-pointed (the
never-pushed/null skips stay in the alarm engine — they are alarm policy, not the shared rule).
### T2 — MQTT plain-mode initial push on `SubscribeAsync`
- RED: `--filter "FullyQualifiedName~MqttSubscriptionManagerRecoveryTests"` ⇒ **4 failed**, each on
`raised.Count` (0 observed, 1 expected) / `raised.Select(r => r.Reference)` — `SubscribeAsync` raised
nothing at all for an already-observed ref.
- GREEN: 77 `MqttSubscriptionManager*` tests pass, incl. the suppression control (an identical message
after the push is still swallowed). Full `Driver.Mqtt.Tests`: 790/790.
### T3 — `MqttConnection.ConnectionLost` + plain-mode stale
- RED: `--filter "…MqttSubscriptionManagerRecoveryTests|…ConnectionLost"` ⇒ **build failure**,
`CS1061: 'MqttSubscriptionManager' does not contain a definition for 'OnConnectionLost'` (×5) and
`CS1061: 'MqttConnection' does not contain a definition for 'ConnectionLost'` (×3).
- GREEN: 11/11 (8 manager recovery + 3 connection edge). Full `Driver.Mqtt.Tests`: 797/797.
- The event fires inside the existing `State == Connected` guard in `OnDisconnectedAsync`, which is
what makes "not on an initial-connect failure" and "not on dispose" true by construction rather
than by a second condition — `DisposeAsync` sets `Disposed` before tearing the client down.
### T4 — Sparkplug ingestor: initial push + connection-loss stale
- RED: `--filter "…SparkplugIngestorRecoveryTests"` ⇒ **build failure**, `CS1061: 'SparkplugIngestor'
does not contain a definition for 'OnConnectionLost'` (×2).
- GREEN: 6/6. Full `Driver.Mqtt.Tests`: 803/803.
- ⚠️ **Deviation from the plan's test rationale (recorded, not silently accepted):** the plan asks for
the identical-DBIRTH-replay assertion as this seam's vacuity guard. It is present, but **it is not a
falsifier here** — Sparkplug ingestion has no unchanged-value suppression at all (births/data
publish directly, never through `Deliver`), so that replay was already raised Good before the
change. That is now asserted explicitly by an added control,
`An_identical_birth_replay_is_raised_even_without_a_preceding_stale`, and the suite header says so.
The load-bearing assertion on this seam is the **stale** itself.
- The stale reports `BadNoCommunication` while a DDEATH keeps `BadCommunicationError`: the death is the
source announcing its own offline state, the stale is our transport losing the broker (and matches
what the host's S15 fan-out writes). The existing const's "no driver emits BadNoCommunication at all"
remark was corrected rather than left to rot.
### T5 — MQTT driver-level end-to-end recovery + docs
- The test is green on first run by construction (T2–T4 already landed), so the plan's TDD bar was met
the other way: **the wiring was deliberately broken and the test seen to fail.** Deleting
`connection.ConnectionLost += OnConnectionLost;` from `MqttSubscriptionManager.AttachTo` ⇒
`ABrokerDrop_MarksCachedRefsBadNoCommunication_AndTheIdenticalReplayRecovers` **FAILED** with
`Timed out waiting for the broker drop to publish a BadNoCommunication snapshot` (15 s). Restored,
green. That is the point of this test: the manager-level suites drive `OnConnectionLost()` directly
and stay green against an unforwarded seam.
- The outage edge is a real socket drop through MiniBroker → MQTTnet `DisconnectedAsync`; ingest is fed
through `Subscriptions.HandleMessage` because MiniBroker answers CONNECT/PINGREQ only and cannot
PUBLISH.
- Docs: `docs/drivers/Mqtt.md` §"Quality on connection loss and re-subscribe" (links the contract in
`DriverLifecycle.md`, records the two distinct "not communicating" codes and the operator-visible
behaviour change).
### T6 — `IOpcUaAddressSpaceSink.WriteStatuses` (interface default + node manager + both sinks)
- RED (the guard, first): adding the member to the interface and running
`--filter "…DeferredSinkForwardingReflectionTests"` ⇒ **`Every_forwarding_interface_method_reaches_the_inner_sink`
FAILED**. Note the failure surfaced as `System.ArgumentNullException : Value cannot be null. (Parameter
'statuses')` rather than the guard's "did NOT forward" message — the reflection harness passes `null`
for an `IReadOnlyList<>` parameter, so the *interface default* ran on the wrapper and threw. Red for
the right reason (the member was not forwarded); green once `DeferredAddressSpaceSink.WriteStatuses`
forwards. This is the memory'd "Deferred sink doesn't forward the new member" trap, and here the
default is not merely slower — it writes a **null value**, erasing the very value the annotation exists
to keep.
- RED (node manager): `--filter "…OtOpcUaNodeManagerWriteStatusesTests"` ⇒ **build failure**, `CS1061:
'OtOpcUaNodeManager' does not contain a definition for 'WriteStatuses'` (×5).
- GREEN: 5/5 new; full `OpcUaServer.Tests` 495 passed / 1 skipped; `Commons.Tests` 348/348.
- An unmaterialised NodeId is **skipped and not counted as a failure** (Debug tally via the SDK static
trace — the manager has no `ILogger`): the caller's per-driver index racing a surgical remove is an
expected shape, not a fault.
### T7 — `OpcUaPublishActor.AttributeStatusBatchUpdate`
- RED: `--filter "…OpcUaPublishActorStatusBatchTests"` ⇒ **build failure**, `CS0426: The type name
'AttributeStatusBatchUpdate' does not exist in the type 'OpcUaPublishActor'` (×3).
- GREEN: 58 `OpcUaPublishActor*` tests pass (3 new).
- Metered `OpcUaSinkWrite kind:"status"` so a status annotation is separable from a value write on the
same counter; the recording fake asserts **zero** value writes, since a handler falling through to the
value path would null every node.
### T8 — `DriverHostActor.FanCommsLossToValueNodes`
- RED: `--filter "…DriverHostActorCommsLossValueNodesTests"` ⇒ **2 failed**, both
`Timeout 00:00:15 while waiting for a message of type …AttributeStatusBatchUpdate` (the disconnect
produced no value-node write at all — the finding, reproduced).
- GREEN: 10 passed across the new suite + `DriverHostActorCommsLossTests` (mux half) +
`DriverHostActorBatchFanOutTests`, all unchanged.
- **Deviation (sequencing, not design):** the plan's snippet calls `TrackCommsLatchSet(...)` from inside
this method, but that helper is T9's — calling it here would not compile. The call is added in T9,
where the latch and its audit timer land together. R3-03 boundary respected: this is a new method plus
one branch in `OnDriverConnectivityChanged`; `HandleRestartDriver` and the watchdog are untouched.
### T9 — recovery audit (`_commsLatched` + timer + `otopcua.driver.comms_latched_refs`)
- RED: `--filter "…DriverHostActorCommsLossValueNodesTests"` ⇒ **build failure**, `CS1739: The best
overload for 'Props' does not have a parameter named 'commsRecoveryAuditDelay'`.
- GREEN: 6/6 in the suite; 20 passed across it + `DriverHostActorCommsLossTests` +
`DriverHostActorBatchFanOutTests` + `DriverHostActorPrimaryGateTests`.
- **Falsification of the control** (the plan's discipline, applied to the audit rather than to a dedup
key): deleting the prune loop in `ForwardBatchToMux` ⇒
`A_driver_that_republishes_every_ref_trips_no_audit` **FAILED** with `Received 1 message too many.
Expected 0 messages but received 1 that matched filter [Warning when Message contains "still
BadNoCommunication"]`. Restored, green. So the "no audit fires" assertion is falsifiable and the audit
is not merely dormant.
- Meter assertions filter on a per-test **driver tag**, not on the instrument name alone (#503: a
`MeterListener` sees every measurement in the process).
- The latch counts **RawPaths**, so the raw node and its UNS mirrors report as one ref — the operator is
told how many tags did not come back, not how many nodes.
### T10 — S15 end-to-end recovery contract + silent-resubscribe control
- RED: `--filter "…DriverHostCommsLossRecoveryTests"` ⇒ **2 failed**, `Timeout 00:00:15 while waiting for
a message of type …AttributeValueBatchUpdate` (before `AttributeStatusBatchUpdate` even existed in the
first draft, and again while the fake's instance id disagreed — see below).
- GREEN: 2/2 (33 s wall clock — see the reconnect-interval note). `Runtime.Tests` `~Drivers` filter:
333 passed / 3 skipped.
- **Two harness facts worth keeping** (both cost a red run to find):
- `DriverInstanceActor` takes its instance id from **`driver.DriverInstanceId`**, not from the spec the
host spawned it with. A fake returning a different id publishes under a key the host's ref map does
not contain, and every value is silently dropped as unmapped. The fake now takes the id.
- The fake's push-on-subscribe fires on the FIRST subscribe too, so the pre-outage value must come from
that push rather than from a separate seeding call — otherwise the probe holds an extra batch.
- **Deviation:** the plan sketched driving the outage with the #527 watchdog (`commsLossTimeout` 200 ms).
The host spawns its children with the **default** `reconnectInterval`/`healthPollInterval` and exposes
no seam for either, so the outage is driven by the production `ReconnectDriver` (operator Force
Reconnect) command instead — same `Reconnecting` entry, same `ConnectivityChanged(false)`, and it does
not touch `HandleRestartDriver` (R3-03's). The watchdog's own trip condition stays covered by
`DriverInstanceActorCommsLossWatchdogTests`. The 10 s `DefaultReconnectInterval` is why these two tests
take ~15 s each.
- The identical-value rule is honoured: the driver pushes **5.0 Good** before and after the outage, so the
recovery assertion cannot be satisfied by a value change. The control (`InitialPush = null`) asserts
`ExpectNoMsg` — no Good ever arrives — and that the audit fires with exactly 1 latched ref.
### T12 — `DependencyConsumerMuxAdapter` forwards the real status code
- RED: `--filter "…DependencyConsumerMuxAdapterTests"` ⇒ **2 failed**, both
`Shouldly.ShouldAssertException : consumer.Received[…].StatusCode should be 2150694912u` (0x80310000)
— the consumer received `0` (Good) for a `BadNoCommunication` input, on both the single and the batch
path. That is the finding, reproduced at the seam.
- GREEN: 11 passed (`DependencyConsumerMuxAdapterTests` + `DependencyMuxActorTests`).
- The false "quality is stripped at the mux boundary" comment is deleted and replaced with why it was
false (theme 6: the contract moved at #478/#519 and the comment outlived the fact).
### T13 — `CalculationDriver` input quality (hold on Bad, propagate worst, status-keyed dedup)
- RED: `--filter "…CalculationDriverInputQualityTests"` ⇒ **5 failed**: `Snapshot.StatusCode should be
2150694912u` (BadNoCommunication) on the Bad-input, cold-start and passthrough cases; `should be
1073741824u` (Uncertain) on the Uncertain case; `published.Count should be 1` on the sub-code-change
case. That is the laundering, reproduced from three directions.
- GREEN: whole `Driver.Calculation.Tests` project 61/61 — the 9 new plus every pre-existing case
unchanged (the Good-only paths are byte-identical).
- **Vacuity check performed** (the plan's requirement): reverting the dedup key to value-only
(`if (st.HasValue && Equals(st.LastValue, value)) return null;`) ⇒
`An_identical_value_is_republished_when_recovering_from_a_bad_input` **FAILED** with
`published.Count should be 1` — i.e. the recovery was swallowed, exactly the "Bad forever" trap.
Restored, green.
- `TagState.LastPublishedBad` (two-state) became `HasPublished` + `LastPublishedStatus` (uint), which is
what lets a Bad→different-Bad transition republish and what makes `ReadAsync` agree with the
subscriber. The evaluator is still handed bare values here — T14 hands it the snapshots.
### T14 — `CalculationEvaluator` snapshot overload
- RED: `--filter "…CalculationEvaluatorTests"` ⇒ **build failure**, `CS1503: cannot convert from
'Dictionary<string, DataValueSnapshot>' to 'IReadOnlyDictionary<string, object?>'` (×3) — no snapshot
overload existed.
- GREEN: `Driver.Calculation.Tests` 64/64.
- `BuildReadCache` is **deleted**, not fixed: the snapshots now pass straight through as the read cache.
The value-only overload survives (tests use it) but is now the one that fabricates Good, and its
xmldoc says so and points at the snapshot overload.
- Docs: `docs/drivers/Calculation.md` gains an "Input quality" section (the policy table + the
behaviour change for null-tolerant scripts) and the dedup bullet now says **(value, status)**.
### T15 — `IVirtualTagEvaluator` snapshot overload + Roslyn read-cache pass-through
- RED: `--filter "…RoslynVirtualTagEvaluatorTests"` ⇒ **build failure**, `CS1503: cannot convert from
'Dictionary<string, DataValueSnapshot>' to 'IReadOnlyDictionary<string, object?>'` (×3).
- GREEN: 24/24 in that suite; full solution build clean (0 errors), so **all five existing
`IVirtualTagEvaluator` fakes compile untouched** — which is the point of default-implementing the
overload. A test pins that fallback explicitly (`ValueOnlyEvaluator`).
- `RoslynVirtualTagEvaluator.BuildReadCache` is **deleted**; the snapshots are the read cache. The
value-only overload now projects up into the snapshot one, so there is a single evaluation body.
### T16 — `VirtualTagActor` honours input quality
- RED: `--filter "…VirtualTagActorInputQualityTests"` ⇒ **7 failed**, incl. `bad.StatusCode should be
2150694912u`, `result.StatusCode should be 1073741824u` (Uncertain), `reassert.StatusCode should be
2150694912u`, and `Expected no messages … instead we received EvaluationResult { … StatusCode = 0 }`
for the cold-start case — i.e. a Bad input produced a fresh Good result.
- GREEN: 58 passed across `VirtualTag*` (the 7 new + the R2-03 transition suites + the host bridge's
new historize-parity case), all pre-existing cases unchanged.
- **Vacuity check performed** (the plan requires this one explicitly): reverting the dedup key to
value-only ⇒ `An_identical_value_is_republished_when_recovering_from_a_bad_input` **FAILED** with
`Timeout 00:00:15 while waiting for a message of type …EvaluationResult` — the recovery was swallowed
entirely. Restored, green.
- ⚠️ **Deviation from the plan's design, deliberate and load-bearing.** The plan says the worst-of-input
walk should treat a **never-pushed** dependency as `BadWaitingForInitialData` and hold. Implemented
literally, that HOLDS evaluation during ordinary warm-up — but this actor (unlike the Calculation
driver) has always evaluated on partial arrival and published the partial result, and two existing
tests assert exactly that (`VirtualTagActorTests.Evaluator_result_flows_to_parent_as_EvaluationResult`
and `DependencyMuxActorTests.VirtualTagActor_PreStart_registers_deps_…_end_to_end`, which both went red
under the literal reading). The worst-of walk therefore covers **arrived** deps only — the same skip
`ScriptedAlarmEngine.WorstInputStatus` has always made — and cold start stays governed by R2-03's
existing `AllDependenciesArrived` gate on the publish tails. The plan's own risk note ("every Good-path
assertion in the existing suites stays byte-identical") is what this preserves.
- `_lastPublishedBad` became `_hasPublished` + `_lastPublishedStatus`, which is also what makes
`ReassertValue` re-emit `BadNoCommunication` after a deploy instead of guessing Good.
- Docs: `docs/ScriptEditor.md` §"Input quality: what a script sees, and when it runs at all".
### T17 — Docs: the contract stated once
- `docs/DriverLifecycle.md` gains §"The quality contract" (the contract quoted verbatim + a table of the
six seams that implement it + the consumer-visible warning) and a new sub-section under the comms-loss
fan-out covering the value-node fan, `WriteStatuses`, and the recovery audit.
- Pointers added, each linking back rather than restating: `docs/Raw.md`, `docs/Uns.md`,
`docs/ScriptEditor.md` (T16), `docs/drivers/Calculation.md` (T14), `docs/drivers/Mqtt.md` (T5).
- `CLAUDE.md`: one bullet under the comms-loss watchdog section — value nodes go Bad, nothing Good on
reconnect (+ the audit meter), and the **(value, status)** dedup rule any future consumer must repeat.
### Merge of master (R3-03 + R3-05 + R3-01) — before T11
`git merge master` at `d79d51dd`. Two conflicts, both **additive-vs-additive** on parameters R3-03 and
this plan each appended last:
- `DriverHostActor` — `reprovisionBackoff` (R3-03) and `commsRecoveryAuditDelay` (T9) both claimed the
final ctor/Props slot, plus adjacent field blocks. Resolved by keeping BOTH in master-then-mine order
across all three positional sites (Props signature, `Props.Create` call, ctor), per the file's own
"new parameters go LAST in all three places" warning.
- `V3ArtifactSeeder` — R3-03 had independently added the same `enabled` + `driverFactory` seams T10
needed (`driversEnabled`). **Master's naming won**; mine was deleted and the T10 call site moved to
`enabled: true` + named `commsRecoveryAuditDelay:`. `Spawn`/`SpawnHostAndApply` carry all three
optional knobs.
- One non-conflicting mis-merge caught by eye: git interleaved the new
`DriverCommsLatchedRefs` counter *between* `PrimaryGateDenied`'s xmldoc and its declaration in
`OtOpcUaTelemetry`. Re-ordered.
- Post-merge: solution builds clean, `Runtime.Tests` **670 passed / 3 skipped**.
### T11 — `DriverInstanceActor.PostStop` announces comms loss
- RED: `--filter "…Stopping_a_connected_child_announces…"` ⇒ **FAILED**, `Expected a message of type
…ConnectivityChanged, but received {<Terminated>…}` — stopping announced nothing at all.
- GREEN: `DriverInstanceActor*` 67/67; full `Runtime.Tests` **672 passed / 3 skipped**.
- **Deviation (narrowing, deliberate):** the Tell is **gated on the child having reached `Connected`**
(`_connectedSinceUtc != default`, the field R3-03 added for the watchdog grace) rather than
unconditional as the plan sketched. A never-connected child's nodes are still
`BadWaitingForInitialData`, which is more precise than "communication lost"; overwriting it would claim
an outage that never happened. Pinned by a control test
(`Stopping_a_never_connected_child_announces_nothing`).
- With R3-03 merged the recovery leg holds: `PostStop` → host latches Bad → R3-03's `SpawnChild` always
sends the desired set → the fresh child subscribes → the driver's initial push clears the latch.
### T18 — full-suite sweep + bookkeeping
- **Full-solution unit sweep green** (`dotnet test ZB.MOM.WW.OtOpcUa.slnx`): every project `Failed: 0`.
Notable per-project counts after this branch: `Commons.Tests` 348, `OpcUaServer.Tests` 498/1 skipped,
`Driver.Mqtt.Tests` 803, `Driver.Calculation.Tests` 64, `Core.ScriptedAlarms.Tests` 94,
`Core.Abstractions.Tests` (incl. the new `InputQualityTests`) green, `Runtime.Tests` 672/3 skipped.
Docker-gated integration suites skip cleanly as usual (Modbus 34 skipped, S7 3, AbCip 3, Galaxy 8,
Historian.Gateway 6).
- **`Runtime.Tests` × 5** for the #500 flake profile: **four runs 672 passed / 3 skipped**; one run
reported **1 failure** whose test name was lost to an output filter on that invocation. Not reproduced
in four subsequent full runs, and every suite this plan touched was run green individually many times.
Recorded rather than hidden — if it recurs, capture it with
`dotnet test … --logger "console;verbosity=normal"` and check it against the #500 list.
- Bookkeeping: `archreview/plans/STATUS.md` gains an R3-02 section (what landed per task, the
client-visible behaviour changes, the suite state, and the fact that **T19 the live gate is not run**).
- **Deviation:** the plan also asks for edits to "the four source reports' prior-finding tables". Those
tables are the round-3 review record written at `12b0035a`, and the sibling round-3 branches (R3-01,
R3-03, R3-04) likewise left them untouched, tracking progress in STATUS.md + the plan register instead.
Followed that precedent rather than diverging mid-round.
---
## Outstanding
- **T19 — the live gate (merge-blocking).** Not run: it needs the docker-dev rig plus the shared Modbus
fixture, and it is the only evidence that the identical-value recovery works end to end on a real
device. Recipe unchanged in §Live gate; the constant HR[5] vs auto-incrementing HR[100] pairing is what
makes it non-vacuous.
---
## Review outcome (verdict MERGE, two Low items applied)
Both were **real** — each is a place where a polled/observed surface disagreed with what the contract
says, i.e. the same class of bug this plan exists to close, one hop further out. Applied TDD, one commit.
1. **`CalculationDriver.ReadOne` guarded on `!st.HasValue`** — so a tag whose only publish was an
inputs-Bad report (no value of its own) read back `BadWaitingForInitialData` while its subscriber had
been told `BadNoCommunication`. The guard is now `!st.HasPublished`, which is the state that actually
means "nothing has ever been said about this tag".
- RED: new `ReadAsync_after_an_inputs_Bad_with_no_prior_value_reports_the_bad_status` ⇒
`Shouldly.ShouldAssertException : read[0].StatusCode should be 2150694912u`.
- The pre-existing `An_unevaluated_tag_still_reads_BadWaitingForInitialData` is the control for the
other side of the same guard and stayed green.
2. **`VirtualTagActor.OnInputsBad` metered `VirtualTagEval{outcome=inputs-bad}` above the
`AllDependenciesArrived()` return** — so ordinary cold start produced an "inputs bad" measurement per
trigger. The `Add` moved below **both** guards (readiness and transition-once), so the counter now
measures tags that actually report.
- RED: new `The_inputs_bad_counter_does_not_tick_during_warm_up` ⇒
`Shouldly.ShouldAssertException : meter.Count("inputs-bad") should be 0`.
- The absence half carries a **positive control in the same test** (completing the dep set ⇒ exactly
one measurement), so it cannot pass for want of a working `MeterListener`.
**Suites after the fixes:** `Driver.Calculation.Tests` **65/65**; `Runtime.Tests` `~VirtualTag`
**59/59**; `Core.ScriptedAlarms.Tests` **94/94**.
**STATUS.md corrected:** the anonymous Runtime.Tests flake is
`DriverHostActorNativeAlarmAckRoutingTests.RouteNativeAlarmAck_routes_to_driver_AcknowledgeAsync_with_principal`
— untouched by this branch, known #500 profile, 3× green in isolation per the reviewer.
@@ -5,19 +5,19 @@
{
"id": "T1",
"subject": "InputQuality.WorstOf shared helper (Core.Abstractions) + truth-table tests; re-point ScriptedAlarmEngine.WorstInputStatus",
"status": "pending",
"status": "done",
"blockedBy": []
},
{
"id": "T2",
"subject": "MQTT plain-mode initial push of cached snapshots on SubscribeAsync — push-model forceRaise (05/STAB-18 shape b)",
"status": "pending",
"status": "done",
"blockedBy": []
},
{
"id": "T3",
"subject": "MqttConnection.ConnectionLost event + plain-mode stale observed refs to BadNoCommunication (value retained) so the identical replay recovers (05/STAB-18 shape a)",
"status": "pending",
"status": "done",
"blockedBy": [
"T2"
]
@@ -25,7 +25,7 @@
{
"id": "T4",
"subject": "SparkplugIngestor: initial push on subscribe + connection-loss stale; rewrite the 'values are deliberately not staled' remark",
"status": "pending",
"status": "done",
"blockedBy": [
"T3"
]
@@ -33,7 +33,7 @@
{
"id": "T5",
"subject": "MQTT driver-level end-to-end recovery test (broker drop -> Bad -> identical retained replay -> Good) + docs/drivers/Mqtt.md section",
"status": "pending",
"status": "done",
"blockedBy": [
"T4"
]
@@ -41,13 +41,13 @@
{
"id": "T6",
"subject": "IOpcUaAddressSpaceSink.WriteStatuses (StatusWrite, default impl) + OtOpcUaNodeManager status-only batch retaining value + SdkAddressSpaceSink/DeferredAddressSpaceSink forwards (03/S15)",
"status": "pending",
"status": "done",
"blockedBy": []
},
{
"id": "T7",
"subject": "OpcUaPublishActor.AttributeStatusBatchUpdate handler -> sink.WriteStatuses (metered, partial-failure Warning)",
"status": "pending",
"status": "done",
"blockedBy": [
"T6"
]
@@ -55,7 +55,7 @@
{
"id": "T8",
"subject": "DriverHostActor.FanCommsLossToValueNodes: one BadNoCommunication status batch over raw + UNS nodes on ConnectivityChanged(false); nothing on reconnect (new method — R3-03 boundary)",
"status": "pending",
"status": "done",
"blockedBy": [
"T7"
]
@@ -63,7 +63,7 @@
{
"id": "T9",
"subject": "Recovery audit: _commsLatched per driver, pruned by routed values, CommsRecoveryAudit timer + otopcua.driver.comms_latched_refs meter (dedup-trap tripwire)",
"status": "pending",
"status": "done",
"blockedBy": [
"T8"
]
@@ -71,7 +71,7 @@
{
"id": "T10",
"subject": "S15 end-to-end recovery contract test: real DriverInstanceActor + scripted fake with IDENTICAL-value initial push; silent-resubscribe falsification control",
"status": "pending",
"status": "done",
"blockedBy": [
"T9"
]
@@ -79,7 +79,7 @@
{
"id": "T11",
"subject": "DriverInstanceActor.PostStop Tells ConnectivityChanged(false) so operator restart / respawn latches nodes Bad (land after R3-03 merges)",
"status": "pending",
"status": "done",
"blockedBy": [
"T8"
]
@@ -87,13 +87,13 @@
{
"id": "T12",
"subject": "DependencyConsumerMuxAdapter forwards the real StatusCode; delete the false 'quality is stripped' comment (05/STAB-19)",
"status": "pending",
"status": "done",
"blockedBy": []
},
{
"id": "T13",
"subject": "CalculationDriver input quality: snapshots per dep, hold on Bad, publish worst-of-input once per status change, status-keyed dedup bypass on recovery, ReadOne parity",
"status": "pending",
"status": "done",
"blockedBy": [
"T1",
"T12"
@@ -102,7 +102,7 @@
{
"id": "T14",
"subject": "CalculationEvaluator snapshot overload: read cache carries real input status + timestamps; passthrough fast path on snapshots",
"status": "pending",
"status": "done",
"blockedBy": [
"T13"
]
@@ -110,13 +110,13 @@
{
"id": "T15",
"subject": "IVirtualTagEvaluator snapshot overload (default-implemented) + RoslynVirtualTagEvaluator.BuildReadCache pass-through (02/S14)",
"status": "pending",
"status": "done",
"blockedBy": []
},
{
"id": "T16",
"subject": "VirtualTagActor honours input quality: snapshot map, hold on Bad, worst-of status, (value,status) dedup key, reassert carries status; host-bridge historize parity test",
"status": "pending",
"status": "done",
"blockedBy": [
"T1",
"T15"
@@ -125,13 +125,13 @@
{
"id": "T17",
"subject": "Docs: the quality contract stated once (DriverLifecycle.md) + Raw/Uns/ScriptEditor/Calculation pointers + CLAUDE.md bullet",
"status": "pending",
"status": "done",
"blockedBy": []
},
{
"id": "T18",
"subject": "Full-suite sweep (Runtime.Tests 3x for the #500 flake profile) + STATUS.md / source-report bookkeeping",
"status": "pending",
"status": "done",
"blockedBy": [
"T5",
"T10",
+38
View File
@@ -499,3 +499,41 @@ and the 12-item action list are in `00-OVERALL.md`.
**Round-3 plans: NOT yet written.** Next step is one plan per action-list item (R3-01…), house format,
as round 2 did.
---
## R3-02 — the stale-Good family (branch `fix/r3-02-stale-good-family`)
Plan: [`R3-02-stale-good-family-plan.md`](R3-02-stale-good-family-plan.md) (19 tasks; per-task red-before
evidence in that file's **Execution notes**). Closes OVERALL action item #2 — **03/S15**, **05/STAB-19**,
**05/STAB-18**, **02/S14** — by stating the quality contract once and pinning it per seam.
**Landed (T1–T18, one commit each; branched off `72329eab`, merged master `d79d51dd` mid-flight):**
| Task | What |
|---|---|
| T1 | `Core.Abstractions.InputQuality` (`WorstOf`/`IsUsable`) — the one worst-of-input rule; `ScriptedAlarmEngine.WorstInputStatus` re-pointed at it (its never-pushed skip stays alarm-local) |
| T2–T5 | **MQTT (05/STAB-18)**: initial push of cached snapshots on every (re)subscribe (plain + Sparkplug); `MqttConnection.ConnectionLost` → stale observed refs to `BadNoCommunication`, value retained; end-to-end driver test through a real socket drop; `docs/drivers/Mqtt.md` |
| T6–T10 | **03/S15**: `IOpcUaAddressSpaceSink.WriteStatuses` (status-only, value retained) + node manager + both sinks; `OpcUaPublishActor.AttributeStatusBatchUpdate`; `DriverHostActor.FanCommsLossToValueNodes`; the recovery audit (`_commsLatched` + `otopcua.driver.comms_latched_refs`); the identical-value recovery contract test + silent-resubscribe control |
| T11 | `DriverInstanceActor.PostStop` announces comms loss (gated on having reached Connected), so an operator Restart / supervision respawn latches Bad — recovery rides R3-03's re-provision |
| T12–T14 | **05/STAB-19**: mux adapter forwards the real status code; Calculation holds on Bad, propagates the worst input status, status-keyed dedup; evaluator read cache carries real status + timestamps |
| T15–T16 | **02/S14**: `IVirtualTagEvaluator` snapshot overload (default-implemented) + Roslyn pass-through; `VirtualTagActor` holds on Bad, inherits the worst input status, `(value, status)` dedup, reassert carries the status |
| T17 | The contract stated once in `docs/DriverLifecycle.md` §"The quality contract"; pointers from `Raw.md`, `Uns.md`, `ScriptEditor.md`, `drivers/Calculation.md`, `drivers/Mqtt.md`; one `CLAUDE.md` bullet |
| T18 | Full unit sweep — every project green; `Runtime.Tests` run 5× (see flake note below) |
**Client-visible behaviour changes** (called out in the docs task): value nodes of a comms-lost driver
now go **Bad with the last value retained**; MQTT tags go Bad on a broker drop rather than at the 90 s
watchdog; calc tags and virtual tags **hold** while an input is Bad and report that input's sub-code, so
a script that relied on receiving `null` for a dead input no longer runs while it is Bad.
**Test-suite state at `62c5bc3a`:** full-solution unit sweep green. `Runtime.Tests` 672 passed / 3
skipped on four separate runs; one further run showed a single failure —
`DriverHostActorNativeAlarmAckRoutingTests.RouteNativeAlarmAck_routes_to_driver_AcknowledgeAsync_with_principal`
(named by the reviewer, who saw it once and then 3× green in isolation). It is untouched by this branch
and matches the known #500 flake profile (~13 %/run); not reproduced in four subsequent full runs. No
pre-existing red on the base commit.
**NOT done here — T19, the live gate**, which is the merge-blocking rig session (docker-dev + the Modbus
`standard` fixture: constant HR[5] proves the force-republish, HR[100] is the positive control; plus the
deadband falsification leg, the operator-restart leg, and the MQTT retained-replay leg). Recipe in the
plan's §Live gate.