docs(tracking): close NEXT-07, file NEXT-08/09/10, record the runner token reset

NEXT-07 is struck: windev was redeployed from origin/main (a346d51) and the service is
healthy, and the root cause the row predicted is confirmed -- the 2026-06-25 build's
Auth.ApiKeys 0.1.2.0 supports auth-DB schema 2 while the database sits at schema 3, which
is the current shared-lib version, so deploying forward was the fix rather than touching
the DB. The original text stays for the triage record.

Three findings surfaced by that work, each deliberately left for the next cycle rather
than patched in passing:

- NEXT-08: the shared GLAuth offers no TLS, so SEC-06 makes GatewayConfiguration.md's
  "deployed hosts must set Ldaps or StartTls" unsatisfiable for anything genuinely
  labelled Production. windev's relabel to Staging is honest for a dev rig but defers
  the posture question rather than answering it.
- NEXT-09: Directory.Build.props:29 quotes a path ending in a backslash, so the SHA-stamp
  git invocation is malformed on Windows and ContinueOnError stamps git's stderr into
  InformationalVersion -- a Windows binary cannot be correlated to a commit, which is what
  TST-11 exists to guarantee.
- NEXT-10: glauth.md's pre-provisioned-user table contradicts both the directory and its
  own dashboard section, and was the root cause of the NEXT-06 fixture drift. Reconciling
  it sweeps the OPC-UA group taxonomy, so it is scoped out here on purpose.

The TST-30 runner work is hygiene, not closure: runner-1 now mounts its registration token
from a 0600 file like runner-2, but both still share one instance-scope token that was
world-readable for months and is provably still live. Gitea 1.26.4 cannot rotate it from
the CLI or API, so the UI reset is recorded as a pending operator action with its
follow-through (refresh the token file, shred the token-bearing compose backups).
This commit is contained in:
Joseph Doherty
2026-08-07 10:31:29 -04:00
parent 1d6858939d
commit 129e47e541
3 changed files with 18 additions and 2 deletions
@@ -185,3 +185,4 @@ Sequence these together rather than piecemeal — several are one change set spa
| 2026-08-07 | **TST-30 → fully `Done`** — the operator half (register a second CI runner) executed live on `10.100.0.35` per `docs/runbooks/TST-30-second-ci-runner.md` option (a). A second **instance-level** `act_runner` container, `gitea-runner-2` (runner id 5, capacity 2, labels `ubuntu-latest`/`ubuntu-22.04`), was added to the `/opt/gitea` compose stack with the same `container.network: traefik` setting as the original, its registration token mounted from a `0600` file rather than inlined in compose. The existing `gitea-runner` (id 1, capacity 4) was **not** touched — total capacity went 4 → 6 purely by addition, so the change reverts by removing one container. **Concurrency verified live** (the finding's acceptance test): HEAD `a346d51` was pushed to two scratch branches, `scratch/tst30-a` (run 661) and `scratch/tst30-b` (run 662), while an unrelated run 660 was already in flight; at 13:07:53Z jobs from **three** runs were `in_progress` at once — run 660 `portable` plus run 662 `portable`/`java` on runner 1, and run 661 `portable`/`java` on `gitea-runner-2` — a state the pre-change single-runner topology could not produce. Runner attribution came from `GET /repos/dohertj2/mxaccessgw/actions/runs/{id}/jobs`, which exposes `runner_id`/`runner_name` per job, and was cross-checked against `docker logs gitea-runner-2` (`task 1145 …`, `task 1146 …` pickups at 13:05:2223Z). **`gitea:3000` resolution holds on the new instance** — the property option (c) was rejected for losing: run 661's `portable` job (task 1145, scheduled on `gitea-runner-2`) logged `git remote add origin http://gitea:3000/dohertj2/mxaccessgw` then a successful `fetch … From http://gitea:3000/dohertj2/mxaccessgw`, and its job container's `/workspace/dohertj2/mxaccessgw` was confirmed checked out at `a346d514dd24e775640e5667aa7cd8e561fec68a`. **Runbook fact corrected:** its checklist expected `GET /repos/dohertj2/mxaccessgw/actions/runners` to show ≥2, but that endpoint still returns `total_count: 0` — correctly, since both runners are instance-scoped, which is exactly what this finding documented; the working check is `GET /api/v1/admin/actions/runners` (lists ids 1, 4 = an unrelated local macOS runner, and 5). A dated "Executed 2026-08-07" note recording this was added to the top of the runbook rather than rewriting its history. No source, workflow, or `docs/GatewayTesting.md` change was needed — the no-cancel reality and the `run-windev-ci.sh` bypass are unaffected by runner count, as that prose already stated. Scratch branches deleted after verification. Tracking: both registers' TST-30 rows, the pending-operator-actions list in `90-candidate-findings-next-cycle.md`, and this row. |
| 2026-08-07 | **CLI-39 publish executed** — the release the version train was cut for is now live on `gitea.dohertylan.com`. Published from HEAD `a346d51` via `scripts/pack-clients.ps1`: `nuget` `ZB.MOM.WW.MxGateway.Client` **0.2.0** + `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** (the Java exception recorded in the CLI-39 row above — 0.2.0 was already taken by the pre-conformance build). All five coordinates re-queried through `GET /api/v1/packages/dohertj2/{type}/{name}/{version}` after the push and confirmed `200`. Go ships no artifact: module tag `clients/go/v0.2.0` was created and pushed at `a346d51` by `scripts/tag-go-module.ps1` (its CLI-21 version-match guard passed against `ClientVersion = "0.2.0"`), and `go list -m …@v0.2.0` resolves. Full suites ran pre-push with no failures: 128 .NET, 164 Python, 107 Rust, 79 Java. **Deviation found and fixed:** the first `cargo publish` returned `401` because `CARGO_REGISTRIES_DOHERTJ2_GITEA_TOKEN` held the raw token — cargo forwards that value as the `Authorization` header verbatim and Gitea's cargo registry requires `Bearer <token>` there (confirmed both ways against `/api/v1/user`: bare → 401, `Bearer`-prefixed → 200), unlike the other feeds' username/token basic-auth pairs. The publish succeeded on retry with the prefix exported shell-locally; the permanent fix was then applied to the operator's `~/.zshenv` (outside this repo; prior file backed up `0600` as `~/.zshenv.bak-cli39`) and re-verified via a `Bearer`-authed `GET /api/v1/user``200`. The requirement is now documented in `docs/ClientPackaging.md`'s Rust section, and that doc's "Versioning" section carries the published-coordinates record so the next bump starts from the real registry state rather than a re-check. No source, proto, script, or test change — publish execution plus docs. Tracking: `docs/ClientPackaging.md` and this row (CLI-39's register rows in both this file and `50-clients.md` were already plain `Done` with no publish-pending annotation to clear). |
| 2026-08-07 | **SEC-36 → fully `Done`** — the operator half (rotate the dev LDAP service-account credential) executed live per `docs/runbooks/SEC-36-ldap-credential-rotation.md`. A new password was generated, the `serviceaccount` `passsha256` in `scadaproj/infra/glauth/config.toml` replaced, and the shared GLAuth recreated on `10.100.0.35`, so the value this repo's git history permanently discloses (live in the directory since 2026-06-04; not reproduced here) **no longer binds** `dc=zb,dc=local` — the load-bearing half of the finding. The new value lives only in the three channels the design named: the GLAuth `passsha256` (committed in `scadaproj`, commit `aada53b`), the NSSM environment on `10.100.0.48`, and each dev box's user-secrets (set on this Mac); it is in no file of this repo. The retired plaintext was also scrubbed from the `scadaproj` glauth comments (`config.toml`, `docker-compose.yml`, `README.md`) and from the docker host's live `docker-compose.yml`; the host's `*.bak-sec36` rollback copies deliberately keep it. **Three runbook facts corrected** in a dated block at its top: (1) step 3's `cd ~/Desktop/scadaproj/infra/glauth` on the docker host is stale — the stack runs from `/home/dohertj2/zb-glauth` (container `zb-shared-glauth`), fed by the `scp` deploy in `scadaproj/infra/glauth/README.md`; (2) `wonder-app-vd03` is out of scope **on documentary evidence**, not merely unchecked — its gateway binds the ScadaBridge/ScadaLink local GLAuth under `dc=scadalink`/`dc=scadabridge`, a different directory that never held this credential (and the host is unreachable from the dev network), so no env var was staged there; (3) the "3-fail / 10-minute per-IP lockout" caution is inert for this instance — `config.toml:14` sets `LimitFailedBinds = false`. **One Done criterion is met with a caveat:** the new value is staged on windev (10th `AppEnvironmentExtra` entry on the `MxAccessGw` NSSM service) but the runbook's primary dashboard `/login` check **could not run** — windev's gateway is crash-looping on an unrelated pre-existing fault (deployed Server binary of 2026-06-25 predates the 2026-07-15 auth-DB migration, so it opens a schema-3 DB it supports only at schema 2 and aborts; ~10k Hosting-failed events/day since at least 08-06). Verified instead by the equivalent primitive: a direct `ldapsearch` bind as `cn=serviceaccount,dc=zb,dc=local` with the new value against `10.100.0.35:3893` succeeded and returned the `multi-role` entry — the same search bind the dashboard performs. The deferred check is recorded in the runbook with its completion path. **Two new next-cycle candidate findings** filed in `90-candidate-findings-next-cycle.md`: NEXT-06 (`DashboardLdapLiveTests` fixture drift — its only positive test binds `admin`/`admin123` while the directory's `admin` password is the standard dev one, and fixture user `readonly` does not exist in the GLAuth config, so the suite carries **no positive-proof coverage** of the service-account bind and could not have substituted for the dashboard check) and NEXT-07 (the windev stale deployment / crash loop above). Tracking: both registers' SEC-36 rows, the pending-operator-actions list in `90-candidate-findings-next-cycle.md`, and this row. |
| 2026-08-07 | **NEXT-07 resolved and SEC-36's deferred dashboard check discharged** — the two loose ends the SEC-36 row above left open. windev (`10.100.0.48`) was repaired by a fresh portable framework-dependent publish of `origin/main` (`a346d51`) built in a clean clone at `C:\build\mxgw-redeploy`, deployed to `C:\publish\mxaccessgw\Server-20260807` with the `MxAccessGw` NSSM service repointed; the service holds a stable PID with `5120`/`5130` listening, a worker spawned, the Galaxy snapshot restored (129 objects / 56,731 attributes) and a clean event log. The predicted root cause is confirmed: the deployed 2026-06-25 build carried `ZB.MOM.WW.Auth.ApiKeys` 0.1.2.0 (auth-DB schema 2) against a database stamped at schema 3 on 2026-07-15 by an ephemeral run of newer code — schema 3 is the **current** shared-lib version (`SqliteAuthSchema.CurrentVersion=3` in Auth 0.1.5), so redeploying forward was the fix and the DB was left untouched; `gateway-auth.db.bak-next07` (+`-wal`/`-shm`) and the previous `Server` publish directory stand as rollback. Two side effects: the old deploy's `appsettings.json` carried the LDAP bind password in **plaintext on disk**, while the new one keeps the repo's `${secret:ldap/mxgateway/bind}` token with the NSSM environment supplying the value; and the redeploy tripped the SEC-06 `Ldap:Transport=None` production hard-stop (`GatewayOptionsValidator.cs:178`), resolved by relabelling `DOTNET_ENVIRONMENT` to `Staging` (that entry only — the other nine preserved byte-identical), which matches what windev actually is: a host running `Dashboard:DisableLogin=true` against a GLAuth with no TLS. SEC-06 is untouched for real production hosts, and the posture gap that relabelling defers is filed as NEXT-08. With windev live, **SEC-36's deferred check ran for real**: a foreground run of the new build with `Dashboard:DisableLogin=false` as a process-env-only override served `GET /login` (200 + antiforgery token), accepted `POST /auth/login` as `multi-role`/`password` (302 to `/` + `MxGatewayDashboard` cookie), rendered the admin nav on the authenticated `GET /`, and redirected the anonymous control to `/login?ReturnUrl=%2F` — proving the rotated credential through the real `DashboardAuthenticator` search bind on the deployed host, not just by `ldapsearch`. Also executed host-side: runner-1 on `10.100.0.35` moved to the same `0600` token-file mount as runner-2 (identity preserved), leaving the shared instance registration token — proven still live — as a **pending operator UI reset**. Three new candidate findings filed: NEXT-08 (GLAuth has no TLS, so SEC-06 makes `docs/GatewayConfiguration.md`'s "deployed hosts must set `Ldaps` or `StartTls`" unsatisfiable), NEXT-09 (`src/Directory.Build.props:29` stamps git's error text into `InformationalVersion` on Windows), NEXT-10 (`glauth.md`'s pre-provisioned-user table contradicts the directory and the rest of its own file — the root cause of NEXT-06, deliberately deferred because reconciling it sweeps the OPC-UA group taxonomy). Tracking: `90-candidate-findings-next-cycle.md` (NEXT-07 struck, NEXT-08/09/10 added, operator-action list), `docs/runbooks/SEC-36-ldap-credential-rotation.md` Correction 3, `docs/runbooks/TST-30-second-ci-runner.md`, `docs/GatewayConfiguration.md` SEC-35 section. |
@@ -10,12 +10,16 @@ These were discovered while remediating the 2026-07-12 backlog but were **out of
| NEXT-04 | Worker frame writer | Low | WRK-22/WRK-25 cancellation path: a frame `Claimed` by a concurrent lock-holder just before its caller's cancellation races in is never awaited by that caller; if the write then faults, `TrySetException` lands on a `Task` nobody observes (unobserved-task-exception). By-design residual, non-crash (no `UnobservedTaskException` handler registered), pre-existing to single-frame WRK-22 and amplified per-batch by WRK-25. Hygiene fix: attach a fault-observing continuation to abandoned/tombstoned frame completions. |
| NEXT-05 | Worker frame writer | Info | A batch whose remaining frames are tombstoned by cancellation leaves dead `PendingFrame` entries in `_eventFrames`/`_controlFrames` until a future `DequeueNext` pops and skips them. Same pre-existing behavior as single-frame WRK-22, amplified per-batch; in practice heartbeats purge them promptly, so not a real leak. |
| ~~NEXT-06~~ | Testing / live LDAP | Medium | **Resolved 2026-08-07** — fixtures realigned to the shared directory (`admin`/`password` for the GwAdmin success path, `gw-viewer`/`password` for the bind-succeeds-but-no-role path); verified `Failed: 0, Passed: 5` live against the shared GLAuth at `10.100.0.35:3893`, so the success-path assertion (GwAdmin group claim + Admin role claim) now fails if the service-account credential is wrong. Original finding: `DashboardLdapLiveTests` fixtures have drifted from the shared GLAuth directory, leaving the suite with **no positive-proof coverage of the service-account bind**. Its only success-path test, `AuthenticateAsync_AdminInGwAdminGroup_Succeeds`, binds `admin`/`admin123`, but the directory's `admin` user carries the standard dev password (`scadaproj/infra/glauth/config.toml`), so that assertion cannot pass. `AuthenticateAsync_ReadOnlyUserMissingGwAdminGroup_Fails` binds fixture user `readonly`, which **does not exist** in the GLAuth config at all — it passes for the wrong reason (user-not-found rather than the group-missing branch it names; the `readonly` name is in fact barred by the README's user/group case-collision rule). The three remaining tests are negative assertions that pass whether or not the service account can bind. Net effect: a green `DashboardLdapLiveTests` run proves nothing about the bind credential — surfaced during SEC-36, where the suite was considered as a substitute for the deferred dashboard-login check and rejected. Fix: realign the fixtures to real directory users (e.g. `multi-role`/`gw-viewer`) or add the missing users to the GLAuth config, and add one test that fails when the service-account credential is wrong. |
| NEXT-07 | Deployment / windev | High | The `10.100.0.48` (windev) gateway deployment is **stale and crash-looping**, and has been since at least 2026-08-06 (~10k Hosting-failed events/day). The deployed Server binary dates to 2026-06-25 and predates the auth-DB migration of 2026-07-15: it opens a schema-version-3 `gateway-auth.db` that it supports only at version 2 and aborts at startup, so the `MxAccessGw` service never reaches a listening state. Not a code defect in the current tree — a deploy-drift/operations gap — but it means the repo's only deployed host has been dark for over a day and any host-level verification (including SEC-36's dashboard-login check) is blocked until it is repaired. Fix: deploy a current Server build to windev, or restore/downgrade the auth DB to schema 2 if the old binary must stand. Worth asking separately why a service in a permanent restart loop raised no alert. Discovered during SEC-36. |
| ~~NEXT-07~~ | Deployment / windev | High | **Resolved 2026-08-07** — a fresh portable framework-dependent publish of `origin/main` (`a346d51`) was built in a clean clone at `C:\build\mxgw-redeploy`, deployed to `C:\publish\mxaccessgw\Server-20260807`, and the `MxAccessGw` NSSM service repointed at it; the service now holds a stable PID with both `5120`/`5130` listening, a worker spawned, the Galaxy snapshot restored (129 objects / 56,731 attributes) and a clean event log. Root cause confirmed as the version skew this row predicted: the deployed 2026-06-25 build carried `ZB.MOM.WW.Auth.ApiKeys` 0.1.2.0, which supports auth-DB schema 2, against a `gateway-auth.db` stamped at schema 3 on 2026-07-15 by an ephemeral run of newer code — schema 3 is the current shared-lib version (`SqliteAuthSchema.CurrentVersion=3` in Auth 0.1.5), so the redeploy is the forward fix and the DB was left alone. Rollback artifacts kept: `C:\ProgramData\MxGateway\gateway-auth.db.bak-next07` (with `-wal`/`-shm`) and the previous `C:\publish\mxaccessgw\Server` directory. Two side effects worth recording: the old deploy's `appsettings.json` held the LDAP bind password in **plaintext on disk**, while the new one keeps the repo's `${secret:ldap/mxgateway/bind}` token with the NSSM environment supplying the value, so no plaintext LDAP secret remains on that host; and the redeploy tripped the SEC-06 `Ldap:Transport=None` production hard-stop (`GatewayOptionsValidator.cs:178`), resolved by relabelling the host — windev runs `Dashboard:DisableLogin=true`, which this repo's own docs mark dev/test-only, so its `Production` label contradicted its configuration and `DOTNET_ENVIRONMENT` was changed to `Staging` (that one NSSM environment entry only; the other nine preserved byte-identical). SEC-06 is untouched for genuinely production hosts — see NEXT-08 for the posture problem that relabelling defers. Original finding: The `10.100.0.48` (windev) gateway deployment is **stale and crash-looping**, and has been since at least 2026-08-06 (~10k Hosting-failed events/day). The deployed Server binary dates to 2026-06-25 and predates the auth-DB migration of 2026-07-15: it opens a schema-version-3 `gateway-auth.db` that it supports only at version 2 and aborts at startup, so the `MxAccessGw` service never reaches a listening state. Not a code defect in the current tree — a deploy-drift/operations gap — but it means the repo's only deployed host has been dark for over a day and any host-level verification (including SEC-36's dashboard-login check) is blocked until it is repaired. Fix: deploy a current Server build to windev, or restore/downgrade the auth DB to schema 2 if the old binary must stand. Worth asking separately why a service in a permanent restart loop raised no alert. Discovered during SEC-36. |
| NEXT-08 | Security / LDAP posture | Medium | **The shared GLAuth offers no TLS, so SEC-06 makes it undeployable from a `Production`-labelled host.** `GatewayOptionsValidator` (`src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs:178`) refuses to start when `Ldap:Transport=None` in the `Production` environment, and `docs/GatewayConfiguration.md`'s `Transport` row states "Deployed hosts must set `Ldaps` or `StartTls`" — but the shared instance at `10.100.0.35:3893` has `[ldaps] enabled=false`, port `3894` closed, and answers StartTLS with `protocolError`, so neither value can work against it. That instruction is currently unsatisfiable for every host that authenticates there. windev sidestepped it on 2026-08-07 by moving to the `Staging` environment name (NEXT-07), which is honest for a dev/test rig but is not available to a real production host. Resolution needs either LDAPS/StartTLS on the shared GLAuth (certificate plus a trust story on each gateway host) or an explicit written posture decision that production gateways bind a different, TLS-capable directory. Surfaced during the NEXT-07 redeploy. |
| NEXT-09 | Build / versioning | Low | **Windows builds stamp git's error text into `InformationalVersion`.** `src/Directory.Build.props:29` runs `git -C "$(MSBuildThisFileDirectory)" …`; MSBuild's directory property ends in a backslash, which escapes the closing quote, so the command is malformed on Windows. The target carries `ContinueOnError`, so the failure is silent and git's stderr is captured as the revision — an observed stamp reads `0.1.2+fatal: cannot change to …`. Any Windows build without a preset `SourceRevisionId` therefore ships a binary that cannot be correlated back to a commit, defeating the point of TST-11. Not reproducible on macOS/Linux, where the separator is `/`. Fix sketch: append `.` to the path or trim the trailing separator before quoting. Surfaced while identifying the deployed binary during NEXT-07. |
| NEXT-10 | Docs / glauth | Medium | **`glauth.md`'s "Pre-provisioned users" table contradicts both the directory and the rest of its own file.** It documents `readonly`/`readonly123` and `admin`/`admin123`, neither of which matches `scadaproj/infra/glauth/config.toml` (`readonly` does not exist there; `admin` carries the standard dev password), and lists the `ReadOnly` gid as `5501` against an actual `5601`. Its dashboard section, by contrast, is correct — so the file is internally inconsistent and a reader cannot tell which half to trust. This table was the **root cause of the NEXT-06 fixture drift**, and it has propagated further: `docs/GatewayTesting.md`'s `MXGATEWAY_LIVE_MXACCESS_WRITE_SECURED_PASSWORD` default and the matching literal in `WorkerLiveMxAccessSmokeTests` both take `admin123` from it. Deliberately **not** fixed in the 2026-08-07 pass: the table is entangled with the OPC-UA group taxonomy (gids, role mapping, and the sister-repo consumers of the same directory), so reconciling it means sweeping that taxonomy as one unit rather than patching two rows. |
## Operator actions still pending (from this cycle's runbooks)
These are **live-infrastructure actions the operator must execute** — the repo-side work is complete and merged:
- ~~**SEC-36** — rotate the dev LDAP service-account credential per `docs/runbooks/SEC-36-ldap-credential-rotation.md` (generate new secret in `scadaproj/infra/glauth`, pre-stage the NSSM env var on deployed hosts, rotate GLAuth on `10.100.0.35`, verify dashboard login). The committed literal is gone from the working tree but remains recoverable from git history until rotation completes — **rotation is the load-bearing half.**~~ **Executed 2026-08-07**: the `serviceaccount` `passsha256` was replaced in `scadaproj/infra/glauth/config.toml` (commit `aada53b`) and the shared GLAuth recreated on `10.100.0.35`, so the literal recoverable from this repo's history no longer binds `dc=zb,dc=local`. The new value lives only in the GLAuth hash, windev's NSSM environment, and dev user-secrets. `wonder-app-vd03` was out of scope (it binds a different, `dc=scadalink`/`dc=scadabridge` directory). **Caveat:** windev's dashboard `/login` verification is deferred that host's gateway is crash-looping on the unrelated stale-deployment fault filed as NEXT-07 so the bind was verified directly by `ldapsearch` as `cn=serviceaccount,dc=zb,dc=local` instead. SEC-36 is now fully `Done`.
- ~~**SEC-36** — rotate the dev LDAP service-account credential per `docs/runbooks/SEC-36-ldap-credential-rotation.md` (generate new secret in `scadaproj/infra/glauth`, pre-stage the NSSM env var on deployed hosts, rotate GLAuth on `10.100.0.35`, verify dashboard login). The committed literal is gone from the working tree but remains recoverable from git history until rotation completes — **rotation is the load-bearing half.**~~ **Executed 2026-08-07**: the `serviceaccount` `passsha256` was replaced in `scadaproj/infra/glauth/config.toml` (commit `aada53b`) and the shared GLAuth recreated on `10.100.0.35`, so the literal recoverable from this repo's history no longer binds `dc=zb,dc=local`. The new value lives only in the GLAuth hash, windev's NSSM environment, and dev user-secrets. `wonder-app-vd03` was out of scope (it binds a different, `dc=scadalink`/`dc=scadabridge` directory). **Caveat, since closed:** windev's dashboard `/login` verification was deferred while that host's gateway was crash-looping on the unrelated stale-deployment fault filed as NEXT-07, so the bind was verified directly by `ldapsearch` as `cn=serviceaccount,dc=zb,dc=local` instead. After the 2026-08-07 redeploy the real check ran on windev (login as `multi-role` → 302 + dashboard cookie, anonymous control → `/login`), so the rotated credential is now proven through the `DashboardAuthenticator` path itself; see `docs/runbooks/SEC-36-ldap-credential-rotation.md` Correction 3. SEC-36 is fully `Done`.
- ~~**TST-30** — register a second Gitea `act_runner` on `10.100.0.35` per `docs/runbooks/TST-30-second-ci-runner.md` to relieve the single-shared-runner bottleneck.~~ **Executed 2026-08-07**: `gitea-runner-2` (id 5, capacity 2) is online on `10.100.0.35` via the `/opt/gitea` compose stack, same `container.network: traefik`, token from a `0600` file mount; the existing runner (id 1, capacity 4) was untouched. Concurrency verified — jobs from three runs ran simultaneously across both runners, and a `gitea-runner-2` job cloned successfully from `http://gitea:3000`. TST-30 is now fully `Done`.
- **TST-30 follow-up — reset the Gitea instance runner registration token.** Runner-1's compose block was moved to the same `0600` file-mount pattern as runner-2 on 2026-08-07 (compose and both backups now `0600 root:root`, runner-1 recreated with its identity intact), but hygiene alone does not retire the token: both runners share **one instance-scope registration token** that was world-readable for roughly five months and is still live — a probe registered runner id 6 with it, then deleted it. Gitea 1.26.4 exposes no rotation via CLI or API (both paths are get-or-create and hand back the same value), so the reset must be done in the admin web UI ("Reset registration token"). Afterwards, refresh `/opt/gitea/runner_token` on `10.100.0.35` and shred the two token-bearing compose backups — they are the last copies of the old value. See `docs/runbooks/TST-30-second-ci-runner.md`.
- **TST-25 follow-ups** — old **TST-05** (scheduled live-MXAccess smoke) is now covered by the `nightly-windev` job; old **TST-24** (client wire tests in CI) is unblocked by the working Windows tier.