Commit Graph

2593 Commits

Author SHA1 Message Date
Joseph Doherty 2e46d0544e Merge docs/phase2-stop-condition: Phase 2 Task 0 STOP condition resolved (does not fire)
v2-ci / build (push) Successful in 4m54s
v2-ci / unit-tests (push) Failing after 9m57s
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-21 03:46:56 -04:00
Joseph Doherty f2049a31d0 docs(localdb-phase2): pre-answer Task 0's STOP condition — it does not fire
Phase 2's recon carries a STOP condition: a BLOB payload column in the legacy
alarm store-and-forward table could not be registered for replication and would
have to become base64 TEXT, sized against the chunk guidance. Resolved ahead of
execution so the answer survives into the Phase 2 session:

- No BLOB. All 8 columns are TEXT/INTEGER; the payload is PayloadJson TEXT NOT
  NULL. No base64 conversion, no chunk sizing needed.
- The executed DDL (SqliteStoreAndForwardSink.cs:657-667) matches the class
  doc-comment at :17-26 exactly — checked because a doc comment is not evidence.
- Payload is bounded by shape: a serialized AlarmHistorianEvent is 10 scalar
  fields, no collections or nesting, so realistic worst case is low single-digit
  KB against a 171 KB guidance.
- The PK IS autoincrement, confirming the plan's prediction that the migrator
  must mint deterministic mig-{node}-{legacyId} ids and the new table needs a
  TEXT GUID PK.
- Also captured for reuse: the drain's covering index (DeadLettered, RowId), and
  the exact legacy column list the migrator's pragma_table_info intersection
  check compares against.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-21 03:46:56 -04:00
Joseph Doherty d218282cd6 Merge docs/track-deferred-items: track deferred work as issues; fix stale limitation
v2-ci / build (push) Successful in 4m38s
v2-ci / unit-tests (push) Failing after 13m22s
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-21 03:37:26 -04:00
Joseph Doherty 5ebf9876a5 docs: file Gitea issues for every deferred item; correct a stale limitation
Deferred work was living in plan docs and a CLAUDE.md banner rather than the
issue tracker, so it was invisible unless you happened to read the right file.
Each item was verified against the current tree before filing — nothing
speculative, nothing already fixed:

- #487 scripted-alarm redeploy recovery (CONFIRMED pre-existing bug). Verified
  still unfixed: ReassertValue exists only for VirtualTags, there is no
  OnAlarmsLoaded equivalent. Carries double-alarm-history risk, hence the
  careful proposed shape (node-only re-assert, never the alerts topic).
- #488 periodic desired-vs-actual subscription reconcile. Records what exists
  today (Connected-entry re-assert + a health-poll tick that never touches
  subscriptions) and why a true reconcile needs a new ISubscribable member
  across 7 drivers, plus the cheap no-interface-change variant to try first.
- #489 raw-tag rename hot-rebind (today: deploy-THEN-recreate).
- #490 absolute-RawPath Monaco tag-path completion.
- #491 continuous-historization live end-to-end + restart-convergence gate.

CLAUDE.md's "KNOWN LIMITATION 1" was STALE and told the reader not to trust a
cutover that has in fact been live-validated (read/write-persist/alarm-send all
green; alarm-readback skipped as a confirmed protocol limitation, not a bug) —
evidence in docs/plans/2026-06-27-otopcua-historian-followups.md. Rewritten as
a passed gate, keeping the recipe for re-running it. Limitation 2 is genuinely
open and now points at #491.

The cross-repo ScadaBridge cutover was already tracked in that repo (its #14
open, #20 closed), so no duplicate was filed; the Batch 4 doc now says so, and
records that the umbrella-index half is done.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-21 03:37:25 -04:00
Joseph Doherty 7808faa7f6 Merge fix/486: fail the apply when the deployment artifact could not be read
v2-ci / build (push) Successful in 12m43s
v2-ci / unit-tests (push) Failing after 12m43s
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-21 03:27:58 -04:00
Joseph Doherty 689fc06e85 docs(486): live-gate the failed-apply fix on docker-dev
Regression check first, since this changes ACK semantics: a normal deploy still
records Applied on all six nodes. Then the same induction as #485 — the node
now records Failed with a reason while the five nodes that read the real
artifact record Applied (pre-fix all six claimed Applied), and the log shows it
staying on the PREVIOUS revision rather than the dispatched one, which is what
lets a retry land. Values kept flowing throughout (59603, current), and the
next real deploy applied normally.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-21 03:27:54 -04:00
Joseph Doherty ef41a43102 fix(drivers): fail the apply when the artifact could not be read (#486)
ApplyAndAck advanced _currentRevision and recorded NodeDeploymentState=Applied
immediately after ReconcileDrivers, which returns null when the artifact could
not be read. So a node that applied NOTHING reported success, claimed a
revision whose configuration it never applied, and — because
HandleDispatchFromSteady short-circuits on a revision match — could never be
healed by re-dispatching that revision. Only a later, different revision
recovered it.

Now a null blob fails the apply: the revision is left where it was,
NodeDeploymentState records Failed with the reason, and the coordinator gets a
Failed ACK. Leaving the revision alone is what makes the retry actually land
instead of being waved through.

This is about telling the truth, not about tearing anything down — the node
keeps serving its last-known-good address space, drivers and subscriptions
throughout (#485).

Tests, RED-first (both verified failing with "should be Failed but was
Applied"): the ACK/revision assertion, plus the one that matters — after a
failed apply, re-dispatching the SAME revision now genuinely applies. Its
recovered artifact adds a second driver precisely so a short-circuited ACK
(which has no side effects) cannot satisfy it.

Four existing tests changed, and both changes are deliberate:

- DriverHostActorTests.SeedDeployment never set ArtifactBlob at all. Its three
  consumers are about the apply/ACK/state machine, not the artifact, and now
  need a genuine apply — so the helper seeds a well-formed artifact declaring
  no drivers. That is what the composer emits for an empty configuration, and
  is precisely what "bytes we could not read" is NOT.
- EmptyArtifact_IsNotCached asserted "the apply still succeeds — an empty
  artifact is a legitimate no-op deployment". That premise is the bug. Flipped
  to Failed; the test's actual subject (nothing is cached) is untouched and now
  holds for two independent reasons.

Closes #486

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-21 03:22:40 -04:00
Joseph Doherty 5627d325eb Merge docs/485-live-gate: live verification of both #485 fixes
v2-ci / build (push) Successful in 4m1s
v2-ci / unit-tests (push) Failing after 8m56s
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-21 03:02:07 -04:00
Joseph Doherty e4397aa84e docs(485): live-gate both #485 fixes on docker-dev
All three guards observed firing in production, on two nodes, with the live
values proving the subscription half rather than just the log line: the Modbus
tag advanced 56278 -> 56336 -> 56354 straight across the induction.

The RED control was not staged. site-a-1 had hit #485 during the PREVIOUS
gate's SQL flapping and had been serving an empty address space for 34 minutes
(PureRemove, removed=17) on the pre-fix image — the bug in its natural habitat.
Rebuilding on the fixed image restored it, and the induction then failed to
reproduce the teardown.

The original trigger was a race, so the gate drives the empty-bytes branch
instead — byte-for-byte what the code sees when the row cannot be read, and the
branch the driver-side guards key on (their throw paths already returned early
before this work). Pausing the target node opens the window deterministically;
both runs measured <1s, well inside acceptable-heartbeat-pause, so no failover
was triggered. Of the six nodes exactly one took the guard path — the other
five read the real artifact and applied it, which is its own control.

Records one pre-existing defect this work does NOT fix: ApplyAndAck advances
_currentRevision and writes NodeDeploymentState=Applied before knowing whether
anything applied, so a node that skipped reports success for a revision it
never applied — and since dispatch short-circuits on a revision match it cannot
self-heal from a re-dispatch. Data plane is unaffected (the node keeps serving
its last good config); it is a reporting/convergence bug. Left out of these
fixes because correcting it changes deploy ACK semantics fleet-wide.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-21 03:02:02 -04:00
Joseph Doherty 5aad1e33de Merge fix/485 driver-side: keep drivers + subscriptions when an artifact reads back empty
v2-ci / build (push) Successful in 4m20s
v2-ci / unit-tests (push) Failing after 9m37s
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-21 02:40:18 -04:00
Joseph Doherty b0c031f09f fix(drivers): don't tear down field I/O when a deploy's artifact reads back empty (#485)
The driver-side half of the same mistake the address-space fix corrected.
ReconcileDrivers and PushDesiredSubscriptionsFromArtifact both read the
artifact as `...FirstOrDefault() ?? Array.Empty<byte>()`. Their THROW paths
already returned early, but empty bytes flowed onwards as a real answer: zero
driver specs, so DriverSpawnPlanner planned every running child for StopChild,
and then an empty desired set dropped each surviving driver's live
subscription handle. A node lost its entire field I/O and still ACKed Applied.

Same rule as the address space: no bytes is no answer, not "a configuration
with no drivers". Nothing legitimate produces a zero-length blob — deleting
the last driver still deploys a JSON document with empty arrays. Both sites
now skip and keep what is running. The two guards are independent because
PushDesiredSubscriptions does its OWN ConfigDb read, so the row can go missing
between them.

Also corrects the ReconcileDrivers doc comment, which claimed an empty blob
made it "effectively a no-op" — with children running it was the opposite.

Tests: DriverHostActorUnreadableArtifactTests, RED-first (verified failing —
after the empty dispatch the driver list was empty). A zero-length ArtifactBlob
reproduces byte-for-byte what the missing-row case delivers, so the race does
not have to be constructed.

Two controls, both load-bearing:
- a READABLE driverless artifact still stops the driver, so the guard keys on
  "the read gave us nothing", not on "fewer drivers than before";
- dropping a driver's LAST TAG still clears its subscription. This one was
  added after the absence assertion was caught passing on a race: the
  unsubscribe is an async self-tell, so with the second guard deleted the test
  still went green. The control observes that same unsubscribe arriving well
  inside the settle window, which is what makes the absence meaningful — with
  the guard deleted the suite now correctly goes RED.

SubscribableStubDriver gains an UnsubscribeCount for that observation.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-21 02:40:14 -04:00
Joseph Doherty c7f5b9cfa3 Merge fix/485: hold the last-known-good address space when a deploy's artifact cannot be read
v2-ci / build (push) Successful in 3m53s
v2-ci / unit-tests (push) Failing after 9m54s
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-21 02:29:43 -04:00
Joseph Doherty 4b0be1335e fix(opcua): hold last-known-good when a deploy's artifact cannot be read (#485)
A transient ConfigDb error while loading a deployment's artifact emptied the
served address space. LoadArtifact caught the exception, logged "rebuild
becomes no-op" and returned zero bytes; those parsed to an EMPTY composition,
which the planner diffed against the live one as a PureRemove and the applier
faithfully executed — 16 nodes gone, while the log claimed nothing happened.

An artifact we could not obtain is not an empty configuration. Nothing
legitimate produces a zero-length blob (an operator who really deletes
everything still deploys a JSON document with empty arrays), so "no bytes" can
only mean "no answer". HandleRebuild now abandons the rebuild on one, leaving
both the materialised nodes and _lastApplied intact so the retry diffs against
what is actually being served. The loader's own log line is now true.

The two cases are logged differently: warn when there is a live address space
being protected, debug when nothing has been deployed to this node yet.

Covered by a RED-first actor test (verified failing: the load error tore down
eq-2's subtree), plus a positive control proving a READABLE artifact that
genuinely drops an equipment still removes it — the guard suppresses teardown
only when the read failed.

Found on the LocalDb Phase 1 follow-up live gate, which induced it by flapping
SQL; that gate's log is the production evidence for the seam.

Closes #485

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-21 02:29:39 -04:00
Joseph Doherty 5fdb5a5a5e Merge fix/localdb-phase1-followups: close both LocalDb Phase 1 live-gate follow-ups
v2-ci / build (push) Successful in 3m35s
v2-ci / unit-tests (push) Failing after 8m51s
- Oplog growth on default-OFF nodes, fixed at the source: the artifact cache
  now skips the store entirely when the pointer already names this
  deployment/revision, the SHA matches the bytes, and the expected chunk count
  is present. Both extra conditions are load-bearing — guard tests go RED
  against a naive pointer-only skip.
- Wiped-node back-fill, fixed upstream in ZB.MOM.WW.LocalDb 0.1.2 + 0.1.3 and
  pinned here.

Live-gated on docker-dev (docs/plans/2026-07-21-localdb-followups-live-gate.md).
The gate found a third defect that offline tests could not have found first,
because it lived behind the one being fixed: once back-fill worked, the rebuilt
node's own writes turned out to go nowhere. It also surfaced one unrelated
pre-existing bug, filed as issue #485.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-21 02:17:51 -04:00
Joseph Doherty 09df440675 docs(localdb): link the live gate's unrelated finding to issue #485
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-21 02:17:32 -04:00
Joseph Doherty f587e7972e docs(localdb): live-gate the two Phase 1 follow-ups; pin LocalDb 0.1.3
The gate closed both follow-ups on the docker-dev rig and found a third defect
that offline tests could not have found first: it lived behind the one being
fixed. Once 0.1.2 made back-fill work, a rebuilt node could be observed writing
for the first time — and its writes went nowhere, because the healthy node kept
the old peer's seq watermark. Library 0.1.1 -> 0.1.2 -> 0.1.3 over the course
of the gate; both consumers now pinned to 0.1.3.

Check 4 (was PARTIAL, now PASS): with SQL stopped and site-a-2's LocalDb volume
destroyed, a-1 logged "Snapshot sent (as_of_seq 0, 4 rows)" — a line that could
not appear on 0.1.1 — and a-2 came back with a row_version dump byte-identical
to a-1's, carrying a-1's origin node ids and its ORIGINAL applied_at_utc. It
then booted from cache and served 17 ns=2 nodes, diff-identical to its peer,
from a configuration it never applied and could not have fetched.

Check 8 (PASS): two restarts with an unchanged artifact left the oplog at 7 and
the pointer timestamp frozen — the re-cache was skipped outright. Positive
control: a real config change still wrote, 7 -> 10 -> 13.

Also records one unrelated pre-existing finding the gate's SQL flapping
surfaced: a transient ConfigDb error during artifact load empties the served
address space while logging "rebuild becomes no-op". The cache layer correctly
refused to store the bad artifact and the node recovered on restart, but the
two logs disagree and it is the same class as the Phase 1 gate's check-3
defect. Untouched by this work; wants its own issue.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-21 02:14:00 -04:00
Joseph Doherty 232bff5df7 fix(localdb): close both Phase 1 live-gate follow-ups
1. Wiped-node back-fill (live gate check 4). Fixed upstream in
   ZB.MOM.WW.LocalDb 0.1.2 and pinned here. The gate's diagnosis was slightly
   off: the oplog cap was not the gate. Snapshot detection measured the peer's
   gap against the oldest surviving oplog row and read an empty oplog as "no
   gap possible" — and an empty oplog is the steady state of a converged pair,
   since ack-pruning deletes everything the peer confirmed. The healthy state
   was the one state that could not heal a wiped peer. Now measured against
   last_acked_seq when the oplog is empty.

   Pinned at this level too, not just the library's: the pair harness grows a
   WipePassiveAsync (a NEW database — a rebuilt node comes back with a new node
   id and a zero watermark), and the new scenario asserts the emptied oplog as
   its precondition before wiping, then requires the deployment artifact to
   come back byte-identical with no new deploy. Verified RED against the pinned
   0.1.1 (times out waiting for back-fill) and green on 0.1.2.

2. Oplog growth on default-OFF nodes (live gate check 8), fixed at the source:
   StoreAsync now skips entirely when the pointer already names this
   deployment/revision, the SHA matches the bytes, and the expected chunk count
   is present. Re-caching an artifact the node already holds — every restart's
   boot-from-cache, every RestoreApplied — writes nothing and mints no oplog
   rows, where before it cost a delete plus an insert per chunk plus a pointer
   update, all identical to what was already there.

   Identity is over the bytes and the chunks are counted, both deliberately: a
   re-composed artifact can carry the same ids with different bytes, and a
   pointer can name a deployment whose chunks are missing, where skipping would
   make an unreadable cache permanent. Both guards verified RED against a naive
   pointer-only skip.

   The gate's stated bound was also wrong and is corrected in the doc: growth
   was never headed for the 1M row cap. AddZbLocalDbReplication is registered
   unconditionally, so MaintenanceBackgroundService runs on default-OFF nodes
   and the 7-day MaxOplogAge cap prunes.

Runtime.Tests 412/0/31, Host.IntegrationTests LocalDb 45/45. Pre-existing and
unrelated: AbCip_Green_AgainstSim (needs the AB CIP docker fixture, red on a
clean master too) and a flaky Roslyn race test (green 2/2 in isolation).

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-21 01:27:02 -04:00
Joseph Doherty c957db52e2 Merge feat/localdb-phase1: LocalDb Phase 1 pair-local config cache + boot-from-cache + optional replication
v2-ci / build (push) Successful in 3m39s
v2-ci / unit-tests (push) Failing after 9m26s
2026-07-21 01:02:52 -04:00
Joseph Doherty ebc5fc6742 chore(localdb): record live-gate results in tasks.json 2026-07-20 23:15:21 -04:00
Joseph Doherty 41c36064dd docs(localdb): phase-1 live-gate evidence (8/8 pass; 4 defects fixed, 2 limitations)
Records the docker-dev live gate: 8/8 checks pass (check 4 with a documented
limitation). The gate caught four real defects every offline test missed — the
NU1101 packageSourceMapping gap, the ASPNETCORE_HTTP_PORTS re-bind, the empty
address space on boot-from-cache, and the cache not repopulating on
RestoreApplied — all fixed on-branch with regression tests. Two limitations
documented as follow-ups: no replication back-fill of a fully-wiped node, and
oplog growth on default-OFF nodes.
2026-07-20 23:10:44 -04:00
Joseph Doherty c6a9f93a0c fix(localdb): re-cache the artifact on RestoreApplied so a wiped node self-heals
CacheAppliedArtifact ran only on a fresh apply (ApplyAndAck), never on the
bootstrap RestoreApplied path. So a node whose cache was lost — fresh/wiped
volume, disk failure — recovered its served state from central yet stayed
cache-less, unable to boot-from-cache on the NEXT central outage until some
future new deploy happened to land. Replication does not heal this: a peer's
already-acked cache rows are pruned from its oplog, so a fully-wiped, converged
node is never back-filled. RestoreApplied now writes the served artifact back to
the cache (invariant: the cache holds what the node serves). Found on the
docker-dev live gate wiping a node's LocalDb volume.
2026-07-20 22:56:23 -04:00
Joseph Doherty 9137cb41eb fix(localdb): materialise the address space from the cached blob on boot-from-cache
Boot-from-cache told OpcUaPublishActor to RebuildAddressSpace(deploymentId), which
re-loads the artifact from the ConfigDb by id — the very database that is
unreachable during the outage this path exists to survive. The rebuild no-op'd, so
a cache-booted node ran its drivers but served OPC UA clients an EMPTY address
space. RebuildAddressSpace now carries an optional in-hand Artifact blob;
ApplyCachedArtifact passes the cached bytes so materialisation happens from them
directly. Found on the docker-dev live gate: healthy peer served 16 nodes, the
cache-booted node served 1.
2026-07-20 22:38:54 -04:00
Joseph Doherty ce9fa07ff8 fix(localdb): re-bind ASPNETCORE_HTTP_PORTS surface, not just ASPNETCORE_URLS
The Kestrel re-bind read only urls/ASPNETCORE_URLS. The aspnet:8.0+ base images
set ASPNETCORE_HTTP_PORTS=8080 (all interfaces) as the CONTAINER default in place
of ASPNETCORE_URLS, so a driver node that never sets URLS fell straight through to
Kestrel's localhost:5000 default — silently moving its health/metrics surface to
loopback when the sync listener took over Kestrel. Now falls through URLS →
HTTP_PORTS/HTTPS_PORTS (wildcard, all-interfaces) → localhost:5000, mirroring
Kestrel's own source precedence. Found on the docker-dev live gate (central nodes
were fine — they set ASPNETCORE_URLS=9000 explicitly).
2026-07-20 22:25:31 -04:00
Joseph Doherty 4b2f0e6e15 fix(localdb): map ZB.MOM.WW.LocalDb* to the gitea feed in packageSourceMapping
Task 1 added the package versions but not the packageSourceMapping patterns, so
ZB.MOM.WW.LocalDb / .Replication / .Contracts fell to the '*' → nuget.org rule
and any CLEAN restore (Docker image build, CI, fresh clone) failed NU1101 —
'PackageSourceMapping is enabled, dohertj2-gitea not considered'. Local dev builds
masked it via the warm global NuGet cache. Verified with a temp-packages-dir restore.
2026-07-20 22:16:16 -04:00
Joseph Doherty c08762db1b chore(localdb): phase-1 DoD sweep
Offline sweep: full-solution build 0 errors (824 pre-existing OTOPCUA0001
analyzer warnings live in driver test projects, none in LocalDb files);
no LiteDB/LocalCache code references remain (2 explanatory doc-comment lines
allowed); Runtime.Tests 407/0/31 twice (flagged intermittent not reproduced);
Host.IntegrationTests LocalDb subset 40/0/0. Full-solution test suite with
stash-baseline deferred to CI/live gate — infra-gated suites cannot run offline
on macOS. Not merged to master; live gate (Task 16) awaits operator go-ahead.
2026-07-20 22:11:02 -04:00
Joseph Doherty c834d89ae7 docs(localdb): phase-1 runbook + redundancy/config docs
New operations runbook (enable/disable, fail-closed ApiKey rule, stop/start-
together, tombstone-retention window, MaxBatchSize row-count-vs-4MB, never
sqlite3-a-live-WAL-DB cp-triplet recipe). Configuration.md gains a LocalDb
section; Redundancy.md gains a pair-local config cache section (what boot-from-
cache does and does not cover); CLAUDE.md gains the Phase 1 scope paragraph.
2026-07-20 22:06:58 -04:00
Joseph Doherty 7676ab93c4 chore(localdb): rig config — consolidated path everywhere, replication on the site-a pair
Every driver-role node gets a per-node named volume at /app/data and
LocalDb__Path=/app/data/otopcua-localdb.db so the pair-local config cache
survives container recreates. The site-a pair enables replication (SyncListenPort
9001, a-1 dials a-2, shared dev-only ApiKey, MaxBatchSize 16); the central and
site-b pairs stay default-OFF — site-b is the pin the live gate's check 8
verifies. docker compose config validates; rig not brought up (live gate owns runs).
2026-07-20 22:04:28 -04:00
Joseph Doherty afa5be713a test(localdb): 2-node convergence harness + scenarios
Two driver nodes replicate the deployment-artifact cache over a real loopback
h2c transport through the real fail-closed LocalDbSyncAuthInterceptor; both DBs
init via the production LocalDbSetup.OnReady. Scenarios: byte-identical
convergence with matching pointer HLC+origin, retention prune reaching B as
tombstones, writes-while-transport-down surviving rejoin, and wrong-key
non-convergence with a matching-key positive control.

DoD positive control: commenting out both RegisterReplicated calls turns all
four scenarios red (verified locally, restored before commit).
2026-07-20 22:02:03 -04:00
Joseph Doherty 5a21be0a62 test(localdb): DI pins over the real host composition methods
Builds a container from the same AddOtOpcUa* methods Program.cs's driver branch
calls (AddOtOpcUaLocalDb / AddGrpc / AddOtOpcUaHealth / AddOtOpcUaObservability) and
resolves through it - DI extensions have shipped inert three times in this family.

Does not boot the real host: WebApplicationFactory<Program> would start hosted
services (Akka, OPC UA server, ValidateOnStart) needing SQL + a mesh, per D-5. The
seam under test is DI resolution, so the container is built and resolved without
starting.

Five pins: ILocalDb singleton + exact replicated-table set; IDeploymentArtifactCache
-> concrete; default-OFF ISyncStatus; localdb-replication health check registered and
constructible; admin-only graph has NO ILocalDb and does not demand LocalDb:Path.
2026-07-20 21:51:25 -04:00
Joseph Doherty b350fba233 feat(localdb): replication health check + meter export
LocalDbReplicationHealthCheck reports Healthy when replication is default-OFF or
LocalDb is absent (admin-only graphs) - so a plain node is never degraded by it -
Degraded when configured-but-disconnected, connected-with-unknown-backlog (a failed
oplog poll must not read as zero), or connected-with-backlog past the threshold.
Never Unhealthy: a replication fault does not stop the node serving its address
space. Pure decision core is table-tested; registered unconditionally via a factory
so AddOtOpcUaHealth keeps its no-arg signature and resolves ISyncStatus optionally.

Adds LocalDbMetrics.MeterName to the observability allowlist. o.Meters is a strict
allowlist with no wildcard, so the localdb.sync.* / localdb.oplog.depth series were
otherwise silently absent from /metrics - the omission a ScadaBridge live gate
caught.
2026-07-20 21:48:47 -04:00
Joseph Doherty abe10dbbd7 docs(localdb): phase-1 execution progress / resume state (tasks 0-9 done) 2026-07-20 21:45:35 -04:00
Joseph Doherty a27eff3298 feat(localdb): boot from the pair-local artifact cache when central SQL is unreachable
Hooks the Bootstrap catch that previously went straight to Stale. On a cache hit the
node serves its last-known-good configuration through the outage instead of coming
up with an empty address space; on a miss, behaviour is byte-for-byte what it was.

The read is unkeyed because ClusterId is only derivable from an artifact you already
hold or from the unreachable central DB (recon D-1). Newest pointer wins, which is
correct in every real topology - a node belongs to one cluster and its peer
replicates that same row.

Splits PushDesiredSubscriptionsFromArtifact out of PushDesiredSubscriptions: the
cache path runs precisely when the ConfigDb read cannot succeed, so re-reading the
artifact there would fail by definition.

RunningFromCache is surfaced on NodeDiagnosticsSnapshot (optional param, existing
call sites unaffected). A node running from cache looks entirely healthy from
outside - full address space, live values - but its config is frozen and no deploy
can reach it. It clears only on a real apply from central.
2026-07-20 11:12:19 -04:00
Joseph Doherty 1becf59168 feat(localdb): DriverHostActor stores applied artifacts in the pair-local cache
New Props/ctor parameter appended LAST at all three positions - the forwarding
list inside Props.Create is positional and compiles into an expression tree, so a
mis-ordered argument is usually type-compatible and binds the wrong dependency at
runtime. All 26 existing test construction sites use named arguments and needed no
change.

ReconcileDrivers now returns the blob it loaded. It catches its own DB failures and
returns without rethrowing, so ApplyAndAck can reach its success path having spawned
zero drivers; caching on 'the apply succeeded' would persist an empty artifact as
last-known-good and boot the node into an empty address space during the next
outage. Verified: weakening the guard to null-only turns EmptyArtifact_IsNotCached
red.

The store runs in its own try/catch because an Applied ACK has already been sent by
then - an escaping exception would land in ApplyAndAck's catch and emit a second,
contradictory Failed ACK for a deployment the fleet believes is live.
2026-07-20 11:06:57 -04:00
Joseph Doherty a38a52b831 feat(localdb): chunked deployment-artifact cache over ILocalDb
128 KiB raw chunks base64-encoded into deployment_artifacts, with a per-cluster
pointer carrying a SHA-256 over the raw bytes. Reassembly verifies chunk count
AND the SHA before returning, so a partially replicated artifact reads back as a
clean miss rather than a silently truncated address space.

Adds GetCurrentUnkeyedAsync beyond the plan's interface: at the boot-failure seam
the actor cannot know its ClusterId (it is derivable only from an artifact you
already have, or from the central DB that is unreachable). Recon D-1.

The prune's 'deployment_id <> @DeploymentId' clause makes 'the pointer's target is
always present' structural rather than a side effect of clock resolution. Three
stores inside one timestamp tick fall through to a deployment_id tiebreak, and
since real ids are GUIDs that ordering is random - the row just written could lose,
leaving the pointer naming chunks that no longer exist. Verified red without the
clause.
2026-07-20 10:58:27 -04:00
Joseph Doherty 2bae1b4c9f refactor(localdb): delete the dormant LiteDB LocalCache (superseded by ZB.MOM.WW.LocalDb)
Never registered in DI and dead since Phase 6.1. Keeping two unwired cache designs
invites the next reader to wire the wrong one.

Deletes all three test files - LiteDbConfigCacheTests.cs was missing from the plan's
list and would have failed to compile (it calls new LiteDB.LiteDatabase directly).
StaleConfigFlagTests lives inside ResilientConfigReaderTests.cs, so it goes too.

The XML-doc reference in ILdapGroupRoleMappingService is rewritten rather than removed:
it now records that no sign-in fallback exists and that reviving one means an admin-side
cache on LocalDb.
2026-07-20 10:46:07 -04:00
Joseph Doherty e771a11a30 fix(localdb): rename Runtime.Deployment namespace to Runtime.DeploymentCache
The Runtime.Deployment namespace shadowed the Deployment EF entity type for
every file under ZB.MOM.WW.OtOpcUa.Runtime.Tests.*: C# name lookup walks the
enclosing namespace chain and finds the namespace before a using-imported type,
so 19 pre-existing call sites failed with CS0118. Caught only by a full-solution
build - the Host and its own tests compiled fine.
2026-07-20 10:42:22 -04:00
Joseph Doherty b9ddf20edd feat(localdb): dedicated h2c listener + MapZbLocalDbSync gated on LocalDb:SyncListenPort 2026-07-20 10:39:34 -04:00
Joseph Doherty 6aff9a8332 feat(localdb): wire AddOtOpcUaLocalDb into the driver role 2026-07-20 10:35:02 -04:00
Joseph Doherty 3d29be834e feat(localdb): fail-closed bearer interceptor for the sync endpoint 2026-07-20 10:28:39 -04:00
Joseph Doherty 3b65c24bfb feat(localdb): deployment-cache schema + OnReady registration 2026-07-20 10:28:39 -04:00
Joseph Doherty 44255fcb91 build(localdb): reference ZB.MOM.WW.LocalDb 0.1.1 + Grpc.AspNetCore 2026-07-20 10:21:47 -04:00
Joseph Doherty 42f8550716 docs(localdb): phase-1 recon findings 2026-07-20 10:18:01 -04:00
dohertj2 1adaa2fc01 chore(auth): bump ZB.MOM.WW.Auth 0.1.1 -> 0.1.5 (family alignment) (#484)
v2-ci / build (push) Successful in 4m34s
v2-ci / unit-tests (push) Failing after 10m48s
Functionally inert for OtOpcUa (all intervening changes were in ApiKeys, which it does not consume); build byte-identical to the master baseline.
2026-07-20 06:06:10 -04:00
Joseph Doherty a5eac3ec78 chore(auth): bump ZB.MOM.WW.Auth 0.1.1 -> 0.1.5
v2-ci / build (pull_request) Successful in 3m56s
v2-ci / unit-tests (pull_request) Failing after 11m26s
Aligns OtOpcUa with the rest of the family — mxgw, HistorianGateway and
ScadaBridge have all been on 0.1.5; OtOpcUa was four versions behind and the
only outlier.

The bump is functionally inert here, deliberately so. OtOpcUa consumes
Abstractions + Ldap + AspNetCore, NOT ApiKeys, and every intervening change was
in ApiKeys: 0.1.2 stamped its schema version, 0.1.3 added SetScopes/SetEnabled,
0.1.4 added ExpiresUtc. 0.1.5 is the patched-SQLitePCLRaw pin
(GHSA-2m69-gcr7-jv3q), which this repo already carries independently as its own
surgical transitive pin at Directory.Packages.props:105 — so no vulnerability
was open here either.

The value is removing a false signal: a repo sitting four versions behind reads
as "missing something" every time someone audits the family, and the next real
Auth change would have arrived as a four-version jump instead of one.

Verified: build 861 warnings / 0 errors, byte-identical to the master baseline
(measured, not assumed — master was built to compare). Security 84, AdminUI
665, Configuration 121, ControlPlane 78 — all pass, 0 failures.

Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
2026-07-20 06:03:10 -04:00
Joseph Doherty be87ddeb0b chore(secrets): bump to Secrets 0.2.3 - visible delete modal (scadaproj#2)
v2-ci / build (push) Successful in 4m50s
v2-ci / unit-tests (push) Failing after 10m31s
0.2.3's Secrets.Ui ships ConfirmDeleteModal's own styles under
collision-proof zb-secrets-* class names; on 0.2.2 this host's Bootstrap
.modal{display:none} made the /admin/secrets delete modal permanently
invisible (confirmed live on the docker-dev rig). No other library change
affects this consumer. AdminUI tests 665/665; secrets Host.IntegrationTests 8/8.

Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
2026-07-19 01:22:10 -04:00
Joseph Doherty 5f72ff851d fix(adminui): make /admin/secrets interactive - wrap the Secrets.Ui page with a render mode (#483)
v2-ci / build (push) Successful in 4m0s
v2-ci / unit-tests (push) Failing after 10m19s
The Secrets.Ui RCL's routable page was routed directly, but this host's
router is deliberately static (cookie SignInAsync needs SSR) with per-page
@rendermode opt-in - a directive the RCL page cannot carry, because the
other three family hosts render it under a globally interactive router
where a nested render mode throws. Routing straight to the RCL page
therefore rendered /admin/secrets with no circuit: it displayed, but every
@onclick was silently dead.

Fix: a host-side wrapper page (Pages/SecretsAdmin.razor) now owns
/admin/secrets, carries the standard per-page InteractiveServer render
mode, re-states the RCL page's own authorization policy (the router only
enforces [Authorize] on the routed component, which is now the wrapper),
and renders the RCL page as an ordinary child. The RCL assembly is
de-registered from both AdditionalAssemblies sites (router + endpoint) so
the route is unambiguous.

Guards: SecretsPageWiringTests pins the render mode (the #483 regression),
route parity with the RCL page, and policy parity; the page census in
PageAuthorizationGuardTests classifies the new page and admits the
secrets:manage policy. AdminUI.Tests 665/665.

Live-verified on the rebuilt docker-dev rig with Playwright: /_blazor
circuit negotiated on /admin/secrets and Add secret opens the editor
(before the fix: zero interactive markers, no circuit, dead click).

Closes #483.

Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
2026-07-19 00:22:23 -04:00
Joseph Doherty c878fbbd03 feat(secrets): enable Akka clustered secret replication on the docker-dev rig
v2-ci / build (push) Successful in 3m52s
v2-ci / unit-tests (push) Failing after 9m47s
Turns on Secrets:Replication:Enabled across all six docker-dev host nodes via
one x-secrets-env anchor: replication enabled, a 5s AnnounceInterval so
anti-entropy is observable during dev exercise, and the ONE shared dev-only
KEK every node requires (committed default per the rig's zero-operator-step
pattern, overridable via OTOPCUA_SECRETS_KEK; never reuse outside this rig).

Live-proven on the rig with Secrets 0.2.2: all six nodes start with NO
startup deadlock (the scadaproj#1 signature never appeared), the mesh forms,
and a secret seeded into site-a-1's local store alone converged to all five
peers within 60s — byte-identical ciphertext on all six nodes (same
sha256, kek_id sha256:f3f0c2056e7a), decrypt-verified on central-2 and
site-b-2. site-b-2 restart rejoined cleanly with the row intact; the fleet
also survived a full Docker-engine restart with replication enabled.

Found during verification, tracked separately: the AdminUI /admin/secrets
page renders but is non-interactive (Secrets.Ui RCL components carry no
@rendermode and the host applies render modes per-page), so Add secret does
nothing - pre-existing, unrelated to replication.

Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
2026-07-18 16:00:14 -04:00
Joseph Doherty 2254ae3dea fix(secrets): consume Secrets 0.2.2 - clustered-secrets DI deadlock fixed upstream
v2-ci / build (push) Successful in 3m55s
v2-ci / unit-tests (push) Failing after 11m7s
Bumps the four ZB.MOM.WW.Secrets pins 0.2.1 -> 0.2.2 (closes the OtOpcUa side
of scadaproj#1, tracked here as #482). 0.2.1's Akka replicator deadlocked any
hosted process at startup when Secrets:Replication:Enabled was true: the
package's DI graph closed a circular singleton dependency through factory
lambdas (store decorator -> replicator -> actor provider -> cache invalidator
-> resolver -> store), which MS.DI's StackGuard turns into a silent
cross-thread call-site-lock deadlock. 0.2.2 defers the invalidator edge to
first eviction. The flag stays default-false; enabling remains a
per-environment decision.

The_startup_hook_actually_creates_the_replication_actor is now a real test:
SecretReplicationStarter's docs had promised it since the adoption, and the
upstream fix finally makes a provider-based resolve runnable - container built
exactly as the host does, hook started under a watchdog, replication actor
proven to exist by ActorSelection on a self-joined single-node cluster (no
TestKit needed, which matters because Akka.TestKit.Xunit2 is xunit-v2-only and
this project is on xunit.v3). Also corrects the stale rationale that blamed
the old hang on DistributedPubSub needing a joined cluster - the actor
constructor was never reached; it was the DI cycle.

Verified: SecretsReplicationRegistrationTests 8/8 on the 0.2.2 feed packages;
full slnx build 0 errors; the 2-node Akka live convergence gate re-run against
the published 0.2.2 packages passes 6/6 (write->peer, tombstone propagation
without resurrection, delete visibility through the resolver cache, reverse
direction, wrong-KEK fail-closed).

Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
2026-07-18 15:03:35 -04:00
Joseph Doherty 1ccc237cb6 docs(test): record why these assert descriptors, not a built provider
v2-ci / build (push) Successful in 4m21s
v2-ci / unit-tests (push) Failing after 10m15s
Akka.TestKit is the family convention for non-end-to-end Akka testing and would
be the right tool here, but Akka.TestKit.Xunit2 is xunit-v2-only and this project
is on xunit.v3 - adding it produces CS0433 type conflicts. Directory.Packages.props
already documents the same constraint for the three projects deliberately held on
xunit v2 for TestKit. Revisit when Akka ships an xunit.v3 TestKit.
2026-07-18 12:19:50 -04:00
Joseph Doherty b3d1a26f38 fix(secrets): consume Secrets 0.2.1 - Akka replication was inert in 0.2.0
0.2.0's AddZbSecretsAkkaReplication never bound its own ISecretReplicator: it
called AddZbSecrets first, which TryAdds NoOpSecretReplicator, so the package's
own TryAdd was silently discarded. Replication published into a no-op sink and no
actor was ever spawned - no exception, no log line. Found by the Task 6 wiring
work, which is the first code that ever built a container around that extension.
Fixed upstream in 0.2.1.

Test consequence worth recording: the replication-enabled registration tests
previously passed against a plain ActorSystem only BECAUSE of that bug - the
no-op never touched Akka. With 0.2.1 they resolve a real replicator, which spawns
the actor, whose PreStart needs DistributedPubSub and therefore a joined cluster,
so provider-based assertions hang. Standing a real cluster up inside this test
assembly was attempted and did not work, so these assertions are now made against
the ServiceCollection, which is precisely where the defect lives. Actor creation
and convergence remain covered upstream by the library's TwoNodeClusterReplication
tests against a genuine 2-node cluster.

7 registration tests pass in 23ms. Full solution builds 0 errors; no warnings
originate from these files.
2026-07-18 12:12:02 -04:00