docs(plan): perf follow-ups round 3 — r2 register closes + four local live gates

This commit is contained in:
Joseph Doherty
2026-08-17 07:06:07 -04:00
parent 49d05bc27b
commit f64b10b964
2 changed files with 446 additions and 0 deletions
+432
View File
@@ -0,0 +1,432 @@
# Perf Follow-ups Round 3 Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development
> to implement this plan task-by-task (per the standing preference: Opus implementers,
> classification-driven review chains).
**Goal:** Close the actionable items in the round-2 follow-up register
(`docs/plans/2026-08-17-perf-followups-r2.md` §"Follow-up register") and run the
still-unexercised local live gates (FetchAndCache legs, alarm-bearing surgical ratchet,
ACL-enabled enforcement, docker-dev first-POST-404 catch), on branch `perf/followups-r3`
off master merge `49d05bc2`.
**Architecture:** Two surgical code fixes in already-reviewed code (the ACL cache's
single-shot CAS becomes a monotonic-max loop; S7 read-pass health becomes worst-state-wins
aggregation), one doc-staleness fix found during exploration, and four live gates on the
local docker-dev rig (OrbStack) sequenced on one rig session. No new subsystems.
**Tech Stack:** .NET 10 / C#, xUnit + Shouldly, docker-dev rig (OrbStack), Traefik v3.1
access log, Client.CLI, Chrome browser automation for AdminUI authoring.
**Out of scope (recorded, deliberately not here):**
- **TM/CT decode at a non-zero device number** — needs a real S7 CPU; the test exists and
is env-gated on `S7_TIMER_NUMBER_ADDRESSING=1`. Nothing to do until hardware exists.
- **Cross-process plant-vs-reap race on the shared SQL server** — accepted in r2; the 24 h
age floor is the mitigation. No work.
- **Modbus per-tag health last-writer-wins** — Task 2 fixes S7 only (the recorded item).
Modbus has the same shape (`ModbusDriver.ReadAsync:493/511/518` + the coalesced path);
fixing it here doubles the blast radius of a watchdog-adjacent change. Recorded in this
round's register as a deliberate asymmetry.
- **S7 write-path per-item health** (`S7Driver.cs:1575/1633/1645/1654`) — same
last-writer-wins shape on `WriteAsync`; out of scope for the same reason, recorded.
- Historian end-to-end live gate — needs the `wonder-sql-vd03` VPN (user-driven).
- HistorianGateway batch `WriteLiveValues` RPC — cross-repo.
- Load-scale throughput — needs a load harness; not defined enough to gate.
**Execution rules (unchanged from prior rounds):** shared checkout — explicit-pathspec
`git add`/`git commit -- <files>` only (never a bare commit); never checkout/switch/reset/
stash/clean; retry on `index.lock` and CS2012/MSB3030 build races; reviewers judge commits
via `git show`; falsification mutations applied AND reverted via Edit (never git), run
against a freshly built binary (never `--no-build` after a mutation), and labeled honestly
("pin, not red-first" where the behaviour predates the test; "probabilistic red" where the
mutation trips a race). Live-gate rig work is serial — one rig session, Tasks 4→5→6→7 in
that order, images built from the branch tip AFTER Tasks 1–3 land so the gates also
regression the new code.
---
### Task 1: ACL `InvalidateIfStale` — monotonic-max CAS loop (publication never deferred)
**Classification:** high-risk
**Estimated implement time:** ~4 min
**Parallelizable with:** Tasks 2, 3
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/SessionAclDecisionCache.cs:453-461`
(+ the XML remarks block `:430-452`)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SessionAclDecisionCacheTests.cs`
**Context.** The r2 register records: the single-shot CAS in `InvalidateIfStale` can defer
stamp publication by one call. Two callers read the same `seen` baseline; gen-N wins the
CAS, gen-N+1's CAS fails against the moved stamp and falls out with **no retry** — N+1 is
not published on that call, so its `TryGet` misses and its `Set` is dropped at `:223`.
Benign (verdict safety lives in the per-shard generation gate), but it costs a lost store
exactly in the post-deploy window. Current code:
```csharp
private void InvalidateIfStale(long generation)
{
var seen = Interlocked.Read(ref _generation);
if (generation <= seen) return;
if (Interlocked.CompareExchange(ref _generation, generation, seen) == seen) ClearAll();
}
```
**Step 1: Write the racing pin (red against current code — probabilistic red, say so).**
```csharp
[Fact]
public void Concurrent_lookups_at_mixed_generations_always_publish_the_highest()
{
// The single-shot CAS defect: when gen-1 wins the shared-baseline race, gen-2's CAS
// fails and is never retried, leaving Generation == 1. Red is probabilistic per
// round; 200 barrier-synchronised rounds make it effectively certain.
for (var round = 0; round < 200; round++)
{
var cache = /* fresh cache, generation seed -1 */;
using var barrier = new Barrier(2);
var low = Task.Run(() => { barrier.SignalAndWait(); /* TryGet at generation 1 */ });
var high = Task.Run(() => { barrier.SignalAndWait(); /* TryGet at generation 2 */ });
Task.WaitAll(low, high);
cache.Generation.ShouldBe(2L,
$"round {round}: the newer generation must publish even when it loses the CAS race");
}
}
```
Use the ordinary `TryGet` lookup path (as the straggler test at `:286` does), NOT
`AdvanceGenerationForTest` — the defect lives in the lookup path's invalidation.
**Step 2: Run it, observe red.** `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter "FullyQualifiedName~Concurrent_lookups_at_mixed_generations"` — expect FAIL within a few rounds. If 200 rounds pass green against unmodified code, the test is not exercising the race — fix the test, do not proceed.
**Step 3: Implement the monotonic-max loop.**
```csharp
private void InvalidateIfStale(long generation)
{
while (true)
{
var seen = Interlocked.Read(ref _generation);
if (generation <= seen) return; // guard re-checked per attempt
if (Interlocked.CompareExchange(ref _generation, generation, seen) == seen)
{
ClearAll();
return;
}
}
}
```
Invariants (update the XML remarks `:430-452` to say all of this):
- The `<=` guard is INSIDE the loop against a fresh read — a straggler that lost a CAS
re-reads the newer stamp and exits; hoisting the guard reintroduces the 98d6a190 defect.
- Only the thread whose CAS moved the stamp clears (elected clear preserved). A rollover
now legitimately clears once per distinct generation (N then N+1) — bounded by
generations, not request volume.
- The loop self-terminates: each iteration exits via the guard or CASes against a strictly
larger `seen`, and `_generation` only rises.
- The `-1` seed still admits generation 0.
**Step 4: Run the new test + the full cache suite.** All 18 green (17 existing + 1 new),
especially `A_straggler_from_an_older_generation_neither_clears_nor_rolls_the_stamp_back`
(`:286`) and `No_hit_ever_returns_a_verdict_from_another_generation` (`:363`).
**Step 5: Falsify.** Via Edit, revert the loop to the single-shot form (fresh build, not
`--no-build`) → the new test must go red (probabilistic; report the failing round). Revert
via Edit. Separately, move the `<=` guard outside the loop → the straggler test at `:286`
must go red. Revert.
**Step 6: Commit.**
```bash
git add -- src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/SessionAclDecisionCache.cs \
tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SessionAclDecisionCacheTests.cs
git commit -m "fix(acl): make InvalidateIfStale a monotonic-max CAS loop — stamp publication is never deferred" -- <same paths>
```
---
### Task 2: S7 read-pass health — worst-state-wins aggregation
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** Tasks 1, 3
**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs` — per-item health sites
`:647` (per-tag loop), `:828` (multi-var decode loop), `:941` (span member loop),
`RecordReadFailure:1010-1046`, plus one end-of-pass write in `ReadAsync`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7SpanReadParityTests.cs`
(flip the `:423` pin; doc-comment `:389-393` rewritten; new tests)
**Context.** The r2 register records: per-item decode health is last-writer-wins — a mixed
chunk reports whatever the LAST item's outcome was, so `Healthy` can be reported for a
pass that had failures (pinned at `:396-423`), and the `:1023` access-denied `Faulted` can
be overwritten by a later good item. Fix = aggregate one outcome per `ReadAsync` pass.
**Watchdog contract (MUST hold — CLAUDE.md comms-loss section):** `Degraded` means
connected-but-some-bad; `LastSuccessfulRead` advances on ANY success and is preserved on
failure. The aggregate write must advance `LastSuccessfulRead` when ANY item succeeded,
even when the state lands `Degraded`/`Faulted` — gating it on `Healthy` turns the fix into
a false-positive reconnect storm.
**Step 1: Red tests first** (the flip IS red-first; the rest are new):
1. Flip `:423`: `One_undecodable_multi_var_item...` now expects `Degraded` (rename to say
worst-state-wins; rewrite doc-comment `:389-393` — the last-writer-wins pin is being
deliberately retired).
2. New: a mixed multi-var pass reports `Degraded` AND `LastSuccessfulRead` is advanced
(non-null / newer than the pre-pass value).
3. New: span path — one bad member decode then a good member ⇒ `Degraded` (today: Healthy).
4. New: an access-denied `PlcException` on one tag followed by a success ⇒ state stays
`Faulted` (ordering `Healthy < Degraded < Faulted`), `LastSuccessfulRead` advanced.
Use the per-tag path if the multi-var fake can't produce a per-item PlcException.
5. Must-stay-green: `:326` (`A_failing_multi_var_chunk_falls_back...`, asserts Healthy at
`:346`) and `:432` (short answer, Healthy at `:447`) — a multi-var REQUEST fault whose
tags are re-served Good by the per-tag tail is NOT a failed outcome. The chunk-fault
catch (`:806-814`) stays outside the aggregate.
**Step 2: Run, observe red on 1–4** (each for its own reason; report per-test).
**Step 3: Implement.** A small per-pass accumulator threaded through the pass:
```csharp
private sealed class ReadPassOutcome
{
public bool AnySuccess;
public DriverState WorstFailure = DriverState.Healthy; // Healthy = none recorded
public string? FirstError; // first failure is causal
public void RecordSuccess() => AnySuccess = true;
public void RecordFailure(DriverState state, string message)
{
if (Rank(state) > Rank(WorstFailure)) WorstFailure = state;
FirstError ??= message;
static int Rank(DriverState s) => s switch
{
DriverState.Faulted => 2, DriverState.Degraded => 1, _ => 0
};
}
}
```
- The three success sites (`:647`, `:828`, `:941`) call `outcome.RecordSuccess()` instead
of assigning `_health`.
- `RecordReadFailure` takes the outcome, records `(Degraded|Faulted, message)` into it
instead of assigning `_health`, and KEEPS its `_plcDead` / `MarkConnectionDeadIfFatal`
side effects immediate. It still returns the per-item status code.
- One `_health` write at the end of the `ReadAsync` try body (NOT `finally` — the OCE
arms at `:649`/`:801`/`:920` rethrow and must skip it):
```csharp
_health = new DriverHealth(
outcome.WorstFailure == DriverState.Healthy
? DriverState.Healthy : outcome.WorstFailure,
outcome.AnySuccess ? now : _health.LastSuccessfulRead,
outcome.WorstFailure == DriverState.Healthy ? null : outcome.FirstError);
```
- Untouched: the `:567` connect-failure early return; `HandlePollError` (`:1980`); the
entire write path; init/shutdown sites. `ReadSpanAsync` / `ReadLeftoversByMultiVarAsync`
gain an outcome parameter — signature changes, no logic movement.
**Step 4: Run the full S7 suite** (`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests`)
— all green including the parity anchor `:202` (all-members-fail ⇒ Degraded both paths).
**Step 5: Falsify.** Via Edit: (a) make the end-of-pass write use the LAST recorded state
instead of the worst → tests 1/3/4 red; (b) gate `LastSuccessfulRead` advancement on the
state being Healthy → test 2 red. Fresh builds. Revert via Edit.
**Step 6: Commit** (explicit pathspec, message
`fix(s7): read-pass health is worst-state-wins — a mixed pass can no longer report Healthy`).
---
### Task 3: Fix stale `OTOPCUA_CONFIG_MODE` docs
**Classification:** trivial
**Estimated implement time:** ~2 min
**Parallelizable with:** Tasks 1, 2
**Files:**
- Modify: `docs/Configuration.md` (~line 253) — "Flip only the site nodes with
`OTOPCUA_CONFIG_MODE=FetchAndCache` at `docker compose up`" is stale
- Modify: `CLAUDE.md` — same sentence in the `ConfigSource`/`ConfigServe` section
- Check-and-fix if matched: `grep -rn "OTOPCUA_CONFIG_MODE" docs/ docker-dev/ CLAUDE.md`
**Spec.** Phase 4 made `FetchAndCache` mandatory on driver-only nodes
(`ConfigSourceOptionsValidator` fails host start on `Direct`), and the rig's four site
nodes are hardcoded `ConfigSource__Mode: "FetchAndCache"` in
`docker-dev/docker-compose.yml:572-579/691-696/787-792/858-863`. The env var appears
nowhere in the compose file; setting it is a no-op. Rewrite both sentences to state: the
site nodes run `FetchAndCache` unconditionally (mandatory for driver-only nodes since
Phase 4); central stays `Direct` via its admin role. No review (doc-only). Commit with
explicit pathspec, message `docs(config): OTOPCUA_CONFIG_MODE is gone — site nodes are hardcoded FetchAndCache (Phase 4)`.
---
### Task 4: Live gate — docker-dev first-POST-404 catch + no-body POST binding
**Classification:** live-gate (rig session step 1 — FRESH BOOT window)
**Estimated implement time:** ~10 min wall (mostly rig boot)
**Parallelizable with:** none (rig serial; runs after Tasks 1–3 land, images built from branch tip)
**Files:**
- Possibly modify: `docker-dev/deploy.sh` (the header NOTE about no-body binding),
`docker-dev/README.md` (§"First-POST 404" — mechanism note if caught)
- Evidence: write to the scratchpad; Task 8 folds it into the plan's Outcomes
**Procedure:**
1. Fresh rig: `docker compose -f docker-dev/docker-compose.yml down -v` then
`up -d --build` (build mandatory — the branch tip must be in the images).
2. IMMEDIATELY run `./docker-dev/deploy.sh` (no body). Its 2 s `/health/ready` poll rides
through Traefik during the boot window — every poll is access-logged with
`defaultmode=keep`, and an unmatched request shows an EMPTY `RouterName`.
3. After the deploy completes (or fails), capture
`docker compose -f docker-dev/docker-compose.yml logs traefik` and extract every
non-2xx access-log line from the boot window. **If any 404 appears:** name the
mechanism from `RouterName`/`ServiceName`/timing (the r2 elimination says: AdminUI
cannot 404 that path; backend-down is 503; so 404 = no router matched at that instant
— presumably the window before the dynamic-config file provider loads or before the
health-checked service has a server). Update `docker-dev/README.md` §"First-POST 404"
with the observed mechanism. **If it does not reproduce:** record that honestly —
"did not reproduce under the readiness gate; register item stays open" — do NOT
invent a mechanism.
4. No-body POST binding: with the rig ready, run `./docker-dev/deploy.sh` bare and record
the HTTP status + response body. Determine whether the minimal-API `DeployRequest?`
binds null (and what the endpoint then does) or 400s. Update the header NOTE in
`deploy.sh` from "unverified" to the observed behaviour, and `README.md` if wrong.
5. Leave the rig UP for Task 5.
Commit any `deploy.sh`/`README.md` edits (explicit pathspec). Report: the access-log
excerpt, the no-body verdict, and whether the 404 reproduced.
---
### Task 5: Live gate — FetchAndCache legs on the running rig
**Classification:** live-gate (rig session step 2)
**Estimated implement time:** ~8 min wall
**Parallelizable with:** none (rig serial, after Task 4)
**Procedure** (rig already up from Task 4; a deploy already ran):
1. **Fetch + verify:** `docker compose -f docker-dev/docker-compose.yml logs site-a-1 |
grep -E "Fetched \+ verified artifact"` — expect
`Fetched + verified artifact for {DeploymentId} from http://central-… ({Bytes} bytes).`
2. **Served (central side):** `logs central-1 central-2 | grep "Served artifact"` —
`Served artifact for {DeploymentId} ({Bytes} bytes, {Chunks} chunks)`.
3. **Applied-from-fetch + cached:** `logs site-a-1 | grep -E "applied fetched deployment|cached applied deployment"` — both lines must appear (the `fetched` variant, NOT the
Direct `applied deployment` form).
4. **Boot-from-cache:** `docker compose restart site-a-1`, wait for boot, then
`logs site-a-1 | grep "FetchAndCache boot"` — expect
`restored served state for deployment {Id} (rev {Rev}) from the LocalDb pointer.`
5. **Absence proof:** on site-a-1 logs, assert NO occurrence of `entering Stale`,
`ConfigDb back`, or the Direct-form `applied deployment {Id} (rev` (without
`fetched`). A FetchAndCache node must never reach Stale.
6. Evidence excerpts to the scratchpad for Task 8. Rig stays UP for Task 6.
No commit expected. If any leg fails, STOP and report — that is a real regression, not a
gate formality.
---
### Task 6: Live gate — alarm-bearing surgical ratchet
**Classification:** live-gate (rig session step 3)
**Estimated implement time:** ~12 min wall
**Parallelizable with:** none (rig serial, after Task 5)
**Context.** The surgical-deploys classifier routes an alarm-bearing raw-tag delta
(`Alarm != null` on EITHER side) to `Rebuild`; a whitelisted non-alarm attribute delta
(`Writable` among them) is surgical. Never observed live. The ONLY seam is the applier
summary line (`AddressSpaceApplier.cs:322-326`):
`applied plan (kind={Kind}, …, surgicalRawTags={SurgicalRaw}, …, rebuild={Rebuild})`.
**Procedure:**
1. Pick TWO alarm-free raw tags in the rig's seeded config (browse `/raw` via AdminUI at
`http://localhost:9200`, login disabled — or read the ConfigDb `Tag` rows). Call them
TagS (surgical lever) and TagA (alarm lever).
2. **Surgical control first:** toggle TagS's Access level (Read ↔ ReadWrite) — via the
RawTagModal in the browser, or a direct ConfigDb UPDATE of the tag row (the rig's DB
on `10.100.0.35,14330`; acceptable for a classifier gate, note which route was used).
Deploy via `./docker-dev/deploy.sh`. Expect on central-1:
`kind=AttributeOnly, … surgicalRawTags=1, … rebuild=False`.
3. **Alarm add ⇒ rebuild:** add `"alarm": {"alarmType": "OffNormalAlarm", "severity": 500}`
to TagA's `TagConfig` JSON (raw-JSON textarea — available because the seeded device is
a Galaxy driver, which has no typed editor — or the same direct-DB route). Deploy.
Expect `kind=Rebuild, … surgicalRawTags=0, … rebuild=True`.
4. **Alarm remove ⇒ rebuild:** remove the `alarm` object, deploy, expect `rebuild=True`
again (Alarm != null on the PREVIOUS side also disqualifies).
5. **Restore:** revert TagS's access level, deploy once more (surgical again — also
re-proves the ratchet is per-deploy, not latched).
6. Evidence: the four applier lines (grep `docker compose logs central-1 site-a-1 |
grep "applied plan (kind="` — note which nodes applied). Scratchpad for Task 8. Rig
stays UP for Task 7.
No commit expected. Failure of the expected routing = STOP and report.
---
### Task 7: Live gate — ACL-enabled enforcement on site-a (re-shaped cache)
**Classification:** live-gate (rig session step 4)
**Estimated implement time:** ~20 min wall
**Parallelizable with:** none (rig serial, after Task 6)
**Context.** `Security:Acl:Enabled` has never been exercised live since the r1/r2 cache
re-shape (per-key shards, generation stamp, Task 1's CAS loop). Site-a is the env-gated
demo pair: `OTOPCUA_ACL_ENABLED=true` reaches site-a-1/-2 only
(`docker-compose.yml:585/:702`); site-b stays off as the A/B control. Site-a OPC UA:
host ports 4842/4843; site-b: 4844/4845. Keys/grants reach driver-only site nodes ONLY
via the deployment artifact — every authoring step needs a deploy after it.
**Procedure:**
1. Restart site-a gated: `OTOPCUA_ACL_ENABLED=true docker compose -f docker-dev/docker-compose.yml up -d site-a-1 site-a-2`. Confirm the boot Warning on both:
`node ACL enforcement is ENABLED — EVERY data-plane operation … requires a matching NodeAcl grant`.
2. Mint a key: AdminUI `/apikeys` (Chrome automation; login disabled) — Name
`r3-acl-gate`, Principal `r3gate`. COPY the one-time `otua_…` credential immediately.
3. Author a grant: `/clusters/{site-a cluster id}/acls/new` — Principal `r3gate`, scope
picked via the AclScopePickerModal on a WRITABLE raw tag (or its device — inheritance
covers the tag), permissions: the Operator bundle (must include Browse/Read/
WriteOperate). Save; expect no "authorizes nobody" warning.
4. Deploy (`./docker-dev/deploy.sh`) so the key + grant ride the artifact to site-a.
Confirm on site-a-1: `data-plane authority generation {G} installed (… keys=…, grants=…)`.
5. **The load-bearing ALLOW:** Client.CLI write through the grant —
`dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- write -u opc.tcp://localhost:4842 -n "<granted raw tag NodeId>" -v <value> -U apikey -P otua_…`
Expect success (the session carries NO server-wide role; the grant alone authorizes —
a deny-only gate would pass against a build where the ACL is never consulted).
Note: the rig's Galaxy driver has no live gateway, so the DEVICE write may fail
downstream — the gate is that the request passes the ACL (not `BadUserAccessDenied`);
any downstream Bad status is fine and should be reported as such.
6. **Deny controls:** (a) same write WITHOUT credentials (anonymous) ⇒ denied; (b) a
read/browse on a node OUTSIDE the granted scope with the key ⇒ withheld (grep
site-a-1 for `AclGate: denied {Operation} on {NodeId} for principal r3gate`).
7. **Revocation reaches the artifact path:** disable the key on `/apikeys`, deploy,
confirm a NEW `data-plane authority generation {G+1} installed` on site-a-1, and the
same CLI write now fails auth (`ApiKeyAuth: denied — no enabled, unexpired key`).
8. **Teardown:** restore site-a ungated (`docker compose up -d site-a-1 site-a-2` without
the env), optionally delete the gate key/grant, and `docker compose down` the rig
unless Task 8 needs it. Evidence excerpts to the scratchpad.
No commit expected. If Chrome automation of the scope picker proves impractical, direct
ConfigDb inserts for `ApiKey`/`NodeAcl` rows are acceptable ONLY as a fallback — say so,
and keep the key's peppered HMAC consistent with the rig pepper
(`docker-dev-apikey-pepper-not-for-production`); minting via the UI is strongly preferred
because it exercises the real authoring path.
---
### Task 8: Close-out — sweep + Outcomes + register annotations
**Classification:** close-out (blockedBy: all)
**Estimated implement time:** ~15 min
1. Full build (`dotnet build ZB.MOM.WW.OtOpcUa.slnx`) — 0 errors; warning count vs r2's
904 explained.
2. Full unit sweep, every `*.Tests` project, per-project; reconcile the passed-count
delta against Tasks 1–2's added/changed tests exactly. DB suites must RUN (AdminUI
`0 skipped`, Configuration `0 skipped`).
3. Write `## Outcomes` on this plan: sweep record, per-task table, all four live-gate
evidence excerpts, falsification notes, and this round's register (Modbus + S7-write
asymmetry, TM/CT real-CPU, cross-process reap race carried; whatever Tasks 4–7 leave
open).
4. Annotate the r2 register in `2026-08-17-perf-followups-r2.md` append-only with
"Closed by <sha> (r3)" / "Gated live (r3)" sub-bullets.
5. Update the `.tasks.json` ledger; commit docs with explicit pathspec.
@@ -0,0 +1,14 @@
{
"planPath": "docs/plans/2026-08-17-perf-followups-r3.md",
"tasks": [
{"id": 1, "subject": "Task 1: ACL InvalidateIfStale — monotonic-max CAS loop", "status": "pending"},
{"id": 2, "subject": "Task 2: S7 read-pass health — worst-state-wins aggregation", "status": "pending"},
{"id": 3, "subject": "Task 3: Fix stale OTOPCUA_CONFIG_MODE docs", "status": "pending"},
{"id": 4, "subject": "Task 4: Live gate — first-POST-404 catch + no-body POST binding", "status": "pending", "blockedBy": [1, 2, 3]},
{"id": 5, "subject": "Task 5: Live gate — FetchAndCache legs", "status": "pending", "blockedBy": [4]},
{"id": 6, "subject": "Task 6: Live gate — alarm-bearing surgical ratchet", "status": "pending", "blockedBy": [5]},
{"id": 7, "subject": "Task 7: Live gate — ACL-enabled enforcement on site-a", "status": "pending", "blockedBy": [6]},
{"id": 8, "subject": "Task 8: Close-out — sweep + Outcomes + register annotations", "status": "pending", "blockedBy": [1, 2, 3, 4, 5, 6, 7]}
],
"lastUpdated": "2026-08-17T19:05:00Z"
}