Commit Graph

3110 Commits

Author SHA1 Message Date
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 0125634400 docs(secrets): correct stale non-functional warning on the replication block — pair-local replication is live-gated
v2-ci / build (push) Successful in 4m20s
v2-ci / unit-tests (push) Failing after 16m15s
The Secrets:Replication _comment claimed the feature was KNOWN NON-FUNCTIONAL
against ZB.MOM.WW.Secrets.Replicator.AkkaDotNet 0.2.0 and said "do not enable
until the library is fixed". That has been false since 0.2.2 — 0.2.1 fixed the
shadowed ISecretReplicator registration and 0.2.2 the hosted-process DI
deadlock — and the 2026-08-07 three-mesh live gate proved replication works
(PASS 4/4, docs/plans/2026-08-07-secrets-pair-local-live-gate.md).

Replaced with the truthful operator-facing note: functional since 0.2.2, still
default-off, PAIR-LOCAL in scope (mesh Phase 6 gives each application cluster
its own 2-node mesh, so a secret converges within its pair and nowhere else),
same KEK required per pair, and rewrap-all runs per node because re-wraps do
not replicate.

Comment-only; no behaviour change.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-07 08:57:46 -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 2100987453 docs(deferment): FOCAS live-CNC smoke #54 struck from the hardware-gated row — closed 2026-06-25 on a real 31i-B
v2-ci / build (push) Successful in 6m15s
v2-ci / unit-tests (push) Failing after 19m28s
Residual from the 2026-08-07 truth sweep (29011766): the D-1 row still
counted #54 among the blocked v2 GA exit criteria while the same sweep
marked it resolved in the three gate docs.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-07 02:03:46 -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 8b5c478c36 Merge fix/gitea-548-duplicate-sibling-names: FOCAS duplicate sibling names disambiguate (AA_1/AA_2) (#548)
v2-ci / build (push) Successful in 8m9s
v2-ci / unit-tests (push) Failing after 17m56s
Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-06 21:09:05 -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 5b9ff9d07c Merge fix/gitea-539-abcip-browse-port: AbCip discovery honours the authored EtherNet/IP port (#539)
v2-ci / build (push) Successful in 4m46s
v2-ci / unit-tests (push) Failing after 17m41s
Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-06 20:42:40 -04:00
Joseph Doherty 97a837c147 fix(abcip): controller browse honours the authored EtherNet/IP port (#539)
The two discovery-path libplctag tags (@tags symbol-list enumerator, @udt
template reader) still passed the bare Gateway host, so a browse against a
device authored on a non-default port silently browsed whatever answers on
44818 — #534's defect surviving in its discovery half. Both sites now consume
AbCipTagCreateParams.LibplctagGateway, with the Tag construction extracted
into internal static builders so the seam is unit-assertable (the #534 tests
pinned the property's composition but nothing pinned each site's consumption,
which is exactly how these two survived).

New guard: LibplctagGatewayConsumptionGuardTests scans both AB drivers'
sources and fails on any libplctag Tag built from the bare Gateway, so a
future call site cannot reintroduce the silent 44818 redirect.

Live-gated against the fixture host: browse of ab://10.100.0.35:44818
returns the nine-tag symbol table; browse of ab://10.100.0.35:44999 (nothing
listening — the issue's exact repro) now fails ErrorTimeout where it
previously returned the 44818 fixture's table.

Closes #539.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-06 20:42:40 -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 9420f8efff Merge fix/issue-546-panel: wire DriverStatusPanel into /hosts driver rows (#546)
v2-ci / build (push) Successful in 5m22s
v2-ci / unit-tests (push) Failing after 17m32s
Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-06 17:28:48 -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 1df8470356 Merge fix/issues-543-544-545: whitespace-key fail-closed backport (#543), de-flaked deadline test (#544), h2c accepted-posture record (#545)
Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-06 17:28:48 -04:00
Joseph Doherty 4870785209 docs(auth): #543 add the one-public-ctor remarks paragraph to the two newly-pinned interceptors
ConfigServeAuthInterceptor and LocalDbSyncAuthInterceptor gained HasExactlyOnePublicConstructor
pin tests in this branch but lacked the sibling-standard <remarks> paragraph naming them
(TelemetryStream/MeshCommand/CentralCommand all carry it). Cosmetic consistency item from the
#543 code review.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-06 17:28:31 -04:00
Joseph Doherty b5607d355a refactor(fleet): #542 review residue — drop dead proxyOptions local, retire stale compliance defer line
The proxyOptions local in WithOtOpcUaControlPlaneSingletons was already unused on master
(pre-existing dead code, not orphaned by the deletion commit — every registration passes
singletonOptions); removed while the file is open. The phase-6-1 compliance script still
Assert-Deferred'd the FleetStatusHub /hosts follow-up — an item that is now deleted, not
deferred — replaced with a retirement comment.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-06 17:28:11 -04:00
Joseph Doherty e669fb349a fix(adminui): #546 recycle DriverStatusPanel on Enabled flip via @key
DriverStatusPanel decides whether to subscribe exactly once, in
OnInitializedAsync (`if (!Enabled) return;`), and exposes no OnParametersSet
hook. The detail row was keyed only by DriverInstanceId, so a live config edit
that flipped a row's Enabled true->false — surfaced on the page by the 5 s
LoadConfigAsync refresh — reused the same panel instance: the markup correctly
switched to "Disabled - not deployed" while the panel's two store SnapshotChanged
subscriptions and its 5 s staleness timer stayed wired underneath, until the
operator happened to collapse the row.

Including the flag in the detail row's key recycles the component instead. Both
directions verified against the panel's own lifecycle: true->false disposes the
old instance (unsubscribing both stores, draining both timers) and mounts one
that skips subscription setup entirely; false->true disposes an instance that
never subscribed (the unsubscribes are no-ops, both timers null) and mounts one
that subscribes and primes from the stores.

The data row's key is unchanged. No behaviour change when Enabled is stable.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-06 17:25:41 -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 d33e4e20b5 feat(adminui): #546 wire DriverStatusPanel into /hosts driver rows (collapsed expander)
DriverStatusPanel was orphaned — zero references repo-wide — so the Restart and
Reconnect driver commands had NO reachable UI at all, despite working end to end
over the new gRPC mesh transport. Nothing failed to build, test or deploy while
that was true, which is why it went unnoticed.

The panel is now reachable per driver row on /hosts, behind a per-row "Controls"
expander that is collapsed by default: the table stays scannable, and only the
opened panels hold snapshot-store subscriptions. Expansion state is a HashSet
keyed by DriverInstanceId held on the page, so it survives the 5 s refresh that
replaces every row record. Both rows are @key'd by instance id so the panel —
which binds its DriverInstanceId once in OnInitializedAsync — can never be diffed
onto a different driver.

The panel's Enabled parameter is fed from real config rather than defaulted:
HostsDriverInstanceInfo/HostsDriverRow now carry DriverInstance.Enabled, which
LoadConfigAsync already had in hand. A snapshot with no matching configured
instance is treated as enabled — the row exists only because live health arrived,
so "Disabled — not deployed" would contradict the same row.

The expander is visible to every AuthenticatedRead viewer; the panel self-gates
only its Reconnect/Restart buttons on DriverOperator.

A source-scan pin (HostsPanelWiringTests) asserts /hosts still renders the panel
and still binds it to the row's own driver, cluster and enabled state — removing
the tag would silently restore the orphaned state.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-06 17:18:37 -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 722a222db5 test(controlplane): #544 de-flake deadline-timeout test — seed acks before spawn, un-hittable wall deadline
DeadlineElapsed_for_current_deployment_marks_TimedOut carried two races,
both test-side. ConfigPublishCoordinator itself is untouched.

(a) The NodeDeploymentState row was inserted AFTER Sys.ActorOf. The
coordinator's PreStart reads NodeDeploymentStates to recover the
expected-ack set, and it runs as soon as the actor is scheduled — so if
PreStart won, the expected-ack set was empty and the coordinator could
short-circuit to Sealed instead of ever reaching TimedOut. The row is now
seeded before the spawn.

(b) The applyDeadline was 150 ms of real wall clock, and it arms a real
Akka timer. Under load that timer could fire on its own and race the
test's explicit DeadlineElapsed tell — so the test was intermittently
exercising the timer rather than the message handler it names. The
deadline is now TimeSpan.FromMinutes(5), matching the other two tests in
this class, which makes it un-hittable inside the test's own 3 s
AwaitAssert bound and leaves the explicit tell as the only deadline
signal the coordinator ever sees. That tell, and the AwaitAssert bound,
are unchanged.

Verified: the single test 5/5 green in a row, then the whole class 3/3.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-06 17:15:42 -04:00
Joseph Doherty f10f4c3754 fix(auth): #543 whitespace-only configured key fails closed in the three pre-existing gRPC auth interceptors
TelemetryStreamAuthInterceptor, ConfigServeAuthInterceptor and
LocalDbSyncAuthInterceptor all decided "is a key configured?" with
string.IsNullOrEmpty. A whitespace-only configured value therefore passed
that guard and fell through to the constant-time comparison — where it is
satisfied by a whitespace-only bearer token. The gate would be open to
anyone who guessed a blank credential.

This backports the check the two NEW mesh interceptors already use:
MeshCommandAuthInterceptor (and its central-side clone) validate with
string.IsNullOrWhiteSpace, so the four/five planes now answer the same way
to the same misconfiguration. Nothing else changes — the denial status
codes (PermissionDenied / Unauthenticated / PermissionDenied respectively)
and every denial message are untouched, and no key material is logged.

No deployment configures a whitespace key: the docker-dev rig and
production both set real keys, so this is a fail-closed hardening of an
unreachable-today misconfiguration rather than a live exposure.

Tests: each of the three test files gains a ..._WithBlankKeyConfigured_IsDenied
fact mirroring MeshCommandAuthInterceptorTests.ExecuteMethod_WithBlankPskConfigured_IsDenied
(configured key "   ", presented "Bearer    "), verified red before the fix.
ConfigServeAuthInterceptor and LocalDbSyncAuthInterceptor also gain the
HasExactlyOnePublicConstructor pin their two siblings already had —
Grpc.AspNetCore silently stops invoking an interceptor with more than one
public constructor, which would disable auth without failing anything.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-06 17:14:36 -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 2fa880b6fc Merge feat/grpc-phase3-deletion: delete the Akka ClusterClient cross-mesh transport (Phase 3)
v2-ci / build (push) Successful in 5m43s
v2-ci / unit-tests (push) Failing after 17m18s
MeshTransport:Mode is now Dps | Grpc only; the retired ClusterClient value
fails validation loudly. CentralContactPoints, both receptionist
registrations, the akka.cluster.client HOCON block and the central/node
client machinery are deleted; SplitTopologyTransportValidator requires Grpc
on cluster-role nodes. MeshGrpcBoundaryTests replaces the old boundary test
and pins that no cross-mesh Akka association forms. Docs swept (incl. the
scadaproj index, pushed separately on its main).

Task 3.1 spec+code reviews closed (1 Important fixed: MeshPaths collapse);
Task 3.2 docs-accuracy review closed. DoD full suite: exit 0, 50/50 suites,
9326 passed / 0 failed on 8e25ed55.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-06 16:57:05 -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 cd7135570c refactor(mesh): collapse MeshPaths to local actor names — no longer a wire contract (review follow-up)
MeshPaths.CentralCommunication / .NodeCommunication (the "/user/..." full-path constants) had
ZERO production consumers after the ClusterClient deletion: both gRPC services resolve their
comm actor through ActorRegistry by typed key, never by ActorSelection over a path. Only the
path-probe test still read them, while the class doc went on calling them "the wire contract
of the central↔node boundary" — a claim that stopped being true the moment nothing addressed
an actor across a process boundary by name.

  - The two full-path constants are deleted; the two Name constants remain.
  - MeshPaths' doc now says what it is (the local actor-naming convention the registry-keyed
    services are spawned under) and records what it stopped being, so the next reader does not
    re-derive a contract from the file's history.
  - MeshCommActorPathTests composes "/user/" + Name itself — the absolute path is that test's
    own addressing detail now, not something the product declares. Renamed
    Both_mesh_comm_actors_exist_at_their_contract_paths ->
    ..._are_spawned_under_their_declared_names, because what it still pins is that the two
    registration sites actually ran on a real booted node; a rename is now a compile error, so
    "contract" was the wrong word for what would break.
  - NodeCommunicationActor's summary no longer cross-references the deleted constant or calls
    the name an address. CentralCommunicationActor was checked and makes no such claim.
2026-08-06 16:14:25 -04:00
Joseph Doherty 4d2dfb5b50 test(mesh): step 7 — replace the boundary test with MeshGrpcBoundaryTests and sweep the comments
MeshGrpcBoundaryTests replaces the MeshClusterClientBoundaryTests deleted in step 1. Three
self-seeded single-member clusters sharing the ActorSystem name, joined ONLY by gRPC, driving
the REAL CentralCommunicationActor -> MeshGrpcCommandDialer -> Kestrel h2c ->
MeshCommandAuthInterceptor -> NodeCommandGrpcService -> NodeCommunicationActor -> EventStream:

  1. No_cross_mesh_Akka_association_forms_between_central_and_either_site — the program's own
     point, kept as a test rather than a live-gate note: each cluster stays single-member and
     none ever holds another's address as a member.
  2. One_dispatch_through_central_reaches_a_node_in_every_cluster_mesh — the fan, with each
     site holding its OWN per-cluster PSK, so a dial that selected the wrong cluster's key
     would be refused at the far side.
  3. Falsifiability_control_a_dial_carrying_the_wrong_cluster_key_delivers_nothing — without
     it, (2) cannot distinguish "the gRPC boundary carried it" from "something else in the
     process did". The same node then accepts the right key, so the silence is the auth gate
     and not a dead endpoint.

All three run fully offline — no Docker fixture — and pass.

Comment sweep across every remaining clusterclient|receptionist hit in src/ and tests/:
the program label becomes "mesh gRPC command plane", and every comment that described the
deleted transport as live now describes what actually runs (Dps = pair-local default,
Grpc = cross-mesh). mesh_transport.proto's header/service comments are reworded; no message,
field or enum is touched — the contract is locked. MeshCommActorPathTests is reshaped rather
than retired: the two paths still exist and still matter, but what a mismatch costs is now a
misleading Unavailable from the gRPC ingress rather than a silently dropped receptionist send.

The only surviving mentions of the old names are the ones that must survive: the fail-closed
rules that name "ClusterClient" as a retired value refused at boot, their tests, and this
test class's own account of what it replaces.
2026-08-06 15:58:36 -04:00
Joseph Doherty af3b0c3146 refactor(mesh): step 6 — delete the ClusterClient mode value; split topology now requires Grpc
MeshTransportOptions.ModeClusterClient is gone. MeshTransportOptionsValidator accepts Dps or
Grpc and nothing else, so a node left carrying the string "ClusterClient" is now REFUSED at
boot naming MeshTransport:Mode and both remaining values — deliberately fail-closed rather
than falling back to Dps, which on a split-topology node would deliver nothing at all. A new
fact pins exactly that.

SplitTopologyTransportValidator tightens from an allow-list of two to Grpc only, as its own
comment said it would once ClusterClient was deleted. Dps on a cluster-role node stays
refused; the rule is still an explicit allow-list, so a typo'd mode fails too.

Tests: the two ClusterClient-mode facts in MeshTransportOptionsValidatorTests are deleted
(their Dps siblings already assert the same rule for the one remaining non-Grpc mode), the
SplitTopology fixtures move to Grpc, and the three-mode Theories in
ControlPlaneStarterOrderTests / CentralCommunicationActorTests become two-mode.
2026-08-06 15:49:42 -04:00
Joseph Doherty 85ca86e955 refactor(mesh): step 5 — delete MeshTransport:CentralContactPoints, its rules and its compose entries
The property, the four validator rules that graded it (empty / non-akka.tcp / actor-path
suffix / one-bad-among-good) and all twelve docker-compose entries (6 nodes x 2) are gone.
The compose comment blocks that explained "/system/receptionist is appended at construction
time" and the client's contact rotation go with them, and the surrounding prose now
describes the Grpc command plane it actually runs.

CentralGrpcEndpoints' doc no longer says it "replaces" the Akka contact points — there is
nothing left to replace — and ContactRefreshSeconds' failure message names the node
dispatch set rather than the ClusterClient contact points.

`rg CentralContactPoints src tests docker-dev` is now empty.
2026-08-06 15:45:21 -04:00
Joseph Doherty d67a7e5d0b refactor(mesh): step 4 — drop the receptionist extension and akka.cluster.client from akka.conf
akka.extensions loses ClusterClientReceptionistExtensionProvider and the whole
akka.cluster.client block (buffer-size / reconnect-timeout / receptionist.role) goes with
it. The DistributedPubSub extension and the maximum-frame-size / log-frame-size-exceeding
canary both stay — DPS is the pair-local transport and the frame canary guards every
remoting message, neither of which the cross-mesh deletion touches.

MeshTransportHoconTests drops its three ClusterClientSettings/ClusterReceptionistSettings
asserts and keeps the frame-size pin and the DPS-extension pin, renamed since it no longer
has a receptionist to survive alongside.
2026-08-06 15:42:39 -04:00
Joseph Doherty 5d7ecdf737 refactor(mesh): step 3 — delete both ClusterClientReceptionist registrations
Central's CentralCommunicationActor and a node's NodeCommunicationActor no longer register
with a receptionist; the ActorRegistry entry each already carried is now the whole
reachability story, and it is what CentralCommandGrpcService / NodeCommandGrpcService
resolve to hand an inbound message over. Both `using Akka.Cluster.Tools.Client` directives
are gone with them.
2026-08-06 15:41:25 -04:00
Joseph Doherty ab5b958d47 refactor(mesh): step 2 — delete the node-side ClusterClient and its centralClient parameter
WithOtOpcUaRuntimeActors no longer builds a ClusterClient from
MeshTransport:CentralContactPoints, and NodeCommunicationActor.Props takes only the gRPC
IApplyAckSender. The ackRouter gate narrows from `useClusterClient || useGrpc` to `useGrpc`,
and the CentralClusterClientName constant is gone.

NodeCommunicationActor's no-transport warning now names MeshTransport:CentralGrpcEndpoints
and MeshTransport:Mode only — the old text pointed an operator at a key that no longer
exists.

Ripples in this step: the two ClusterClient-shaped facts in NodeCommunicationActorTests
(Send-not-SendToAll, and sender-wins-over-a-client) are deleted — the first has no subject
left, the second guarded a both-transports-present state that is now unconstructible. The
Runtime ServiceCollectionExtensions fact keeps its ackRouter pin and drops its
"no ClusterClient actor exists" half for the same reason.
2026-08-06 15:38:49 -04:00
Joseph Doherty 0db40a5e7f refactor(mesh): step 1 — delete the central ClusterClient factory and per-cluster client cache
IMeshClusterClientFactory/DefaultMeshClusterClientFactory are gone, along with
CentralCommunicationActor's _clients map, RebuildClient, the SendToAll fan and the
receptionist-address half of the contact load. LoadContactsFromDb SURVIVES — it feeds the
gRPC dialer's endpoint resolution and the known-cluster gate — but now projects only the
columns those two need (the AkkaPort/ActorPath.TryParse parse and its Malformed list went
with the receptionist addresses).

Ripples fixed in this step because the build must be green at every commit:
  - ControlPlane/ServiceCollectionExtensions drops the factory argument.
  - CentralCommunicationActorTests: the six ClusterClient-only facts are deleted (each has a
    Grpc counterpart that already asserted the same property independently), the three
    transport-agnostic facts move to Grpc mode, and the Dps fact keeps its own coverage.
  - MeshClusterClientFactoryTests deleted (its subject is gone).
  - MeshClusterClientBoundaryTests deleted here rather than in step 7: it drives
    DefaultMeshClusterClientFactory directly, so it cannot compile past this commit. Its
    replacement, MeshGrpcBoundaryTests, lands in step 7 and keeps the load-bearing assertion
    (two separate meshes, no cross-mesh Akka association).
2026-08-06 15:35:54 -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 caf2eec66b feat(mesh): Phase 2 cutover — docker-dev rig default MeshTransport mode flips ClusterClient → Grpc
All six nodes now default to the gRPC command/ack transport; OTOPCUA_MESH_MODE
still overrides for rollback drills. Baseline reproduced pre-flip (1bb3a937):
cross-mesh :4053 open unauthenticated, unreachable-cluster dispatch suppressed
into sampled deadLetters INF with the deploy deadline as sole witness.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-06 10:35:38 -04:00
Joseph Doherty 027f0403c4 test(mesh): role-scoping scan handles braceless if (hasAdmin) — closes the phantom-block false pass 2026-08-06 10:33:15 -04:00
Joseph Doherty d7c9ead64e test(mesh): close review findings on the PreStart-race logging fix — non-vacuous once-per-incarnation pin, broken-dialer symmetry, comment idiom
Follow-up to a94eb8a3, addressing CHANGES REQUESTED on code review. Production
logic was confirmed correct on all spec points; this commit fixes the review's
test/comment findings only.

IMPORTANT-1: A_permanently_unresolvable_pskProvider_is_reported_once_per_incarnation_not_once_per_refresh
did not prove the second refresh ran — its Expect(0) window gated on a dialer
dispatch landing, but LoadContactsFromDb is fire-and-forget and the first
block's AwaitCondition already re-Tells MeshCommand per poll, so the dialer
call count could reach the threshold before PublishKnownClusters was ever
re-invoked by the second load. Fixed by having the PSK factory count its own
invocations and gating both AwaitConditions on that count directly (pskCalls
>= 2 proves the first retry; pskCalls > pskCallsAfterFirstLoad, awaited INSIDE
the Expect(0) window, proves the second one). Bite-checked: with
_pskProviderAbsenceReported's guard temporarily removed, the test now
correctly fails ("Received 1 message too many... received 1 that matched
filter"); guard restored and reverified green.

MINOR-2: reworded four comments (CentralCommunicationActor.cs ~109, ~259,
~305-309, ~853) from narrating the change ("this fix removes", "already
existed, unchanged", "added by this fix", "the old two-call-site shape") to
stating the timeless invariant.

MINOR-3: added A_supplied_dialer_factory_that_always_throws_still_drops_each_command_loudly_while_PreStart_stays_quiet
— the symmetric case to the existing never-supplied-dialer test, exercising
Resolve's catch + firstUse:true path (a factory that WAS supplied and always
throws) rather than the null-factory path. Asserts PreStart plus the first
contact load stay silent and DispatchOverGrpc's existing per-dispatch Error
still fires on every dispatch, unguarded.

MINOR-4: "Check the ControlPlane registration wires IMeshPskProvider" ->
"Check that the ControlPlane registration wires up IMeshPskProvider"; no test
pinned the old substring, so no test update was needed for this one.

Skipped item 5 (restart-re-arms pin) per instruction — out of scope.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-06 10:30:20 -04:00
Joseph Doherty 2e06dfe52d test(mesh): pin the admin-only role scoping of IMeshPskProvider/IMeshCommandDialer registrations (security register item 3) 2026-08-06 10:22:29 -04:00
Joseph Doherty a94eb8a335 fix(mesh): PreStart resolve race logs honestly — quiet on heal, loud on permanent absence
PreStart's eager resolve of IMeshCommandDialer/IMeshPskProvider loses a
millisecond-wide race against the Akka builder's remaining starters on
essentially every admin boot (live gates: 1A 2/3 boots, 1B 4/4 boots) and
self-heals at the memoized Dialer/PskProvider properties' first use. Before
this change that produced two FALSE Errors per healthy boot, which made a
soak error-grep on a healthy rig impossible.

Resolve() now takes a `firstUse` flag and logs at Debug on BOTH call sites
(PreStart and the memoized properties' retry) — from inside Resolve a lost
race and a genuinely broken factory are indistinguishable, so severity is
decided by the CONSUMER of a still-null result after the retry, not by
Resolve itself:

  - Dialer: DispatchOverGrpc's existing per-dispatch Error ("no command
    dialer is wired") is unchanged and is now the ONLY loud signal for that
    dependency.
  - PskProvider: PublishKnownClusters gained a new Error carrying the old
    PreStart guard's diagnostic content (gate never published, cached keys
    never dropped, rotated key needs restart), guarded to fire ONCE per
    actor incarnation via `_pskProviderAbsenceReported` — that handler runs
    on the contact-refresh timer for the actor's whole life, so an
    unguarded Error there would repeat every ContactRefreshSeconds forever.

Design choice for the "factory never supplied" case (Dps/ClusterClient test
constructions that pass no factories at all): kept silent at BOTH call
sites for both dependencies, same as before. For the dialer this was
already true (DispatchOverGrpc's check doesn't care why the dialer is
null). For the PSK provider the new once-guarded Error is gated on
`_pskProviderFactory is not null` specifically so the many existing
ClusterClient/Dps facts in CentralCommunicationActorTests that never wire a
PSK provider (using the bare Spawn helper) keep passing unmodified — a
never-supplied factory is a deliberate wiring/test choice, not a resolve
failure, and Resolve() already stayed silent about it for the same reason.

Two new tests pin the net behavior: a healthy boot (factories that throw
once then succeed) produces zero Warning/Error end-to-end through PreStart,
the first contact load and the first dispatch; a permanently-broken PSK
provider logs the absence exactly once across two contact loads, not once
per refresh.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-06 10:14:14 -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 7203d135fc Merge Phase 1B: gRPC ApplyAck path (CentralCommandGrpcService + GrpcApplyAckSender) — live gate PASS 7/7
v2-ci / build (push) Successful in 5m54s
v2-ci / unit-tests (push) Failing after 19m21s
Node→central ApplyAck over gRPC behind MeshTransport:Mode=Grpc: CentralCommandGrpcService
on the AdminUI listener, GrpcApplyAckSender with per-endpoint ack deadlines (Critical fix
e54711a2 proven live in the 4b hang drill), NodeCommunicationActor wiring, plus the
phases 2-4 plan update (targeted-test policy + Task 2.0 must-fix).

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-06 09:28:57 -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