Commit Graph

742 Commits

Author SHA1 Message Date
Joseph Doherty 0739f4b9a4 merge: RawBrowseModal post-await staleness guard (deferment §0.1 race ported from the 2026-08-05 sibling fix) 2026-08-09 21:08:00 -04:00
Joseph Doherty 87637e4e2c fix(adminui): guard RawBrowseModal's resolve against a superseded open
Blazor does not serialize OnParametersSetAsync against its own outstanding
task, and the modal's `_open` bool cannot tell two opens apart. ResetAndResolveAsync
awaits LoadMergedProbeConfigAsync and ResolveGroupContextAsync and then wrote
_driverType/_mergedConfig/_effectiveDeviceId/_groupPrefix/_canBrowse/_disabledReason
with no staleness re-check, so a close-reopen onto another device let the first
open's late continuation install its device's browse context under the displayed
modal — "Connect & browse" would silently dial the wrong controller and the commit
would land under the wrong tag-group.

Ports the per-open `_openGeneration` counter proven in the two sibling modals
(RawManualTagEntryModal, RawTagModal, 2026-08-05) to every post-await write here:
after both awaits, in the catch's error banner, and in the finally. The finally is
the one departure from the sibling shape and it is load-bearing — this modal resolves
inside a try/finally, so a stale `return` still runs the tail and would drop the LIVE
open's spinner mid-resolve.

Pinned by four RawBrowseModalStaleOpenTests facts, each observed red before the guard
and each re-falsified afterwards by reverting only its own guard. The modal calls
StateHasChanged on the open path, which throws without a render handle, so the test
attaches an inert Renderer subclass (file-local BL0006 pragma); [Parameter]s are set
by reflection per the BL0005 convention.

AdminUI suite 1117/1117 green.
2026-08-09 21:07:07 -04:00
Joseph Doherty 20a816dbf4 feat(galaxy): consume the gateway's correlated write statuses; close archreview 06/S-1
The mxaccessgw OnWriteComplete correlation is live (server b948e69, worker
53f69cd — all four unary write kinds), so every write reply's statuses[0] now
carries the real COM commit outcome. Live-gating the cutover on windev found
and fixed three defects:

- StatusCodeMap.FromMxStatus truncated the correlated failure row's MxStatus
  detail code to a byte (1007 & 0xFF = 239 -> OPC DA Good band), mapping a
  REFUSED write to Good. New invariant: a row that is not IsSuccess() never
  maps Good — details > 0xFF (and contradictory Good-band substatuses) route
  by category (SECURITY_ERROR -> BadUserAccessDenied, named const covered by
  StatusCodeParityTests; COMMUNICATION/CONFIGURATION keep their codes).

- The borrow-from-subscription-registry write optimization could never commit:
  a subscription handle is plain-advised, and MXAccess refuses both the
  no-login write through it (SECURITY_ERROR detail 1008, live-captured) and
  the supervisory advise upgrade (E_INVALIDARG) — one advise per HANDLE, not
  per attribute. Removed: writer subscribedHandleSource ctor param + borrow
  branch, GalaxyDriver wiring, SubscriptionRegistry.TryResolveItemHandle +
  its forward index and test file. The writer always AddItems its own
  supervisory-advised handle, which commits fine while the same attribute is
  subscribed (live-proven; pinned by the new write-while-subscribed smoke).

- (gateway-side, fixed there) the correlation initially covered WriteSecured
  only, while the dominant no-login path emits plain Write.

Live gate, all legs PASSED against windev: happy write Good off a confirmed
status row with galaxy.writes.unconfirmed + advise_failed both FLAT; read-only
refusal surfaces Bad-family (the #5 node-revert input Galaxy could never
produce before); WriteCompletionWaitMilliseconds=0 control -> provisional Good
+ metered unconfirmed (override removed after, restoration re-verified). New
env-gated smokes: Live_correlated_write_statuses_confirm_commit_and_surface_refusal,
Live_write_to_subscribed_tag_commits_via_own_handle (replaces the borrow
smoke), GatewayGalaxyWriteMeterLiveTests (run filtered — meters are
process-wide).
2026-08-09 20:11:55 -04:00
Joseph Doherty dac7e1885d feat(resilience): route IDriver.InitializeAsync through the capability invoker (R2-10 T10 closed)
The connect path was the one capability call the resilience pipeline never
saw, so a driver that could not connect contributed zero breaker input and
the /hosts resilience panel read 'All targets healthy' against a dead
endpoint. InitializeAsync now routes through the invoker as
DriverCapability.Initialize, keyed on the driver-instance id.

- No pipeline retry: the actor's RetryConnect timer is the retry loop
  (parser forces retryCount overrides to 0, same funnel as write-shaped).
- Breaker threshold 2, not the Read-sized 5: the breaker samples 30 s and
  the reconnect timer fires every 10 s, so at most three attempts fit one
  window — threshold 5 could never open. Pinned by its own test.
- An open breaker fast-fails into the same InitializeFailed path as a real
  connect failure; the 15 s break window costs at most one extra tick.
  The best-effort teardown ShutdownAsync stays unwrapped.
- Companion fix: DriverCapabilityInvokerFactory.Create now invalidates the
  instance's tracker snapshots with its pipelines — a respawn during an
  outage discarded the only breaker whose OnClosed could clear the OPEN
  flag, stranding a false 'Breaker OPEN' chip forever.

Live gate PASSED on docker-dev (image symbol-verified per container):
rtu-gate against dead 10.100.0.35:5021 opened its Initialize breaker on
both centrals with the red 'BREAKER OPEN' chip on /hosts; a socat
5021→5020 forwarder recovered it (both breakers closed within 1 s, panel
back to 'All targets healthy'); healthy MAIN-modbus recorded zero breaker
events. Full suite 50/50 green. Evidence:
docs/plans/2026-08-07-adminui-followups-and-live-gates.md §7.
2026-08-08 05:45:08 -04:00
Joseph Doherty 1e02661598 chore(gates): close adminui-followups by audit; R2-03 T11/T12 + reconfigure Task 3 PASSED, R2-10 re-diagnosed
The two items deferment.md §0.1 named as the remaining untracked engineering.

adminui-followups (19 pending / 0 completed) — CLOSED by the overlap audit the plan
itself asks for first, with no code written. All 19 tasks are shipped, superseded by
v3, or moot. WS1's premise had also expired: it assumed driver-embedded tag lists in
DriverConfig are the runtime source of truth, which v3 ended (the artifact populates
RawTags). Its capability shipped in the v3 shape — TagConfigEditorMap + DeviceFormMap,
both verified reachable from RawTagModal/DeviceModal rather than merely present.

Live gates on docker-dev (image rebuilt: the rig was running a build 3 h older than the
last code commit; parity asserted per container plus a positive check that the symbols
under test are in the shipped DLLs):

- R2-03 T11 PASSED — build 0 errors, Runtime.Tests 555/555, RoslynVirtualTagEvaluator 20/20.
- R2-03 T12 PASSED — VT 42 -> 0x80020000 -> 42, staged WITHOUT a deployment (a Modbus
  register flipped on the fixture) so the VT child survived; a deploy-driven trip creates
  a fresh child with no last value, which is correct-by-design and masked the result twice.
  P7 confirmed: two byte-identical redeploys, zero disturbance.
- driver-reconfigure Task 3 PASSED — a faulted driver adopted a corrected config and
  connected with the node's startedAt unchanged and RestartCount=0.
- R2-10 T10 still OPEN, but the recorded cause was wrong. Not ConnectionBackoff
  throttling (the breaker opens on 2 failures / 30 s): the dead-endpoint failure mode
  never enters the resilience pipeline. IDriver.InitializeAsync is not invoker-wrapped
  (DriverInstanceActor.cs:707) and ModbusDriver swallows per-tag failures in BOTH
  directions, so even the plan's fallback trigger cannot work. Live-proven: a driver
  timing out every read against a blackhole reported ConsecutiveFailures=0 while the
  panel read "All targets healthy."

Finding from T12: the Bad publish's documented "carrying the last-known value" is
unobservable by any client — subscription and a raw Read service call both return NULL.
The actor is correct; every existing failure assertion checked StatusCode alone, so the
value half was asserted nowhere. Adds
VirtualTagActorTests.The_Bad_result_carries_the_last_known_value, which proves the actor
emits it. The documentation is what overstates.
2026-08-07 06:28:22 -04:00
Joseph Doherty 1bd79c8e4a docs(secrets): pair-local replication live gate on the three-mesh rig — PASS 4/4
v2-ci / build (push) Successful in 6m33s
v2-ci / unit-tests (push) Failing after 17m17s
Re-gates Akka peer-to-peer secret replication on the current three-mesh docker-dev
rig and supersedes the 2026-07-18 six-node gate (lmxopcua#482, c878fbbd) as evidence.
That gate proved convergence to all five peers on the old single fleet-wide mesh;
after mesh Phase 6 (2839deb5) partitioned the rig into three independent 2-node
meshes, six-node convergence would be a FAILURE, not a pass. Phase 6's own leg 6
recorded "PASS (by fallback criterion)" — it inferred pair-locality structurally and
never seeded a secret. This replaces that inference with direct evidence.

All four checks PASS, on master @ 21009874 with one image (OtOpcUa.Host.dll
a434b04f byte-identical on all six nodes), Secrets libs 0.2.3:

  1. Pair convergence — seeded on site-a-1 alone, on site-a-2 in <=7s (budget 60s),
     byte-identical ciphertext + kek_id + updated_utc, decrypt-verified on the partner.
  2. No cross-pair propagation — absent from site-b and central across 8 samples /
     240s / 48 announce intervals, and clean in the reverse direction too. Isolation
     controls seeded on site-b-1 and central-1 converged within their OWN pairs, so
     the absence is isolation rather than a dead replicator.
  3. Delete propagates within the pair — issued on site-a-2 (the receiving node, so
     this also covers the reverse intra-pair direction); tombstone on site-a-1 in <1s.
  4. Clean pair restart (both together) — mesh re-formed in 7s, no resurrection of the
     tombstone, live secret survived and decrypted correctly on both nodes.

Zero secret-related warnings or errors fleet-wide across the whole run. Satisfies the
scadaproj#3 precondition for the OtOpcUa production topology (Akka pair-local).
Secrets:Replication:Enabled remains default-false in the product; the rig KEK is dev-only.

Rig config: SqlitePath moved onto each node's existing per-node /app/data volume. The
appsettings default resolves to /app, inside the image's writable container layer, so
the store was destroyed by any `up -d --build` recreate and was unreachable from the
host — seeding or inspecting a node meant stopping the container to docker cp the file
out. Still one private store per node; replication is still what syncs them.

Residuals in the gate doc: the live-push path is not covered (anti-entropy is what a
driver-only node can be gated on), and appsettings.json still carries a stale
"KNOWN NON-FUNCTIONAL against 0.2.0 / do not enable" comment that this gate disproves
— left uncorrected here because this run is config-only.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-07 06:08:50 -04:00
Joseph Doherty 290117660d docs: truth sweep — reconcile stale deferment/archreview/plan records with shipped state
v2-ci / build (push) Successful in 6m26s
v2-ci / unit-tests (push) Failing after 18m0s
- deferment.md: node ACLs no longer authored-but-inert (#520 shipped through stage 7, opt-in Security:Acl:Enabled); failure-visibility trio mostly closed (01/S-1 + 03/S4 fixed, 06/S-1 partial); bulkhead + WriteIdempotentAttribute closed by R2-02; Sql Postgres+Oracle dialects shipped (d54f3c83); akka-hosting partials verified superseded
- archreview/00-OVERALL.md: staleness banner on the carried-open block (04/C-1→R2-05, 03/S2+S3→R2-08, 03/P1→R2-07, driver fleet→R2-09/R2-11/#457, hygiene→R2-12, trio per above; what genuinely remains from theme 4)
- archreview/plans/STATUS.md: carved out the true exceptions to "every Round-2 live gate GREEN" (R2-03 T11/T12 deferred-live, R2-10 T10 breaker-OPEN never forced); #459 closed by mesh Phase 7 drill D4 (c50ebcf7)
- archreview/plans/INTEGRATION-SWEEP-STATUS.md: "follow-ups unstarted" → all 7 shipped as PRs #446–#452
- archreview/06-gateway-integrations.md: 06/S-1 STILL OPEN → PARTIAL (fail-closed advise + unconfirmed meter; residual cross-repo)
- 2026-07-31-apikey-acl-enforcement-design.md: status → implemented through stage 7, #520 CLOSED 2026-08-01
- 2026-07-21-per-cluster-mesh-design.md: "Phases 1–7 not started" → PROGRAM COMPLETE 2026-07-24 (Phase 7 c50ebcf7)
- 2026-07-20-localdb-phase2-live-gate.md: merged as 06b5d81b; open design question shipped by mesh Phase 6 (checks 3/4 still never re-run on the three-mesh topology)
- 2026-07-20-localdb-phase1-progress.md: Tasks 10–16 REMAIN → Phase 1 complete, live-gated 8/8, merged c957db52
- 2026-08-06-clusterclient-to-grpc-tracking.md: defect 4 OPEN → fixed by c0475f2d + e54883a8, merged @ b360239e
- 2026-08-06-clusterclient-to-grpc.md.tasks.json: Task 15 pending → completed (Phase 4 gate PASS 8/8 @ 0b194003, record d0ac6fc5)
- 2026-07-22-mesh-phase2-live-gate.md: DriverStatusPanel orphan → wired into /hosts by #546 (9420f8ef)
- 2026-07-21-issue-485-live-gate.md: pointer from the "NOT fixed here" finding to the #486 fix (DriverHostActor.cs:1696-1717 + FetchAndCache mirror)
- 2026-08-05-sql-postgres-oracle.md: chrome/AdminUI gate not deferred — PASSED 2026-08-05, found + fixed the Test-Connect provider gate (4e461951)
- v2-ga-lab-gates-plan.md / v2-release-readiness.md / live-hardware-validation-runbooks.md: FOCAS live-CNC smoke #54 RESOLVED on a real 31i-B 2026-06-25 (cnc_rdparam caveat preserved)
- Mqtt-Test-Fixture.md: STATE/DCMD "not implemented" → shipped + live-gated (#508 2a7d1167, #511 857a10b4)
- 2026-08-01-continuous-historization-live-gate.md: poison-tag FIFO pin "filed, not fixed" → #538 fixed same day (e7fbe977, gate 4bdce910; Capacity default now 500k)
- 2026-05-26-akka-hosting-alignment-plan.md.tasks.json: F8/F9/F10/F13/F14 partial → completed, superseded by the v3 address-space rework (Phase7Composer/ScriptedAlarmActor deleted; evaluators/SDK sinks/security policies production-wired)

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-07 02:02:54 -04:00
Joseph Doherty 8b10d32728 fix(focas): disambiguate duplicate sibling axis/spindle names in the fixed tree (#548)
A CNC can report duplicate sibling names — the wonder Makino returns two
axes both called AA — which made the Axes/ subtree untrustworthy: the
browser offered two identical folders the commit gate had to refuse, and
the poll loop's name-keyed snapshot cache let the second axis silently
overwrite the first. Spindles and servo loads had the same collision in
their own keying, plus a resolve-to-first-match on the spindle read path.

Scheme: every member of a collision set gets a 1-based occurrence-ordinal
suffix in slot order (AA_1, AA_2); unique names stay bare. Chosen over the
alternatives deliberately:

- Names, not slot numbers, stay the primary identity — the segment map is
  re-derived from the live device each session, so a name-keyed path
  follows the physical axis across config changes, where a slot-qualified
  path would silently rebind on any axis-list shift and rename every axis
  on every machine for one pathological CNC.
- The WHOLE collision set is suffixed: bare AA ceases to exist when
  ambiguous, so a tag authored against it reads BadNodeIdUnknown rather
  than one arbitrary axis's data — the #534/#540 silent-wrongness class.
- Occurrence ordinal, not absolute slot: inserting/removing an unrelated
  axis never renames an existing duplicate pair. If the collision-set
  membership itself changes, segments change on the next browse and stale
  authored segments read Bad, never another axis's data.

One pure helper (FixedTreeNames.Disambiguate, collision-proof against
genuine names by suffix escalation) feeds FocasFixedTreeCache.AxisSegments
/ SpindleSegments, and the emitter, axis snapshot keys, servo-load keys
(cnc_rdsvmeter answers in slot order, so the k-th AA load belongs to the
k-th AA axis) and the spindle read resolution all consume those — they can
no longer disagree. The nameless-spindle S{n} fallback applies before
disambiguation so a fallback name colliding with a real one is handled
like any other duplicate.

Tests are red-first on the pre-fix driver (all five driver-level tests
fail on master's code): browse emits AA_1/AA_2 and no bare AA, each
duplicate reads its own position and servo load, duplicate spindles
resolve per slot, bare AA reads Bad. The wire half runs live against
focas-mock (duplicate names survive the managed wire parse and publish as
suffixed segments); the mock's dynamic store is name-keyed so the
distinct-values half stays with the offline tests. FOCAS 327/327,
integration 11/11, FOCAS.Cli 52, AdminUI 1113, ControlPlane 231.

Closes #548.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-06 21:09:05 -04:00
Joseph Doherty 0acfc79ad9 docs(security): sweep out pre-#520 authz claims (#547)
v2-ci / build (push) Successful in 6m1s
v2-ci / unit-tests (push) Failing after 16m16s
The summary list still described the pre-#520 world: NodeAcl/PermissionTrie
'built and unit-tested but never wired', reads/browse/subscribe/HistoryRead
carrying no authorization at all, and UserName tokens authenticated by LDAP
bind. The body had been updated by #520 stages 5-7 but the summary, the
Anonymous auth-table row, the LDAP bind-flow paragraph (still routing the OPC
UA UserName path through the deleted LdapOpcUaUserAuthenticator), the
'sourced from Security:Ldap:GroupToRole' role-source section, and the
'Dispatch gate (no such gate exists)' section all contradicted it — a reader
who stopped at any of them got the pre-#520 answer.

All now state the flag-gated reality: with Security:Acl:Enabled false (the
default) the only enforcement is the two coarse server-wide role gates and
reads are ungated; with it true, scoped NodeAcl grants decide every
data-plane operation via DataPlaneAuthorizer -> TriePermissionEvaluator.
Data-plane roles derive from cluster-scoped NodeAcl grants
(ApiKeyInterimRoleMapper); GroupToRole and LdapGroupRoleMapping feed AdminUI
login only. The acl-design.md pointer no longer calls the design 'unshipped
work'.

Closes #547.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-06 20:46:38 -04:00
Joseph Doherty d0ac6fc58c docs(plans): ClusterClient→gRPC Phase 4 terminal live gate PASS 8/8 — PROGRAM CLOSED
v2-ci / build (push) Successful in 4m29s
v2-ci / unit-tests (push) Failing after 14m14s
Ran on master @ 0b194003, post-deletion image d2a290fc5711 (provenance-guarded).
PSK matrix 7/7 incl. unset-key boot refusal; command matrix with Restart/Reconnect
driven from the real /hosts panel (#546 closed the 1A composition carry-forward by
direct evidence); exactly-once ack matrix sealed 6/6; central-kill + site-kill drills
honest (one loud WRN, +2:00 deadlines, boot-replay, no retro-ack); mid-deploy
coordinator kill: recovered coordinator fired the deadline at exactly +2:00, all
landed acks preserved, zero dupes; boundary: 3 established :4053 associations, all
pair-internal, zero site↔central; full-rig restart transport grep = 0 on all six
(one non-mesh observation: central pair reproduced the documented guard-off
cold-start split-brain, healed by single-node rejoin); MeshGrpcBoundaryTests 3/3;
terminal full suite RC=0 direct capture, 50/50 suites, 9,333/0.

I-4 cold-PSK-cache re-carried out of the program (unmeasurable on docker-dev).
No new defects.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-06 20:29:05 -04:00
Joseph Doherty 0b19400395 docs(plans): all five program-filed issues (#542–#546) fixed, live-gated, merged @ 9420f8ef and CLOSED; #547 filed
v2-ci / build (push) Successful in 5m24s
v2-ci / unit-tests (push) Failing after 17m0s
Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-06 19:37:54 -04:00
Joseph Doherty d01b717eb9 Merge fix/issue-542-fleetstatus: delete the dead fleet-status chain (#542)
Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-06 17:28:48 -04:00
Joseph Doherty f1c188114b refactor(fleet): #542 delete the dead fleet-status chain (broadcaster, SignalR bridge, hub, messages)
#542 filed FleetStatusBroadcaster as blind to site nodes since the Phase 6 per-cluster mesh
split: it derives "the fleet" from central's own Akka gossip ring, which after the split is
just central's own pair. Scoping the cross-mesh rewrite found the deeper truth — the whole
chain is dead end to end, so this deletes it rather than reviving it.

  - Zero consumers. FleetStatusBroadcaster published FleetStatusChanged on the DPS
    "fleet-status" topic; FleetStatusSignalRBridge forwarded it to FleetStatusHub
    ("/hubs/fleet-status"); nothing anywhere connected. The AdminUI bans self-connecting
    HubConnections, no browser JS subscribes, and /fleet (Fleet.razor) reads
    NodeDeploymentState from the ConfigDb plus IClusterRoleInfo on a 5 s poll instead.
  - Its second input never existed. DriverHostStatusHeartbeat had no production sender —
    only the broadcaster's own test told it — so CurrentRevision was always null and the
    30 s staleness -> Degraded flip could never fire.
  - Post-Phase-6 DPS is pair-local, so even the central-only view it did produce was an
    accident of topology rather than a design.

The fleet visibility that actually works is unchanged and already cross-mesh-correct:
/fleet over NodeDeploymentState, and the four Phase 5 per-node telemetry dials.

Precedent for deleting inert machinery rather than activating it: #522 (driver stability
tiers), #507 (discovered-node injection), #525 (GetMemoryFootprint).

Deleted: FleetStatusBroadcaster (+ its nested DriverHostStatusHeartbeat), its admin
singleton registration and FleetStatusBroadcasterKey, FleetStatusSignalRBridge and its
key, FleetStatusHub and its route mapping, and the FleetStatusChanged / FleetNodeStatus /
FleetNodeHealth messages. Both Fleet/ directories survive — ControlPlane keeps
ClusterNodeAddressReconciler, Commons keeps GetDiagnostics. Docs swept where they claimed
a fleet-status channel exists (Telemetry.md's Phase 5 non-migrated channels,
Configuration.md, CLAUDE.md); completed-program plan docs under docs/plans and docs/v2 are
left as the historical record they are.

Build 0 errors, 868 warnings — byte-identical to master's rebuild baseline.
ControlPlane.Tests 231/231, AdminUI.Tests 1109/1109.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-06 17:21:30 -04:00
Joseph Doherty 17a822b7dc docs(security): #545 record the accepted h2c posture for the five internal gRPC planes
OtOpcUa runs five internal gRPC planes between its own processes, all of
them cleartext HTTP/2 (h2c) with a shared-bearer interceptor: the live
telemetry stream, config-serve artifact fetch, LocalDb pair sync, mesh
commands and mesh deploy acks. The bearer therefore crosses the wire in
the clear on every one of them. That was true and undocumented; the two
mesh planes are command surfaces, so it is worth saying out loud rather
than leaving to be rediscovered per plane.

docs/security.md gains "Internal gRPC planes (h2c) — accepted cleartext
posture" under Transport Security (whose existing content is entirely
about the OPC UA endpoint). It carries a five-row table — service name,
listener config key, credential config key, interceptor — the honest
read-vs-write breakdown per plane, and the decision itself.

Decision (issue #545, option 1): risk ACCEPTED 2026-08-06, once, for all
five planes together, on the trusted-link assumption every plane already
makes (VPN / dedicated WAN). Per-cluster PSKs already bound blast radius
on the two command planes — a captured SITE-A key cannot drive SITE-B —
and production keys arrive as ${secret:...} references that must never be
reused across clusters. Recorded future work: a UseTls/scheme option,
which if built must cover all five planes together, since a mixed posture
is harder to reason about than either uniform answer.

docs/Configuration.md's MeshTransport section gains one cross-reference
paragraph pointing at the new subsection.

Docs only — no code, no behaviour change. Every service name, config key,
interceptor name and shared-listener claim was verified against the
source; ports 4056/4055 are phrased as the docker-dev rig's values of
Telemetry:GrpcListenPort / ConfigServe:GrpcListenPort, not as hardcoded
ports.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-06 17:17:51 -04:00
Joseph Doherty 97854a074e docs(plans): Phase 3 MERGED to master @ 2fa880b6 — Phase 4 awaits go-ahead
v2-ci / build (push) Successful in 5m38s
v2-ci / unit-tests (push) Failing after 3h14m30s
Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-06 16:57:39 -04:00
Joseph Doherty f8a43dfe96 docs(plans): Phase 3 COMPLETE at DoD — deletion review-approved, grep-gates clean, full suite 9326/0 green on branch tip
Task 3.1: 7 ordered deletion steps + MeshPaths review fix, spec+code reviews closed.
Task 3.2: docs sweep + accuracy review, scadaproj index pushed on its main.
DoD full-suite: run 3 exit 0 (50 suites, 9326 passed); runs 1-2 each hit one
pre-existing unrelated timing flake (3 distinct tests, all untouched by the
branch, 9/9 green in isolation). Also records the exit-code-capture trap found
in run 1 and its retroactive caveat on Phase 2's check-8 wording.

Merge to master is user-owned — STOPPED at the Phase 3 DoD.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-06 16:56:17 -04:00
Joseph Doherty 8e25ed55f6 docs: fix two stale mesh-program claims found in review (Phase 5 gate passed; Phase 6 ended fleet-wide gossip)
Review of the ClusterClient-deletion docs sweep (406b1ae5) flagged two
pre-existing staleness items in the swept files, unrelated to ClusterClient
but caught while touching the same passages:

- docs/Telemetry.md: the Phase 5 telemetry section's Status line still said
  "code-complete ... live gate has not yet run" — the gate PASSED 2026-07-23
  (merge 35552a96), already recorded in CLAUDE.md. Updated to match.
- docs/v2/config-db-schema.md: the AkkaPort/GrpcPort note asserted in the
  present tense that "central shares a gossip ring with every node," which
  Phase 6 (2026-07-24) ended — central now runs one 2-node Akka mesh per
  application Cluster and reaches a site pair only over gRPC. Reworded the
  premise to past tense so it no longer contradicts the ClusterClient-deleted
  parenthetical sitting right after it.

docs-only; dotnet build ZB.MOM.WW.OtOpcUa.slnx stays green (0 errors).

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-06 16:30:47 -04:00
Joseph Doherty 406b1ae5ac docs(mesh): sweep for ClusterClient deletion — Dps|Grpc only, CentralContactPoints retired
Task 3.2 of the ClusterClient->gRPC migration. Sweeps the docs that still
described the Akka ClusterClient transport as live, following its Phase 3
deletion (this branch): MeshTransport:Mode is now Dps (pair-local DPS) or
Grpc (cross-mesh unary calls) only, ClusterClient fails validation loudly,
CentralContactPoints is deleted (not superseded), and the split-topology
validator requires exactly Grpc rather than ClusterClient.

- docs/Configuration.md: rewrite the MeshTransport section — table,
  dark-switch/PSK-seeding prose, listener sharing (:4056 driver-role,
  :4055 admin-role), remove the "shipped dark, cutover pending" framing.
- docs/Redundancy.md: replace the Dps/ClusterClient transport table with
  Dps/Grpc, fix the Phase 6 CentralCommunicationActor + validator bullets.
- docs/Telemetry.md: fix stale ClusterClient cross-refs, add a note that
  the driver-role telemetry listener also hosts NodeCommandService.
- docs/v2/config-db-schema.md: one-line "(retired: ...)" annotation on the
  historical AkkaPort/GrpcPort note — left as a historical record otherwise.
- CLAUDE.md: rewrite "## Mesh command transport (MeshTransport)" for the
  post-deletion world (no ClusterClient/receptionist, per-node gRPC dial,
  fire-and-forget semantics unchanged) and fix two Phase 6 bullets.

deferment.md has no mesh-transport mentions; left untouched.

docs-only change; `dotnet build ZB.MOM.WW.OtOpcUa.slnx` stays green (0
errors) after the edit.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-06 16:23:23 -04:00
Joseph Doherty 5510452662 docs(plans): Phase 2 COMPLETE — gate PASS 8/8, soak 14/14 clean, full suite exit 0 on cutover SHA; carry-forwards closed/re-carried
v2-ci / build (push) Successful in 4m30s
v2-ci / unit-tests (push) Failing after 15m27s
Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-06 13:26:56 -04:00
Joseph Doherty eaf14e0177 docs(plans): Phase 2 check 8 PASS — full suite exit 0 on the cutover SHA, run against the live rig
Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-06 11:13:59 -04:00
Joseph Doherty bcc91268ba docs(plans): Phase 2 gate — checks 1-6 + PSK negatives PASS with evidence; soak + full suite running
Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-06 11:04:36 -04:00
Joseph Doherty 1bb3a937ae docs(plans): Phase 2 baseline REPRODUCED — cross-mesh :4053 open unauthenticated; site-b drop suppressed into sampled deadLetters INF, deadline sole witness
Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-06 09:58:09 -04:00
Joseph Doherty 46be4c8dac docs(plans): record Phase 1B merge SHA 7203d135
v2-ci / build (push) Successful in 5m49s
v2-ci / unit-tests (push) Failing after 18m50s
Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-06 09:29:23 -04:00
Joseph Doherty cf229a9579 docs(plans): phases 2-4 — targeted-test policy + Task 2.0 (must-fix: honest PreStart-race logging) before the cutover 2026-08-06 09:24:16 -04:00
Joseph Doherty ddea041309 docs(plans): Phase 1B COMPLETE — gate PASS 7/7, awaiting PR/merge 2026-08-06 09:21:20 -04:00
Joseph Doherty a397cdad91 docs(mesh): Phase 1B live gate record
Node→central ApplyAcks over gRPC, gated live on the docker-dev rig at
4a2a3d3a. All 7 checks PASS (the 6 planned plus the mandated 4b hang
drill).

The 4b drill is the one that earns its place: with central-1 PAUSED
rather than stopped, both site-a nodes burned a full AckTimeoutSeconds
on it as DeadlineExceeded and still reached central-2 with a live
deadline — the per-endpoint FRESH deadline from 667639e6, proven on the
failure shape a hoisted deadline could not survive. Observed failover
latency ~10 s, exactly one AckTimeoutSeconds.

It also produced the run's only duplicate ack, and that turned into
evidence rather than a defect: the frozen central had in fact received
both acks and replayed them on unpause, and the coordinator reported
2/6 acks landed, not 4/6 — the idempotent absorption the sender's
at-least-once remark asserts, observed instead of argued.

Coexistence is per pair in one deployment: site-a's rows are Applied
with matching ReportApply lines on central and zero receptionist
associations, site-b's are Applied with zero ReportApply lines and a
live receptionist association. Neither pair could have used the other's
transport.

Carry-forwards recorded: the I-4 cold-PSK-cache latency is structurally
unmeasurable on docker-dev (ClusterPsks is read ahead of the store, so
InvalidateAll clears an empty cache) and moves to a store-backed rig;
a restarted central's cached channel can stay in backoff ~2 min on the
node that watched it die, absorbed by the walk with no ack lost. The
1A finding-5 wording fix gains a second confirmation — 4 of 4 admin
boots lost the PreStart race, and every ack central-1 accepted proves
the provider it said would NEVER be published was.

Docs only; the rig flip and the four Serilog Debug overrides were
reverted and are not in this diff. Rig restored to six healthy,
all-ClusterClient, sealing 6/6.
Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-06 09:19:57 -04:00
Joseph Doherty 4a2a3d3ab8 docs(plans): restore tasks.json union after rebase — 1A rows + 1B completions 2026-08-06 08:42:37 -04:00
Joseph Doherty 973ce7ee26 docs(plans): 1B.2 close-out — reviews ✅, deadline Critical fixed + pinned 2026-08-06 08:42:11 -04:00
Joseph Doherty 1368578435 docs(plans): 1B.1 close-out — reviews ✅ 2026-08-06 08:42:11 -04:00
Joseph Doherty a400b41502 docs(plans): record Phase 1A merge SHA b360239e
v2-ci / build (push) Successful in 4m27s
v2-ci / unit-tests (push) Failing after 15m14s
2026-08-06 08:41:42 -04:00
Joseph Doherty 252618182f docs(plans): Phase 1A close-out — carry-forwards from the gate, #546 filed 2026-08-06 08:39:45 -04:00
Joseph Doherty e3a0ddbc84 docs(mesh): Phase 1A live gate re-run — PASS
Re-ran the 1A gate on the docker-dev rig at e54883a8 after the defect-4 boot
fix. Image rebuilt (otopcua-host:dev -> 1d8452a29018); six host nodes plus
both one-shots recreated; run 1's preserved image was stale and deliberately
not reused. VERDICT: PASS.

Boot: 6/6 nodes healthy INCLUDING the central pair, 0 Hosting failed to start,
/health/ready 200 on every node, MAIN mesh 2/2.

Check 1 PASS: one DispatchDeployment, one correlation id, accepted by all six
nodes; central logs 6 accepted / 0 failed; zero ClusterClient lines on either
central node with Akka.Cluster.Tools.Client at Debug.
Check 2 PASS: deployment Sealed with all six NodeDeploymentState rows Applied
inside ~90 ms, while the four site nodes hold receptionist associations — the
only ack path they have in 1A. Mixed transport proven.
Check 4 PASS: Ack clicked on the real /alerts page against a live ACTIVATED
row; central fanned one AlarmCommand to all six over gRPC and the condition
acknowledged 0.54 s later carrying the operator identity.
Check 7 PASS: with site-b-2's GrpcPort NULLed, both central nodes warned once
and the next dispatch reached exactly five nodes; the deployment then honestly
ended TimedOut with site-b-2 alone unapplied. Row restored, 6/6 sealed after.
Check 8 PASS and no longer vacuous: 0 denials on all four interceptors, and
this time config-serve (28 KB verified artifact fetches from :4055) and
telemetry (streams open on all six, live driver health on /hosts) both carried
real traffic.
Check 3 closed by composition, with the reason now established rather than
assumed: <DriverStatusPanel has 0 usages repo-wide, no rendered page carries a
Restart/Reconnect control, and the only non-script-analysis POST is
/api/deployments. Run 1 drove the node-side leg for both commands; check 4
drives the identical AdminOperationsActor.Dispatch -> DispatchOverGrpc origin
path from the AdminUI.
Checks 5 + 6 stand from run 1 — nothing in the fix touches the interceptor.

New finding 5, recorded because it is load-bearing for the fix's design: the
PreStart resolve LOSES its race on real boots — 2 of 3 observed admin boots
logged Failed to resolve for both dependencies with the same
AuditWriterActorKey cause. The memoize-on-first-use hardening is therefore not
theoretical, and its heal was proven live by moving the coordinator singleton
onto the node that lost the race (stop central-2) and watching central-1 fan a
dispatch to all five live nodes with no dialer error. Residual is cosmetic and
carried to Phase 2: the Error text reads as permanent, and it will trip a soak
error-grep on a healthy node.

Defect 4 marked CLOSED; run 1's record kept in full underneath the combined
table. Rig restored: flip reverted, six nodes healthy on all-ClusterClient on
the committed image, three mesh ClusterClients back on each central node.
2026-08-06 08:37:48 -04:00
Joseph Doherty 410c2e2925 docs(mesh): Phase 1A live gate record
Ran the 1A live gate on the docker-dev rig at fbf7b708 (image rebuilt from
this worktree, all eight containers recreated). VERDICT: FAIL.

Both central nodes fail to boot on this branch with
MissingActorRegistryEntryException: AuditWriterActorKey — the first WithActors
starter eagerly resolves IMeshCommandDialer/IMeshPskProvider, whose graph
reaches ActorAuditWriter, whose ctor reads an ActorRegistry key registered ~60
lines later in the same builder. Reproduced on the same image with
MeshTransport__Mode=ClusterClient, so it is mode-independent and not caused by
the gate's rig-only flip. Recorded as defect 4; no product change made.

Checks 5 (auth negative/positive on real Kestrel h2c) and 6 (wrong-key
isolation, both directions, with a positive control) PASS. Checks 3 and 4 pass
their node-side leg only, driven with real RestartDriver/ReconnectDriver/
AlarmCommand envelopes that restarted and reconnected SITEA-modbus. Checks 1,
2 and 7 are blocked outright — no central, no dispatch, no contact refresh —
and check 8 is vacuous for telemetry + config-serve because central dials both
and never booted. All five gaps are named in the carry-forward register and
must be closed by a 1A gate re-run, not carried past the phase.

Issues filed by this task: lmxopcua#544 (ConfigPublishCoordinatorTimeoutTests
deflake) and lmxopcua#545 (mesh planes are h2c-only — accept the risk or add a
UseTls option); the tracking table's two placeholder rows now carry them.

Also recorded: DriverStatusPanel.razor is referenced by no page or component,
so the AdminUI Reconnect/Restart controls are unreachable on this branch and on
master — pre-existing, out of program scope, carried forward.

Rig restored: compose flip reverted, six nodes healthy on all-ClusterClient.
The rig runs the PRE-gate image (the 1A build is kept as otopcua-host:grpc-1a);
a re-run must rebuild.
2026-08-06 07:38:18 -04:00
Joseph Doherty fbf7b70847 docs(plans): 1A.2 + 1B.2 close-out — register items 1-2 CLOSED, defect log (3), carry-forwards, decisions 2026-08-06 06:58:34 -04:00
Joseph Doherty 2edef72f6f docs(plans): 1A.1 close-out — reviews ✅, plan FromEnvelope wording fix 2026-08-06 05:19:07 -04:00
Joseph Doherty 3f96115cab docs(plans): record Phase 0 merge SHA 10b6d209 in tracking
v2-ci / build (push) Successful in 4m30s
v2-ci / unit-tests (push) Failing after 15m38s
2026-08-06 05:04:41 -04:00
Joseph Doherty fe2ae81666 docs(plans): Phase 0 COMPLETE — gate PASS 4/4, issues #542/#543 filed, awaiting PR/merge 2026-08-06 05:00:26 -04:00
Joseph Doherty 23555ed0fd chore(mesh): rig PSK config + docs stub
Phase 0 task 0.6 of the ClusterClient->gRPC migration: wire the dark
MeshTransport Grpc-mode keys into the docker-dev rig (GrpcPsk +
CentralGrpcEndpoints on all six nodes, ClusterPsks on the central pair
only), document the new keys in docs/Configuration.md as shipped
dark/cutover pending, and record the Phase 0 live-gate evidence
(rig boot with no validator fires, two deliberate-misconfig refusals,
full suite green).

Files gitea.dohertylan.com/dohertj2/lmxopcua#542 (FleetStatusBroadcaster
mesh-blind post-Phase-6) and #543 (three pre-existing interceptors accept
a whitespace-only key as set) as follow-ups out of Phase 0's scope.
2026-08-06 04:56:57 -04:00
Joseph Doherty c2bfad4af6 docs(plans): Batch B close-out — Phase 1A security register, issue queue, tracking 2026-08-06 04:24:45 -04:00
Joseph Doherty f8325373ef docs(plans): Task 0.2 close-out — tracking + Phase 1 dispatch notes 2026-08-06 03:29:02 -04:00
Joseph Doherty 09db9e47de chore(mesh): Batch A review close-out — fusion-invariant note on DispatchTimeoutSeconds, tracking updates 2026-08-06 03:12:09 -04:00
Joseph Doherty 9e9a963255 docs(plans): ClusterClient→gRPC migration program — design, implementation plan, live-gate template, central tracking doc 2026-08-06 02:44:11 -04:00
Joseph Doherty 6e9e4fa5ab docs: live-gate round two — truncation note + central proto path both proven (#541)
25 unmappable tags on SITE-A's sa-modbus2 closed both gaps in one setup: chip
25/26 SKIPPED, sample capped at exactly MaxSample=20, tooltip '(+5 more not
shown)'. Trimming to 20 skipped gave 20/21 with NO truncation note, pinning
the off-by-one boundary.

SITE-A is the discriminator for the proto path. Phase 6 partitioned it into
its own Akka mesh -- central's MEMBERS panel lists only central-1/central-2,
and DistributedPubSub does not federate across meshes. The single site-a
reference in central's log is the ClusterClient receptionist, a
point-to-point command path, not gossip. So the report's only route to the
page is TelemetryProtoMapNode -> gRPC -> TelemetryProtoMapCentral, and
central's view tracking a change made solely on the site node is conclusive.

Also corrects round one's claim that the fused central pair meant telemetry
never crossed gRPC -- wrong about this rig. All six ClusterNode rows carry
GrpcPort 4056 and Telemetry__Mode=Grpc; the two 'Telemetry stream to node'
lines that suggested a two-node dial set were startup failure warnings.

Rig restored: test tags deleted, SITE-A redeployed, sa-modbus2 back to 1 tag.
2026-08-06 02:12:44 -04:00
Joseph Doherty b25279dbbd docs: live-gate record for #541 on docker-dev (PASS; found the 0-tags defect) 2026-08-05 20:26:00 -04:00
Joseph Doherty dd900f970a fix(hosts): a driver that never built a tag table reports NO ANSWER, not zero skips
Found on the docker-dev live gate, not in tests. Two drivers that faulted
during initialise (rtu-gate, opcua1) each rendered "0 tags" on /hosts against
two authored tags apiece, because a ledger that had never committed returned
SkippedTagReport.Empty.

A zero report is a positive claim -- "this driver skipped nothing" -- and
making it on behalf of a driver that never got as far as looking is exactly
the quiet inaccuracy #541 exists to remove. GetSkippedTags() and
SkippedTagLedger.Snapshot() now return null until the first committed build,
so the column renders the same em-dash it already used for a driver that
cannot report at all. Both are 'no answer'; neither is 'nothing skipped'.

Knock-on: a FOCAS driver whose only init threw now reports null rather than a
zero report, which is strictly more honest than the assertion the test
originally made.
2026-08-05 20:16:31 -04:00
Joseph Doherty 39db75083d feat(drivers): every driver reports the authored tags it skipped (Gitea #541)
A driver builds its RawPath -> definition table at init and tolerates a
per-tag miss: an unmappable TagConfig, an unparseable address, a
capability-matrix rejection. That tolerance is right -- one bad tag must not
take down the other few thousand. What was missing is any statement of the
fact afterwards. A skip is not an error and happens before any read, so the
driver stays Healthy, errors/5 min stays 0, Last error stays empty, and Last
read advances live off the tags that did map. On the wonder Makino in #540
that reading held while focas-z-34184 ignored 20 of its 22 tags.

New optional capability ISkippedTagReporter, backed by a shared
SkippedTagLedger so the reset/commit/cap rules are one implementation rather
than one per driver. The report -- authored count, exact skipped count, and a
capped sample of RawPath + reason -- rides DriverHealthChanged to a new Tags
column on /hosts, over the Phase 5 telemetry stream. No table: a driver-only
node has no ConfigDb to write to, the lesson from #521.

The issue named eight drivers. It is twelve -- every driver in
DriverTypeNames.All has an authored-tag skip path. Calculation (no scriptId),
MTConnect (no readable DataItem id), Galaxy (no attributeRef) and OpcUaClient
(no nodeId) were unlisted, and OpcUaClient's was the quietest of the twelve:
NamespaceMap drops such an entry with a bare `continue` and not even a log
line. SkippedTagReporterFleetTests pins the invariant reflectively over every
IDriver in src/Drivers/, so a thirteenth cannot re-introduce the silence.

Sql drops a tag at TWO points and its report is the union of both: a blob
that does not map, AND an identifier the 8.1 catalog gate rejects against the
live catalog. The second is what schema drift produces -- a renamed column, a
dropped view -- months after a deployment sealed green, and reporting only
the first would have that driver claim "0 skipped" while polling half its
tags, which is worse than reporting nothing because the column would then be
actively reassuring.

Deliberately additive to driver state, never a change of it. Latching
non-Healthy for a config problem would satisfy both halves of the #527
comms-loss conjunction -- 90 s without a successful read AND non-Healthy --
and a driver whose tags were ALL skipped has no successful reads by
construction, so it would reconnect-loop forever against a healthy device.

Three details that are load-bearing rather than incidental:

- The skip digest is part of the publish fingerprint. A skipped tag leaves
  every other fingerprint component identical to a clean driver's, so without
  it PublishHealthSnapshot's dedup swallows exactly the publish carrying the
  signal -- the trap that already bit the rediscovery and host-status
  channels. The digest covers the sample, not just the counts: a redeploy
  that fixes one tag and breaks another leaves both counts unchanged.
- The count is exact; only the sample is capped, and the tooltip says so when
  it is showing fewer than it counted. A truncated list that looked complete
  would have an operator fix twenty tags and believe the job done.
- The ledger stages and commits, so an init that throws part-way leaves the
  previous report standing. A partial snapshot would read as "we now skip
  fewer tags", the opposite of the truth.

The proto field needs no companion presence flag, unlike host_statuses: a
singular message field carries real presence in proto3, and a has_ flag would
collide with protoc's generated HasSkippedTags.

Verified red-first three ways: dropping SkippedTagDigest from the fingerprint
fails the two dedup tests, removing the seam from one driver fails the fleet
guard, and dropping the catalog-gate leg fails the Sql union test. Full
solution builds with zero new warnings against 4e22f024.
2026-08-05 19:28:46 -04:00
Joseph Doherty b61fe38823 fix(focas): browse-committed fixed-tree tags are readable (Gitea #540)
Authoring FOCAS tags through /raw -> Browse device... produced tags the driver
could never read. Every committed tag read BadNodeIdUnknown forever, and the
failure was silent end to end: the AdminUI accepted it, the deploy gate sealed
green, and the driver reported Healthy with 0 errors while ignoring the tags.
Net effect: the whole fixed-tree surface could not be published through any v3
authoring path. Found cutting wonder-app-vd03 over to v3 against a real Makino
Pro 5 / Fanuc 30i, where 0 of 20 committed tags read while two hand-authored
MACRO: tags on the same device read Good.

A captured browse leaf's id IS its DriverAttributeInfo.FullName, and the fixed
tree emitted "{deviceHost}/{path}" there -- an internal cache key, not an
address. Both authoring paths wrote it verbatim into TagConfig.address, and
FocasAddress.TryParse accepts no such form, so the driver skipped the tags at
init.

Fixed at the emitter rather than by stripping the host at commit, which fixes
the browse-commit flow and the manual picker's tree-select together and leaves
one canonical address form:

- FocasAddress gains a fourth kind, FIXED:<path>, and moves to Contracts so the
  AdminUI and ControlPlane can parse without referencing the runtime driver
  (#526). A FIXED: read is served from the fixed-tree snapshot the poll loop
  already maintains -- no area read, no connection. Writes refuse: nothing in
  that tree is a writable location on the CNC.
- The address carries no device. A tag's device comes from its Device row, and
  a second routing channel for one fact is what created this defect.
- An authored FIXED: tag arms the fixed-tree poll loop by itself. Enabled
  defaults false and the browser force-enables it only for a browse, so the
  operator would otherwise browse, commit, deploy, and land on a driver whose
  loop never starts -- every tag Bad with a correct address.
- Fixed-tree values are now dated from the last answered poll and go
  BadCommunicationError once it is stale (8 cadences, floored at 30s). The poll
  loop swallows a failed tick by design, so a dead CNC would otherwise serve its
  last snapshot as Good indefinitely -- harmless while nothing could bind to the
  tree, not harmless now.

Guardrails, so this class cannot recur silently:

- FocasTagConfigModel.Validate() runs the real parser instead of a non-empty
  check, and FocasTagDefinitionFactory.Inspect() warns at the deploy gate, which
  covers CSV/API config that never touches the modal.
- The manual picker offered axis/spindle/program/status "parameter groups" --
  none of which is a FOCAS addressing space -- and its own default value,
  axis:5, was unusable. It now composes PMC / PARAM: / MACRO: through
  FocasAddress, so what it emits is parseable by construction.

Tests assert addresses ROUND-TRIP through the parser rather than matching an
expected string: the previous FocasAddressBuilderTests pinned "axis:5" exactly,
which is how a green suite coexisted with a picker nobody could use. Verified
red-first against the old emitter. FOCAS 308, AdminUI 1095, ControlPlane 169,
Core 581 -- all pass, 0 warnings.

The remaining half of #540 -- a driver reporting Healthy/0 errors while
skipping tags at init -- is fleet-wide (eight drivers do this) and is filed
separately rather than patched per-driver.
2026-08-05 17:32:18 -04:00
Joseph Doherty 4e4619514c docs(sql): chrome/AdminUI gate PASSED; records the Test-Connect provider-gate defect it found
Also adds the two fixture connection strings to both docker-dev centrals, so
the rig can author + browse the Postgres and Oracle fixtures on 10.100.0.35.
2026-08-05 07:55:12 -04:00
Joseph Doherty c89da8eb73 docs(sql): shared-host live run PASSED (11/11); chrome/AdminUI gate handed to the operator 2026-08-05 07:28:52 -04:00
Joseph Doherty d54f3c8342 merge: Sql driver Postgres + Oracle providers (managed Npgsql/ODP.NET, dialect-owned binding, live-gated fixtures) 2026-08-05 07:28:29 -04:00