Commit Graph

2718 Commits

Author SHA1 Message Date
Joseph Doherty 35552a9608 Merge feat/mesh-phase5: per-cluster mesh Phase 5 — gRPC live-telemetry stream
v2-ci / build (push) Successful in 3m40s
v2-ci / unit-tests (push) Failing after 13m12s
Node-hosted telemetry gRPC server + central dial supervisor replace the DPS fan-out
of alerts/script-logs/driver-health/driver-resilience-status, behind the
Telemetry:Mode/TelemetryDial:Mode dark switch (Dps default), fail-closed bearer auth.
3 channels deferred with rationale (redundancy-state/fleet-status/deployment-acks).
Live gate PASSED (12-stream mesh, pill live, kill-and-reconnect recovered).

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 17:20:53 -04:00
Joseph Doherty f134935f99 docs(mesh-phase5): live gate PASSED — record + program status DONE
Full 12-stream telemetry mesh formed in Grpc mode, AdminUI pill live (data path
proven), kill-and-reconnect recovered a node's stream in ~5s; Dps baseline dials
nothing. Surfaced+validated the ClusterNode.GrpcPort upgrade-backfill gotcha.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 17:11:52 -04:00
Joseph Doherty 97b2911327 chore(mesh-phase5): task ledger — tasks 0-11 complete, live gate pending
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 16:56:13 -04:00
Joseph Doherty d15c613ef3 feat(mesh-phase5): dark-switch central telemetry ingest (Dps bridges | Grpc dialer)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 16:48:34 -04:00
Joseph Doherty 84fa2e1e43 fix(mesh-phase5): dial supervisor — re-dial on endpoint change, pin no-restart invariant, throttle skip warnings
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 16:43:09 -04:00
Joseph Doherty ffb75b7854 chore(mesh-phase5): rig telemetry ports + shared key + ClusterNode.GrpcPort seed
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 16:39:29 -04:00
Joseph Doherty e99ea40e2f docs(mesh-phase5): telemetry stream — Telemetry.md, config keys, supersede §6.3, program status
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 16:33:29 -04:00
Joseph Doherty 1104785c89 feat(mesh-phase5): central telemetry dial supervisor (discover + reconnect + feed sinks)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 16:28:57 -04:00
Joseph Doherty af9c9d78da test(mesh-phase5): 3-port listener reachability + comment fix for the telemetry endpoint
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 16:18:15 -04:00
Joseph Doherty a279a43ed4 fix(mesh-phase5): harden telemetry client onError contract (validate/isolate/defend)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 16:12:02 -04:00
Joseph Doherty f131c1cca5 feat(mesh-phase5): host the telemetry gRPC server on driver nodes (dedicated h2c port)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 16:05:40 -04:00
Joseph Doherty 2e70ef88aa fix(mesh-phase5): telemetry service — graceful client-disconnect + null-safe required-string mapping
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 16:02:26 -04:00
Joseph Doherty 50f1620d79 feat(mesh-phase5): fail-closed bearer interceptor for the telemetry stream
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 15:58:45 -04:00
Joseph Doherty c78034f0b9 feat(mesh-phase5): central telemetry dialer client + proto->domain converters
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 15:55:06 -04:00
Joseph Doherty e742fee452 feat(mesh-phase5): node-side TelemetryStreamService (hub -> server-streaming)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 15:48:06 -04:00
Joseph Doherty 909d75357b test(mesh-phase5): cover the ScriptedAlarmHostActor + VirtualTagActor telemetry taps
Closes two test-completeness gaps flagged in review of the seam-tap commit — the
ScriptedAlarmHostActor and VirtualTagActor hub emits had no test proving the hub
actually receives them.

- ScriptedAlarm_activation_emits_one_Alarm_item_with_matching_payload: spawns the
  host with a capturing fake hub, drives a real engine Inactive->Active transition
  through the existing ScriptedAlarms harness, and asserts exactly one
  TelemetryItem.Alarm carrying the Activated transition.
- VirtualTag_script_log_emits_one_Script_item_with_same_payload: passes a fake hub
  via Props + a publisherFactory, drives an evaluator failure, and asserts a
  TelemetryItem.Script carrying the SAME ScriptLogEntry instance handed to the
  publisher.

To make the VirtualTagActor tap observable, its hub emit moved to the TOP of
PublishLog, before the test-only publisherFactory early-return branch, so the emit
fires on BOTH the production DPS path and the factory path. Production behavior is
unchanged: production passes publisherFactory: null, so it still reaches the DPS
Tell; the emit just moved a couple lines earlier (both are fire-and-forget).

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 15:38:12 -04:00
Joseph Doherty 7bdb6d00ec fix(mesh-phase5): telemetry hub — plain Dictionary under gate, concurrency stress test, priming/eviction notes
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 15:30:24 -04:00
Joseph Doherty 8e5090edf3 feat(mesh-phase5): tap the 4 telemetry publish seams into the local hub (DPS intact)
Each of the four telemetry publish seams now also emits its DPS payload into the
node-local ITelemetryLocalHub (Phase 5), leaving the existing DistributedPubSub
publish untouched:

- driver-health   AkkaDriverHealthPublisher            -> TelemetryItem.Health
- resilience      DriverResilienceStatusPublisherService -> TelemetryItem.Resilience
- alerts          ScriptedAlarmHostActor + DriverHostActor (native) -> TelemetryItem.Alarm
- script-logs     VirtualTagActor + DpsScriptLogPublisher -> TelemetryItem.Script

DI services take the hub as a required ctor param; actors take an optional
nullable hub threaded through their Props and the real spawn sites
(DriverHostActor <- ServiceCollectionExtensions; VirtualTagActor/ScriptedAlarmHostActor
<- DriverHostActor), so existing test Props constructions keep compiling and simply
do not emit. The hub is a no-op until a gRPC client subscribes, so the emit is safe
on every node.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 15:27:29 -04:00
Joseph Doherty 9882b1d250 feat(mesh-phase5): node-local telemetry hub (snapshot-replay + drop-oldest fan-out)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 15:08:56 -04:00
Joseph Doherty 53ae0100a2 feat(mesh-phase5): Telemetry/TelemetryDial options + fail-closed validators
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 15:03:40 -04:00
Joseph Doherty a845a6d2fd feat(mesh-phase5): telemetry.proto contract + oneof + contract-lock test
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 14:58:53 -04:00
Joseph Doherty 094c29d13d docs(mesh-phase5): plan — gRPC live-telemetry stream (4-channel scope, dark switch, authed)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 14:56:20 -04:00
Joseph Doherty 3a590a0cb7 refactor(mesh-phase4): retire EfAlarmConditionStateStore + drop dead ScriptedAlarmState table (Task 9)
v2-ci / build (push) Successful in 4m3s
v2-ci / unit-tests (push) Failing after 13m59s
Scripted-alarm condition state lives in LocalDb (Phase 4); the ConfigDb-backed
Ef store + ScriptedAlarmState table are now dead. Removes the test-harness Ef
fallback (a DB-backed node with no store now skips the alarm host), deletes the
store + entity + model config, and drops the table via migration.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 14:33:11 -04:00
Joseph Doherty 26fad75c8a Merge branch 'feat/mesh-phase4' — per-cluster mesh Phase 4 (cut the driver-side ConfigDb connection)
Driver-only nodes now run with NO ConfigDb connection string: config via
FetchAndCache into LocalDb (Phase 3), scripted-alarm condition state in a
replicated LocalDb table, and central persists their deploy acks. DB-reachability
retired from ServiceLevel on DB-less nodes (a healthy site node holds 240/250 with
central SQL down). OpcUaPublish guard split fixes the driver-only address-space
wipe. Live gate PASSED on docker-dev.

Tasks 0-8 + 1b + 10-11 done; Task 9 (drop dead ScriptedAlarmState table) deferred.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 14:18:28 -04:00
Joseph Doherty 1281aebfa7 docs(mesh-phase4): live gate PASSED — driver nodes run ConfigDb-free
Deploy sealed green with 4 DB-less site nodes acking (central persists acks);
ServiceLevel held 240 on a site node with central SQL stopped (survive-alone);
restarted site node booted last-known-good from the LocalDb pointer, no ConfigDb
read; alarm_condition_state table live + replicated. Full record in
2026-07-23-mesh-phase4-live-gate.md. Task 9 (drop dead table) deferred.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 14:03:28 -04:00
Joseph Doherty 0170614961 docs(mesh-phase4): Task 10 done; Task 11 live gate in progress
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 13:22:35 -04:00
Joseph Doherty 0be20ab4fb chore(mesh-phase4): rig site nodes run with no ConfigDb string (FetchAndCache mandatory)
The four driver-only site nodes lose their ConfigDb connection string and hardcode
ConfigSource:Mode=FetchAndCache (Direct is now rejected by the validator for a
driver-only node). Central-1/2 keep ConfigDb + Direct + ConfigServe. Proves the
Phase-4 cut on the rig for the live gate.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 13:22:18 -04:00
Joseph Doherty 95fa7bd5a0 docs(mesh-phase4): Task 8 done (55a6e1f2 recorded)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 13:19:27 -04:00
Joseph Doherty 55a6e1f2b2 docs(mesh-phase4): ServiceLevel semantics on DB-less nodes + driver ConfigDb cut
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 13:18:57 -04:00
Joseph Doherty 6f21213f70 test(mesh-phase4): pin driver-only ConfigDb-free DI graph + fused-node mapper ordering
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 13:10:42 -04:00
Joseph Doherty 4253d20248 docs(mesh-phase4): Task 3 done (73ae8ec5) — address-space wipe fixed
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 12:58:37 -04:00
Joseph Doherty 73ae8ec5c5 fix(mesh-phase4): OpcUaPublish diff-applies from in-hand bytes with no ConfigDb (no wipe)
A driver-only node has a null dbFactory but a wired applier; the old combined
guard routed it to the raw-sink fallback that wipes the address space and never
re-materialises. Split the guard so in-hand artifact bytes drive the real
diff-and-apply; a missing artifact abandons the rebuild (keep last-known-good,
#485) instead of wiping.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 12:53:28 -04:00
Joseph Doherty e791832d7c docs(mesh-phase4): Tasks 4+6 done (d9071607); Task 3 confirmed hard prereq
Task 4+6 code review re-surfaced the OpcUaPublishActor raw-sink wipe on a
null-factory node (the exact #485/#486 defect Task 3 fixes) — Task 3 must land
before any node flips to FetchAndCache. Ordering already enforces this
(3 → 7 → 10 → 11).

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 12:48:13 -04:00
Joseph Doherty d907160747 feat(mesh-phase4): DriverHostActor runs ConfigDb-free (LocalDb alarm store, guarded acks)
Nullable ConfigDb factory; UpsertNodeDeploymentState no-ops when absent
(central persists acks from the ApplyAck); scripted-alarm condition state
served from the LocalDb store instead of the ConfigDb-backed Ef store.
Combines Phase-4 Tasks 4 + 6. Removes the interim dbFactory! cast.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 12:39:41 -04:00
Joseph Doherty 4f4cdd05ec feat(mesh-phase4): LocalDb alarm-condition-state store (replicated, pair-local)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 12:19:44 -04:00
Joseph Doherty 34b613d942 docs(mesh-phase4): Task 2 done; fold review findings into Task 4 brief
Task 2 code review surfaced two unguarded ConfigDb sites reachable on a
driver-only node once Tasks 0+1 landed (SpawnScriptedAlarmHost's Ef store,
unconditional UpsertNodeDeploymentState). Task 4 brief now names them + the
dbFactory! removal; Task 6 re-homes the alarm store to LocalDb.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 12:07:42 -04:00
Joseph Doherty 9565827275 feat(mesh-phase4): retire DB-reachability from ServiceLevel on DB-less nodes
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 12:06:42 -04:00
Joseph Doherty 5c40908a55 docs(mesh-phase4): add Task 1b (driver-only LDAP mapper) discovered during Task 1
Gating ConfigDb on hasAdmin exposed a 6th driver-side ConfigDb consumer the
§6.1 audit missed: OtOpcUaGroupRoleMapper's ILdapGroupRoleMappingService dep.
Task 7 gains a fused-node ordering pin (Task 1b review gap).

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 11:48:45 -04:00
Joseph Doherty 833e45db9c fix(mesh-phase4): driver-only nodes map LDAP groups from appsettings (no ConfigDb grants)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 11:44:44 -04:00
Joseph Doherty a41582ea6a feat(mesh-phase4): register ConfigDb only on admin-role nodes
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 11:38:40 -04:00
Joseph Doherty d630a7e267 feat(mesh-phase4): driver-only node must be FetchAndCache (validator)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 11:29:45 -04:00
Joseph Doherty bc611969a9 docs(mesh-phase4): plan — cut the driver-side ConfigDb connection
Phase 4 of the per-cluster mesh program. Removes the four remaining driver-side
ConfigDb consumers + the registration itself so a driver-only node boots with no
ConfigDb connection string:

- ConfigDb registered iff hasAdmin (Program.cs); driver-only requires none
- driver-only ⇒ FetchAndCache mandatory (validator)
- DbHealthProbeActor not spawned on driver-only; DbReachable=true constant
  (client-visible ServiceLevel change — a healthy DB-less node publishes 240/250
  with central down, per user decision 2026-07-23)
- DriverHostActor: nullable factory, drop redundant SQL ack-writes (central's
  PersistNodeAck is the ack system of record)
- EfAlarmConditionStateStore → LocalDbAlarmConditionStateStore (new replicated
  alarm_condition_state table)

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 11:22:48 -04:00
Joseph Doherty d01b0695c2 Merge branch 'feat/mesh-phase3' — per-cluster mesh Phase 3 (config fetch-and-cache)
v2-ci / build (push) Successful in 4m1s
v2-ci / unit-tests (push) Failing after 13m19s
Driver nodes can obtain config via a gRPC fetch from central + LocalDb cache instead of
reading central SQL, behind the ConfigSource:Mode=FetchAndCache dark switch (Direct
default). Central serves the artifact over a dedicated h2c listener gated by a shared node
key; the node SHA-verifies against the dispatched RevisionHash, applies from the bytes in
hand, and boots from the LocalDb pointer with no SQL read. Live gate PASSED on docker-dev
(caught + fixed a Status==Sealed serve-gate deadlock). Phase boundary: config READS only.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-22 22:35:14 -04:00
Joseph Doherty 248e70b425 docs(mesh): Phase 3 live-gate record — PASSED (caught+fixed the Sealed-gate deadlock)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-22 20:48:58 -04:00
Joseph Doherty d9de4e3019 fix(mesh): serve the artifact regardless of deployment status (Phase 3 live-gate deadlock)
The Phase 3 live gate deadlocked every FetchAndCache deploy: DeploymentArtifactGrpcService
gated on Status==Sealed, but central seals a deployment only AFTER every node acks Applied,
and a FetchAndCache node cannot ack until it has fetched the artifact — seal-needs-ack ->
ack-needs-fetch -> fetch-needs-sealed. The node's fetch reached central and passed the
shared-key interceptor, then got a clean NotFound; the #485 apply-failure path correctly
kept last-known-good, but the deploy could never seal.

Direct mode reads the same ArtifactBlob from SQL while the row is still AwaitingApplyAcks,
so the serve path must too. Drop the Sealed gate: serve any deployment whose blob is
non-empty (unknown id / empty blob still collapse to NotFound — existence-hiding + #485).
Access is already gated by the interceptor, so there is no reason to hide a non-sealed
deployment a node is legitimately applying. The Task 2 'non-sealed -> NotFound' test flips
to 'non-sealed with a blob is served'.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-22 20:21:47 -04:00
Joseph Doherty 44df30236e fix(docker-dev): pin the build stage to linux/amd64 so Grpc.Tools protoc runs
Phase 3 added an in-repo .proto compiled by Grpc.Tools; its bundled linux_arm64 protoc
segfaults (exit 139) on Apple-Silicon Docker, so the native-arm64 build stage could not
generate the gRPC stubs. dotnet publish is framework-dependent (portable IL + every RID's
native assets), so pinning ONLY the build stage to linux/amd64 leaves the runtime image
native arm64 (fast) while the one-time build is emulated. Matches the Dockerfile's stated
'targeting linux-x64' intent.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-22 20:11:28 -04:00
Joseph Doherty cf6110d02c docs(mesh): ConfigSource/ConfigServe config, rig flip switch, Phase 3 as shipped
Phase 3 Task 9. docker-dev rig: central-1/2 carry ConfigServe (:4055 h2c + committed
dev key) and stay Direct; the four site nodes carry ConfigSource (both central
endpoints + matching key) defaulting to Direct. Flip only the site nodes with
OTOPCUA_CONFIG_MODE=FetchAndCache at 'docker compose up' — central keeps SQL and models
the target topology. Binding :4055 activates the dedicated-h2c Kestrel takeover on
central (re-binds :9000 too). Docs: new docs/Configuration.md ConfigSource/ConfigServe
section; docs/Redundancy.md 'config bytes travel out-of-band' note; the program plan
Phase 3 section + tracking row flipped to CODE-COMPLETE; a CLAUDE.md 'Config source'
section.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-22 20:06:23 -04:00
Joseph Doherty b3210686e2 chore(mesh): mark Phase 3 tasks 7-8 complete
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-22 19:58:10 -04:00
Joseph Doherty ffbcaa93f2 test(mesh): real two-Host gRPC artifact-fetch boundary test with wrong-key control
Phase 3 Task 8. Stands a real h2c-only Kestrel listener serving the real
DeploymentArtifactGrpcService behind the real ConfigServeAuthInterceptor (in-memory
config DB seeded with one sealed deployment), and drives the real
GrpcDeploymentArtifactFetcher across it — proving a stream crosses the real boundary,
not just that the fetcher's logic is right (Task 4's fakes). Asserts: (1) right key +
>256 KB blob reassembles byte-equal and verifies against RevisionHash; (2) FALSIFIABILITY
CONTROL — wrong key returns null while the right key succeeds on the SAME server (so the
null is the auth gate, not a dead server); (3) unknown id -> null (NotFound-hidden);
(4) [dead-port, real-port] failover still returns the bytes. h2c GrpcChannel over http://
works out of the box in .NET 10. Tagged [Trait Category=ArtifactBoundary].

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-22 19:57:48 -04:00
Joseph Doherty fc568ae0fa test(mesh): #485 empty/corrupt/mismatch coverage on the FetchAndCache paths
Phase 3 Task 7. Regression net pinning the #485 negative on every FetchAndCache seam —
nothing rebuilds, nothing is torn down on a failure: (1) a null fetch mid-steady-state
(all endpoints down / NotFound / SHA mismatch — indistinguishable to the actor) keeps
the served address space and revision (no RebuildAddressSpace for the failed revision);
(2) a zero-length blob handed to the actor fails via ReconcileDriversFromBlob's own #485
guard, independent of the fetcher's null contract; (3) a corrupt cache at boot (surfaced
as GetCurrentUnkeyedAsync == null) lands Steady-no-revision and applies NOTHING — never
a partial address space. No new mechanism; all green as a net. Removing the #485 empty
guard reddens the zero-length test.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-22 19:54:42 -04:00