diff --git a/deferment.md b/deferment.md index 42af16c7..93b16382 100644 --- a/deferment.md +++ b/deferment.md @@ -41,6 +41,8 @@ Nothing here is a data-loss or outage defect. The sharpest edge is §3 — silen ### 0.1 What is actually left (re-verified at `4bdce910`, 2026-08-04) +> **Refreshed 2026-10-08.** The re-verification below is as of `4bdce910` (2026-08-04) and is *not* current. Since then the #562–#591 issue sweep closed on master (`3baa332d`), and the work it deferred was filed as **#592–#625** — those are the live tracker, not "0 open". Re-derive counts from the Gitea API before relying on any figure in this subsection; the §4.1 rows for telemetry eviction, the `TODO(v3 WaveC)` set and the `/raw` Browse-device placeholder are resolved (struck through below). + > **Read this subsection, not the table below it.** The 2026-07-30 table that follows is preserved as the > record of what the remediation left behind; every tracker item in it has since closed. The live picture > is here. @@ -362,11 +364,11 @@ commented-out DI registrations.** Real deferred work lives in prose doc-comments | Item | Evidence | |---|---| -| **Telemetry snapshot cache never evicted** — a decommissioned driver replays last-known Health/Resilience to new subscribers forever | `TelemetryLocalHub.cs:40` `TODO(mesh-phase5+)` — self-declared, bounded, "pre-production, accepted" | -| **Device host recovered by string-matching instead of `DeviceConfig`** — 7 of the repo's 9 TODOs are this one item, across 4 drivers | `FocasDriver.cs:137,287`; `TwinCATDriver.cs:775`; `AbLegacyDriver.cs:557`; `AbCipDriver.cs:89,1272` + `AbCipDriverOptions.cs:130` — all `TODO(v3 WaveC)` | +| ~~**Telemetry snapshot cache never evicted** — a decommissioned driver replays last-known Health/Resilience to new subscribers forever~~ ✅ **FIXED** — `TelemetryLocalHub.Evict` drops a removed driver's snapshots (`DriverHostActor.StopChild` via `plan.ToStop`, #609); central-side stores are not evicted | `TelemetryLocalHub.cs:40` `TODO(mesh-phase5+)` — self-declared, bounded, "pre-production, accepted" | +| ~~**Device host recovered by string-matching instead of `DeviceConfig`** — 7 of the repo's 9 TODOs are this one item, across 4 drivers~~ ✅ **RESOLVED (#608)** — no `TODO(v3 WaveC)` remains in `src/` (re-verified 2026-10-08) | `FocasDriver.cs:137,287`; `TwinCATDriver.cs:775`; `AbLegacyDriver.cs:557`; `AbCipDriver.cs:89,1272` + `AbCipDriverOptions.cs:130` — all `TODO(v3 WaveC)` | | **Galaxy writes can never confirm a COM commit** — empty status array ⇒ provisional Good, metered `galaxy.writes.unconfirmed` | `GatewayGalaxyDataWriter.cs:44,328,354` — **cross-repo**, blocked on mxaccessgw | | **Subscription liveness undetectable** — `ISubscriptionHandle` is opaque | `DriverInstanceActor.cs:527` (#488) — well-reasoned, deferred "until a real occurrence" | -| **`/raw` "Browse device" is still a placeholder** despite the universal discovery browser landing | `RawTree.razor:15,426` — **verify**; may be reachable now via `DiscoveryDriverBrowser` | +| ~~**`/raw` "Browse device" is still a placeholder** despite the universal discovery browser landing~~ ✅ **RESOLVED** — `RawTree.razor` wires `OnBrowseDevice` to the discovery modal | `RawTree.razor:15,426` — **verify**; may be reachable now via `DiscoveryDriverBrowser` | ### 4.2 Per-driver diff --git a/docs/AlarmTracking.md b/docs/AlarmTracking.md index 445037d0..afc82cc2 100644 --- a/docs/AlarmTracking.md +++ b/docs/AlarmTracking.md @@ -398,9 +398,11 @@ Scripted alarms use a separate inbound path that converges on the `OtOpcUaNodeManager` wires the Part 9 condition methods (Acknowledge / Confirm / AddComment / OneShotShelve / TimedShelve / Unshelve) on each scripted-alarm `AlarmConditionState` node. Every call is **gated on the -`AlarmAck` LDAP role** — fail-closed: sessions with no role or without -`AlarmAck` group membership receive `BadUserAccessDenied` immediately. -The LDAP-resolved role set is carried past `OpcUaApplicationHost` by +`AlarmAck` role** — fail-closed: sessions with no role or without +an `AlarmAck`-conferring grant receive `BadUserAccessDenied` immediately. +The session's roles come from its API-key principal's `NodeAcl` grants (or the +reserved `anonymous` principal's; LDAP is AdminUI-only — `docs/security.md`) and are carried past +`OpcUaApplicationHost` by `RoleCarryingUserIdentity` (a `UserIdentity` subclass), making it readable inside the method handler at dispatch time. diff --git a/docs/Client.CLI.md b/docs/Client.CLI.md index 61b73e2a..e72d6403 100644 --- a/docs/Client.CLI.md +++ b/docs/Client.CLI.md @@ -250,7 +250,7 @@ otopcua-cli alarms -u opc.tcp://localhost:4840/OtOpcUa \ ### ack -Acknowledges an active alarm condition via the OPC UA Part 9 `Acknowledge` method. The server gates this on the **`AlarmAck`** LDAP role (fail-closed — a session without `AlarmAck` group membership gets `BadUserAccessDenied`; see [ScriptedAlarms.md](ScriptedAlarms.md) §"AlarmAck gate"). The `--event-id` is the hex-encoded `EventId` taken from a prior `alarms` subscription notification for the same condition. +Acknowledges an active alarm condition via the OPC UA Part 9 `Acknowledge` method. The server gates this on the **`AlarmAck`** role (fail-closed — a session whose API-key principal holds no `AlarmAck`-conferring `NodeAcl` grant gets `BadUserAccessDenied`; the data plane does not use LDAP; see [ScriptedAlarms.md](ScriptedAlarms.md) §"AlarmAck gate"). The `--event-id` is the hex-encoded `EventId` taken from a prior `alarms` subscription notification for the same condition. ```bash otopcua-cli ack -u opc.tcp://localhost:4840/OtOpcUa \ diff --git a/docs/DriverLifecycle.md b/docs/DriverLifecycle.md index 60320bc8..c1651178 100644 --- a/docs/DriverLifecycle.md +++ b/docs/DriverLifecycle.md @@ -311,12 +311,18 @@ shutdown paths deliberately keep the single trailing write. > **A value consumer never reports a quality better than its source's.** A node derived from a driver > value — the raw node, every UNS mirror, a Calculation output, a virtual tag, a scripted condition's > quality — carries, at worst, the worst status among its inputs: a Bad input makes the derived node Bad -> **with that input's sub-code and the last-known value**; an Uncertain input makes it Uncertain; a source +> **with that input's sub-code** (the last-known value is kept server-side, but see the note below: a client never sees it beside a Bad status); an Uncertain input makes it Uncertain; a source > that has stopped communicating is a Bad input (`BadNoCommunication`) for everything downstream of it, > **including the external OPC UA data plane**. Recovery is a *publish*, never an assumption: the first > post-recovery value must land even when it equals the value held before the outage, so every dedup on > the path — driver caches, the node's own status, a consumer's last-published memory — must be keyed on > **(value, status)**, not value alone. +> +> **"Value retained" is server-side state, not wire-observable.** The node keeps its last value under the +> Bad status (that is what lets the next Good re-publish carry it and what the `(value, status)` dedup +> compares against), but the OPC UA SDK's `NodeState.ReadAttribute` nulls the value of any Bad-status read +> and monitored-item sample, so a client sees `Value = null` with the Bad status and never the last-known +> value. Pinned server-side by `OtOpcUaNodeManagerWriteStatusesTests`; observation: `archreview/plans/R3-02-stale-good-family-plan.md`. This is one rule with one implementation of its comparison, `ZB.MOM.WW.OtOpcUa.Core.Abstractions.InputQuality` (`WorstOf` / `IsUsable`), used by every derived-value @@ -325,7 +331,7 @@ a fifth (archreview round 3, theme "the stale-Good family"). The seams that impl | Seam | What it does | Where | |---|---|---| -| Driver comms loss → **value nodes** | one status-only batch stamps `BadNoCommunication` on the driver's raw NodeIds and every referencing UNS NodeId, **value retained** | `DriverHostActor.FanCommsLossToValueNodes` (below) | +| Driver comms loss → **value nodes** | one status-only batch stamps `BadNoCommunication` on the driver's raw NodeIds and every referencing UNS NodeId, **value retained server-side** (clients see a null value with the Bad status) | `DriverHostActor.FanCommsLossToValueNodes` (below) | | Driver comms loss → dependency mux | synthetic Bad per ref, so scripts and alarms see the source die | `DriverHostActor.FanCommsLossToDependencyMux` | | Driver comms loss → Part 9 conditions | condition quality annotated out of band | `DriverHostActor.OnDriverConnectivityChanged` | | MQTT / Sparkplug | connection loss stales observed refs; every (re)subscribe pushes the cached snapshot | [`docs/drivers/Mqtt.md`](drivers/Mqtt.md#quality-on-connection-loss-and-re-subscribe) | diff --git a/docs/OpcUaServer.md b/docs/OpcUaServer.md index 1c17345c..b9f7f6f5 100644 --- a/docs/OpcUaServer.md +++ b/docs/OpcUaServer.md @@ -56,8 +56,9 @@ The server binds a TCP endpoint at `opc.tcp://{PublicHostname}:{OpcUaPort}/OtOpc `OpcUaApplicationHost` subscribes to `SessionManager.ImpersonateUser` after `ApplicationInstance.Start`. The handler (`HandleImpersonation`) deals with the token types as follows: -- `UserNameIdentityToken` → the password is decrypted, then `IOpcUaUserAuthenticator.AuthenticateUserNameAsync` validates the credential (`LdapOpcUaUserAuthenticator` in production, a stub in tests). On success a `UserIdentity` carrying the token is attached and the LDAP-derived roles are logged; on failure `ImpersonateEventArgs.IdentityValidationError` is set to `BadIdentityTokenRejected`. -- `AnonymousIdentityToken` and X.509 tokens → the handler returns without intervening, so the SDK's default validation stands. +- `UserNameIdentityToken` → the password is decrypted, then `IOpcUaUserAuthenticator.AuthenticateUserNameAsync` validates the credential (`ApiKeyOpcUaUserAuthenticator` in production — fixed username `apikey`, `otua__` password, no network I/O — and a stub in tests). On success a `RoleCarryingUserIdentity` carrying the token and the key's resolved roles is attached; on failure `ImpersonateEventArgs.IdentityValidationError` is set to `BadIdentityTokenRejected`. +- `AnonymousIdentityToken` → admitted only when `OpcUa:AllowAnonymous` (default `true`); the session then authenticates as the reserved `anonymous` principal and carries whatever roles `NodeAcl` rows naming it grant (`HandleAnonymousImpersonation`). A failure to read those grants denies. +- X.509 tokens → the handler returns without intervening, so the SDK's default validation stands. Decryption failures and authenticator exceptions also map to `BadIdentityTokenRejected`. diff --git a/docs/ScriptedAlarms.md b/docs/ScriptedAlarms.md index b7b61e4f..eeebc949 100644 --- a/docs/ScriptedAlarms.md +++ b/docs/ScriptedAlarms.md @@ -347,7 +347,7 @@ Operators interact with active scripted alarms through two surfaces — both con ### AlarmAck gate (OPC UA method path) -`OtOpcUaNodeManager` wires the OPC UA Part 9 condition methods (Acknowledge / Confirm / AddComment / OneShotShelve / TimedShelve / Unshelve / Enable / Disable) on each `AlarmConditionState` node. Every method call is gated on the `AlarmAck` LDAP role — fail-closed: a session with no resolved roles or no `AlarmAck` group membership receives `BadUserAccessDenied` immediately without reaching the engine. The role is carried on the session by `RoleCarryingUserIdentity` (a `UserIdentity` subclass that preserves the LDAP-resolved role set past `OpcUaApplicationHost`). +`OtOpcUaNodeManager` wires the OPC UA Part 9 condition methods (Acknowledge / Confirm / AddComment / OneShotShelve / TimedShelve / Unshelve / Enable / Disable) on each `AlarmConditionState` node. Every method call is gated on the `AlarmAck` role — fail-closed: a session with no resolved roles or no `AlarmAck`-conferring grant on its API-key principal (`NodeAcl`; LDAP is AdminUI-only, see `docs/security.md`) receives `BadUserAccessDenied` immediately without reaching the engine. The role is carried on the session by `RoleCarryingUserIdentity` (a `UserIdentity` subclass that preserves the API-key-resolved role set past `OpcUaApplicationHost`). On allow, the handler publishes a `Commons.OpcUa.AlarmCommand` (containing command kind, condition id, comment, and operator principal) onto the `alarm-commands` DPS topic. The node manager itself stays Akka-free: the dispatch action is a settable `Action` injected at boot by the hosted service. diff --git a/docs/Telemetry.md b/docs/Telemetry.md index b4430173..f653a071 100644 --- a/docs/Telemetry.md +++ b/docs/Telemetry.md @@ -140,9 +140,9 @@ Three things to know before touching it: The digest is a flattened, host-name-ordered **string** for the converse reason: a tuple holding an `IReadOnlyList` compares by reference and would re-publish on every 30 s heartbeat forever. -⚠️ **`DriverInstanceResilienceStatus` is the same defect, still open.** That table also has no writer -and no reader — the only reference in the repo is its DbSet declaration — while the live data reaches -the AdminUI over the `driver-resilience-status` channel above. Keep-or-delete is Gitea **#524**. +**`DriverInstanceResilienceStatus` was dropped (Gitea #524, migration +`DropDriverInstanceResilienceStatusTable`).** The table had no writer and no reader; the live data +reaches the AdminUI over the `driver-resilience-status` channel above, which is its only home. ## The three non-migrated channels (NOT migrated in Phase 5 — do not read this as "seven done") diff --git a/docs/Uns.md b/docs/Uns.md index 15a1277a..575fe19e 100644 --- a/docs/Uns.md +++ b/docs/Uns.md @@ -391,7 +391,8 @@ See the individual driver docs under `docs/drivers/` for per-driver implementati An equipment tag is **writable by default** — the materialised OPC UA node is created `CurrentReadWrite` and inbound client writes are routed to the device (subject to the -`WriteOperate` LDAP role gate). Setting the optional `"writable": false` key in a tag's +`WriteOperate` gate — a role the session's API-key principal earns from its `NodeAcl` grants, see +[security.md](security.md)). Setting the optional `"writable": false` key in a tag's `TagConfig` makes the node **read-only** at the driver seam. The six typed tag editors (Modbus, S7, AB CIP, AB Legacy, TwinCAT, FOCAS) expose this as a **Writable** checkbox (checked by default); it can also be hand-authored in the raw-JSON textarea. @@ -405,7 +406,7 @@ An equipment tag is **writable by default** — the materialised OPC UA node is The key is honoured by Modbus, S7, AB CIP, AB Legacy, and TwinCAT. **FOCAS forces read-only regardless** — FOCAS PMC writes are unsupported in the wire client, so its editor shows the checkbox disabled/unchecked and any authored `writable:true` is ignored -(and flagged at the deploy gate below). Node-level authz (LDAP `WriteOperate`) remains the +(and flagged at the deploy gate below). Node-level authz (`WriteOperate` via the API-key principal's grants, not LDAP) remains the effective write gate above this flag. ## Deploy-time TagConfig validation (`Deployment:TagConfigValidationMode`) diff --git a/docs/drivers/Calculation.md b/docs/drivers/Calculation.md index e808467a..db7f6f7f 100644 --- a/docs/drivers/Calculation.md +++ b/docs/drivers/Calculation.md @@ -121,7 +121,8 @@ Notes worth knowing before authoring against this: ## Error semantics An evaluator failure publishes **Bad quality carrying the last-known value** — the value is not -zeroed, because a stale-but-flagged reading is more useful to an operator than a fabricated one. It +zeroed, so the node holds it server-side for the next Good re-publish and the `(value, status)` dedup. (Over the wire the +OPC UA SDK nulls the value of a Bad-status read, so a client sees only the Bad status — see `docs/DriverLifecycle.md`.) It publishes: - **once per Good→Bad transition**, not on every failed evaluation (a permanently broken script does diff --git a/docs/drivers/MTConnect.md b/docs/drivers/MTConnect.md index 3768a4c1..1068984b 100644 --- a/docs/drivers/MTConnect.md +++ b/docs/drivers/MTConnect.md @@ -378,10 +378,11 @@ for anything beyond values-and-conditions. — it would materialise nodes nobody approved and no deployment artifact records. - **`IHostConnectivityProbe` is still unconsumed** — `GetHostStatuses()` has - no production call site and nothing writes a `DriverHostStatus` row. That - half remains a fleet-wide gap affecting every driver that implements it - (Gitea #521). + **`IHostConnectivityProbe` is consumed** (Gitea #521, closed) — + `DriverInstanceActor` subscribes `OnHostStatusChanged`, pulls + `GetHostStatuses()` fresh, and the result rides `DriverHealthChanged` to + the AdminUI `/hosts` page (no SQL; the `DriverHostStatus` table was + dropped). See `docs/Telemetry.md`. 2. **`RawTagEntry.DeviceName` is accepted on the wire shape but ignored for routing.** One driver instance owns exactly one Agent client scoped by `MTConnectDriverOptions.DeviceName` (the top-level config key); the diff --git a/docs/drivers/Mqtt.md b/docs/drivers/Mqtt.md index 5a064550..bfbb3dc1 100644 --- a/docs/drivers/Mqtt.md +++ b/docs/drivers/Mqtt.md @@ -428,7 +428,7 @@ which is archreview 05/STAB-18. - **Connection loss stales, keeping the value.** On the `Connected → Reconnecting` edge `MqttConnection.ConnectionLost` fires, and both ingest paths mark every **already-observed** - reference `BadNoCommunication` with its **last value retained** and publish the change. A + reference `BadNoCommunication` with its **last value retained** (server-side; a client reads the Bad status with a null value) and publish the change. A never-observed reference (still `BadWaitingForInitialData`) and an already-Bad one are left alone. The event does **not** fire for a failed *initial* connect or during dispose — neither is an outage. - **Every (re)subscribe pushes the cached snapshots.** `SubscribeAsync` republishes each observed diff --git a/docs/plans/2026-08-15-perf-roadmap-remainder.md.tasks.json b/docs/plans/2026-08-15-perf-roadmap-remainder.md.tasks.json index b07c6c1d..6765c402 100644 --- a/docs/plans/2026-08-15-perf-roadmap-remainder.md.tasks.json +++ b/docs/plans/2026-08-15-perf-roadmap-remainder.md.tasks.json @@ -1,19 +1,19 @@ { "planPath": "docs/plans/2026-08-15-perf-roadmap-remainder.md", "tasks": [ - {"id": 1, "subject": "Task 1: Outbox batch append with group commit", "status": "in_progress", "note": "6ff64ca0 + fix 73205865 (incremental bookkeeping, second-reader durability pin); re-review in flight"}, + {"id": 1, "subject": "Task 1: Outbox batch append with group commit", "status": "completed", "note": "6ff64ca0 + fix 73205865 (incremental bookkeeping, second-reader durability pin); merged to master (verified 2026-10-08)"}, {"id": 2, "subject": "Task 2: Batched mux→subscriber delivery, single-eval virtual tags", "status": "completed", "note": "content inside 140bf0c1 (shared-index sweep; message belongs to Task 22 — needs an outcomes note); spec + code approved; code review's two Importants (yielding-fake batch test, mid-batch exception policy) folded into Task 4's brief"}, {"id": 3, "subject": "Task 3: Parallel drain with bounded concurrency", "status": "completed", "note": "a76b4e07; spec + code APPROVED; follow-up (inherited, pre-existing): bare catch around WriteLiveValuesAsync swallows OperationCanceledException at shutdown → spurious _tagWriteFailures increments when succeeded>0; recommend rethrowing genuine ct cancellations"}, {"id": 4, "subject": "Task 4: Recorder batched ingest — one fsync per burst", "status": "completed", "blockedBy": [1, 2, 3], "note": "384568b4 + follow-up 451273c8 (append-failure branch pinned via 3 independent mutations, loss-accounting docs corrected, recorder 64→1024 literals aligned); all reviews APPROVED; register: #491 falsification nondeterministic under load, IngestFaults not surfaced in docs/gauges, latch re-arms on any non-throwing item"}, {"id": 5, "subject": "Task 5: Historian defaults, config drift, docs", "status": "completed", "blockedBy": [3, 4], "note": "0298a0b9 (new Historian.md key table, cross-repo batch-RPC follow-up recorded); code APPROVED; recorder 64→1024 literals aligned in 451273c8; minor: pinning-test doc comment overclaims appsettings-drift coverage"}, {"id": 6, "subject": "Task 6: Per-session decision-cache shards", "status": "completed", "note": "debd44c2; follow-ups: _globalCount drift under races, concurrency stress test, bound-ratio comment"}, - {"id": 7, "subject": "Task 7: Authorizer hot path — lookup first, no closures, lazy deny reasons", "status": "in_progress", "blockedBy": [6]}, + {"id": 7, "subject": "Task 7: Authorizer hot path — lookup first, no closures, lazy deny reasons", "status": "completed", "blockedBy": [6], "note": "d8a17f2e; merged to master (verified 2026-10-08)"}, {"id": 8, "subject": "Task 8: Browse and TranslateBrowsePath compaction", "status": "completed", "note": "541a223b + follow-up test aad7583b (survivor-order pin, falsified red, review APPROVED); minor: FanOutTargetIdList fake couples to SDK single-Add contract — comment if pattern reused"}, - {"id": 9, "subject": "Task 9: RevalidateMonitoredItems dedup", "status": "in_progress", "blockedBy": [8], "note": "fbe8aaad; realm-qualified 3-tuple key (safer than spec); spec+code reviews in flight; watch: its clock-count observable couples to DataPlaneAuthorizer reading the clock once — Task 7 edits that file"}, + {"id": 9, "subject": "Task 9: RevalidateMonitoredItems dedup", "status": "completed", "blockedBy": [8], "note": "fbe8aaad; realm-qualified 3-tuple key (safer than spec); merged to master (verified 2026-10-08); watch: its clock-count observable couples to DataPlaneAuthorizer reading the clock once — Task 7 edits that file"}, {"id": 10, "subject": "Task 10: Modbus InitializeAsync transport leak", "status": "completed", "note": "c3970a2e + 409a58b6"}, {"id": 11, "subject": "Task 11: PollGroupEngine fixed-rate pacing, overrun counter, async unsubscribe", "status": "completed", "blockedBy": [10, 12, 13, 14], "note": "9cf400f1 + tolerance fix 485f4ea1 (fast-reader test: 200ms interval, <=1 blip); spec ✅ + code APPROVED; follow-ups: NoteOverrun conflates slow-success/slow-failure (doc caveat), backoffCap param doc note for future callers"}, {"id": 12, "subject": "Task 12: S7 narrow-scalar span codec", "status": "completed", "note": "8d91833f; SByte spec mention was a plan error"}, - {"id": 13, "subject": "Task 13: S7 span planner + grouped ReadAsync", "status": "in_progress", "blockedBy": [12], "note": "39f67a0f; spec passed (demotion rule + factory-binding 6th file accepted); code review in flight; follow-up: live gate vs s7_1500 fixture before trusting 16/220 defaults"}, + {"id": 13, "subject": "Task 13: S7 span planner + grouped ReadAsync", "status": "completed", "blockedBy": [12], "note": "39f67a0f; spec passed (demotion rule + factory-binding 6th file accepted); merged to master (verified 2026-10-08); follow-up: live gate vs s7_1500 fixture before trusting 16/220 defaults"}, {"id": 14, "subject": "Task 14: AbCip bounded-parallel reads", "status": "completed", "note": "d6a4fbd6 + fix d5f573b0 (per-device ConnectGate single-flight); re-review APPROVED; minor follow-ups: stale EvictRuntime-race comment ~L1161, test timing margin on slow CI"}, {"id": 15, "subject": "Task 15: Dirty-set coalescing in ScriptedAlarmEngine", "status": "completed", "note": "5df793e7; follow-ups: TrackBackgroundTask registration-ordering gap (pre-existing), stale line-334 comment, non-volatile _disposed"}, {"id": 16, "subject": "Task 16: Deployments list projection", "status": "completed", "note": "dbc9a132"}, diff --git a/docs/plans/2026-08-17-perf-followups-r3.md.tasks.json b/docs/plans/2026-08-17-perf-followups-r3.md.tasks.json index 10258dd7..4cbd7357 100644 --- a/docs/plans/2026-08-17-perf-followups-r3.md.tasks.json +++ b/docs/plans/2026-08-17-perf-followups-r3.md.tasks.json @@ -8,7 +8,7 @@ {"id": 5, "subject": "Task 5: Live gate — FetchAndCache legs", "status": "completed", "note": "GATE PASSED, no commit. All 6 legs: fetch+SHA-verify on all 4 site nodes (1584 bytes from central-1:4055), Served×4 central-side, applied-FETCHED + cached on site-a-1 (acks=6), boot-from-cache after restart (restored rev matches what it cached 52s earlier), absence proof (0 Stale/ConfigDb-back/Direct-form/ERR/WRN over full lifetime), post-restart browse proves both namespaces materialised (ns=2 raw + ns=3 UNS). Method: same-revision deploys short-circuit the fetch path (immediate-ACK at HandleDispatchFromSteady) — gate forced a temp UnsArea row then reverted; rig config restored byte-identical (rev 315de861). Register: central-2 serve path unexercised (first-success endpoint walk always hit central-1)"}, {"id": 6, "subject": "Task 6: Live gate — alarm-bearing surgical ratchet", "status": "completed", "note": "GATE PASSED, no commit. Four deploys, all routings correct: Writable flip ⇒ kind=AttributeOnly surgicalRawTags=1 rebuild=False; alarm ADD ⇒ kind=Rebuild rebuild=True; alarm REMOVE ⇒ rebuild=True (previous-side disqualifies); restore ⇒ surgical again (ratchet per-deploy, not latched). All sealed 6/6 acks. Authoring: direct UPDATE on the rig's OWN sql container (Database=OtOpcUa — NOT 10.100.0.35; sqlcmd needs -I for QUOTED_IDENTIFIER). Findings: only cluster-MAIN nodes emit applier lines (site plans empty via ResolveClusterScope, apply early-returns); artifact hash can never return to baseline after any UPDATE — ConfigComposer snapshots SQL rowversion (verified by gunzip+diff: only the RowVersion differs). Rig config semantically restored; rig UP"}, {"id": 7, "subject": "Task 7: Live gate — ACL-enabled enforcement on site-a", "status": "completed", "note": "GATE PASSED, no commit. TOPOLOGY ADAPTATION: gated node = central-1 via scratchpad-only compose overlay (site-a serves NO value nodes — the rig's one tag is cluster MAIN; central-2 became the ungated A/B control). ALLOW leg genuinely load-bearing: ungated baseline captured first — same key+call DENIED 0x801F0000 with ACL off (Tag grant confers no role), SUCCEEDED with ACL on ⇒ ACL decision REPLACES the role check, live. Deny×3 (anonymous write, out-of-scope browse, ungated control reads fine); revocation split into grant-removal (gen 1→2, primed per-key shard discarded — exercises this round's CAS-loop path) and key-disable (pre-cache re-check). Authoring: DB fallback (Chrome extension not connected) — ApiKeySecret hashing reproduced from source, pepper verified live. Restore byte-identical (rev a3a9061b), zero residue. FINDINGS for register: (1) browse transit stops below the realm root — grant holder cannot browse DOWN to its own tag from ns=2;s=OtOpcUa (fails closed; file as issue); (2) ns=0 core nodes are ungated (CoreNodeManager bypasses DataPlaneAuthorizer) — boot warning overstates 'see nothing'"}, - {"id": 8, "subject": "Task 8: Close-out — sweep + Outcomes + register annotations", "status": "completed", "blockedBy": [1, 2, 3, 4, 5, 6, 7, 9, 10], "note": "c4d6cba7 (Outcomes + sweep record + r2 register annotations, +383/−0) + the coordinator's flake-register append. Rig torn down BEFORE the sweep (rig-up skews skips: Host.IntegrationTests 229/0/2 up vs 226/0/5 down). Build 0 errors / 904 warnings — IDENTICAL to r2 (note: incremental builds under-report at 896). Sweep 43/43 green: 10,377 / 0 / 19 — +7 reconciled exactly (OpcUaServer +1 T1, S7 +6 T2); skips identical project-for-project; AdminUI 1540/0-skipped + Configuration 190/0-skipped ran vs 10.100.0.35,14330. Host.IntegrationTests (outside the 43-project baseline) +9 vs merge-base (LocalDbSetupTests 7→15, WiringTests 6→7). One flake isolated: Client.CLI LoggerLifecycleTests — pre-existing global-Serilog race, no r3 commit touches it, registered"} + {"id": 8, "subject": "Task 8: Close-out — sweep + Outcomes + register annotations", "status": "completed", "blockedBy": [1, 2, 3, 4, 5, 6, 7, 9, 10], "note": "c4d6cba7 (Outcomes + sweep record + r2 register annotations, +383/−0) + the coordinator's flake-register append. Rig torn down BEFORE the sweep (rig-up skews skips: Host.IntegrationTests 229/0/2 up vs 226/0/5 down). Build 0 errors / 904 warnings — IDENTICAL to r2 (note: incremental builds under-report at 896). Sweep 43/43 green: 10,377 / 0 / 19 — +7 reconciled exactly (OpcUaServer +1 T1, S7 +6 T2); skips identical project-for-project; AdminUI 1540/0-skipped + Configuration 190/0-skipped ran vs 10.100.0.35,14330. Host.IntegrationTests (outside the 43-project baseline) +9 vs merge-base (LocalDbSetupTests 7→15, WiringTests 6→7). One flake isolated: Client.CLI LoggerLifecycleTests — pre-existing global-Serilog race, no r3 commit touches it, registered"}, {"id": 9, "subject": "Task 9: Roslyn evaluator racing test — remove timing assumption (ScadaBridge row 38)", "status": "completed", "note": "34829ea0 + 837d8a17 (review round). Option (b): renamed to Evaluate_racing_ClearCompiledScripts_never_leaks_a_raw_disposed_failure; three-bucket classification (leaked=fail-first, exhausted + starved each bounded max(evals/10,8)); duration-bounded loops (10s OR 400 iters) — the REAL load failure was the test's own 45s WaitAsync budget under recompile pressure (old shape RED 4/4 at 4-way concurrency, new GREEN 4/4 + 8/8); reviewer-found starvation-timeout flake vector closed (TimedScriptEvaluator's 5s clock starts at enqueue), falsified via 12ms-budget probe (old classification RED, new GREEN at 0.11%); literals promoted to shared ScriptEvalFailureReasons in Commons (both evaluators + test). Honest disclosure: disposed race does not reproduce on this arm64 box at all. Spec ✅ + code CHANGES REQUESTED → delta re-APPROVED. Host.IntegrationTests 229/0/2, Calculation 53/53"}, {"id": 10, "subject": "Task 10: LocalDb conditional registration + baseline + deregister self-heal (ScadaBridge row 39)", "status": "completed", "note": "3e62f161 + df533916 (review round: content-based data-survival pin, mid-loop loud-fail pin via exhaustive delegating fake, Log.Logger assumption comment). Spec ✅ + code APPROVED + delta re-APPROVED. Predicate = PeerAddress OR ApiKey (SyncListenPort deliberately out — passive endpoint fail-closed on key); 0.2.1 APIs verified by reflection+probe (baseline seeds hlc=0; DeregisterReplicated returns bool) — NO pin bump; 0.3.1 now published (ScadaBridge heads-up) — bump recorded as deliberate both-nodes-together future change (bookkeeping v2→v3 one-way). Surfaced+fixed two vacuous-fixture bugs (PairHarness/AlarmSfLegacyMigrator two-config split). scadaproj index updated + pushed (1145df6). Suites: LocalDbSetupTests 15/15, Host.IntegrationTests 226/0/5"} ],