# Live Actions: SEC-36 Rotation, TST-30 Second Runner, Client Publish — Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task (or subagent-driven-development in-session).

**Goal:** Execute the three repo-complete-but-live-pending operator actions: rotate the dev GLAuth service-account credential (SEC-36), register a second Gitea Actions runner (TST-30), and publish the five client packages at 0.2.0 (Java 0.2.1).

**Architecture:** Three independent workstreams executed by subagents. SEC-36 is a strictly ordered cutover (pre-stage hosts → flip GLAuth → verify → finalize) with secret-hygiene rules. TST-30 is infra work on docker host 10.100.0.35 plus a concurrency verification. Publish runs the existing guarded `pack-clients.ps1 -Publish` + `tag-go-module.ps1` locally on macOS.

**Tech Stack:** ssh (BatchMode works to 10.100.0.35 and 10.100.0.48), PowerShell/nssm on windev, docker compose on 10.100.0.35, Gitea API (`~/.zshenv` has admin-scoped `GITEA_USERNAME`/`GITEA_TOKEN`), pwsh 7 on macOS.

---

## Preflight facts (verified 2026-08-07 from this macOS box)

- `ssh 10.100.0.35` OK. GLAuth container is **`zb-shared-glauth`**, compose working dir **`/home/dohertj2/zb-glauth`** (NOT the runbook's `~/Desktop/scadaproj/infra/glauth` — that path does not exist on the host; the runbook must be corrected in Task 5). Runner container **`gitea-runner`**, compose working dir **`/opt/gitea`**.
- `ssh 10.100.0.48` (windev) OK; `powershell -NoProfile` works; `nssm` at `C:\Users\dohertj2\AppData\Local\Microsoft\WinGet\Links\nssm.exe`.
- `wonder-app-vd03` does NOT resolve from macOS — check it from windev (Task 2).
- Gitea API: token valid (`/api/v1/user` → 200), admin (`/api/v1/admin/users` → 200, `POST /api/v1/admin/actions/runners/registration-token` → 200).
- Local `~/Desktop/scadaproj/infra/glauth/config.toml` exists (14 `passsha256` entries) — the git source of truth.
- `pwsh` at `/usr/local/bin/pwsh`.

## Secret hygiene (SEC-36, binding for every task)

- The new plaintext password lives ONLY in `$SECRET_FILE = /private/tmp/claude-501/-Users-dohertj2-Desktop-MxAccessGateway/67849767-a07c-4afa-94e2-3ce4a39d8d23/scratchpad/sec36-new-secret` (chmod 600), created in Task 1 and shredded in Task 5.
- **Never echo/cat the plaintext to stdout, never put it in a commit, a repo file, a log line, or a command whose text is captured verbatim.** Always load it into a shell variable from the file (`val=$(cat "$SECRET_FILE")`) and pass it via stdin or remote-side expansion, never inline in an `ssh "...literal..."` string where avoidable.
- The `passsha256` hash MAY appear in `config.toml` commits — that is the established pattern (14 existing entries).
- The OLD password must never be printed either. Its only uses are: GLAuth keeps honoring it until Task 4, and the single old-bind-must-fail probe in Task 4.

---

### Task 1: SEC-36 — Generate secret, stage GLAuth config change (repo + host copy diff)

**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 2, Task 6, Task 9

**Files:**
- Modify: `~/Desktop/scadaproj/infra/glauth/config.toml` (the `serviceaccount` user's `passsha256`) — DO NOT commit yet (Task 5 commits)
- Create: `$SECRET_FILE` (scratchpad, chmod 600)

**Step 1: Generate the new secret and its hash**

```bash
SECRET_FILE="/private/tmp/claude-501/-Users-dohertj2-Desktop-MxAccessGateway/67849767-a07c-4afa-94e2-3ce4a39d8d23/scratchpad/sec36-new-secret"
umask 077
openssl rand -base64 24 | tr -d '\n' > "$SECRET_FILE"
chmod 600 "$SECRET_FILE"
NEW_SHA=$(cat "$SECRET_FILE" | tr -d '\n' | shasum -a 256 | awk '{print $1}')
echo "$NEW_SHA"   # hash only — safe to display
```
Cross-check the hash recipe against `glauth.md` ("Generate `passsha256` from a plaintext password") in this repo and follow that recipe if it differs.

**Step 2: Diff host deployment config vs repo source of truth**

```bash
ssh 10.100.0.35 'cat /home/dohertj2/zb-glauth/config.toml' > /tmp/host-glauth-config.toml 2>/dev/null || true
diff ~/Desktop/scadaproj/infra/glauth/config.toml /tmp/host-glauth-config.toml
```
Small drift (comments, ports) is fine — note it. If the `serviceaccount` stanza differs structurally, STOP and surface before editing.

**Step 3: Edit the repo source of truth**

In `~/Desktop/scadaproj/infra/glauth/config.toml`, replace the `passsha256` value of the `[[users]]` entry whose `name`/`cn` is `serviceaccount` with `$NEW_SHA`. Edit ONLY that line. Do not `docker compose up` anything yet.

**Step 4: Record findings**

Report: hash staged (show hash, never plaintext), drift summary from step 2, and confirm `$SECRET_FILE` exists with mode 600.

---

### Task 2: SEC-36 — Determine wonder-app-vd03 LDAP status (via windev)

**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 1, Task 6, Task 9

**Step 1: Try to reach vd03 from windev**

```bash
ssh 10.100.0.48 'powershell -NoProfile -Command "Test-Connection wonder-app-vd03 -Count 1 -Quiet"'
```

**Step 2: If reachable, read its gateway config for `MxGateway:Ldap:Enabled`**

Try (in order, stop at first success): `ssh` hop from windev; reading `\\wonder-app-vd03\c$\...` appsettings/environment via PowerShell remoting (`Invoke-Command -ComputerName wonder-app-vd03`); or `nssm get MxAccessGw AppEnvironmentExtra` remotely. Look for `MxGateway__Ldap__Enabled` / appsettings `Ldap:Enabled`.

**Step 3: Decide and record**

- `Enabled=false` or host unreachable/no gateway service → vd03 is OUT of scope; record why (runbook says its dashboard is disabled — `false` is the expected answer).
- `Enabled=true` → vd03 is IN scope for Task 3 pre-staging; record the connection method that worked.

---

### Task 3: SEC-36 — Pre-stage the NEW value on LDAP-enabled deployed hosts

**Classification:** high-risk
**Estimated implement time:** ~4 min
**Parallelizable with:** none (blocked by Tasks 1, 2)

**Step 1: Pre-stage windev (10.100.0.48)**

Load the secret locally, then set the env var remotely without leaking it into logged command text more than unavoidable (ssh arguments are not logged remotely by default; do NOT echo the value):

```bash
SECRET_FILE="/private/tmp/claude-501/-Users-dohertj2-Desktop-MxAccessGateway/67849767-a07c-4afa-94e2-3ce4a39d8d23/scratchpad/sec36-new-secret"
val=$(cat "$SECRET_FILE")
ssh 10.100.0.48 'powershell -NoProfile -Command "$v = [Console]::In.ReadLine(); $cur = (& nssm get MxAccessGw AppEnvironmentExtra) -join \"`n\"; Write-Output (\"CURRENT: \" + ($cur -replace \"Password=.*\", \"Password=<redacted>\")); & nssm set MxAccessGw AppEnvironmentExtra (\"MxGateway__Ldap__ServiceAccountPassword=\" + $v)"' <<< "$val"
```
**CAUTION:** `nssm set AppEnvironmentExtra` REPLACES the whole extra-environment block. First inspect `nssm get MxAccessGw AppEnvironmentExtra` (redacting any `Password=` values); if other variables exist, preserve them in the new value (newline-separated). Adapt quoting as needed — verify with a redacted `nssm get` afterwards.

**Step 2: Restart the service**

```bash
ssh 10.100.0.48 'nssm restart MxAccessGw'
```
Expected: service restarts. Binds against GLAuth now fail (old directory, new client value) — expected and brief; proceed immediately to Task 4.

**Step 3: vd03 (only if Task 2 said IN scope)** — same pre-stage + restart via the method Task 2 found.

---

### Task 4: SEC-36 — Rotate GLAuth and verify end-to-end

**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none (blocked by Task 3)

**Step 1: Back up current host config, sync the staged config, recreate**

```bash
ssh 10.100.0.35 'cp /home/dohertj2/zb-glauth/config.toml /home/dohertj2/zb-glauth/config.toml.bak-sec36'
scp ~/Desktop/scadaproj/infra/glauth/config.toml 10.100.0.35:/home/dohertj2/zb-glauth/config.toml
```
**If Task 1's diff showed host-vs-repo drift beyond the serviceaccount line:** do NOT wholesale-copy — instead edit only the serviceaccount `passsha256` line in the host copy (sed on the host), so unrelated host-local drift is preserved.

```bash
ssh 10.100.0.35 'cd /home/dohertj2/zb-glauth && docker compose up -d --force-recreate && sleep 3 && docker compose logs --tail 30'
```
Expected: clean startup, no TOML parse error. On parse error: restore `.bak-sec36`, recreate, STOP, surface.

**Step 2: Verify new credential binds (from the glauth host, ldapsearch or python)**

```bash
SECRET_FILE=".../sec36-new-secret"   # full scratchpad path
val=$(cat "$SECRET_FILE")
ssh 10.100.0.35 'ldapsearch -x -H ldap://localhost:3893 -D "cn=serviceaccount,dc=zb,dc=local" -w "$(cat -)" -b "dc=zb,dc=local" "(cn=multi-role)" cn' <<< "$val"
```
Expected: search returns the `multi-role` entry. (If ldapsearch is missing on the host, run the equivalent from macOS against `10.100.0.35:3893`, or use `docker exec`.) Adjust the bind DN to match the actual `serviceaccount` DN in config.toml.

**Step 3: Verify the OLD value is dead — exactly ONE probe, from 10.100.0.35 itself**

One deliberately failing bind with the old password must return invalid credentials. **Only one attempt** (3-fail/10-min per-IP lockout; never probe from a shared-NAT box). The old value: recover it transiently from `config.toml.bak-sec36`'s hash? No — hash is not the plaintext. Instead: skip the plaintext probe if the old plaintext is not already known out-of-band; the hash replacement in config.toml is itself proof GLAuth no longer honors the old value (GLAuth compares against `passsha256` only). Record that reasoning instead of probing blind.

**Step 4: Verify dashboard login end-to-end on windev**

```bash
curl -sk -o /dev/null -w '%{http_code}' -c /tmp/mxgw-cookies.txt https://10.100.0.48:5001/login
```
Find the actual dashboard port from windev config first (`nssm get`/appsettings; likely https). Then POST the login form as `multi-role`/`password` (the GLAuth TEST USER password, not the service account) and expect a redirect + `__Host-MxGatewayDashboard` (or `MxGatewayDashboard`) cookie:

```bash
curl -sk -o /dev/null -w '%{http_code}\n' -b /tmp/mxgw-cookies.txt -c /tmp/mxgw-cookies.txt -d 'username=multi-role&password=password' <dashboard-base>/login
grep -i mxgatewaydashboard /tmp/mxgw-cookies.txt
```
Inspect the login page HTML first for real form field names / antiforgery token; adapt. A successful `multi-role` login proves the service-account search bind works with the new credential end-to-end. If HTTP verification proves impractical (antiforgery), fall back to grepping the gateway log on windev for a successful LDAP bind/login line after attempting — or run the live-LDAP integration test from macOS:

```bash
export MXGATEWAY_RUN_LIVE_LDAP_TESTS=1
export MxGateway__Ldap__ServiceAccountPassword="$(cat "$SECRET_FILE")"
dotnet test src/ZB.MOM.WW.MxGateway.IntegrationTests/ZB.MOM.WW.MxGateway.IntegrationTests.csproj --filter FullyQualifiedName~DashboardLdapLiveTests
```
Expected: green. (This binds from macOS to 10.100.0.35:3893 directly — it verifies the credential, and the curl/log check verifies windev.)

**Step 5: Rollback (only on failure)** — restore `.bak-sec36` on the host, `docker compose up -d --force-recreate`, re-point windev's env var back (old value from where it was before — if unknown, STOP and surface), `nssm restart MxAccessGw`.

---

### Task 5: SEC-36 — Finalize: commit source of truth, dev secrets, runbook fix, tracker, cleanup

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (blocked by Task 4)

**Step 1: Commit and push the scadaproj glauth change (glauth paths ONLY)**

```bash
cd ~/Desktop/scadaproj
git add infra/glauth/config.toml
git commit -m "sec(glauth): rotate serviceaccount passsha256 (mxaccessgw SEC-36)"
git push
```
(`scadaproj` is a shared monorepo — stage only this path. If the worktree has unrelated staged changes, use `git commit -- infra/glauth/config.toml` style isolation.)

**Step 2: Set dev user-secrets on this macOS box**

```bash
cd ~/Desktop/MxAccessGateway
cat "$SECRET_FILE" | tr -d '\n' | dotnet user-secrets set "MxGateway:Ldap:ServiceAccountPassword" --project src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj
```
(Check `dotnet user-secrets set -h` for stdin support; if unsupported, pass via `"$(cat "$SECRET_FILE")"` — acceptable, it's a local process arg.)

**Step 3: Correct the runbook + flip tracker rows (mxaccessgw repo)**

- `docs/runbooks/SEC-36-ldap-credential-rotation.md`: fix the host deployment path (`/home/dohertj2/zb-glauth`, container `zb-shared-glauth`; repo source of truth remains `scadaproj/infra/glauth/`), and note vd03's actual status per Task 2.
- Grep `archreview/2026-07-12/remediation/` for SEC-36 pending-operator rows; flip to Done citing the runbook + today's date.

```bash
cd ~/Desktop/MxAccessGateway
grep -rn "SEC-36" archreview/2026-07-12/remediation/ docs/ | grep -iv binary
# edit the rows, then:
git add -A docs archreview && git commit -m "docs(sec-36): record live rotation done; correct runbook host paths"
```

**Step 4: Shred the secret file**

```bash
rm -P "$SECRET_FILE" 2>/dev/null || rm "$SECRET_FILE"
```

**Step 5: Done-criteria check** — walk the runbook's Done criteria list; report each as met/not-met.

---

### Task 6: TST-30 — Recon existing runner config on 10.100.0.35

**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 1, Task 2, Task 9

**Step 1: Inspect the existing runner**

```bash
ssh 10.100.0.35 'cat /opt/gitea/docker-compose.yml 2>/dev/null || sudo cat /opt/gitea/docker-compose.yml; ls /opt/gitea'
ssh 10.100.0.35 'docker inspect gitea-runner --format "{{json .Mounts}}"; docker exec gitea-runner cat /config.yaml 2>/dev/null || true'
```
Find: image/version, config file location (look for `container.network: traefik` and `capacity`/`maxParallel`), data volume, registration state file, docker socket mount, labels.

**Step 2: Check host capacity**

```bash
ssh 10.100.0.35 'nproc; free -h; df -h / | tail -1'
```

**Step 3: Decide (a)-variant** — second container vs raising `capacity` on the existing runner. Runbook prefers a second instance; if the existing runner's config shows a simple `capacity: 1` and resources are tight, raising capacity is the smaller change — but a second registered instance is the runbook default and survives one-runner wedge. Record the chosen variant, the exact compose/config snippets to reuse, and where the registration token goes.

---

### Task 7: TST-30 — Register and start the second runner

**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none (blocked by Task 6)

**Step 1: Mint an instance-level registration token**

```bash
source ~/.zshenv
curl -s -X POST -u "$GITEA_USERNAME:$GITEA_TOKEN" 'https://gitea.dohertylan.com/api/v1/admin/actions/runners/registration-token'
```
(Returns `{"token": "..."}` — a registration token, not a secret credential of lasting value; still avoid committing it.)

**Step 2: Create the second runner instance per Task 6's plan**

E.g. add a `gitea-runner-2` service to the compose (distinct name + data volume, same image, same `container.network: traefik`, same socket mount), inject the token via the runner's registration env (`GITEA_RUNNER_REGISTRATION_TOKEN`) or `act_runner register --no-interactive`, then `docker compose up -d gitea-runner-2` from `/opt/gitea`. Back up the compose file first (`cp docker-compose.yml docker-compose.yml.bak-tst30`). Do NOT touch the existing `gitea-runner` service definition.

**Step 3: Confirm both runners online**

```bash
curl -s -u "$GITEA_USERNAME:$GITEA_TOKEN" 'https://gitea.dohertylan.com/api/v1/admin/actions/runners' | python3 -m json.tool
```
Expected: ≥2 runners, both online. Also check `docker logs` of the new container for a clean registration + poll loop.

---

### Task 8: TST-30 — Verify concurrency, gitea:3000 resolution, tracker

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (blocked by Task 7)

**Step 1: Trigger two concurrent runs**

Push two scratch branches to `mxaccessgw` back-to-back (empty commits off `main`, branch names `scratch/tst30-a`, `scratch/tst30-b`):

```bash
cd ~/Desktop/MxAccessGateway
git push origin main:refs/heads/scratch/tst30-a
git commit --allow-empty -m "tst30 concurrency probe" && git push origin HEAD:refs/heads/scratch/tst30-b && git reset --hard HEAD~1
```
(Adapt: any two pushes that fan out jobs. Clean up branches after: `git push origin :scratch/tst30-a :scratch/tst30-b`.)

**Step 2: Confirm parallel execution**

Poll the runs API/UI: the second run's jobs must START before the first run finishes.

```bash
curl -s -u "$GITEA_USERNAME:$GITEA_TOKEN" 'https://gitea.dohertylan.com/api/v1/repos/dohertj2/mxaccessgw/actions/tasks' | python3 -m json.tool | head -60
```

**Step 3: Confirm `gitea:3000` resolves on the new runner** — verify a job scheduled on runner-2 succeeds at checkout (checkout hits `gitea:3000` over the traefik network); identify which runner took each job from the runs UI/API or runner logs.

**Step 4: Flip TST-30 tracker rows** in `archreview/2026-07-12/remediation/` (grep `TST-30`) to Done with today's date; confirm `docs/GatewayTesting.md` prose is still accurate (it should be — it already describes the bypass as valid regardless of runner count). Commit.

---

### Task 9: Publish — Preflight audit (versions, registry collisions, toolchains)

**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 1, Task 2, Task 6

**Step 1: Audit source versions**

```bash
cd ~/Desktop/MxAccessGateway
grep -n 'version' clients/rust/Cargo.toml | head -5
grep -n 'version' clients/python/pyproject.toml clients/python/src/zb_mom_ww_mxgateway/version.py
grep -n 'ClientVersion' clients/go/mxgateway/version.go
grep -n '<Version>' clients/dotnet/ZB.MOM.WW.MxGateway.Client/ZB.MOM.WW.MxGateway.Client.csproj src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj
grep -n 'version' clients/java/build.gradle | head -5
grep -rn 'CLIENT_VERSION' clients/java --include=*.java | grep -i mxgatewayclientversion
```
Expected: Rust/Python/Go/.NET/Contracts = 0.2.0; Java build.gradle AND `MxGatewayClientVersion.CLIENT_VERSION` = 0.2.1. Any mismatch → STOP, surface (do not bump versions yourself; that's a scope change).

**Step 2: Query live registry for collisions**

```bash
source ~/.zshenv
for u in 'nuget/ZB.MOM.WW.MxGateway.Client/0.2.0' 'nuget/ZB.MOM.WW.MxGateway.Contracts/0.2.0' 'pypi/zb-mom-ww-mxaccess-gateway-client/0.2.0' 'cargo/zb-mom-ww-mxgateway-client/0.2.0' 'maven/com.zb.mom.ww.mxgateway-zb-mom-ww-mxgateway-client/0.2.1'; do
  echo "$u => $(curl -s -o /dev/null -w '%{http_code}' -u "$GITEA_USERNAME:$GITEA_TOKEN" "https://gitea.dohertylan.com/api/v1/packages/dohertj2/$u")"
done
```
Expected: 404 for every target (unclaimed). Check the exact maven path convention against `pack-clients.ps1`'s own guard code and use its convention. 200 anywhere → STOP, surface.

**Step 3: Toolchain + workspace check**

```bash
git -C ~/Desktop/MxAccessGateway status --porcelain   # must be clean (publish from a clean tree at origin/main)
for t in dotnet cargo go python3 gradle pwsh; do which $t; done
```
Also confirm `clients/go` module tag `clients/go/v0.2.0` does NOT already exist: `git ls-remote --tags origin 'clients/go/v*'`.

---

### Task 10: Publish — Run the guarded pack-and-publish

**Classification:** high-risk
**Estimated implement time:** ~5 min dispatch (script runtime longer)
**Parallelizable with:** none (blocked by Task 9)

**Step 1: Run pack-clients with publish**

```bash
cd ~/Desktop/MxAccessGateway
source ~/.zshenv
pwsh -NoProfile -File scripts/pack-clients.ps1 -Publish 2>&1 | tee /private/tmp/claude-501/-Users-dohertj2-Desktop-MxAccessGateway/67849767-a07c-4afa-94e2-3ce4a39d8d23/scratchpad/pack-clients-publish.log
```
Expected: per-language build+test+pack, collision guard prints "safe to publish" per artifact, uploads succeed. Timeout generously (Bash timeout 600000). If any language fails MID-loop, record exactly which artifacts pushed and which didn't — partial publish is the known failure mode; do not re-run blindly (re-run is safe only because the guard skips? NO — the guard ABORTS on existing versions. A re-run after partial publish will abort on the already-pushed artifact. If that happens, surface with the log; per-language `-Languages` selective re-run is the fix).
If macOS cannot build a language (e.g. gradle/java env), use `-Languages` to publish what builds and surface the remainder — do not fake success.

**Step 2: Verify each artifact now exists (200)** — re-run Task 9 step 2's loop; expected 200 everywhere published.

---

### Task 11: Publish — Go module tag

**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 10 (blocked by Task 9)

**Step 1: Tag via the guarded script**

```bash
cd ~/Desktop/MxAccessGateway
pwsh -NoProfile -File scripts/tag-go-module.ps1 -Version 0.2.0
```
Read the script's param block first (`-Version` name may differ; it validates semver and that `version.go` matches, then creates+pushes `clients/go/v0.2.0`). Expected: tag created and pushed to origin.

**Step 2: Verify**

```bash
git ls-remote --tags origin 'clients/go/v0.2.0*'
```
Expected: exactly one tag. Optionally `GOPROXY=direct go list -m gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go@v0.2.0` from a temp dir.

---

### Task 12: Publish — Docs/tracker closeout

**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** none (blocked by Tasks 10, 11)

**Step 1:** Update `docs/ClientPackaging.md`'s versioning narrative if it claims 0.2.0/0.2.1 are unpublished (it currently records the maven 0.2.1 exception; add a dated line that 0.2.0 (Java 0.2.1) published on 2026-08-07). Grep `archreview/2026-07-12/remediation/` for publish/CLI-39 pending-operator rows and flip to Done.

**Step 2:** Commit:

```bash
cd ~/Desktop/MxAccessGateway
git add docs archreview && git commit -m "docs(clients): record 0.2.0/0.2.1 publish + close operator actions"
```

**Step 3:** Report the full publish matrix (artifact → version → registry HTTP status).

---

## Dependency graph

```
{T1, T2} ──▶ T3 ──▶ T4 ──▶ T5        (SEC-36, strictly serial after recon)
 T6 ──▶ T7 ──▶ T8                     (TST-30)
 T9 ──▶ {T10, T11} ──▶ T12            (Publish)
```
The three streams are mutually independent and run concurrently. All subagents run with model=opus per operator instruction.

## Out of scope (explicitly)

- Option (b)/(c) runner topologies and the `concurrency:` ci.yml experiment (TST-30 runbook marks them escalation/optional).
- The five next-cycle candidate findings in `archreview/2026-07-12/remediation/90-candidate-findings-next-cycle.md`.
- Any client version bumps (versions are already landed; a mismatch is a STOP-and-surface).
