448 lines
31 KiB
Markdown
448 lines
31 KiB
Markdown
# Deferred Closeout Implementation Plan
|
||
|
||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development to implement this plan task-by-task (Opus implementers per user instruction; reviewer chain per each task's Classification).
|
||
|
||
**Goal:** Close every item the two perf-remediation plans left recorded-but-open: the dead
|
||
`ISessionManager.ReadEventsAsync` chain, the frame-writer lock-parking latency follow-up,
|
||
SEC-25 (per-session dashboard event ACL, design already approved in
|
||
`docs/plans/2026-07-10-dashboard-session-acl-tst15.md`), the structural alarm-truncation
|
||
degraded-status signal (proto change), and the wnwrap live-alarm probes (GUID identity,
|
||
`ALARM_RECORDS/@COUNT` semantics) that need windev state.
|
||
|
||
**Architecture:** Same two-phase posture as the prior plans. Gateway-side work builds and
|
||
tests on macOS via `NonWindows.slnx`; worker-side work (frame writer, alarm consumer,
|
||
worker command executor) is edited on the Mac and verified on windev. **This plan touches
|
||
`.proto` contracts** (Task 8) — contracts regeneration and all five clients rebuild are in
|
||
scope (Task 9), unlike the prior two plans. The windev probe task (Task 3) is gated on
|
||
external state (live alarms) and may legitimately end "blocked — recorded".
|
||
|
||
**Tech stack:** .NET 10 gateway / .NET Framework 4.8 x86 worker / protobuf contracts /
|
||
five language clients / Blazor Server dashboard / GLAuth LDAP.
|
||
|
||
**Branch:** `feat/deferred-closeout` off `main` (`ac3f04f`).
|
||
|
||
---
|
||
|
||
## Ground rules for every implementer subagent
|
||
|
||
- NEVER run `git stash`, `git reset`, `git clean`, or `git checkout <sha/branch>`. Commit
|
||
with explicit pathspecs only — never `git add -A` / `git commit -a`.
|
||
- Build/test mutual exclusion: before any `dotnet build`/`dotnet test`, acquire the lock via
|
||
`mkdir /private/tmp/claude-501/-Users-dohertj2-Desktop-MxAccessGateway/f36938ae-bbca-4245-b5c9-fac512d69e22/scratchpad/buildlock`
|
||
(retry with backoff while it fails); `rmdir` it on ALL exit paths, including failures.
|
||
- `TreatWarningsAsErrors=true`, `Nullable=enable` — new warnings break the build; fix, don't suppress.
|
||
- Follow `docs/style-guides/CSharpStyleGuide.md`: file-scoped namespaces, `sealed` by
|
||
default, `Async` suffix, MXAccess-aligned names.
|
||
- Update affected docs in the same commit as the source change.
|
||
- MXAccess parity is the contract; never synthesize events.
|
||
- Never log secrets, API keys, credentials, or tag values.
|
||
- The `Files:` block is the scope contract. If the task can't be done inside it, that's a
|
||
plan defect — surface it, don't silently expand scope.
|
||
- On macOS build `NonWindows.slnx`; the x86 Worker and full `slnx` only build on windev.
|
||
|
||
---
|
||
|
||
## Task 1: Remove the dead `ISessionManager.ReadEventsAsync` chain
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~6 min
|
||
**Parallelizable with:** Task 2, Task 3, Task 5
|
||
|
||
**Files:**
|
||
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Sessions/ISessionManager.cs:42` (remove member)
|
||
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionManager.cs:191-197` (remove implementation)
|
||
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Sessions/GatewaySession.cs` (~:1517-1545 remove `ReadEventsAsync`; ~:767-776 rewrite the "keep the two bodies in step" comment on `MapWorkerEventsAsync` — with the twin gone it now claims the single worker-event read path directly)
|
||
- Modify: every test fake implementing `ISessionManager` (grep `ISessionManager` under `src/ZB.MOM.WW.MxGateway.Tests/` — the as-built note in `docs/plans/2026-08-15-deferred-remediation.md` estimated ~15 touches; remove the member from each fake and any tests that exercised it *through the interface*)
|
||
- Modify: `docs/plans/2026-08-15-deferred-remediation.md` as-built note "Task 3 — ReadEventsAsync retained" (append one line: removed by this plan, date)
|
||
|
||
**Spec:** `ISessionManager.ReadEventsAsync` has zero production call sites — only test fakes
|
||
implement and exercise it. Remove the interface member, `SessionManager`'s forwarder, and
|
||
`GatewaySession.ReadEventsAsync` (verify with grep first that `MapWorkerEventsAsync` and the
|
||
fakes are truly the only remaining references — if a production caller appears, STOP and
|
||
report; do not force it). **`IWorkerClient.ReadEventsAsync` / `WorkerClient.ReadEventsAsync`
|
||
stay** — that is the live worker-channel claim; most grep hits are that member, read carefully.
|
||
Tests that existed solely to exercise the pass-through die with it; tests that used a fake's
|
||
`ReadEventsAsync` as a convenience seam get rewired to the distributor path or removed if
|
||
redundant — judgment call, state it in the commit body.
|
||
|
||
**Steps:** grep callers → edit → `dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx` →
|
||
`dotnet test src/ZB.MOM.WW.MxGateway.Tests/... --filter "FullyQualifiedName~GatewaySession"`,
|
||
`~SessionManager`, `~EventStreamService` → commit
|
||
`refactor(sessions): remove the dead ISessionManager.ReadEventsAsync chain`.
|
||
|
||
---
|
||
|
||
## Task 2: Frame-writer lock-parking — awaited control-frame completion unparked from the winner's pass
|
||
|
||
**Classification:** high-risk
|
||
**Estimated implement time:** ~10 min
|
||
**Parallelizable with:** Task 1, Task 3, Task 5
|
||
|
||
**Files:**
|
||
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerFrameWriter.cs`
|
||
- Test: `src/ZB.MOM.WW.MxGateway.Worker.Tests/Ipc/` frame-writer tests (windev-run; write them now)
|
||
- Modify: `docs/WorkerFrameProtocol.md` (completion-latency contract paragraph)
|
||
- Modify: `docs/plans/2026-08-15-deferred-remediation.md` as-built note "Task 10" (append: lock-parking closed by this plan)
|
||
|
||
**Spec:** Today `WriteAsync` enqueues the frame, then unconditionally contends for
|
||
`_writeLock`; a caller that loses the race stays parked in `WaitAsync` until the winning
|
||
drainer releases the lock — even though the winner writes *and flushes* the loser's control
|
||
frame mid-pass and completes its per-frame completion source at that moment
|
||
(`WorkerFrameWriter.cs:112-116` documents this parking as the deliberate residual). Close it:
|
||
after enqueueing, the caller awaits **its own frame's completion** racing the lock
|
||
acquisition — when the completion resolves first (frame written+flushed by the winner), the
|
||
caller returns immediately; its abandoned lock-wait must not leak drain responsibility.
|
||
Shape (implementer refines, invariants below are the contract):
|
||
|
||
1. Await `Task.WhenAny(frame.Completion.Task, lockWaitTask)`.
|
||
2. Completion first → detach: register a continuation on `lockWaitTask` that, on acquisition,
|
||
drains any queued frames if present and releases — the lock is never acquired-and-dropped,
|
||
and a frame enqueued between the winner's last dequeue and its release still gets drained
|
||
(the existing "drain everything you can see, then release" loop already covers most of
|
||
this; the continuation is the backstop for the abandoned waiter).
|
||
3. Lock first → drain as today.
|
||
4. Cancellation during the combined wait keeps the existing tombstone semantics
|
||
(`WorkerFrameWriter.cs:104-110`): tombstone only if a drainer hasn't claimed the frame;
|
||
a claimed frame completes normally and cancellation is *not* surfaced for it.
|
||
|
||
**Invariants that must hold (write a test for each):** every enqueued frame is eventually
|
||
written or tombstoned (no stranded frame when the completion-first path abandons its lock
|
||
wait); completions still resolve only after write+flush; batch API (`WriteBatchAsync`)
|
||
semantics unchanged; no double-drain / double-release; a control-frame caller racing a long
|
||
event batch observes its completion before the batch drain finishes (the latency win this
|
||
task exists for — assert with a gated slow-stream fake).
|
||
|
||
**Steps:** edit → macOS `dotnet build` of the shared-source projects is NOT possible for the
|
||
worker — verify compile on windev (`dotnet build src\ZB.MOM.WW.MxGateway.Worker\... -p:Platform=x86`)
|
||
via `ssh windev` before commit if feasible, else mark the commit "edited, windev-pending" and
|
||
Task 10/11 gates it → commit
|
||
`perf(worker): unpark awaited control-frame writers from the winning drain pass`.
|
||
|
||
---
|
||
|
||
## Task 3: windev live-alarm probes — GUID identity and `ALARM_RECORDS/@COUNT` semantics
|
||
|
||
**Classification:** standard (investigation; no gateway code — findings doc only)
|
||
**Estimated implement time:** ~15 min wall (timeboxed; may end "blocked")
|
||
**Parallelizable with:** Task 1, Task 2, Task 4, Task 5
|
||
|
||
**Files:**
|
||
- Read (on windev): `src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/WnWrapConsumerProbeTests.cs` (Skip-gated probe; flip `Skip=null` locally on windev, never commit the flip)
|
||
- Create: `docs/AlarmProbeFindings.md` (findings record, or the explicit "blocked" record)
|
||
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs` — comments ONLY, and only if a finding confirms/refutes a documented assumption (no behavior change in this task)
|
||
|
||
**Spec:** Two questions only a live rig answers:
|
||
1. **GUID identity semantics:** is the alarm record GUID stable for one alarm *instance*
|
||
across polls and across state transitions (UNACK→ACK→RTN)? `ComputeTransitions` keys the
|
||
diff on GUID (`latestSnapshot: Dictionary<Guid, ...>`); if wnwrap mints a new GUID on a
|
||
state change, a transition would read as clear+new instead.
|
||
2. **`ALARM_RECORDS/@COUNT`:** when the fetch is capped (`maxAlmCnt` < actives), does the
|
||
reply's `COUNT` attribute carry the *total* active count (a usable "more available"
|
||
signal) or just the records-in-reply count? If it carries the total,
|
||
`IsTruncatedFetch` can become exact instead of the ≥cap heuristic — feed that finding to Task 8.
|
||
|
||
**Steps:**
|
||
1. `ssh windev` (lands in PowerShell; CI clone `C:\build\mxaccessgw-ci`; first build after a
|
||
pull may fail on stale Contracts obj — clear `src\ZB.MOM.WW.MxGateway.Contracts\obj,bin`
|
||
and rebuild, it is not a regression). Pull the branch.
|
||
2. Determine whether live alarms exist or can be raised: check provider state; try driving a
|
||
known alarmed attribute over its limit via a gateway write or a Galaxy test object.
|
||
Timebox 15 minutes. If no alarm can be made active: write `docs/AlarmProbeFindings.md`
|
||
recording exactly what was tried and that both questions remain open, commit, STOP.
|
||
3. With ≥1 live alarm: run the probe (`WnWrapConsumerProbeTests`, Skip flipped locally, cap
|
||
`maxAlmCnt` low, e.g. 1–2, to force truncation), ack the alarm, let it RTN, capture the
|
||
XML across the transitions. Record: GUID per state, `COUNT` vs records-in-reply under the
|
||
forced cap.
|
||
4. Write findings + implications (for `ComputeTransitions` and `IsTruncatedFetch`) into
|
||
`docs/AlarmProbeFindings.md`; adjust `WnWrapAlarmConsumer` comments where an assumption is
|
||
now confirmed/refuted. Any *behavioral* fix the findings demand is reported to the
|
||
orchestrator (feeds Task 8, or a follow-on task if it's transition-identity surgery) — not
|
||
done here.
|
||
5. Commit `docs(alarms): wnwrap live-probe findings — GUID identity, ALARM_RECORDS COUNT` .
|
||
|
||
---
|
||
|
||
## Task 4: SEC-25 groundwork — `DashboardTags` on the API key, `Tags` on the session
|
||
|
||
**Classification:** high-risk
|
||
**Estimated implement time:** ~8 min
|
||
**Parallelizable with:** Task 2, Task 3, Task 5 (NOT Task 1 — both touch `GatewaySession.cs` and session fakes; run after Task 1 lands)
|
||
|
||
**Files:**
|
||
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyConstraints.cs` (add `IReadOnlyList<string> DashboardTags`, default empty)
|
||
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyConstraintSerializer.cs` (round-trip the new field; absent-in-JSON → empty — old rows keep deserializing)
|
||
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAdminCommandLineParser.cs` + `ApiKeyAdminCliRunner.cs` + `ApiKeyAdminCommand.cs` + `ApiKeyAdminListedKey.cs` (CLI `--dashboard-tags team-a,team-b` on `create-key` — there is no `update` subcommand; shown in list output)
|
||
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Sessions/GatewaySession.cs` (new `public IReadOnlySet<string> Tags { get; }`, set at construction from the owner key's effective constraints; empty = untagged)
|
||
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionManager.cs` (`OpenSession` path copies `ApiKeyIdentity.EffectiveConstraints.DashboardTags` onto the session)
|
||
- Test: serializer round-trip incl. legacy-JSON-without-field; CLI parse; session tag inheritance via the fake-worker harness
|
||
- Modify: `docs/Authorization.md` (`DashboardTags` is dashboard-visibility-only, never a data-access constraint)
|
||
|
||
**Spec:** Implements §3 of `docs/plans/2026-07-10-dashboard-session-acl-tst15.md`. The §9
|
||
open call is settled per the design's own recommendation: **the tag rides in the existing
|
||
`ApiKeyConstraints` JSON blob — no SQLite schema migration.** Tags are immutable per session,
|
||
assigned once at `OpenSession` from the owning key. No enforcement in this task — the field
|
||
and its plumbing only. Case handling: preserve tag strings as entered; comparisons later
|
||
(Task 6) are ordinal-ignore-case — note that on the property doc.
|
||
|
||
**Steps:** edit → build NonWindows.slnx → targeted tests (`~ApiKeyConstraint`, `~ApiKeyAdmin`,
|
||
`~SessionManager`) → commit
|
||
`feat(security): DashboardTags on API-key constraints; sessions inherit owner tags (SEC-25 groundwork)`.
|
||
|
||
---
|
||
|
||
## Task 5: SEC-25 config — `GroupToTag`, `UntaggedSessionVisibility`, validator, mapper
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~6 min
|
||
**Parallelizable with:** Task 1, Task 2, Task 3, Task 4
|
||
|
||
**Files:**
|
||
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Configuration/` — `DashboardOptions` (`Dictionary<string,string[]> GroupToTag` ordinal-ignore-case; `UntaggedSessionVisibility` enum `AdminOnly`(default)`|AllViewers`) + `GatewayOptionsValidator` (keys non-empty, tag arrays non-null/non-empty entries, enum known — mirror the `GroupToRole` validation shape)
|
||
- Create: group→tag mapper as a sibling of `DashboardGroupRoleMapping` (union of tags over the principal's LDAP groups; unknown group → nothing)
|
||
- Test: validator accept/reject cases; mapper union/unknown-group/case-insensitivity
|
||
- Modify: `docs/GatewayConfiguration.md` (`Dashboard:GroupToTag`, `Dashboard:UntaggedSessionVisibility` rows)
|
||
|
||
**Spec:** §3.2 of the design doc, exactly. No coupling to `GroupToRole` — a group may appear
|
||
in one, the other, or both.
|
||
|
||
**Steps:** edit → build → `--filter "FullyQualifiedName~GatewayOptionsValidator"` + mapper
|
||
tests → commit `feat(dashboard): GroupToTag / UntaggedSessionVisibility config (SEC-25)`.
|
||
|
||
---
|
||
|
||
## Task 6: SEC-25 enforcement — ACL service, token/cookie tag claims, both subscribe seams gated
|
||
|
||
**Classification:** high-risk
|
||
**Estimated implement time:** ~10 min
|
||
**Parallelizable with:** none (needs Tasks 4 + 5)
|
||
|
||
**Files:**
|
||
- Create: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/` `IDashboardSessionAcl` + implementation (`CanViewSession(ClaimsPrincipal, string sessionId)`; DI singleton)
|
||
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/HubTokenService.cs` (payload gains `string[]? Tags`; mint stamps resolved granted tags as `zb:dashboardtag` claims; validate rehydrates them)
|
||
- Modify: dashboard cookie principal creation (`DashboardAuthenticator.CreatePrincipal` per the design doc — grep for the exact site) so cookie-authenticated circuits carry the same tag claims
|
||
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/EventsHub.cs:42-60` (`SubscribeSession` checks the ACL; deny → `HubException`, no group join; **remove the `TODO(per-session-acl)`**)
|
||
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionDetailsPage.razor` (in-process seam gate: resolve the circuit principal via `AuthenticationStateProvider`, check `IDashboardSessionAcl.CanViewSession` **before** `Subscribe(SessionId)`; deny → render "Not authorized for this session's events." in place of the event panel, no subscription created)
|
||
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs` (register the ACL)
|
||
- Test: `DashboardSessionAclTests` (admin bypass; session-not-found deny; untagged under both visibility values; tag intersection match/non-match; principal-with-no-claims = empty-grant Viewer); `HubTokenService` tag round-trip; `EventsHub` deny-does-not-join; SessionDetailsPage gate (deny renders banner, allow subscribes — follow the existing bUnit/component test idiom in `Gateway/Dashboard/`)
|
||
- Modify: `docs/plans/2026-07-10-dashboard-session-acl-tst15.md` — add an as-built note: the 2026-08 in-process feed refactor created a **second seam** (`IDashboardSessionEventSubscriber` used by SessionDetailsPage); "gating at join is sufficient — no second seam" (§4) no longer held, so the same ACL now gates both.
|
||
|
||
**Spec:** §§2, 4, 4.1 of the design doc, with one deviation the doc predates: the dashboard
|
||
now also consumes session events **in-process** (SessionDetailsPage → `Subscribe(sessionId)`),
|
||
so enforcement lands in two places — the hub `SubscribeSession` (remote surface) and the
|
||
page-side check before the in-process subscribe. ACL decision order: Admin role → allow;
|
||
session unknown → deny; untagged → `UntaggedSessionVisibility == AllViewers`; else
|
||
`session.Tags ∩ grantedTags ≠ ∅` (ordinal-ignore-case). Anonymous localhost = Viewer with
|
||
empty grant (§4.1) — this *tightens* SEC-02's loopback posture by design; `DisableLogin`
|
||
auto-login carries both roles → admin bypass, unchanged. Fail closed everywhere. Never log
|
||
tag grants alongside credentials.
|
||
|
||
**Steps:** edit → build → `--filter "~DashboardSessionAcl"`, `~EventsHub`, `~HubTokenService`,
|
||
`~SessionDetailsPage` → commit
|
||
`feat(dashboard): per-session event ACL on both subscribe seams (SEC-25 / TST-15)`.
|
||
|
||
---
|
||
|
||
## Task 7: SEC-25 closure — live-LDAP tests, docs sweep, design-doc status
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~7 min
|
||
**Parallelizable with:** Task 8, Task 9
|
||
|
||
**Files:**
|
||
- Test: extend `DashboardLdapLiveTests` (gated `MXGATEWAY_RUN_LIVE_LDAP_TESTS=1`): map the existing GLAuth groups via config — `gw-viewer`'s group granted `team-a` through `Dashboard:GroupToTag`, a `team-a`-tagged session allows, a `team-b`-tagged session denies; `multi-role` (Admin) allows both. **Use the existing GLAuth users/groups (`glauth.md`) — no GLAuth server change.**
|
||
- Modify: `docs/Sessions.md` (session-tag model, dashboard event visibility), `gateway.md` dashboard section, `CLAUDE.md` dashboard-auth paragraph (Viewer is tag-scoped; Admin sees all; anonymous localhost = empty-grant Viewer), `glauth.md` (test-grant mapping used by the live tests — config-side only), `docs/GatewayDashboardDesign.md` (remove/replace the two "SEC-25 outstanding" passages at ~:277 and ~:699 — the ACL now exists; describe it)
|
||
- Modify: `docs/plans/2026-07-10-dashboard-session-acl-tst15.md` header — Status: Implemented (this plan, date)
|
||
- Modify: `archreview` tracking rows for TST-15/SEC-25 if a `Not started` row exists (mark Done with commit ref)
|
||
|
||
**Spec:** §§7–8 of the design doc. Live tests are opt-in-gated exactly like the existing
|
||
`LiveLdapFactAttribute` suite; they must skip cleanly where GLAuth is unreachable.
|
||
|
||
**Steps:** edit → build → run unit portions + (if GLAuth reachable from this Mac,
|
||
`MXGATEWAY_RUN_LIVE_LDAP_TESTS=1` run; else record skipped per `docs/GatewayTesting.md`) →
|
||
commit `test(dashboard)+docs: SEC-25 live-LDAP ACL coverage; design marked implemented`.
|
||
|
||
---
|
||
|
||
## Task 8: Alarm-truncation degraded-status signal — proto + worker + gateway + dashboard
|
||
|
||
**Classification:** high-risk
|
||
**Estimated implement time:** ~10 min
|
||
**Parallelizable with:** Task 7 (after Task 3's findings are in hand, or Task 3 recorded "blocked")
|
||
|
||
**Files:**
|
||
- Modify: `src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto` — `ActiveAlarmSnapshot` gains `bool from_truncated_snapshot` (next free field number); `QueryActiveAlarmsReplyPayload` gains `bool snapshot_truncated`. Additive only; comment style per the file.
|
||
- Regenerate: `dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj` (never hand-edit `Generated/`)
|
||
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs` (expose the retained truncated state, e.g. `bool LastSnapshotTruncated`, maintained where `ApplySnapshotUpdate` already receives `truncated`) + `IMxAccessAlarmConsumer.cs` + the `QueryActiveAlarms` reply builder in `MxAccessCommandExecutor.cs`/`AlarmDispatcher.cs` (stamp both new fields)
|
||
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs` + `IGatewayAlarmService.cs` (+ its implementation) — propagate degraded state to the dashboard snapshot model (`DashboardActiveAlarm.cs` or a sibling flag on the alarm snapshot the service exposes)
|
||
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Grpc/MxAccessGatewayService.cs` — the public `QueryActiveAlarms` stream carries `from_truncated_snapshot` through
|
||
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/AlarmsPage.razor` — degraded banner ("Alarm snapshot may be incomplete — provider returned a capped fetch") driven by the service flag; reuse the existing alert-banner idiom, do not disturb the poll-loop structure landed in `dcbec97`
|
||
- Test: worker XML tests (`WnWrapAlarmConsumerXmlTests` idiom) for the exposed state; gateway fake-worker test that a truncated reply surfaces the flag end-to-end; AlarmsPage banner test
|
||
- Modify: `gateway.md` (alarm surface paragraph), `docs/DesignDecisions.md` (one entry: why per-record flag + reply-payload flag, wire-compatibility, absence-inference already suppressed worker-side since the truncation cliff fix)
|
||
|
||
**Spec:** The truncation cliff fix (perf plan Task 23) made transitions safe but *silent*:
|
||
only a worker-stderr warning says the snapshot is degraded. Give it a structural signal:
|
||
worker stamps truncation state into the `QueryActiveAlarms` reply; gateway propagates it to
|
||
the public stream (per-record flag — the RPC returns a bare `stream ActiveAlarmSnapshot`
|
||
with no envelope, so a per-record boolean is the only additive carrier) and to the dashboard
|
||
alarm service; AlarmsPage shows the degraded banner. **If Task 3 found that
|
||
`ALARM_RECORDS/@COUNT` carries the true total, also replace the `IsTruncatedFetch` ≥cap
|
||
heuristic with the exact comparison in the same commit** (parse `@COUNT`, compare to records
|
||
delivered; keep the heuristic as fallback when the attribute is absent) — cite the findings
|
||
doc. If Task 3 was blocked, keep the heuristic untouched and say so in the commit body.
|
||
MXAccess parity note: this flag describes *our* fetch mechanics, not provider behavior — it
|
||
is additive gateway metadata, not a parity deviation.
|
||
|
||
**Steps:** proto edit → regenerate → worker+gateway edits → build NonWindows.slnx + gateway
|
||
tests (`~Alarm`, `~AlarmsPage`) — worker compile/tests defer to the Task 11 windev gate →
|
||
commit `feat(alarms): structural degraded-status signal for truncated alarm snapshots`.
|
||
|
||
---
|
||
|
||
## Task 9: Client regeneration + rebuild for the new alarm fields
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~8 min
|
||
**Parallelizable with:** Task 7 (needs Task 8's proto committed)
|
||
|
||
**Files:**
|
||
- Regenerate per each client's README: `clients/dotnet`, `clients/python`, `clients/rust`, `clients/java`, Go (`clients/go` / `mxgw-go` layout — follow its README)
|
||
- Modify: each client's README alarm section IF it documents the snapshot shape (one line: `from_truncated_snapshot` means the provider fetch was capped and absent-implies-cleared inference was suspended)
|
||
- Test: `dotnet build clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx` + tests; `python -m pytest` in `clients/python`; `cargo fmt && cargo check --workspace && cargo test --workspace && cargo clippy --all-targets -- -D warnings` in `clients/rust`; `gradle test` in `clients/java`; `gofmt`/`go build ./...`/`go test ./...` in `clients/go`
|
||
|
||
**Spec:** Additive proto fields — codegen carries them; no typed wrapper work unless a client
|
||
already wraps `ActiveAlarmSnapshot` in a typed model (then add the field there too, following
|
||
how `ReplayGap` surfacing was done per-client). All five clients must build and test green.
|
||
Use the build lock around each build/test.
|
||
|
||
**Steps:** regenerate → build/test each client → commit
|
||
`chore(clients): regenerate for alarm truncation fields; READMEs note the degraded flag`.
|
||
|
||
---
|
||
|
||
## Task 10: Phase gate — full gateway suite on macOS
|
||
|
||
**Classification:** high-risk (gate)
|
||
**Estimated implement time:** ~5 min wall
|
||
**Parallelizable with:** none (after Tasks 1, 4–9)
|
||
|
||
Full `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj` (with the
|
||
build lock) + `dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx`. Any failure: fix
|
||
forward with a scoped commit, re-run the failed filter, then re-run the full suite once.
|
||
|
||
---
|
||
|
||
## Task 11: windev gate — full Windows verification
|
||
|
||
**Classification:** high-risk (gate)
|
||
**Estimated implement time:** ~15 min wall
|
||
**Parallelizable with:** none (last verification)
|
||
|
||
On windev (`ssh windev`, PowerShell, clone `C:\build\mxaccessgw-ci`; expect the stale
|
||
Contracts-obj first-build quirk — clear `src\ZB.MOM.WW.MxGateway.Contracts\obj,bin` and
|
||
rebuild, not a regression):
|
||
|
||
```powershell
|
||
git pull # the feat/deferred-closeout branch
|
||
dotnet build src\ZB.MOM.WW.MxGateway.slnx
|
||
dotnet build src\ZB.MOM.WW.MxGateway.Worker\ZB.MOM.WW.MxGateway.Worker.csproj -p:Platform=x86
|
||
dotnet test src\ZB.MOM.WW.MxGateway.Worker.Tests\ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86
|
||
dotnet test src\ZB.MOM.WW.MxGateway.Tests\ZB.MOM.WW.MxGateway.Tests.csproj
|
||
```
|
||
|
||
This is the first verification of Task 2's writer restructure and Task 8's worker-side
|
||
changes — expect iteration; fix on the Mac, commit, re-run the failed leg. Live MXAccess
|
||
smoke (`MXGATEWAY_RUN_LIVE_MXACCESS_TESTS=1`) if provider state allows; else record why
|
||
skipped.
|
||
|
||
---
|
||
|
||
## Task 12: Wrap-up — closure notes, umbrella check, final review
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none (last)
|
||
|
||
- Append closure lines to `docs/plans/2026-08-15-deferred-remediation.md`'s out-of-scope
|
||
table (each row: resolved by this plan + date, or — for the probe row if Task 3 blocked —
|
||
"probes attempted <date>, blocked on live alarms, findings doc records the attempt").
|
||
- Check `../scadaproj/CLAUDE.md`'s MxAccessGateway entry: the proto *contents* changed
|
||
(additive fields) — update only if the index records a fact that changed (expected: no
|
||
change needed; verify, don't assume).
|
||
- Update `.tasks.json`; update auto-memory (`perf-remediation-branch.md` or successor).
|
||
- Dispatch the final integration code review (Opus) over `git diff main..feat/deferred-closeout`.
|
||
Merge remains the user's decision.
|
||
|
||
---
|
||
|
||
## Explicitly out of scope
|
||
|
||
| Item | Why |
|
||
|---|---|
|
||
| gRPC "all-sessions admin scope" (epic Task 16's gRPC half) | Stays with the session-resilience epic; the design doc itself scopes it out of TST-15. |
|
||
| Session-list row filtering by tag on SessionsPage | The approved design gates *event subscription* only; list-metadata visibility is a separate call. |
|
||
| jti-denylist token revocation | Rejected in the design (§10.3); 5-min token lifetime bounds grant staleness. |
|
||
| Behavioral rework of `ComputeTransitions` identity if the GUID probe refutes stability | Needs its own reviewed design against real captures; Task 3 records the evidence, a follow-on plan acts on it. |
|
||
| `MxAccessWriteCompletionCache` clone | Consciously kept (prior plan Task 12.5); unchanged posture. |
|
||
|
||
---
|
||
|
||
## As-built notes (execution record, 2026-08-17)
|
||
|
||
All 12 tasks completed on `feat/deferred-closeout`; every implement went through its
|
||
classification's review chain to Approved. Verification: macOS build 0W/0E + gateway
|
||
1123/1123; windev full slnx 0W/0E, worker x86 523/523 (11 standing opt-in skips),
|
||
gateway 1123/1123, live MXAccess smoke 8/8 — green first try, no stale-obj quirk.
|
||
|
||
- **Task 3 ended blocked, with evidence.** The rig refuses the alarm-condition writes
|
||
(`SecurityError` from the responding automation object — the test UDAs need
|
||
`AuthenticateUser`+`WriteSecured` or the in-engine flip script that drove the
|
||
2026-05-01 capture). `docs/AlarmProbeFindings.md` records what was tried, the
|
||
partial GUID answer (active→returned leg confirmed), and the unblock paths.
|
||
- **Commit `693a78d` contains two tasks' work.** A concurrent implementer's `git commit`
|
||
without pathspecs swept Task 6's staged ACL files into Task 8's alarm commit. Nothing
|
||
was lost; both halves were reviewed separately and their fix rounds (`7ec0b35`,
|
||
`b9fb0dd`) are clean single-task commits. Process rule tightened mid-run: pathspecs
|
||
on the commit itself, not just the add.
|
||
- **Task 8's atomicity fix went structural.** Review found the flag/snapshot pairing
|
||
relied on STA serialization while claiming a read-order guarantee; the fix made
|
||
`SnapshotActiveAlarms(out bool truncated)` the *only* accessor, so the unpaired read
|
||
is unexpressible.
|
||
- **Task 6 review caught a real regression** (async attach re-entrancy under rapid
|
||
navigation) — closed with a generation guard and a mutation-verified interleaving test.
|
||
- **Live-LDAP ACL tests ran green against the shared GLAuth** (7/7, plus 7/7 clean-skip
|
||
without the env gate). `multi-role` sits in both GLAuth groups, so the Admin-bypass
|
||
test asserts the sessions a dropped bypass would actually lose (team-b + untagged).
|
||
- **Task 9 found and fixed pre-existing drift**: `clients/rust/protos/mxaccess_gateway.proto`
|
||
had diverged from Contracts (masked by the in-repo build path); refreshed byte-identical,
|
||
and the client protoset descriptors were regenerated.
|
||
|
||
Follow-ups recorded, not started:
|
||
- `IGatewayAlarmService.StreamAsync` / `AlarmFeedMessage` does not carry the truncation
|
||
signal — live central-feed consumers (lmxopcua, ScadaBridge) cannot see snapshot
|
||
degradation; add if those consumers need completeness reasoning.
|
||
- No guard keeps `clients/rust/protos/` in sync with Contracts (a `diff` check in
|
||
`scripts/check-codegen.ps1` would close it).
|
||
- `EffectiveDashboardConfiguration` (dashboard settings page) doesn't display
|
||
`GroupToTag` / `UntaggedSessionVisibility`, though it shows `GroupToRole`.
|
||
- ApiKeysPage's `ConstraintText` neither offers tag input nor lists `DashboardTags`,
|
||
and since `IsEmpty` now counts tags, a tags-only key renders `-` where a truly
|
||
unconstrained key renders `unconstrained` — two spellings of one meaning.
|
||
- `AlarmsHubPublisher` broadcasts alarm transitions with `current_value`/`limit_value`
|
||
unredacted — the `ShowTagValues` redaction covers only the events hub mirror
|
||
(pre-existing; now noted in `docs/GatewayConfiguration.md`).
|
||
- The alarm probes' remaining questions (ack-leg GUID stability, `@COUNT` semantics)
|
||
unblock via the paths in `docs/AlarmProbeFindings.md`.
|
||
|
||
---
|
||
|
||
## Execution notes for the orchestrator
|
||
|
||
- Branch `feat/deferred-closeout` off `main` before Task 1.
|
||
- Opus implementers per user instruction; reviewer chain per Classification
|
||
(high-risk = spec-reviewer serial then code-reviewer; standard = parallel pair; small = code-reviewer only).
|
||
- Waves: **Wave 1:** 1, 2, 3, 5 · **Wave 2:** 4 (after 1) · **Wave 3:** 6 (after 4+5), 8 (after 3 findings/blocked) ·
|
||
**Wave 4:** 7, 9 · then 10 → 11 → 12. Per-task `Parallelizable with` fields are the contract.
|
||
- Each implementer gets its full task text + the ground rules block. `Files:` is the scope contract.
|
||
- Task 3 and Task 11 run against windev over `ssh windev` (PowerShell); psbridge is available
|
||
as fallback transport.
|