Commit Graph

38 Commits

Author SHA1 Message Date
Joseph Doherty c2bdb7d5de feat(hosts): driver diagnostics channel on /hosts, S7 negotiated PDU size first (#640)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 16:57:38 -04:00
Joseph Doherty 413bc5c04f fix(host): fatal-log, flush and exit non-zero when the host fails to start (#618)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 06:52:54 -04:00
Joseph Doherty 341417f193 fix(ab): share status map + libplctag orphan release, harden confinement guard, pin width-mismatch + DefaultTransport wiring (#568 #569 #580 #585)
- Ab.Transport: AbOpcUaStatusMap is the single AbStatus->OPC UA table; AbCip/AbLegacy mappers delegate.
- Remove dead MapCipGeneralStatus / MapPcccStatus / AbLegacy MapException (+ tests).
- LibplctagOrphanRelease is one source file (Ab.LibplctagShared) compiled into both drivers.
- LibplctagConfinementGuardTests: tokenising comment/literal stripper, block comments, csproj PackageReference guard.
- LibplctagStatusTranslationTests: real LibPlcTagException round trip, WrapSync/Wrap OCE pass-through, adapter routes decode via WrapSync.
- AbCipTypeMismatchTests: libplctag width-mismatch path (BadTypeMismatch, handle kept, no Degraded, warn once).
- Host: AddAbCipDriverDefaults extension + Program.cs source pin for Drivers:AbCip:DefaultTransport.
- docs/drivers/AbCip.md: layout rows + benign Degraded-after-outage note.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 22:59:06 -04:00
Joseph Doherty 2cea1a0687 fix(ab-transport): final review I2 — wire DeviceSessionPool.Shared to the host logger
ConfigureSharedLogger had no production caller, so the pool's only
diagnostics (first-wins options conflicts, scheduler/close failures)
went to NullLogger. DriverFactoryBootstrap now configures it from the
driver registry factory and from the AbCip/AbLegacy probe factories
(admin-only nodes reach the pool only through Test Connect). Wiring tests
reset the pool to a null logger first, so removing the call goes red
(verified).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 21:36:37 -04:00
Joseph Doherty b75e013d0a feat(abcip): final review I1 — node-level Drivers:AbCip:DefaultTransport
The old AdminUI cannot author the per-driver transport key (#576), so the
'pin before upgrading' recipe had no working path. A node-level appsetting
(env Drivers__AbCip__DefaultTransport) now supplies the default for AbCip
drivers with no key: explicit key > node default > compiled Native. Bound
and validated unconditionally (bad value fails host start); the driver
factory and the Test Connect probe both read it so they agree. AbCip.md
upgrade recipe rewritten around the key; Configuration.md documents it.
Also documents first-wins for AllowPacking/probe and host-alias throttles
(final review M6) and moves the status-bit note out of AbCip.md (M5).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 21:34:48 -04:00
Joseph Doherty c657b2e03d fix(acl): make the API-key store deadline a HARD race (R3-05 S18 live-gate failure)
BoundedApiKeyStore bounded ConfigDbApiKeyStore with a
CancellationTokenSource(TimeSpan, TimeProvider) linked into the inner call. That
bounds the call only if the callee chooses to observe the token, and
Microsoft.Data.SqlClient does not while a connect/login is in flight on a
black-holed socket. The 2026-08-20 live gate paused the SQL container and watched
a keyed activation hang 42.9 s past its 2000 ms bound — the "did not answer
within 2000 ms" Warning fired only at unpause — and because this runs inside the
SDK's synchronous SessionManager-lock-holding impersonation callback, an
unrelated already-open subscription stalled 57 s behind it. That is precisely the
hazard the bound exists to prevent, and the TTL cache cannot help a miss.

BoundAsync now races the inner call against Task.Delay on the injected clock with
Task.WhenAny. Inner wins: stop the timer, await it, faults and genuine caller
cancellation propagate unchanged. Delay wins: cancel the linked token anyway (a
cooperative inner store should still stop), hand the abandoned task to
ObserveAbandoned, meter outcome=timeout, log the now-honest Warning, and throw
OperationCanceledException — the same exception the authenticator's catch-all
already maps to the standard opaque denial. Fail-closed is unchanged: nothing
here can admit.

ObserveAbandoned disposes the CTS and TOUCHES finished.Exception (the observation
itself), so a late SqlClient fault can never become an UnobservedTaskException.
Debug, not Warning — the timeout is already reported at Warning, and an outage
must not produce two lines per activation for one event.

An abandoned answer is NEVER cached: it was taken before the deadline and would
be served after it, so a key revoked or expired in the meantime could be admitted
from a lookup nobody was still waiting for. StoreTimeoutMs <= 0 still
short-circuits to the unbounded call byte-for-byte (the documented rollback).

RED BEFORE GREEN. Every pre-existing deadline test drives GatedApiKeyStore, whose
waits take the token, so a cooperative CancelAfter unblocks them and all 15
passed against the defective build — the suite could not see the gate's failure
at all. The new UncooperativeApiKeyStore ignores ct entirely and completes only
when the test releases it. Against the unmodified store the three new tests fail
3/18 with "should throw OperationCanceledException but threw TimeoutException"
under a test-side WaitAsync(2 s), so the red is a failure and not a hang — and
that exception swap IS the finding restated as an assertion.

Host.Tests 137/137.
2026-08-20 13:29:21 -04:00
Joseph Doherty 62515f3279 fix(security): pin the cached-key expiry guard, meter cache_hit after it, and close two wiring gaps (review fixes) 2026-08-20 10:18:52 -04:00
Joseph Doherty a4f12bccef fix(host): run monitored-item revalidation on a low-frequency timer (archreview 03/S19) 2026-08-20 09:55:01 -04:00
Joseph Doherty bd80ff96ca feat(security): AclRevalidationTimer — clock-driven monitored-item revalidation (archreview 03/S19) 2026-08-20 09:46:56 -04:00
Joseph Doherty 39a10a277a fix(security): wrap the ConfigDb key store on admin nodes + registration guard 2026-08-20 09:45:08 -04:00
Joseph Doherty 6048257dfe feat(observability): meter bounded API-key store lookups 2026-08-20 09:43:07 -04:00
Joseph Doherty 37288d4df5 feat(security): short TTL read-through cache in front of the ConfigDb key store 2026-08-20 09:40:56 -04:00
Joseph Doherty 3b56e5bd01 fix(security): bound the API-key store call under the SessionManager lock (archreview 03/S18) 2026-08-20 09:38:41 -04:00
Joseph Doherty 894444376e feat(security): register TimeProvider + S18/S19 option knobs (archreview 03/S18,S19) 2026-08-20 09:36:14 -04:00
Joseph Doherty c8b5c997b2 fix(logging): #561 was a measurement artifact — measure sink growth through an open handle
v2-ci / build (push) Successful in 4m34s
v2-ci / unit-tests (push) Failing after 16m39s
Gitea #561 ("production writes a 0-byte log") is closed as not-a-defect. The
production log on wonder-app-vd03 listed as 0 bytes with LastWriteTime ==
CreationTime; opening it showed 4,658,893 bytes of correct output, written
continuously since service start.

On Windows the directory entry's size and last-write time are not refreshed while
the writing process holds the file open, so Get-ChildItem, dir and FileInfo.Length
all report size-at-open for the life of the handle. A live, healthy log is
indistinguishable from a dead sink by metadata alone — which is what made the
false reading survive six weeks of investigation.

That same trap was in the 29a202f5 instrumentation: CheckFileSinkGrowth measured
with FileInfo.Length, so it would have reported "self-check FAILED: the sink did
not grow" at every boot on a perfectly healthy Windows service — and a
self-check's false positive reads as confirmation of the very bug it was added to
find. MeasureFamilyBytes now reads Length through an opened FileStream:
FileShare.ReadWrite to open it at all while the sink holds its write handle, and
FileShare.Delete so the probe never blocks a roll or retention delete.

Both instruments are kept. A sink that cannot write its file is still a real and
otherwise-invisible failure — a sibling repo reproduced it via handle contention,
where RollingFileSink logs the lock-out to SelfLog and falls through to _001. Only
the two doc claims that are now false are corrected: the class summary's
"production ran that way for six weeks" and the warning text's "#561 failure mode".

Tests: A_working_file_sink_passes already measured while Serilog held the file
open — precisely the broken case — and passed throughout, because it only ever ran
on macOS, where the length is honest. That platform blindness is now recorded in
the class summary, alongside two tests pinning the sharing contract the fix
depends on.
2026-08-12 02:09:23 -04:00
Joseph Doherty b037cc7ac4 chore(secrets): adopt ZB.MOM.WW.Secrets 0.6.2 — fail-closed store-path validation
v2-ci / build (push) Successful in 4m42s
v2-ci / unit-tests (push) Failing after 15m19s
Four-minor jump 0.2.3 → 0.6.2. The release line exists because a deploy
renaming the app directory took the MxGateway credential store with it
(2026-08-09, two silent days): Secrets:SqlitePath must now be absolute AND
outside the content root, rejected at boot rather than relocated. 0.6.2 over
0.6.1 is message-only (the rejection text now teaches the mounted-volume
case and forecloses the override hunt) — the live rig evidence below was
gathered at 0.6.1 and the validator behavior is identical.

Adoption changes beyond the pins:

- appsettings.json drops the relative "otopcua-secrets.db" (now boot-fatal);
  dev gets the library's absolute per-user default, deployments supply their
  own (wonder already pins an absolute out-of-root path in the service env).
- Program.cs's pre-host ${secret:} expander now calls the FOUR-argument
  AddZbSecrets with builder.Environment.ContentRootPath. This call site is
  the actual fix, not the pin: the throwaway container has no
  IHostEnvironment, and without the explicit root the under-content-root
  rule is skipped at exactly the moment the migrator creates the store file
  (the 0.6.0 defect). Do NOT simplify back to the 3-argument overload — it
  compiles and silently reopens the gap. Pinned by a Program.cs source-scan
  test AND a runtime falsification control that RUNS the migration and
  demonstrably creates the store at the rejected path under the 3-arg form.
- docker-dev: Secrets__SqlitePath moves /app/data → /data via a SECOND mount
  of the same per-node volume (same file at the volume root, so seeded
  stores carry over) because /app is the container's content root and the
  new rule correctly rejects it. LocalDb stays on /app/data (secrets-only
  rule).
- SecretsStorePathValidationTests: positive-control probes, artifact-first
  (relative and in-content-root paths must FAIL leaving no file; outside-
  root passes; the no-explicit-root control proves the file assertions can
  fail) — a clean boot is equally consistent with the validator never being
  wired.

Live-verified on the six-node docker-dev rig (at 0.6.1): all nodes boot
under the new validation with stores at /data; an AdminUI-written secret
converged into BOTH central nodes' independent stores; site-a held only its
own pair's secrets (Phase 6 pair-local isolation intact). Replicator wire
behavior unchanged 0.3.0–0.6.x per CHANGELOG; registration-graph pins and
the full suite pass.
2026-08-11 09:06:43 -04:00
Joseph Doherty 29a202f5ee fix(logging): #561 — SelfLog to file+stderr and a startup file-sink growth self-check
v2-ci / build (push) Successful in 6m38s
v2-ci / unit-tests (push) Failing after 17m30s
Production ran six weeks with a healthy process and a 0-byte log because
Serilog isolates per-sink failures: a file sink whose every write fails is
invisible from anywhere but SelfLog, which was not enabled. Two instruments:

- LogPipelineDiagnostics.EnableSelfLog (before the first logger builds):
  SelfLog to logs/serilog-selflog.txt (rotated at 5 MB) + stderr — the only
  channel that carries the exception the sink swallows.
- CheckFileSinkGrowth (after the startup banner): verifies each configured,
  unbuffered file sink's rolled-file family physically grew across a marker
  event; a silent sink is reported at boot via SelfLog/stderr and the
  console sink instead of discovered months later. Growth-across-marker,
  not existence — a previous run's bytes must not mask a silent sink (the
  false pass an existence check would have given on wonder-app-vd03).

Never throws: both instruments swallow their own failures — a diagnostics
channel that can fail startup is worse than the defect it watches for.

The load-bearing test is the failure case (a sink that writes nothing IS
reported); a check that can only pass is indistinguishable from no check.
2026-08-11 05:52:54 -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 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 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 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 48dc2120c6 fix(mesh): gate follows the endpoint not the mode + review fixes
The known-cluster gate published only under Mode=Grpc, but the
interceptor it feeds is registered on every admin node and 1B maps
CentralCommandService by LISTENER PORT — neither consults the mode. A
dark-switch rollback to ClusterClient or Dps would therefore have left
that endpoint serving with the gate permanently inactive, silently
restoring both the timing oracle and the pre-auth secret-store/audit
flood, as a side effect of a rollback nobody would connect to
authentication. The gate is central's cluster inventory, not transport
state, so it is now decoupled from the mode entirely:

- SetKnownClusters + InvalidateAll run on EVERY successful contact load
  in EVERY mode, and the contact-refresh timer starts in all three.
  Under Dps this is a ConfigDb query every ContactRefreshSeconds that
  did not run before; central always has a ConfigDb, and one indexed
  read a minute is the cheaper half of the trade.
- The GATE set is built from ALL ClusterNode rows. The eligibility
  filter moved out of the query's WHERE and into the loop, so the FAN
  set is byte-for-byte what it was (enabled && !maintenance) while the
  gate widens. This also dissolves the maintenance coupling flagged in
  1A.2: a cluster whose nodes are all disabled or in maintenance can
  still ack, and refusing it would make maintenance mode an
  authentication switch. Central still refuses clusters it has never
  heard of.
- HandleContactsLoaded keeps a documented order — targets adopted, gate
  published, cache dropped — now pinned by test.

Guards: PreStart mirrors the null-dialer error for a null PSK provider
(silent absence is the worst outcome there — nothing malfunctions, so
nothing is investigated, while the gate never publishes and keys never
rotate); a failed contact load now says out loud that the gate is stale
and, before any load has succeeded, inactive; MeshGrpcCommandDialer
clamps a non-positive DispatchTimeoutSeconds to 10 with a construction
Warning, because the validator for that key is driver-role-scoped and a
pure-admin central reads it unvalidated; the dialer's local PSK refusal
was shortened so its remediation survives the actor's 200-char
truncation of remote-authored status detail; the portless-node warn-once
set is rebuilt each refresh, so a node that regains then loses a port
warns again — the regression is the occurrence worth seeing.

Docs/pins: dialer disposal comment no longer claims one-way; the two
interceptor-test comments claiming Matches has a default implementation
corrected (it deliberately has none); new pins for per-refresh
invalidation, the SetKnownClusters-before-InvalidateAll order, and one
channel per endpoint via a new CachedChannelCount.

The_known_cluster_set_is_not_published_by_the_other_two_modes is
replaced — its premise is obsolete — by three facts: every mode
publishes, gate set is a strict superset of the fan set, and the handler
ordering.

ControlPlane.Tests 230 -> 236, Host.Tests 92, Cluster.Tests 184,
0 warnings.
2026-08-06 06:44:20 -04:00
Joseph Doherty 14d21496fe feat(mesh): known-cluster gate + Matches on the PSK provider
Phase 1A security register items 1 and 2, landed ahead of the command
fan-out because they are independently testable and the fan-out is the
first caller of TryGetPsk.

Item 1 — known-cluster gate. MeshPskProvider gains SetKnownClusters, an
atomic whole-set swap consulted by TryGetPsk BEFORE the config map and
before the secret store. A cluster id outside the set is refused in O(1)
with no store read and no audit write, which closes two things at once:
the pre-auth store/audit flood an unauthenticated caller could drive with
a forged x-otopcua-cluster header, and the timing oracle that defeated
CentralCommandAuthInterceptor's uniform denial text (unknown clusters are
never cached, so they paid a store read on every probe while known ones
answered from memory). Empty or never-published leaves the gate inactive
— central publishes the set from its DB contact refresh, and 'the DB told
me nothing' must not become a fleet-wide authentication outage during
admin boot.

Item 2 — bool Matches(clusterId, presented) on IMeshPskProvider, with
FixedTimeEquals inside the provider. CentralCommandAuthInterceptor now
calls it and never holds key material; its two provider-side denial logs
collapse into one (the provider answers both as false) and the operator
hint is restated there.

No production caller publishes a cluster set yet, so behaviour is
unchanged until the fan-out commit wires the contact refresh.

ControlPlane.Tests 190 -> 205, Host.Tests 91 -> 92.
2026-08-06 05:31:08 -04:00
Joseph Doherty 64774652b7 feat(mesh): NodeCommandService on the telemetry listener 2026-08-06 05:14:12 -04:00
Joseph Doherty 24e55d3395 fix(mesh): Batch B review fixes — bounded store read, cache poison guard, test hardening 2026-08-06 04:10:03 -04:00
Joseph Doherty 68a416e4fc feat(mesh): per-cluster PSK provider + central interceptor 2026-08-06 03:44:23 -04:00
Joseph Doherty fbda015c6e feat(mesh): node-side PSK interceptor 2026-08-06 03:37:02 -04:00
Joseph Doherty f692f6afe4 feat(security): enforce node ACLs on write, alarms and HistoryRead (#520 stage 5)
`Security:Acl:Enabled` (default false) makes scoped `NodeAcl` grants decide
data-plane authorization at the three seams that have one, and gives HistoryRead
a check it never had. Read, Browse and Subscribe stay ungated — stage 6.

The ACL decision REPLACES the server-wide role check rather than chaining with
it. Chained, a grant would have to confer the role as well as pass the scope
check, and only Cluster-scoped grants confer a role — so every narrow grant
would have stayed inert and the flag would have looked inert too. The
load-bearing test is an ALLOW: a session carrying no roles at all writing
successfully on the strength of its grant. A deny-only test passes against a
build where the ACL is never consulted, because the role gate denies too.

Found en route: the principal never reached the session. The authenticator
passed it as OpcUaUserAuthResult.DisplayName, but RoleCarryingUserIdentity
derives DisplayName from the presented token — for a key-bearing client that is
the static username `apikey`, identical fleet-wide. So the trie's matching key
was dropped at the identity boundary, and the audit ClientUserId on a rejected
write recorded `apikey` for every consuming system. Principal and ApiKeyId are
now carried explicitly.

Beyond the stage as sketched:

- Revocation reaches an OPEN session. Every decision re-checks the
  authenticating key against the current snapshot; without it a key revoked by a
  deployment kept its authority until the client chose to reconnect, which for a
  machine-to-machine consumer can be indefinitely.
- UserAuthorizationState is built per CALL, not per session. The per-session
  shape exists so an LDAP session can re-resolve membership that changed out of
  band; nothing can change out of band under API keys, so the generation stamp is
  the whole invalidation signal and a per-call record picks it up for free.
  IsStale/NeedsRefresh therefore cannot fire here and are documented as vestigial.
- The trie is built inside DataPlaneAuthorityState.Apply, under the same
  generation as the snapshot, rather than by a second wiring at the publish
  actor. An uninstalled trie denies everything — indistinguishable from working
  enforcement. Deleting the install turns 13 of the 22 new tests red.
- The deploy log reports what the BUILD dropped instead of re-deriving it. The
  old count agreed with the builder on one of its four drop reasons; a rule with
  a blank ScopeId, a retired scope kind, or a path not starting at a realm
  vanished with the log reading zero.

Both IApiKeyStore implementations widened to the full grant set. The narrowing
was redundant — ApiKeyInterimRoleMapper applies the same filter itself — and
became a trap once scope was enforced. Pinned by asserting the roles are
unchanged, with the two grants carrying different flags so it cannot pass by
accident.

Falsified by mutation: enforcement never engaging, the write seam ignoring the
ACL, the realm dropped from the scope, the revocation check removed, and the
trie never installed each turn the intended tests red and no others.

Full solution suite green.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-08-01 05:00:28 -04:00
Joseph Doherty 3a6b2a1daf feat(acl): variable-depth scope model + the scopePaths producer (#520 stage 3)
Makes the /raw device tree addressable by an ACL rule, and builds the
scope-path producer PermissionTrieBuilder has accepted since it was written
but which had no caller anywhere.

The plan for this stage said "add NodeHierarchyKind.Raw, whose path is the
RawPath segments Folder/Driver/Device/TagGroup/Tag". That shape is wrong.
RawFolder.ParentRawFolderId and TagGroup.ParentTagGroupId are both
self-referencing, so folders and tag groups each nest without bound and a real
RawPath is folder…/Driver/Device/group…/Tag. NodeScope was fixed-arity and
PermissionTrie.CollectMatches was five hardcoded steps, so the scope model
could not address a real raw tree at all. NodeScope is now {ClusterId, Kind,
Segments} and the walk is a segment loop. The trie STRUCTURE was already
variable-depth (NodeAclPath.Segments is a list, Children is a recursive
dictionary), so only the walker and the scope record had to change.

Four decisions worth keeping:

- The namespace level became the realm. Level 1 gated on NodeScope.NamespaceId,
  but v3 has no Namespace entity — the snapshot was retired when the address
  space went dual-namespace, and the identifier appears nowhere in production
  outside NodeScope. So the level could only ever fail to match, which silently
  made every sub-cluster grant unreachable while cluster grants kept working.
  It is now a realm token derived from the scope's Kind, not carried on it, so
  the walk and the producer cannot disagree about its spelling.
- Provenance comes from the granting row's own ScopeKind, recorded on TrieGrant
  at build time. Depth-derived provenance could not name a raw level at all,
  since the depth of "Device" varies with folder nesting.
- The trie comparer moved from OrdinalIgnoreCase to Ordinal. Raw sibling-name
  uniqueness is ordinal, so Speed and speed can both exist under one device and
  a grant on either would have matched both.
- The null-scopePaths "deterministic test mode" is deleted. It planted
  unresolvable rows at a direct child of the trie root, a position the walker
  cannot reach, so the grant looked placed and could never match — and every
  test using that mode proved nothing about production placement. scopePaths is
  now required, and a row that does not resolve is dropped with a diagnostic.

A scope resolves to a LIST of paths: a raw tag projected into N equipments is
reachable at N+1 NodeIds and a write through any of them lands on the same
driver reference, so a Tag-scoped grant covering only the raw node would be
bypassable through a UNS one. The lookup is keyed on (ScopeKind, ScopeId), not
ScopeId alone — ScopeId has no FK and no type discriminator, so an id collision
across entity types would hand a grant the wrong node's paths.

Also closed here, all found by review rather than asked for:

- NodeAcl rows had no deploy-time validation of any kind and were not reachable
  from DraftValidator at all (DraftSnapshot carried no NodeAcls). They are now
  gated for dangling ids, cross-cluster scopes, blank/contradictory ScopeId, the
  retired FolderSegment kind, and undefined ordinals.
- ConfigDbApiKeyStore conferred roles from Cluster-scoped grants authored in ANY
  cluster, because it filtered on scope kind with no ClusterId predicate while
  ArtifactApiKeyStore read an already-cluster-filtered snapshot. On a fused
  admin+driver node that was a cross-cluster over-grant. Its doc comment claimed
  the two stores matched.
- The two AdminUI ACL banners still said enforcement was LDAP-group -> role
  mapping, which stages 1/2 made false in both directions.
- The /apikeys page was never classified in PageAuthorizationGuardTests, so that
  test has been red since the key-minting commit merged — I did not run
  AdminUI.Tests before merging stage 4.

Falsified rather than assumed: 8 mutations against the trie tests, 5 against the
deploy gate (including one proving a cross-cluster row is not silently
mis-diagnosed as unresolved), a staged red->green for the over-grant regression,
and the realm gate, the comparer and the malformed-path guard each broken
individually to confirm only their own tests go red.

Node ACLs are still not ENFORCED — nothing constructs a NodeScope or calls the
evaluator yet. That is stages 5 and 6; this stage makes the model able to
express what those will enforce.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-08-01 01:07:59 -04:00
Joseph Doherty a438ac1640 feat(acl): keys + ACLs reach driver-only nodes via the artifact (#520 stage 4)
A driver-only node has had no ConfigDb since per-cluster mesh Phase 4, so it
registered NullOpcUaUserAuthenticator and denied every UserName token. Its keys
now ride the deployment artifact instead.

- ConfigComposer snapshots ApiKeys alongside the NodeAcls it already carried.
  Only the peppered HMAC ships, never a secret, and the pepper is node config
  that is deliberately not in the artifact.
- DeploymentArtifact.ParseDataPlaneAuthority decodes both node-side. Grants are
  scoped to the node's own cluster; keys are not, because ApiKey carries no
  ClusterId — a key is a fleet-wide credential and the ACL rows are what bind it.
- OpcUaPublishActor installs them on DataPlaneAuthorityState on every apply,
  BEFORE its plan.IsEmpty early-return: an artifact that only revokes a key
  produces an empty address-space plan, so feeding after that return would leave
  the revoked key working until an unrelated structural change redeployed.
- ArtifactApiKeyStore serves them to session activation; a node with a ConfigDb
  keeps ConfigDbApiKeyStore, whose per-request read is what makes revocation
  immediate there.
- OtOpcUaNodeManager gains DataPlaneAuthority — the cluster identity blocker #4
  named. NodeScope.ClusterId is required and the evaluator fails closed on a
  mismatch, so until now no scope it built could have evaluated. Nothing reads it
  until stages 5/6.

The generation is a node-local COUNTER driven by the RevisionHash, not derived
from its bits. PermissionTrieCache treats the highest GenerationId as current, so
a generation taken from hash bytes would install a lower number about half the
time and the cache would go on serving the previous deployment's ACLs, silently.

Sessions are not stamped with it yet — that belongs with the first seam that
reads a UserAuthorizationState, in stage 5, rather than being built now and
consulted by nothing.

Live-gated on docker-dev against site-a-1 (driver-only, no ConfigDb): key
authenticates, wrong secret denied, both halves of the revocation asymmetry
observed, and a SITE-A grant confirmed not to reach site-b-1. Key minted with an
independent Python HMAC so the verifier was checked against something other than
itself. Rig left clean.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-07-31 14:37:18 -04:00
Joseph Doherty dd939d3c97 feat(acl): anonymous becomes a normal grantable principal (#520 D3)
Anonymous sessions used to short-circuit in HandleImpersonation — "let the SDK's
default validation stand" — so they always connected and always carried no roles.
Now admission and authority are separated:

  admission  -> OpcUa:AllowAnonymous, the HOST's decision (it owns token policies)
  authority  -> the authenticator, resolving the reserved principal "anonymous"
                through the SAME grant path a key's principal goes through

So anonymous can be granted WRITE, not merely tolerated as read-only, and it
arrives at the downstream gates as the same RoleCarryingUserIdentity a key
produces — which is the real payoff: no gate carries an anonymous-shaped branch,
so there is no bypass path to get wrong.

AllowAnonymous defaults to TRUE on purpose. Defaulting it false would silently
deny every client without a key the moment this deploys, including ScadaBridge's
Data Connection Layer in another repo. Making keys mandatory is a rollout
decision, not an upgrade side effect. When false, the Anonymous token policy is
withheld so a client is refused at DISCOVERY rather than only at activation, and
activation is rejected independently in case a client attempts a token type the
endpoint never advertised.

Two fail-closed choices, both falsified:
- A failure to read anonymous's grants DENIES rather than admitting with no
  roles. Admitting is the pre-#520 behaviour, and defaulting to it exactly when
  the server cannot determine what anonymous may do stops being harmless once
  reads are gated. Making it admit reds Anonymous_denies_when_grants_cannot_be_read.
- A key may not authenticate as the reserved "anonymous" principal, or a
  credentialled client silently inherits whatever anonymous was granted.

AuthenticateAnonymousAsync is a DEFAULT interface implementation returning
admit-with-no-roles, i.e. exactly the pre-#520 behaviour, so
NullOpcUaUserAuthenticator and every test double keep working rather than being
forced to reimplement it — important because driver-only nodes run the Null one
and would otherwise black out. Note a default interface method is only reachable
through the interface-typed reference, which is how OpcUaApplicationHost holds it.

An existing test asserted the old fall-through and correctly went red; it is
rewritten to pin the new contract rather than deleted, with the reason recorded.

OpcUaServer 366, Host 42 green.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-07-31 13:20:14 -04:00
Joseph Doherty 0329fa4d4b feat(acl): API-key identity replaces LDAP on the OPC UA data plane (#520)
Stage 2a. Clients present the static username "apikey" with an
otua_<keyId>_<secret> credential as the password; the key resolves to a
principal. LDAP registrations are deliberately RETAINED for AdminUI login —
only IOpcUaUserAuthenticator moves, which is what D2 asked for.

The load-bearing decision here is what roles a key gets. The two live gates
(WriteOperate on Value writes, AlarmAck on Part 9 methods) check a SERVER-WIDE
role set, so the cutover has to keep producing one or it would lock every client
out of writes long before per-node evaluation lands in stage 5.

ApiKeyInterimRoleMapper therefore maps ACL grants to roles, but ONLY
cluster-scoped ones. The obvious version — union the permission flags across all
of a principal's grants — would let a grant authored narrowly ("WriteOperate on
Equipment X") confer server-wide write authority, because nothing in the pipeline
can enforce scope yet. That fails in the dangerous direction: the operator's
narrow intent silently becomes broad. Restricting to cluster scope under-grants
instead, which is no worse than today (where every ACL row does nothing) and can
never exceed what was authored. Falsified: deleting the scope check reds exactly
A_narrower_scoped_write_grant_confers_nothing_yet and nothing else.

Verification is one indexed lookup plus one HMAC, with no network I/O at all.
That matters because the SDK raises ImpersonateUser synchronously while holding a
SessionManager-wide event lock, so this path does not stall one request — it
serializes every session activation server-wide. It is the main practical gain
over the LDAP bind it replaces.

Every rejection returns one opaque message so the server cannot be used as an
oracle for whether a key id exists or which half of a credential was wrong; a
test asserts the failure modes really are indistinguishable AND that a valid key
still succeeds, so it cannot pass by denying everything.

Caught while wiring: OtOpcUaServerHostedService takes IOpcUaUserAuthenticator as
a REQUIRED ctor parameter, so leaving a driver-only node unregistered is not a
quiet fallback to deny-all — it fails DI and the node does not boot.
NullOpcUaUserAuthenticator is now registered explicitly there. A driver-only node
has no ConfigDb since Phase 4 and so has no key source until the artifact-backed
store lands in stage 4; it denies UserName tokens until then, which is the safe
direction, and anonymous sessions are unaffected.

LdapOpcUaUserAuthenticator is now registered nowhere and is dead code. Deleting
it plus LdapOpcUaUserAuthenticatorTests / LdapAuthResilienceTests /
LdapGlauthOutageLiveTests is stage 2b, kept separate because those cover a
live-gated resilience feature (hard wall-clock bound, bounded concurrency,
directory-outage circuit) that exists precisely BECAUSE the LDAP bind ran inline
on session activation — a constraint the key path does not have. That deserves
its own commit, not a footnote in this one.

Anonymous-as-a-grantable-principal (D3) is also stage 2b.

Host 37, Security 89, OpcUaServer 364 green.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-07-31 12:57:51 -04:00
Joseph Doherty 30d0697c28 feat(hosts): surface per-host connectivity on /hosts; drop the unwritable table (#521)
`IHostConnectivityProbe` was a dead surface: eleven drivers implement it, `GetHostStatuses()`
had ZERO production call sites, and `OnHostStatusChanged` had no subscriber outside the Galaxy
driver's own aggregator. Per-host connectivity was computed by every driver and read by nobody.

The issue offered "build the publisher or delete it". The publisher as its entity doc described
it — driver nodes upserting `DriverHostStatus` rows — is not buildable: per-cluster mesh Phase 4
gates `AddOtOpcUaConfigDb` on the `admin` role, so a driver-only node has no ConfigDb connection
to write rows with. So the capability is kept and the transport changed.

`DriverHealthChanged.HostStatuses` now carries the probe result to `/hosts` as a Hosts column.
That channel already reached the page, already survives the mesh split via the Phase 5 gRPC
telemetry stream, and already replays a last-value snapshot on re-subscribe — so per-host state
re-primes after a reconnect without a durable store. Both halves of the interface finally do
what they are for: the event triggers a prompt publish, the pull is the source of truth.

The point of the column is the case the driver-level state chip structurally cannot express: a
multi-device driver stays aggregate-Healthy while ONE of its devices is unreachable.

Two traps, both pinned by tests that were falsified against the prod code:

- The host digest MUST be in the publish fingerprint. On a single-host-down transition every
  other fingerprint component is unchanged, so the dedup would swallow exactly the publish
  carrying the news — the trap that already bit the rediscovery signal. Removing it turns the
  guard test red, verified.
- null (no probe) must stay distinct from empty (probe with no hosts). proto3 cannot tell an
  absent repeated field from an empty one, hence the explicit `has_host_statuses` flag;
  collapsing them would render every probe-less driver as one whose devices are all fine.

Dropped: the DriverHostStatus entity, enum, DbSet, model config and table (migration
DropDriverHostStatusTable — empty on every deployment, so the scaffolder's data-loss warning is
moot, and Down() recreates it exactly).

Found en route, NOT fixed here: `DriverInstanceResilienceStatus` is the identical defect — no
writer, no reader, only a DbSet declaration, while the live data rides the
`driver-resilience-status` telemetry channel. Its doc-comment now states that rather than
describing the sampler and AdminUI join that were never built. Filed as #524 rather than widening
this schema change beyond what was asked.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-07-30 04:21:08 -04:00
Joseph Doherty 88adec047b refactor(health): adopt the shared active-node check (Health 0.3.0)
ClusterPrimaryHealthCheck was written three days' worth of debugging ago because
the shared ActiveNodeHealthCheck selected by RoleLeader and reported Healthy for
any node lacking the role. Health 0.3.0 fixes the shared check — oldest Up
member, role-preference scoping, Unhealthy when the node owns no active work —
so the private copy is now duplication rather than a workaround, and it is
deleted.

RolePreference [admin, driver] reproduces exactly what it did: a fused central
node answers for admin (where the singletons and the AdminUI are pinned), a
driver-only site node answers for driver.

SelectOldestUpMemberOfRole now delegates to the shared ClusterActiveNode instead
of re-implementing the age ordering. This is the point of the change rather than
a tidy-up: the redundancy snapshot drives the OPC UA ServiceLevel 250/240 split
while the health tier drives Traefik's admin routing, so two copies of "oldest Up
member of a role" would be two chances to advertise one node as authoritative
while gating the data plane on another. ControlPlane takes a ZB.MOM.WW.Health.Akka
reference for it; the layering trade-off is recorded at the PackageReference.

Behaviour note: a node carrying neither admin nor driver now answers 503 on the
active tier instead of 200. That case is reachable — RoleParser admits dev-only
and cluster-role-only nodes — and 503 is correct, since such a node owns no
active work and must not be in an active-tier pool. No docker-dev node is
affected: every rig node is admin+driver or driver-only.

Tests replaced in kind. The rule itself is now pinned in the library against a
real two-node cluster; what stays OtOpcUa's own decision is the wiring, so
ActiveTierRegistrationTests pins which check is on the active tag, that it is
registered on driver-only nodes too, and that it is not on the ready tier.

Verified: Host.Tests 25/25, ControlPlane redundancy 15/15.
2026-07-24 13:21:11 -04:00
Joseph Doherty 4550486144 fix(health): active tier reports the cluster Primary, not the admin role leader
Closes the remaining half of #494, and corrects the half fixed in 8dd9da7d.

The shared ActiveNodeHealthCheck(role: "admin") answered the wrong question twice
over. It returns Healthy for any node LACKING the role, so all four driver-only
site nodes called themselves active and no consumer could find the Primary of a
site Cluster. And it selects by RoleLeader - the lowest-ADDRESSED member - which
is not where Akka places singletons.

Replaced with ClusterPrimaryHealthCheck ("cluster-primary"). One rule: this node
is active iff it is the OLDEST Up member carrying its own active role, where the
active role is admin when the node has it and driver otherwise. That serves both
consumers correctly - a fused admin node answers for admin, so Traefik pins the
AdminUI to the node hosting the singletons; a driver-only site node answers for
driver, which is exactly SelectDriverPrimary, the same election behind
IsDriverPrimary and the OPC UA ServiceLevel 250/240 split. Per-mesh scoping is
free after Phase 6: ClusterState.Members already contains only this node's own
Cluster.

SelectDriverPrimary is generalised to SelectOldestUpMemberOfRole so the
age-ordering rule has one implementation. Two copies of "oldest Up member of a
role" would be two chances to silently disagree about who is in charge, and the
tier and the redundancy snapshot must never disagree.

THE ROLE-LEADER HALF MATTERED IN PRACTICE, not just in theory. On the rebuilt rig
the two orderings diverge right now:

  akka leader (lowest address) = central-1
  oldest admin member          = central-2   <- hosts the singletons

8dd9da7d made the tier a real 503 but still selected by RoleLeader, so it would
have pinned Traefik to central-1 - the node NOT hosting the work. With this change
Traefik correctly routes to central-2.

Live-verified, exactly one 200 per mesh:

  MAIN     central-2 200   central-1 503     traefik: central-2 UP, central-1 DOWN
  SITE-A   site-a-1  200   site-a-2  503
  SITE-B   site-b-1  200   site-b-2  503

Tests: role-selection matrix and the startup-safe Degraded path in Host.Tests
(26 pass); parity between the tier's selector and the redundancy snapshot's, plus
role scoping, added to RedundancyPrimaryElectionTests, which forms a real two-node
cluster deliberately built so the oldest member is not the lowest-addressed one
(5 pass).
2026-07-24 12:43:27 -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 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