docs(v3): implementation plans — master orchestration + 4 batch plans

- v3 design finalized: all 7 open items resolved (live historian probe:
  255-char tagname limit; SDK multi-notifier spike: native AddNotifier,
  never duplicate ReportEvent; NamespaceKind.Simulated retired; CSV column
  dictionaries; ScadaBridge re-bind sized; rename-warning scan decided),
  reconciled with the pre-v3 universal discovery browser (its new §11
  v3 forward note re-targets the browse commit contract).
- Calculation pseudo-driver mini-design (IDependencyConsumer capability,
  mux-fed change/timer triggers, Tarjan cycle deploy gate, VT-parity
  error semantics).
- Implementation plans for Opus-agent execution, grounded against exact
  files/symbols: master plan (batch DAG, worktree-isolated parallel
  waves, contract-first fan-out, global gotcha list) + one plan per
  batch (1 schema+RawPath identity, 2 /raw UI+Calculation, 3 UNS
  reference-only+{{equip}}, 4 dual-namespace address space = v3.0),
  each with per-wave file ownership and a mandatory docker-dev live gate.
This commit is contained in:
Joseph Doherty
2026-07-15 16:50:30 -04:00
parent fbe393471e
commit cf03ca279d
8 changed files with 1449 additions and 60 deletions
@@ -127,12 +127,57 @@ narrow the driver config or use manual entry" rather than OOM on a 100 k-node ba
capture on a short interval until the node set is non-empty and stable across two passes,
bounded by the open-timeout — the same contract `DriverInstanceActor` honours at deploy.
5. `await driver.ShutdownAsync(...)` in a `finally` — the tree is fully captured; hold nothing
live. (No lazy expand ⇒ no need to keep the connection.)
live. (No lazy expand ⇒ no need to keep the connection.) **The shutdown itself is bounded**
(the R2-01 per-op-deadline rule, program doc §3.1 — no unbounded waits anywhere): it runs
under its **own 10 s linked CTS**, because a driver wedged during discovery may be equally
wedged in shutdown, and cleanup must not extend the open beyond open-timeout + that bound.
On shutdown timeout: log a warning, **abandon the instance** (drop the reference — never
block on it or rethrow), and still return / fail the open on the **discovery outcome**
a hung shutdown neither fails a successful capture nor masks the real discovery error.
6. return `new CapturedTreeBrowseSession(capture.Root)`.
`CanBrowse(driverType, configJson)` = `TryCreate` (cheap, no connect) succeeds **and** the
instance is `ITagDiscovery { SupportsOnlineDiscovery: true }`. Used by the AdminUI to decide
whether to render the **Browse** button vs. manual entry, before any connect.
#### Capture concurrency — coalescing + global cap
Every `OpenAsync` is heavy: it constructs, connects, and fully enumerates a **real driver
against the live device**. Repeated Browse clicks (or two operators picking against the same
controller) must not stack parallel symbol walks on one PLC. Two controls, both inside
`DiscoveryDriverBrowser`:
- **In-flight coalescing**, keyed on `(driverType, hash of configJson)` (e.g. SHA-256 of the
caller-supplied JSON; `PatchForBrowse` is a pure function of `driverType`, so hashing the
pre-patch config is equivalent per key). A second open for the same key **awaits the same
capture task** rather than starting another walk; each awaiting caller then receives its
**own** `CapturedTreeBrowseSession` (own token, own TTL in the `BrowseSessionRegistry`)
over the **shared, immutable captured tree**. A failed capture fails every coalesced
awaiter with the same error. The coalescing map holds **in-flight captures only** — no
result caching: the entry is removed when the capture completes (success or failure), so
the next open for that key (including a Refresh, §4.3) triggers a fresh capture.
- **A global cap on simultaneous captures** — `MaxConcurrentCaptures = 4` (a const, like
`BrowserSessionService.PerCallTimeout`). Excess opens **queue** (semaphore, FIFO) and still
honour the caller's cancellation token while queued. Coalesced awaiters don't consume a
slot — only distinct in-flight captures count against the cap.
`CanBrowse(driverType, configJson)` = `TryCreate` (cheap, no connect) yields a non-null
instance **and** that instance is `ITagDiscovery { SupportsOnlineDiscovery: true }`. Used by
the AdminUI to decide whether to render the **Browse** button vs. manual entry, before any
connect. Two hardening rules:
- **`TryCreate` can throw, not just return null.** Factories parse `configJson` *inside*
`TryCreate`, so malformed/half-typed JSON (exactly what a picker holds mid-authoring) can
surface as an exception. `CanBrowse` must catch **any** exception → `false` — it is a UI
affordance gate and must never throw into the page.
- **Defensive teardown of the throwaway instance.** Whatever the outcome (not discovery-capable,
flag false, or an exception after construction), `CanBrowse` best-effort
`ShutdownAsync`s/disposes any instance it *did* create — bounded, swallow-all — so probing
never leaks driver instances.
**Invariant the throwaway-instance pattern depends on:** driver constructors are
**connection-free** today — every driver in the tree connects in `InitializeAsync`, never in
its ctor. `CanBrowse` (construct → inspect → discard, potentially on every picker render) and
the capture path's construct-then-patch flow both rely on this. A future driver that opens a
device connection in its constructor would silently turn `CanBrowse` into a connect storm —
treat "no I/O in driver ctors" as a driver-authoring rule this browser now depends on (worth a
line in the program doc's cross-cutting rules when P1 lands).
### 4.3 `CapturedTreeBrowseSession : IBrowseSession`