Commit Graph

3952 Commits

Author SHA1 Message Date
Joseph Doherty e46c1db5e5 fix(cluster): gossiped-role predicates compare case-insensitively — a miscased peer no longer un-elects the Primary 2026-08-20 10:04:16 -04:00
Joseph Doherty a9b592aeb8 docs(security): correct revocation latency (activation vs open session) + S18/S19 knobs 2026-08-20 10:04:03 -04:00
Joseph Doherty 04019e9414 feat(runtime): publish actor routes status-only batches to the sink 2026-08-20 10:03:25 -04:00
Joseph Doherty dfab7f614d test(integration): key expiry stops a live subscription over the wire, with no deploy 2026-08-20 10:00:52 -04:00
Joseph Doherty ec5b48bfcd feat(opcua): IOpcUaAddressSpaceSink.WriteStatuses — status-only batch write retaining the node value (03/S15) 2026-08-20 10:00:47 -04:00
Joseph Doherty 2b9a397b2d fix(runtime): re-provision a driver child after a supervision restart (archreview 03/S6, S16) 2026-08-20 09:56:05 -04:00
Joseph Doherty 6874a562f3 test+docs(mqtt): end-to-end Bad-then-identical-replay recovery 2026-08-20 09:55:11 -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 d97e441979 test(runtime): operator restart re-subscribes the respawned child (archreview 03/S16, U6)
Seen RED against a T3-reverted build: all 4 failed, Created[1].SubscribeCount == 0
on the headline case. 20x isolation run green after the fix was restored.
2026-08-20 09:53:38 -04:00
Joseph Doherty b7d20d14a8 fix(mqtt/sparkplug): initial push on subscribe + BadNoCommunication on connection loss 2026-08-20 09:52:15 -04:00
Joseph Doherty 1759f60476 docs(plans): mark R3-01 T3 done 2026-08-20 09:51:43 -04:00
Joseph Doherty e3cf363703 fix(host): LocalDb sync endpoint opts out of the cookie fallback via an empty-prefix anon group
MapZbLocalDbSync returns IEndpointRouteBuilder, not an IEndpointConventionBuilder,
so .AllowAnonymous() cannot be chained onto it the way the other four mesh maps
are (T2). app.MapGroup(string.Empty).AllowAnonymous().MapZbLocalDbSync() carries
the same convention while leaving the gRPC route pattern byte-identical -
verified directly (LocalDbSync_AllowAnonymousGroup_LeavesTheRoutePatternByteIdentical).

The census's RealMethodEndpoints filter had to move off a "route pattern starts
with /" heuristic to an exact-route-pattern-set match: inside the empty-prefix
group, grpc-dotnet's own "Unimplemented service"/"Unimplemented method" fallback
endpoints also gain a leading "/", so the heuristic let them back in and
over-counted (caught by DriverOnly_WithASyncPort_AlsoMapsTheLocalDbSyncEndpoint
and LocalDbSync_AllowAnonymousGroup_LeavesTheRoutePatternByteIdentical going red
against the naive filter before this fix).

All five mesh gRPC endpoints now carry IAllowAnonymous (archreview 03/S14, T3).
2026-08-20 09:51:33 -04:00
Joseph Doherty bcec88ef70 fix(cluster): all four role-scoped validators read the authority — 'Driver' no longer skips every driver rule (archreview 01/C-9) 2026-08-20 09:51:23 -04:00
Joseph Doherty b09f78396f test(opcua): pin clock-driven expiry in the revalidation pass; correct the only-trigger remark 2026-08-20 09:50:39 -04:00
Joseph Doherty ac2f8dc801 fix(mqtt): stale observed refs BadNoCommunication on connection loss so the identical replay recovers (05/STAB-18 shape a) 2026-08-20 09:46:56 -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 2f90156408 docs(plans): mark R3-01 T2 done 2026-08-20 09:46:40 -04:00
Joseph Doherty b05ebd6353 fix(host): mesh gRPC endpoints opt out of the AdminUI cookie FallbackPolicy (archreview 03/S14)
.AllowAnonymous() on DeploymentArtifactGrpcService, CentralCommandGrpcService,
TelemetryStreamGrpcService and NodeCommandGrpcService — the four MapGrpcService
maps that are chainable. On a login-enabled admin node the global cookie
FallbackPolicy (RequireAuthenticatedUser) applies to every endpoint without
explicit authorization metadata, gRPC included; the PSK bearer interceptors run
INSIDE the endpoint invocation, strictly after that middleware, so a
correctly-keyed node call was challenged before the interceptor's verdict could
matter. Safe because all four interceptors fail closed on an unset key.

The census now asserts IAllowAnonymous on all four (red before this change,
green after) plus a scope guard proving the opt-out never leaks onto an
unrelated endpoint. MapZbLocalDbSync (T3) is not chainable the same way and is
asserted absent here on purpose, so this test cannot start passing for the
wrong reason once T3 lands.
2026-08-20 09:46:30 -04:00
Joseph Doherty 792e8f422a fix(host): Program.cs derives roles from the one authority, not OTOPCUA_ROLES alone (archreview 01/S-12 = 03/C5) 2026-08-20 09:46:27 -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 7dfd6dcc9f fix(cluster): AkkaClusterOptions.Roles is the EffectiveRoles instance, not a second derivation 2026-08-20 09:43:54 -04:00
Joseph Doherty 6048257dfe feat(observability): meter bounded API-key store lookups 2026-08-20 09:43:07 -04:00
Joseph Doherty ac9a71bcbd docs(plans): mark R3-01 T1 done 2026-08-20 09:43:06 -04:00
Joseph Doherty 6dab72a479 refactor(host): extract MapOtOpcUaMeshGrpcServices (one authority for the five mesh maps)
Behaviour-preserving: Program.cs:743-784 collapses to a single call, same gates,
same order, every existing per-map rationale comment carried over. A new
endpoint-metadata census (MeshGrpcAnonymousMetadataTests) drives the SAME
production mapper across all three role shapes (admin-only / driver-only /
fused), so the guard can never assert a shape production no longer has -
archreview 03/S14, T1.
2026-08-20 09:42:49 -04:00
Joseph Doherty b3a71f6655 test(runtime): recording subscribable driver factory + enabled-driver v3 seed 2026-08-20 09:42:33 -04:00
Joseph Doherty 7af9baab39 fix(mqtt): initial push of cached snapshots on (re)subscribe — the push-model forceRaise (05/STAB-18 shape b) 2026-08-20 09:42:25 -04:00
Joseph Doherty 7702616716 feat(cluster): EffectiveRoles — one role authority with one precedence + two-layer normalization (archreview 01/S-12, 01/C-9) 2026-08-20 09:41:54 -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 faea4ebfca feat(core): InputQuality.WorstOf — the one worst-of-input rule (archreview R3-02 contract) 2026-08-20 09:38:30 -04:00
Joseph Doherty daaeb854d6 fix(runtime): every spawn hands the child its desired subscriptions (archreview 03/S16) 2026-08-20 09:36:52 -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 bdc81a1372 refactor(runtime): one desired-subscription payload authority (_desiredByDriver) 2026-08-20 09:34:49 -04:00
Joseph Doherty 72329eabdd docs(archreview): round-3 plan R3-02 (stale-Good family — one quality contract, four seams); index task count 2026-08-20 09:33:36 -04:00
Joseph Doherty a801e2aefb fix(runtime): comms-loss grace is per connection — max(LastSuccessfulRead, connectedSince) (archreview 03/S17) 2026-08-20 09:32:30 -04:00
Joseph Doherty dd7f495432 docs(archreview): round-3 plan R3-05 (ACL hot-path deadline + clock-driven key expiry); index task count 2026-08-20 09:28:53 -04:00
Joseph Doherty 5fd65d190e docs(archreview): round-3 plan R3-03 (restart re-subscribe + watchdog grace); index task count 2026-08-20 09:28:03 -04:00
Joseph Doherty a011a53329 docs(archreview): round-3 tranche-1 plans R3-01 (mesh gRPC AllowAnonymous) + R3-04 (one role authority); 00-INDEX round-3 table with executor model assignments 2026-08-20 09:27:17 -04:00
Joseph Doherty 7c77a8c799 Merge branch 'docs/archreview-r3-rereview' — round-3 architecture re-review at 12b0035a 2026-08-20 09:09:46 -04:00
Joseph Doherty 44f6408e1c docs(archreview): round-3 re-review at 12b0035a — 7 domain reports + 00-OVERALL rewritten in place, STATUS.md round-3 section
Every round-2 finding re-verified against code (73 closed / 31 partial / 97 carried);
matrix 19/28 cells up, one down. New Critical 03/S14 (AdminUI FallbackPolicy 401s every
mesh gRPC map on a login-enabled central; masked on every rig gate by DisableLogin=true).
New Highs 03/S15, 05/STAB-19, 05/STAB-18, 03/S16 — the stale-Good family at four seams.
2026-08-20 09:09:46 -04:00
Joseph Doherty 12b0035a6b Merge branch 'fix/acl-anchor-browse-transit' — ACL browse transit reaches the shared root folder (narrow Browse grants were unreachable by navigation; r3 T7 live-gate finding)
v2-ci / build (push) Successful in 6m24s
v2-ci / unit-tests (push) Failing after 17m36s
2026-08-18 10:02:18 -04:00
Joseph Doherty f679b2f10a docs(plans): annotate the r3 register — browse-transit-below-realm-root closed by 89630ee4+ff9d617a 2026-08-18 10:01:48 -04:00
Joseph Doherty ff9d617a1b docs(acl): correct the Browse override's 'two namespace root folders' remark — one shared anchor
The XML remark above OtOpcUaNodeManager.Browse still carried the exact claim the
security.md correction in 22ae1836 removed: there is ONE shared root folder
(V3NodeIds.SharedRootFolderId, raw namespace) fronting both realm trees, not one
per namespace. Rewritten to name the anchor, the ns=0 listing behaviour, and the
zero-segment realm-root mapping that now admits transit holders.

Also completes the BuildScope collision remark (code-review Minor): the degenerate
raw folder literally named OtOpcUa loses the accidental grant leak onto the
anchor's Read/Write that the old one-segment scope allowed — a narrowing,
fail-closed side effect, now recorded.

Comment-only change; addresses the code review's sole Important + Minor.
2026-08-18 09:59:03 -04:00
Joseph Doherty 22ae18365b docs(acl): record the shared-root-folder browse-transit mapping
CLAUDE.md's "Browse needs ancestor visibility" bullet and docs/security.md's
ancestor-visibility bullet now name the topmost ancestor: the single shared
root folder (s=OtOpcUa, raw namespace) that both realm trees hang under, its
mapping onto the trie's zero-segment realm-root scope, the either-realm transit
(it fronts both trees), and the realm qualification.

Also corrects the adjacent security.md bullet that claimed "the two namespace
root folders remain visible" — there is one anchor, not one per namespace, and
what an ungranted session sees is the ns=0 Objects listing of it rather than a
browse into it. Flagged rather than silently left, since it contradicts the
mechanism the new bullet documents.
2026-08-18 09:45:09 -04:00
Joseph Doherty 89630ee418 fix(acl): browse transit reaches the shared root folder (narrow Browse grants were unreachable)
With Security:Acl:Enabled=true, a session holding any grant narrower than
Cluster could not browse the served address space at all — confirmed by the
live gate.

Mechanism. Both realm trees hang under ONE anchor folder minted in
CreateAddressSpace: NodeId("OtOpcUa", <raw ns>). ResolveParentFolder parents
every top-level raw folder AND every top-level UNS area under it, and the
Browse override throws BadUserAccessDenied for the WHOLE call when its source
node is denied. DataPlaneAuthorizer.BuildScope split that identifier like a
path, producing {Kind: Raw, Segments: ["OtOpcUa"]} — but trie grant paths are
["raw", <RawPath segments…>] and "OtOpcUa" is never a path segment, so
PermissionTrie.HasBrowseTransit failed at the realm-child lookup and
CollectMatches yielded only Cluster-scoped grants. Deny, at the front door.

The trie already handled a zero-segment scope as "the realm root, itself an
ancestor of everything granted in that realm" (PermissionTrie.cs:101-118) —
no production path had ever produced that scope. This wires it up.

- V3NodeIds.SharedRootFolderId names the anchor identity; CreateAddressSpace
  now mints the NodeId/BrowseName/DisplayName from it (behaviour-identical,
  makes the coupling explicit).
- BuildScope maps that identifier (realm-qualified to Raw — in the UNS
  namespace s=OtOpcUa is an ordinary Area) onto the zero-segment Raw scope.
- Evaluate adds a second-realm OR-leg for Browse on the anchor only: it fronts
  both trees, so a principal whose only Browse grant lives in the UNS tree
  still enters through this raw node. Browse only; transit confers navigation,
  never read/write/subscribe.
- No trie, evaluator or builder change. No reordering above the decision cache
  — key revocation is still re-checked before the lookup — and the cache key
  is unchanged (the combined verdict caches under the anchor's own
  identifier/realm; a generation advance clears it).

Falsifications actually run (implement-then-revert-the-line, plus one
genuine red-first):

- DataPlaneAuthorizerTests: the 7 new facts were written BEFORE the fix and 4
  went red (the 3 deny-only ones passed vacuously, as a deny-only test must).
- Deleting the UNS OR-leg reddens exactly one fact,
  The_shared_root_folder_is_browsable_on_a_uns_only_browse_grant (26/27 pass).
- Dropping the realm qualification from IsSharedRootFolder reddens exactly
  A_uns_area_named_like_the_anchor_gets_no_anchor_semantics. Its first draft
  did NOT catch this — a raw-only grant is refused either way; the
  discriminating grant had to be an unrelated UNS one, whose own transit marks
  the zero-segment UNS scope the un-qualified mapping would land on.
- Disabling the BuildScope anchor branch reddens the new end-to-end fact
  NodeManagerStage6EnforcementTests.The_shared_root_folder_is_browsable_on_a_narrow_grant_and_still_filters
  (real booted server, real Browse dispatch: BadUserAccessDenied on the whole
  call), which also pins that the reference filter still withholds the
  non-granted sibling.
- Core PermissionTrieBrowseTransitTests gains 3 characterization facts pinning
  the previously-dead realm-root branch (transit true for the granting realm's
  root, false for the other realm's, false without the Browse bit). These were
  green on arrival — the trie was always correct; nothing called it.

Tests: Core 351 -> 354 passed; OpcUaServer 482 -> 490 passed. Build clean.
2026-08-18 09:45:04 -04:00
Joseph Doherty 698a818cb1 Merge branch 'perf/followups-r3' — r2 register closed (ACL CAS loop, S7 worst-state-wins health, 404 mechanism named) + ScadaBridge rows 38/39 (Roslyn test de-flake, LocalDb conditional registration) + four live gates (FetchAndCache, alarm ratchet, ACL-enabled enforcement, 404 catch)
v2-ci / build (push) Successful in 5m49s
v2-ci / unit-tests (push) Failing after 3h1m31s
2026-08-18 09:11:23 -04:00
Joseph Doherty caf8830bbe docs(plan): r3 ledger finalized + Client.CLI logger-flake register entry 2026-08-18 08:50:11 -04:00
Joseph Doherty c4d6cba7e1 docs(plan): r3 close-out — outcomes, sweep record, register annotations 2026-08-18 08:48:40 -04:00
Joseph Doherty 837d8a17eb test(scripting): timeout under starvation is not a leak; share the exhaustion-reason constant
Review follow-ups to 34829ea0.

IMPORTANT - the leak filter had its own flake vector. This test saturates the
thread pool by design, and TimedScriptEvaluator starts its 5s clock at WaitAsync
rather than when the inner Task.Run gets a thread, so a legitimately-enqueued
evaluation can blow the budget while still queued and surface "script timed out
after 5.0s". The reason != exhaustion filter classified that as a raw disposed
escape and hard-failed on it - handing the test back the load-fragility the
reshape removed. Timeouts are now their own bucket, tolerated and rate-bounded
exactly as exhaustions are; a raw disposed escape still fails on first occurrence.
Doc-comment names this second residual vector alongside the exhaustion calibration.

MINOR (scope extension authorised by review) - "evaluator disposed during
evaluation" existed as three unshared literals. Promoted to
ScriptEvalFailureReasons alongside VirtualTagEvalResult in Commons.Engines (the
assembly all three already reference) and referenced from both evaluators and the
test. The timeout prefix joins it, since the test now classifies on it too. String
values are byte-identical - no behaviour change.

Falsified: with a 12ms script budget the race produced real timeouts (3/2695).
Classified as leaks (the pre-review filter) they hard-failed the test 6/6 items;
bucketed as starvation they sit 0.11% under the 10% bound and the test is green.
2026-08-18 08:05:51 -04:00
Joseph Doherty 3f04a4d4e5 docs(docker-dev): first-POST-404 caught — Traefik entrypoint-before-dynamic-config window + no-body POST binding verified
Live gate on the local OrbStack docker-dev rig (branch tip images, fresh
down -v + up -d --build).

404: did NOT reproduce through deploy.sh on a fresh boot — the readiness
gate passed on its first poll, and compose's depends_on: service_healthy
serialization means up -d returns ~30 s after Traefik starts, so the window
is already closed. Reproduced it directly instead, by rapid-polling :9200
across a traefik container restart: connection-refused -> 404 for ~65-160 ms
-> 200. Body is exactly Go's "404 page not found" (19 bytes) and the access
log records it with an EMPTY RouterName/ServiceName at 0ms, so no router
matched and no backend was dialled. Mechanism: the entrypoint binds before
the dynamic file-provider config is loaded. The competing candidate (health-
checked service with no healthy server) is excluded — that is a matched
router answering 503, and there are zero 503s in the log. Caveat recorded:
the earliest 404s predate the accesslog handler itself and are not logged,
so an empty access log does not prove absence.

No-body POST: verified, not 400. The minimal-API DeployRequest? binds both
a missing body and a Content-Length: 0 body as null; the handler falls back
to createdBy = "rest-api". A bare ./docker-dev/deploy.sh answered 202
Accepted and the Deployment row carried CreatedBy = rest-api.

deploy.sh's header NOTE goes from "unverified" to the observed truth.
2026-08-18 08:00:47 -04:00