Files
lmxopcua/docs/plans/2026-08-07-secrets-pair-local-live-gate.md
T
Joseph Doherty 1bd79c8e4a
v2-ci / build (push) Successful in 6m33s
v2-ci / unit-tests (push) Failing after 17m17s
docs(secrets): pair-local replication live gate on the three-mesh rig — PASS 4/4
Re-gates Akka peer-to-peer secret replication on the current three-mesh docker-dev
rig and supersedes the 2026-07-18 six-node gate (lmxopcua#482, c878fbbd) as evidence.
That gate proved convergence to all five peers on the old single fleet-wide mesh;
after mesh Phase 6 (2839deb5) partitioned the rig into three independent 2-node
meshes, six-node convergence would be a FAILURE, not a pass. Phase 6's own leg 6
recorded "PASS (by fallback criterion)" — it inferred pair-locality structurally and
never seeded a secret. This replaces that inference with direct evidence.

All four checks PASS, on master @ 21009874 with one image (OtOpcUa.Host.dll
a434b04f byte-identical on all six nodes), Secrets libs 0.2.3:

  1. Pair convergence — seeded on site-a-1 alone, on site-a-2 in <=7s (budget 60s),
     byte-identical ciphertext + kek_id + updated_utc, decrypt-verified on the partner.
  2. No cross-pair propagation — absent from site-b and central across 8 samples /
     240s / 48 announce intervals, and clean in the reverse direction too. Isolation
     controls seeded on site-b-1 and central-1 converged within their OWN pairs, so
     the absence is isolation rather than a dead replicator.
  3. Delete propagates within the pair — issued on site-a-2 (the receiving node, so
     this also covers the reverse intra-pair direction); tombstone on site-a-1 in <1s.
  4. Clean pair restart (both together) — mesh re-formed in 7s, no resurrection of the
     tombstone, live secret survived and decrypted correctly on both nodes.

Zero secret-related warnings or errors fleet-wide across the whole run. Satisfies the
scadaproj#3 precondition for the OtOpcUa production topology (Akka pair-local).
Secrets:Replication:Enabled remains default-false in the product; the rig KEK is dev-only.

Rig config: SqlitePath moved onto each node's existing per-node /app/data volume. The
appsettings default resolves to /app, inside the image's writable container layer, so
the store was destroyed by any `up -d --build` recreate and was unreachable from the
host — seeding or inspecting a node meant stopping the container to docker cp the file
out. Still one private store per node; replication is still what syncs them.

Residuals in the gate doc: the live-push path is not covered (anti-entropy is what a
driver-only node can be gated on), and appsettings.json still carries a stale
"KNOWN NON-FUNCTIONAL against 0.2.0 / do not enable" comment that this gate disproves
— left uncorrected here because this run is config-only.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-07 06:08:50 -04:00

14 KiB
Raw Blame History

Secrets replication — pair-local live gate on the three-mesh rig

Result: PASS — 4/4 checks. Run 2026-08-07 on the docker-dev rig.

Purpose

Re-gate pair-local Akka peer-to-peer secret replication on the current three-mesh docker-dev rig, and supersede the 2026-07-18 six-node gate (lmxopcua#482, closed at master @ c878fbbd) as evidence.

That gate proved a secret seeded on site-a-1 converged to all five peers. It ran on the old single fleet-wide Akka mesh. Mesh Phase 6 (2839deb5, 2026-07-24) partitioned the rig into three independent 2-node meshes, so DistributedPubSub — and with it the zb-mom-ww-secrets topic — is now pair-local by construction. The #482 result is therefore not merely stale, it asserts the opposite of the intended behaviour: six-node convergence would now be a failure.

Phase 6's own live gate (2026-07-24-mesh-phase6-live-gate.md) recorded its secrets leg (leg 6) as "PASS (by fallback criterion)" — it observed zero secret errors fleet-wide and inferred pair-locality structurally from the mesh split. No secret was ever seeded. This gate replaces that inference with direct evidence, in both directions.

This is the OtOpcUa half of the production-topology decision in scadaproj#3 (design: scadaproj/docs/plans/2026-08-07-secrets-production-topology-and-grpc-hub.md), which fixes OtOpcUa production on Akka pair-local replication and required pair-local semantics to be re-gated on a real three-mesh rig before enabling.

Scope: config-only. No product code was changed. One rig-config change was made (below).

Rig state

Fact Value
Repo / branch OtOpcUa @ master
Git SHA 2100987453dfb44850f0deccbfb5583b49be6104 (21009874)
Image otopcua-host:dev = sha256:384ce9aa7bab, built 2026-08-07 09:49 UTC
OtOpcUa.Host.dll a434b04f7d198e39… — byte-identical on all six nodes
Secrets libs ZB.MOM.WW.Secrets* 0.2.3 (Directory.Packages.props)
Replication Secrets__Replication__Enabled=true on all six nodes (pre-existing, x-secrets-env anchor)
Announce interval 00:00:05 (rig override; product default 30 s)
KEK one shared committed dev-only key, identical on all six (kek_id sha256 bb00d9490aa1… on every row observed)
Meshes three independent 2-node: MAIN central-1/2, SITE-A site-a-1/2, SITE-B site-b-1/2

The rig was rebuilt from current master at the start of the run. It was found running mixed images — the site nodes were on a 2026-08-06 17:55 build, predating 5b9ff9d0 (#539) and 8b10d327 (#548), while the central pair was current. Rebuilding removed the ambiguity; all six now report the same Host.dll hash. (The rebuild also confirmed the build is deterministic: the freshly built Host.dll hash equals the one the already-current central pair carried.)

Mesh formation after rebuild — each node handshakes only its pair partner:

site-a-1  Node [akka.tcp://otopcua@site-a-2:4053] is JOINING, roles [driver,cluster-SITE-A]
site-b-1  Node [akka.tcp://otopcua@site-b-2:4053] is JOINING, roles [driver,cluster-SITE-B]
central-1 Welcome from [akka.tcp://otopcua@central-2:4053]

Rig-config change made by this gate

docker-dev/docker-compose.yml — x-secrets-env gained:

Secrets__SqlitePath: "/app/data/otopcua-secrets.db"

The appsettings default is the relative otopcua-secrets.db, which resolves to /app — inside the image's writable container layer. That cost two things, both of which obstructed this gate: the store is destroyed by any up -d --build container recreate, and it is unreachable from the host, so seeding or inspecting a node's store meant stopping the container and docker cp-ing the file out. /app/data is the node's existing per-node volume (the one LocalDb already uses) — still a private store per node, so replication is still what syncs them, but it survives recreate and a helper container can mount it while the node keeps running. Dev-rig config only; the product default is untouched.

Method

Both driver-only site nodes have no AdminUI, so there is no in-app authoring surface on them. Secrets were seeded and read with the ZB.MOM.WW.Secrets.Cli (secret) published from scadaproj/ZB.MOM.WW.Secrets and run in a throwaway container mounting one node's volume, with that node's KEK. This is the #482 method.

This exercises the anti-entropy path, not the live-push path — see Residual 1. Anti-entropy is the library's own correctness path (SecretReplicationActor: every node publishes a SecretManifestAnnounce each interval; a peer computes push and pull sets in one pass), and it reads the node's live store, so a row written into that store by the CLI is announced by the running node on its next tick.

Ciphertext identity is evidenced as a SHA-256 over the encrypted columns only (ciphertext‖nonce‖tag‖wrapped_dek‖wrap_nonce‖wrap_tag), plus a hash of kek_id. No plaintext and no key material appears in this document. The secret values used were throwaway smoke strings.

Two isolation controls were seeded alongside the subject secrets — one on site-b-1, one on central-1. Without them, "the site-a secret never appeared on site-b" would be equally consistent with site-b's replicator being dead. The controls prove each other pair's replication is alive while still not crossing.

Results

# Check Result Convergence
1 Seed on one site-a node ⇒ partner converges, byte-identical, decrypt-verified PASS ≤ 7 s (budget 60 s)
2 No cross-pair propagation to site-b or central PASS absent across 240 s / 48 announce intervals
3 Delete propagates within the pair PASS < 1 s (budget 60 s)
4 Clean pair restart — no resurrection, live secret survives PASS mesh re-formed in 7 s

Baseline before seeding: all six stores empty (clean slate from the rebuild).

Check 1 — pair convergence · PASS

Seeded on site-a-1 only, 09:53:02–09:53:03Z. First poll of site-a-2 at 09:53:10Z already had both rows, i.e. ≤ 7 s, consistent with a single 5 s announce cycle.

site-a-1   pair-gate-smoke-1   rev=0 deleted=0 cipher_sha256=dd7c08f4c6e1fda7cae900e6 kek=bb00d9490aa1 updated=2026-08-07T09:53:02.0367742+00:00
           pair-gate-survivor  rev=0 deleted=0 cipher_sha256=a373b6f2b985315f70b3ac34 kek=bb00d9490aa1 updated=2026-08-07T09:53:02.9838162+00:00
site-a-2   pair-gate-smoke-1   rev=0 deleted=0 cipher_sha256=dd7c08f4c6e1fda7cae900e6 kek=bb00d9490aa1 updated=2026-08-07T09:53:02.0367742+00:00
           pair-gate-survivor  rev=0 deleted=0 cipher_sha256=a373b6f2b985315f70b3ac34 kek=bb00d9490aa1 updated=2026-08-07T09:53:02.9838162+00:00

Ciphertext hashes, kek_id and updated_utc are identical on both nodes — the row is replicated verbatim, not re-encrypted, so the LWW ordering key is preserved. Decrypt-verified on the partner: secret get on site-a-2 returned the exact plaintext seeded on site-a-1 for both secrets.

Check 2 — no cross-pair propagation · PASS

site-b-1 and central-1 were each seeded with their own control secret at 09:53:50–09:53:52Z. site-b-1/2 and central-1/2 were then sampled 8 times over 240 s (09:53:35Z → 09:57:46Z — 48 announce intervals at 5 s). Every sample was identical:

site-b-1   pair-gate-siteb-probe    rev=0 deleted=0 cipher_sha256=87ca3d3ee8b00df59ef51b73 kek=bb00d9490aa1
site-b-2   pair-gate-siteb-probe    rev=0 deleted=0 cipher_sha256=87ca3d3ee8b00df59ef51b73 kek=bb00d9490aa1
central-1  pair-gate-central-probe  rev=0 deleted=0 cipher_sha256=4f09d442b742907322546f94 kek=bb00d9490aa1
central-2  pair-gate-central-probe  rev=0 deleted=0 cipher_sha256=4f09d442b742907322546f94 kek=bb00d9490aa1
  • Neither site-a secret ever appeared on site-b or central, across all 8 samples.
  • The controls converged within their own pairs (byte-identical, same updated_utc) — so SITE-B and MAIN replication is demonstrably alive, and the absence above is isolation, not a dead replicator.
  • Reverse direction also clean: sampled at 09:54:25Z, the site-a pair held only its own two secrets — neither control had crossed into it.

Isolation therefore holds in every direction between all three pairs. This is the correct post-Phase-6 behaviour, not a defect.

Check 3 — delete propagates within the pair · PASS

Deleted on site-a-2 — deliberately the receiving node, so this also proves the reverse intra-pair direction (site-a-2 → site-a-1), which Check 1 did not cover.

Deleted 09:58:02Z; the tombstone was on site-a-1 at the first poll, < 1 s:

site-a-1   pair-gate-smoke-1   rev=1 deleted=1 cipher_sha256=dd7c08f4c6e1fda7cae900e6 updated=2026-08-07T09:58:02.1938781+00:00
site-a-2   pair-gate-smoke-1   rev=1 deleted=1 cipher_sha256=dd7c08f4c6e1fda7cae900e6 updated=2026-08-07T09:58:02.1938781+00:00

Delete is a soft tombstone (is_deleted=1, revision bumped, updated_utc restamped), which is what lets it win under last-writer-wins instead of being re-created by the peer's older live row. pair-gate-survivor was untouched on both nodes.

Check 4 — clean pair restart · PASS

docker compose restart site-a-1 site-a-2 (both together — the supported pattern), 09:58:10Z → 09:58:20Z. The SITE-A mesh re-formed by 09:58:27Z (site-a-1 founded, site-a-2 joined, both to Up) — 7 s.

After restart, and re-confirmed at 10:06:41Z (> 60 s later, 12+ announce intervals):

  • No resurrection. pair-gate-smoke-1 remained rev=1 deleted=1 on both nodes with its updated_utc unchanged. secret get returns {"error":"not-found"}; secret list returns ['pair-gate-survivor'] on both nodes.
  • Live secret survived and resolves. pair-gate-survivor present on both nodes with its original ciphertext hash and updated_utc, and decrypted to the correct plaintext on both.

Final fleet-wide sweep at 10:06:41Z: each pair holds exactly its own secrets and nothing else. Zero secret-related warnings or errors on any of the six nodes across the entire run.

Conclusion

Pair-local Akka secret replication is live-proven on the three-mesh rig: convergence and deletion within a pair are well inside the 60 s budget, isolation between all three pairs holds in every direction, and a clean pair restart neither resurrects a tombstone nor loses a live secret. This satisfies the scadaproj#3 precondition for the OtOpcUa production topology (Akka pair-local), and supersedes both the #482 six-node result and the Phase 6 leg-6 structural inference.

Unchanged by this gate: Secrets:Replication:Enabled remains default-false in the product. It is enabled on the docker-dev rig only. Production enablement additionally requires one fleet-wide KEK supplied via appsettings/env and never committed — the rig's key is dev-only.

Residuals

  1. The live-push path is not covered by this gate. Seeding wrote into a node's store underneath the running app, so what converged is anti-entropy (SecretManifestAnnounce → push/pull), not the PublishLocalWrite fast path a write through the app's own ReplicatingSecretStore takes. Anti-entropy is the library's correctness path and the one that must hold, so this is the right thing to gate on a driver-only node — but the fast path remains covered only by the library's own two-node in-process cluster tests. Covering it live needs a write issued through a node's app, which on the site pairs means an authoring surface that does not exist there today; the central pair's AdminUI /admin/secrets page could cover it for MAIN.

  2. appsettings.json carries a stale, actively misleading comment. The Secrets:Replication block still reads "KNOWN NON-FUNCTIONAL against ZB.MOM.WW.Secrets.Replicator.AkkaDotNet 0.2.0 … Do not enable until the library is fixed." The repo has pinned 0.2.3 since 2026-07-19; 0.2.1 fixed the inert-registration defect and 0.2.2 fixed the DI deadlock (scadaproj#1). The comment now tells an operator not to enable a feature this gate just proved works. Not corrected here — this gate's mandate is config-only and that file is product config, not rig config. Worth a one-line follow-up.

  3. Working tree was not clean, and another session was editing the repo concurrently. At the start of the run one unrelated file was dirty (docs/plans/2026-05-29-adminui-followups.md.tasks.json, a docs-bookkeeping edit); by the end a second had appeared (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagActorTests.cs). Neither is product code on the gate path, and the rig image was built from committed master (21009874), not from the working tree, so the gated artifact is unaffected. The commit carrying this gate is file-scoped and includes neither. Recorded for provenance — a concurrent editing session is the condition under which a "rebuild the rig" step could otherwise pick up someone else's in-flight change silently.

  4. Announce interval is 5 s on the rig, 30 s by product default. The convergence figures here (≤ 7 s, < 1 s) are bounded by the rig's shortened interval. At the product default, expect worst-case convergence around one 30 s cycle — still inside the 60 s budget, but the measured numbers should not be quoted as production latencies.

  5. Not re-gated: pair restart with a delete issued while one node is down. Check 4 restarts both nodes after both already held the tombstone. The harder resurrection case — deleting while a peer is offline, then bringing it back with a live copy of the row — is covered by the library's LWW tests but not live here.