# 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 here — all closed or narrowed on 2026-08-18 (branch
`feat/followup-closeout`, plan `docs/plans/2026-08-17-followup-closeout.md`).
Each bullet states what it said when written, then its closure:

- `IGatewayAlarmService.StreamAsync` / `AlarmFeedMessage` did not carry the truncation
  signal — live central-feed consumers (lmxopcua, ScadaBridge) could not see snapshot
  degradation. **Closed 2026-08-18 — shipped.** `AlarmSnapshotStatus` with
  `snapshot_status = 5` on the feed: gateway-side edge emission plus open-time priming
  (`fccf753`, `312e072`), rolled out to all five clients (`c748361`, `4d4f443`).
- "No guard keeps `clients/rust/protos/` in sync with Contracts." **Was stale when
  written.** `scripts/check-codegen.ps1` Check 3 already existed and already byte-compared
  by SHA-256 in CI. The real gap was narrower — the sweep ran in one direction only, so a
  canonical proto with no vendored copy passed. **Closed 2026-08-18** by `711793f`: Check 3
  now sweeps both directions and a canonical proto missing its vendored copy fails.
- `EffectiveDashboardConfiguration` (dashboard settings page) didn't display
  `GroupToTag` / `UntaggedSessionVisibility`, though it showed `GroupToRole`.
  **Closed 2026-08-18 — shipped** (`094f2ff`; comparer propagation fixed in `ff16c69`).
- ApiKeysPage's `ConstraintText` neither offered tag input nor listed `DashboardTags`,
  and since `IsEmpty` counts tags, a tags-only key rendered `-` where a truly
  unconstrained key rendered `unconstrained`. **Closed 2026-08-18 — shipped** (`c037d99`),
  with the create form de-duplicating typed tags ordinal-ignore-case to match the CLI
  (`fab600d`).
- `AlarmsHubPublisher` broadcast alarm transitions with `current_value`/`limit_value`
  unredacted — the `ShowTagValues` redaction covered only the events hub mirror.
  **Closed 2026-08-18** together with the `/browse` residual: `ShowTagValues` now gates
  all three seams (`eff17d1`, `1ea6f60`, `e50ec49`).
- The alarm probes' remaining questions (ack-leg GUID stability, `@COUNT` semantics).
  **Narrowed 2026-08-18** by a second probe attempt that used the secured-write verb the
  first attempt lacked (`90331b6`, `540a118`):
  - `@COUNT` — **answered.** It reports records in the reply, not matching records in the
    provider, so exact truncation detection through it is permanently refuted; the
    heuristic in `IsTruncatedFetch` stays as the only available signal.
  - clear-then-re-raise GUID — **answered.** The re-raise carries a new GUID that replaces
    the record rather than coexisting with it, which makes the existing `Clear`+`Raise`
    handling correct as written.
  - ack leg — **still blocked**, now with a sharper cause: the ack returns `rc=0` and is
    accepted but inert, so the GUID never moves. Remaining unblock paths are recorded 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.
