Files
mxaccessgw/docs/plans/2026-08-18-followups-and-tickets.md
T
Joseph Doherty fd941c249e
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m21s
ci / java (push) Successful in 2m11s
ci / portable (push) Successful in 8m53s
docs(plan): followups-and-tickets — 10 tasks closing every recorded follow-up
2026-08-18 06:36:47 -04:00

462 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Follow-ups and Tickets Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development
> to execute this plan task-by-task in this session.
**Goal:** Close every follow-up recorded in `docs/plans/2026-08-17-followup-closeout.md`
(as-built notes, "Follow-ups recorded, not started") plus the two ticket-worthy windev
findings — leaving no recorded open item anywhere.
**Architecture:** No new components. Ten independent closures: two toolchain/script fixes
(Check 4 Windows pin, Gradle 9 `checkGeneratedClean`), two dashboard display gaps
(Settings recent-limit rows, push-driven Alarms truncation banner), two documentation
audits (Server-0xx resolution regression sweep, `Authentication.md` runnable-examples
pass), one worker-test investigation (the deterministic `WorkerPipeSessionTests` failure
on windev), one bounded rig probe (ack-leg unblock paths), a windev verification pass
(including the `protoc-gen-go-grpc` 1.6.2 pin install), and bookkeeping.
**Tech Stack:** PowerShell (codegen scripts), Gradle/Groovy (Java client), Blazor
server-side Razor + xUnit/bUnit-style render tests (dashboard), .NET Framework 4.8 x86
xUnit (worker tests, windev-only), wnwrap probe harness (windev rig), Markdown.
**Branch:** `feat/followups-tickets` off `main` (`45058d5`).
---
## Ground rules for every implementer subagent
- **Git discipline (Mac tree):** NEVER `git stash`, `git reset`, `git clean`, or
`git checkout <sha/branch>`. Commit with **pathspecs on the commit**:
`git commit -m "..." -- <paths>`. If `index.lock` blocks you, wait 5 s and retry.
- **Build/test lock:** before any `dotnet build`/`dotnet test`/`cargo`/`gradle`/`go test`
on the Mac, acquire the lock:
`mkdir /private/tmp/claude-501/-Users-dohertj2-Desktop-MxAccessGateway/f36938ae-bbca-4245-b5c9-fac512d69e22/scratchpad/buildlock`
(retry with backoff until it succeeds); `rmdir` it on ALL exit paths, including failure.
- **Quality gates:** `TreatWarningsAsErrors=true`, `Nullable=enable`. Follow
`docs/style-guides/CSharpStyleGuide.md` (file-scoped namespaces, `sealed` by default,
`Async` suffix). Update affected docs in the same commit as source.
- **MXAccess parity:** never synthesize events; the dashboard/gateway forwards only what
the worker/monitor emits. The `snapshot_status` frame is a gateway-status frame (like
`provider_status`), NOT a synthesized MXAccess event.
- **Never log or echo secrets, API keys, credentials, or tag values.**
- **Scope contract:** the task's `Files:` block is the `files_to_edit` contract. If the
task needs other files, that is a plan defect — surface it in your report, don't
silently expand scope. (Exception: Tasks 7 and 8 are investigations; their Files list
is starting points, and they must report every file they end up touching.)
- **Windev access (Tasks 7, 8, 9 only):** `ssh windev` lands in PowerShell. The CI clone
is `C:\build\mxaccessgw-ci`. Quirks: the first `slnx` build after a pull often fails on
stale Contracts obj artifacts (CS2001/CS0016) — clear the Contracts `obj`/`bin` and
rebuild, it is not a regression; the gateway suite is load-sensitive — rerun a filtered
subset before believing a flake. To get branch code there:
`git -C C:\build\mxaccessgw-ci fetch origin && git -C C:\build\mxaccessgw-ci checkout feat/followups-tickets && git -C C:\build\mxaccessgw-ci pull`
which requires the Mac side to have pushed the branch first (the controller pushes;
ask if the branch tip you need is not on origin).
---
### Task 1: check-codegen Check 4 — tolerate the Windows `.exe` version banner
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 2, Task 3, Task 4, Task 5, Task 7
**Files:**
- Modify: `clients/go/generate-proto.ps1` (pin compare at `:52-67`, pin constants at `:10-11`)
- Modify: any doc that states Check 4 is unrunnable on Windows (grep `docs/` and
`code-reviews/` for "Check 4"; `docs/GatewayTesting.md` is the likely holder)
**Problem.** On Windows the plugins report their invoked name with an `.exe` suffix
(`protoc-gen-go.exe v1.36.11`), so the exact-string compare against
`'protoc-gen-go v1.36.11'` throws and Check 4 (which shells this script) is unrunnable on
any Windows host — including windev, the box where regeneration actually happens.
**Step 1: Normalize the banner before comparing.** Add a small helper and use it for both
plugin compares (protoc stays warn-only and gets the same normalization for a fair warn):
```powershell
function Get-NormalizedToolVersion {
# On Windows a plugin reports its argv[0] name, so the banner carries an `.exe`
# suffix ("protoc-gen-go.exe v1.36.11"). Strip it so the pin compare is
# host-independent; the version part must still match exactly.
param([string]$RawBanner)
return ($RawBanner -replace '\.exe(?=\s)', '')
}
```
Apply to `$protocGenGoVersion`, `$protocGenGoGrpcVersion`, `$protocVersion` at the point
each is read (`:54`, `:59`, `:64`), keeping the pinned constants unchanged. Update the
comment block at `:4-9` to note the normalization.
**Step 2: Verify on macOS.** Acquire the build lock, then run
`pwsh scripts/check-codegen.ps1` — all 4 checks must pass (banner has no `.exe` here, so
this proves no regression). Also assert the helper logic directly:
`pwsh -c "& { <paste helper> ; Get-NormalizedToolVersion 'protoc-gen-go.exe v1.36.11' }"`
must print `protoc-gen-go v1.36.11`, and a name-only match check for
`protoc-gen-go-grpc.exe 1.6.2``protoc-gen-go-grpc 1.6.2`.
**Step 3: Fix the stale prose.** Wherever docs state Check 4 cannot run on Windows,
rewrite to: Check 4 runs on Windows; the plugin pins must be installed
(`go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.11`,
`go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.2`).
Do NOT edit `docs/plans/2026-08-17-followup-closeout.md` — Task 10 owns plan bookkeeping.
**Step 4: Commit** with pathspecs on the touched files:
`fix(codegen): normalize .exe off plugin version banners so Check 4 runs on Windows`.
Windows-side proof is deferred to Task 9 (windev runs check-codegen 14 after installing
the 1.6.2 pin).
---
### Task 2: Gradle 9 — revive `checkGeneratedClean` without `Project.exec`
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 1, Task 3, Task 4, Task 5, Task 7
**Files:**
- Modify: `clients/java/zb-mom-ww-mxgateway-client/build.gradle` (`checkGeneratedClean`, `:71-92`)
- Modify: `docs/GatewayTesting.md` only if it describes the task's mechanism (grep
`checkGeneratedClean`)
**Problem.** The task's `doLast` calls the script-level `exec {}` (i.e. `Project.exec`),
removed in Gradle 9 — the task is dead on any Gradle 9 host.
**Step 1: Replace with `ProviderFactory.exec`** (available since Gradle 7.5, so it works
on both the current toolchain and Gradle 9, and is configuration-cache safe). Capture the
provider reference at configuration time, call `.get()` in `doLast` so the git status
runs at execution time:
```groovy
tasks.register('checkGeneratedClean') {
group = 'verification'
description = 'Fails if the committed generated Java tree differs from a fresh regeneration.'
dependsOn 'generateProto'
def generatedDir = 'clients/java/src/main/generated'
def repoRoot = rootProject.projectDir.parentFile.parentFile
// Project.exec was removed in Gradle 9; ProviderFactory.exec runs the probe lazily
// at doLast time and works on Gradle 7.5+.
def gitStatus = providers.exec {
workingDir = repoRoot
commandLine 'git', 'status', '--porcelain', '--', generatedDir
ignoreExitValue = true
}
doLast {
def dirty = gitStatus.standardOutput.asText.get().trim()
if (!dirty.isEmpty()) {
throw new GradleException(
"Generated Java is stale:\n${dirty}\n" +
"Regenerate and commit the Java client after a .proto change " +
"(gradle :zb-mom-ww-mxgateway-client:generateProto).")
}
}
}
```
Preserve the explanatory comment block above the task (`:61-70`) — amend it, don't delete.
**Step 2: Verify.** Acquire the build lock. From `clients/java`:
`gradle :zb-mom-ww-mxgateway-client:checkGeneratedClean` must pass (tree is clean), and
`gradle --version` must be recorded in your report. Then prove the failure path: touch a
scratch edit inside `clients/java/src/main/generated/` (append a comment line to one
generated file), rerun the task, confirm it fails with the stale message, then **revert
that file with `git checkout -- <that one file>`** (this narrow file-level checkout is the
one permitted use; the file is committed generated output).
**Step 3: Commit:**
`fix(java-client): checkGeneratedClean via ProviderFactory.exec — Project.exec is gone in Gradle 9`.
---
### Task 3: SettingsPage — RecentFaultLimit / RecentSessionLimit rows
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 1, Task 2, Task 4, Task 5, Task 7
**Files:**
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SettingsPage.razor`
(dashboard rows around `:98-102`)
- Test: `src/ZB.MOM.WW.MxGateway.Tests/` — extend the existing settings render coverage
(`SettingsPageTagVisibilityRenderTests` or its sibling settings render test class; find
with `grep -rl "SettingsPage" src/ZB.MOM.WW.MxGateway.Tests`)
- Modify: `docs/GatewayDashboardDesign.md` only if it enumerates settings rows (grep
`RecentFaultLimit` / "Snapshot interval" there first)
**Problem.** `EffectiveDashboardConfiguration` carries `RecentFaultLimit` and
`RecentSessionLimit` (already projected by `GatewayConfigurationProvider.cs:62-63`), but
`SettingsPage` renders every member except these two.
**Step 1: Write the failing test.** Extend the settings render test: rendered page
contains `Recent fault limit` with the configured value and `Recent session limit` with
the configured value (use non-default values in the arranged options so the assertion
proves plumbing, not defaults).
**Step 2: Run it, expect FAIL** (`dotnet test --filter "FullyQualifiedName~SettingsPage"`
under the build lock).
**Step 3: Add the two rows** next to the other Dashboard rows (after "Snapshot interval",
matching the existing `<tr><th scope="row">…</th><td>…</td></tr>` idiom):
```razor
<tr><th scope="row">Recent fault limit</th><td>@Snapshot.Configuration.Dashboard.RecentFaultLimit</td></tr>
<tr><th scope="row">Recent session limit</th><td>@Snapshot.Configuration.Dashboard.RecentSessionLimit</td></tr>
```
**Step 4: Run the filtered test, expect PASS.**
**Step 5: Commit:**
`feat(dashboard): settings page shows RecentFaultLimit and RecentSessionLimit`.
---
### Task 4: Authentication.md — runnable-as-written examples pass
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 1, Task 2, Task 3, Task 5, Task 7
**Files:**
- Modify: `docs/Authentication.md`
**Problem.** The doc's CLI/scope examples were flagged during the previous branch as not
runnable as written — notably samples that grant only invoke/event scopes with no
`session:open`, which since Server-004's validation would produce keys that cannot open a
session (or, for unknown scope strings, be rejected at create time). The canonical scope
catalog is: `session:open`, `session:close`, `invoke:read`, `invoke:write`,
`invoke:secure`, `events:read`, `metadata:read`, `admin`; the verb is `apikey create-key`
with `--key-id` required.
**Step 1: Sweep every example** (` ```-fenced blocks and inline commands) in the doc. For
each, either (a) make it runnable as written — canonical verb, required flags, only
canonical scopes, scope sets that support what the surrounding prose says the key is for
(a key described as opening sessions needs `session:open`) — or (b) if it is deliberately
a fragment, mark it explicitly (e.g. "illustrative — not a complete command"). Prefer (a);
use (b) only where completeness would obscure the point being made.
**Step 2: Cross-check** each corrected scope list against
`src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/GatewayScopes.cs` (read-only) —
do not invent scope strings.
**Step 3: Commit:** `docs(auth): make Authentication.md examples runnable as written`.
---
### Task 5: AlarmsPage — push-driven truncation banner from the snapshot_status frame
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 1, Task 2, Task 3, Task 4, Task 7
**Files:**
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/AlarmsPage.razor`
(in-process feed loop around `:199-230`, `_snapshotTruncated` at `:170`/`:401`)
- Test: `src/ZB.MOM.WW.MxGateway.Tests/` — the AlarmsPage/dashboard alarm test class
(find with `grep -rl "AlarmsPage" src/ZB.MOM.WW.MxGateway.Tests`); if no AlarmsPage
render/behavior test exists, add coverage at the level the existing dashboard tests use
- Modify: `docs/GatewayDashboardDesign.md` (truncation-banner description, if it says the
banner is poll-driven)
**Problem.** The truncation banner (`_snapshotTruncated`, rendered at `:39`) updates only
from the 3-second poll (`:401`), even though the page already holds an in-process
subscription to the alarm feed (the loop at `:211-213` that feeds the provider-status
badge). The feed now carries an edge-triggered `snapshot_status` frame
(`AlarmFeedMessage.PayloadCase.SnapshotStatus`, shipped `fccf753`) — consume it so the
banner flips on the edge instead of up to 3 s late.
**Spec:**
- In the existing in-process feed loop, add a case for
`AlarmFeedMessage.PayloadCase.SnapshotStatus`: set `_snapshotTruncated =
message.SnapshotStatus.Truncated` and re-render (same invoke/StateHasChanged pattern the
provider-status case uses).
- Keep the poll's assignment at `:401` — the poll is the reconcile baseline and both
sources derive from the same `GatewayAlarmMonitor` verdict, so they cannot disagree
except transiently. Do not remove or restructure the poll.
- Remember `StreamAsync` primes every subscriber with an unconditional `snapshot_status`
baseline frame after `provider_status` — so on attach the page gets the current verdict
push-side too. No extra priming logic needed in the page.
- Do NOT touch `GatewayAlarmMonitor` or the proto — gateway emission is done and shipped.
**Steps:** failing test first (a `SnapshotStatus` frame delivered through the in-process
subscription flips the banner state without a poll tick; a `false` frame clears it), then
implement, then filtered dashboard/alarm tests green under the build lock, then commit:
`feat(dashboard): alarms page consumes snapshot_status feed frame for the truncation banner`.
---
### Task 6: Server-0xx resolution audit — doc-only "Resolved" entries re-verified
**Classification:** standard
**Estimated implement time:** ~5 min (audit) + fixes as found
**Parallelizable with:** none (runs after Task 4 lands — both may touch `docs/Authentication.md`)
**Files:**
- Modify: `code-reviews/Server/findings.md` (annotations only — do not rewrite history)
- Modify: any file where a claimed correction is found absent (expected candidates:
`CLAUDE.md`, `docs/*.md`, XML doc comments named in findings — report each)
**Problem.** Server-012 was recorded *Resolved 2026-05-18* claiming two scope-list
corrections that were absent from the tree when `feat/followup-closeout` looked — a
resolution that regressed or was never applied. Findings that read Resolved are never
re-examined, so every *documentation/comment-only* resolution needs the same spot-check.
**Procedure:**
1. Enumerate every `Server-0xx` entry in `code-reviews/Server/findings.md` whose
Resolution describes documentation-only or comment-only changes (no test named, prose
like "Pure documentation change" — at minimum Server-011, Server-012, Server-013/014
remarks rewrites; sweep all entries, don't assume).
2. For each, verify the specific claimed text exists in today's tree (grep the exact
phrases/identifiers the resolution names).
3. Where present: append one line to that finding's Resolution:
`Re-verified present 2026-08-18 (feat/followups-tickets).`
4. Where absent: re-apply the correction in the target file, and append:
`Regressed or never applied; re-fixed 2026-08-18 in <file> (feat/followups-tickets).`
(Server-012 itself was already re-fixed on the previous branch — annotate it as such,
citing commits `a5f843c`/`f2a422b`, rather than re-fixing.)
5. Report a table: finding id → verified/regressed → action.
**Commit:** `docs(reviews): re-verify doc-only Server-0xx resolutions; re-fix regressions`.
---
### Task 7: WorkerPipeSessionTests deterministic failure — investigate and fix (windev)
**Classification:** high-risk
**Estimated implement time:** investigation timeboxed ~10 min; fix ≤5 min
**Parallelizable with:** Task 15 (only windev user in wave 1)
**Files (starting points — investigation task, report everything touched):**
- Test: `src/ZB.MOM.WW.MxGateway.Worker.Tests/Ipc/WorkerPipeSessionTests.cs`
(`RunAsync_LongInFlightCommandThatKeepsPumping_DoesNotFaultAndDeliversReply`)
- Suspect: `src/ZB.MOM.WW.MxGateway.Worker/` pipe-session / frame-protocol sources the
test exercises (follow the test's references)
**Problem.** The test fails deterministically on windev, and reproduces on `main` —
pre-existing, not introduced by any recent branch. Everything else in the worker suite
passes (523/523 otherwise).
**Procedure (all building/testing on windev over ssh; edits in the Mac tree, pushed by
the controller, pulled on the CI clone — coordinate via your report if you need a push):**
1. Repro on the CI clone at this branch:
`dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86 --filter "FullyQualifiedName~RunAsync_LongInFlightCommandThatKeepsPumping"`.
Capture the full failure output (assertion text, timeout, stack).
2. Read the test and the code under test. Classify: (a) test defect (bad timing
assumption, races in the harness), (b) product defect in the pipe session under a
long in-flight command, or (c) environment-specific (windev timing/load).
**Timebox: if the cause is not isolated after ~10 minutes of investigation, stop and
report your best hypothesis with evidence — do not churn.**
3. Fix minimally per the classification. A product fix in the frame/pipe layer is
high-risk territory: preserve the frame protocol (`docs/WorkerFrameProtocol.md`), the
STA pumping rules, and MXAccess parity. A test fix must keep the scenario's intent —
a long in-flight command that keeps pumping must not fault the session and must
deliver its reply; don't weaken it into a sleep-and-hope.
4. Verify: the fixed test passes 3 consecutive runs on windev; then the full worker suite
(`-p:Platform=x86`) is green. If you touched product code, also build the full `slnx`
on windev and run the gateway suite filtered to any shared-surface tests.
5. Docs in the same commit if behavior/rules changed.
**Commit:** `fix(worker): <cause> — WorkerPipeSessionTests long-in-flight repro` (adjust
`fix(worker-tests)` if the defect is in the test).
---
### Task 8: Ack-leg probe — bounded unblock attempt (windev rig)
**Classification:** standard
**Estimated implement time:** timeboxed ~15 min of probing
**Parallelizable with:** Task 6 (runs after Task 7 — serialize windev use)
**Files (starting points — investigation task):**
- Modify: `docs/AlarmProbeFindings.md` (append a third-attempt section, whatever the outcome)
- Reference (read-only): the probe harness locations named in that doc's earlier attempts;
the mxaccess analysis project at `C:\Users\dohertj2\Desktop\mxaccess` (windev)
**Problem.** The acknowledge leg remains unobserved: every wnwrap ack surface is
accepted-but-inert (`rc=0`, state stays `UNACK_ALM`), and `.Acked` is write-rejected
(OperationalError 1007). Two recorded unblock paths remain
(`docs/AlarmProbeFindings.md`, "Remaining unblock paths for the acknowledge leg").
**Procedure — attempt path 2 first (it is inspectable), path 1 only if non-interactive:**
1. **Path 2 — ack-security configuration:** inspect whether the rig's galaxy/`alarmmgr`
is configured with an alarm-acknowledgement security requirement the wnwrap consumer
(operator *name* string, no authenticated identity) cannot meet. Look in galaxy
configuration (the `ZB` SQL Galaxy Repository — read-only queries only), area/object
security settings for the `TestMachine_00x` objects, and any alarm-security docs in
the mxaccess analysis project. **Read-only: do not change galaxy security config.**
2. **Path 1 — platform-side ack:** only if a *scriptable, non-interactive* path exists on
the rig as-installed (e.g. an existing harness or automation entry point). Do NOT
install software, do NOT drive GUI automation, do NOT change rig state beyond the
established raise/clear pattern on `TestMachine_001.TestAlarm001`. If only interactive
IDE/InTouch paths exist, record that and stop.
3. Whatever the outcome, append the third-attempt section: what was inspected, evidence,
and the leg's final status (observed / unavailable-by-configuration / still assumed
with the paths requiring a human). If the ack was actually observed, record whether
`STATE` reached `ACK_ALM` and whether the GUID survived — that answers the original
question and should update the findings table at the top of the doc.
4. **Stop condition:** at ~15 minutes of probing without a decisive result, write up what
was learned and conclude "still assumed" — the doc itself notes this is a
documentation gap, not a correctness one.
**Commit:** `docs(probe): ack-leg third attempt — <outcome>`.
---
### Task 9: Windev toolchain pin + full verification
**Classification:** verification (no review chain)
**Estimated implement time:** ~15 min wall (mostly build/test wait)
**Parallelizable with:** none (after all code tasks land and are pushed)
**Files:** none in-repo except possibly `docs/ToolchainLinks.md` (update the windev
`protoc-gen-go-grpc` entry if it records 1.6.1).
**Procedure (all over `ssh windev`, CI clone `C:\build\mxaccessgw-ci` at this branch tip):**
1. `go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.2` — then
`protoc-gen-go-grpc --version` must report 1.6.2. Update `docs/ToolchainLinks.md` if
it pins the old version (commit from the Mac tree).
2. Full `slnx` build — 0 warnings / 0 errors (clear Contracts obj/bin on CS2001/CS0016).
3. Worker tests `-p:Platform=x86` — expect the Task 7 outcome (fully green if fixed;
otherwise the documented known-failure only).
4. Gateway tests — full suite; rerun filtered on load flakes before believing a failure.
5. `powershell scripts/check-codegen.ps1` — **all 4 checks**, proving Task 1 on the
platform that had the bug.
6. `gradle --version` + `gradle :zb-mom-ww-mxgateway-client:checkGeneratedClean` from
`clients\java` if Gradle is installed there — record version and result either way.
7. Live MXAccess smoke: `MXGATEWAY_RUN_LIVE_MXACCESS_TESTS=1`, filter
`WorkerLiveMxAccessSmokeTests` — 8/8.
8. Report every result verbatim (counts, not "green").
---
### Task 10: Bookkeeping — close the follow-ups record
**Classification:** trivial
**Estimated implement time:** ~3 min
**Parallelizable with:** none (last)
**Files:**
- Modify: `docs/plans/2026-08-17-followup-closeout.md` (follow-ups block `:483-507`):
annotate each bullet closed with this branch's closing commit (or precisely narrowed,
e.g. ack leg if still assumed)
- Modify: `docs/plans/2026-08-18-followups-and-tickets.md` (this file): append as-built
notes — per-task commits, review outcomes, verification results, anything learned
- Modify: `docs/plans/2026-08-18-followups-and-tickets.md.tasks.json`: final statuses
**Commit:** `chore(plan): followups-and-tickets as-built record; prior follow-ups closed`.
---
## Execution notes for the controller
- **Model/review chains** (subagent-driven-development skill): Tasks 14 small → Sonnet
implementer + Haiku code review (diffs <100 LOC). Task 5 standard → Opus implementer,
spec (Haiku) ∥ code (Sonnet). Task 6 standard → Opus implementer, spec ∥ code. Task 7
high-risk → Opus implementer, serial spec (Haiku) → code (Sonnet). Task 8 standard →
Opus implementer, spec ∥ code. Task 9 verification → Opus, no review. Task 10 trivial →
Sonnet, no review.
- **Waves:** Wave 1 = Tasks 1, 2, 3, 4, 5, 7 (disjoint files; Task 7 alone on windev).
Wave 2 = Task 6 (after 4) and Task 8 (after 7). Wave 3 = push branch, Task 9.
Wave 4 = Task 10, final integration review, then hold for the user's merge decision.
- **Push cadence:** controller pushes the branch before any windev task needs its code
there (Task 7 investigates on-branch; Tasks 89 need the wave-1/2 tips).