diff --git a/docs/plans/2026-11-11-warm-redundancy-717-plan.md b/docs/plans/2026-11-11-warm-redundancy-717-plan.md
index de06eb4ee..63cf86089 100644
--- a/docs/plans/2026-11-11-warm-redundancy-717-plan.md
+++ b/docs/plans/2026-11-11-warm-redundancy-717-plan.md
@@ -301,7 +301,11 @@ failover also works against `Unset` servers.
T1–T10 and T12 are implemented on `master`, default-off (`OpcUa:Redundancy:Mode` defaults to `Unset`, pinned by
`WarmRedundancyWireTests.Unset_is_todays_server_exactly` and `OpcUaPublishActorWarmTests.Unset_never_drives_the_projection_and_keeps_todays_levels`).
-T11 is ScadaBridge#138 (separate). T13 has not run: `docker-dev/docker-compose.warm.yml` is ready (see T13).
+T11 is ScadaBridge#138 (separate). **T13 ran 2026-11-13** on an isolated two-node pair (not the shared docker-dev rig):
+every applicable leg passes after `c569f806`, which fixes two crash-failover defects the gate found. The survivor no
+longer demotes when auto-down removes the Primary, and the verdict reads its own cluster events instead of the lagging
+read view. Evidence: `docs/plans/evidence/2026-11-13-717-t13/RESULTS.md`. L8 (None) was not run, and L12 is not
+applicable (durable subscriptions are off). G8/G9 (#615) stay open.
Test homes: `WarmStandbyUnitTests` (OpcUaServer.Tests: marker, rewrite, decision record, the SDK-field regression pin,
option rules), `WarmRedundancyWireTests` (OpcUaServer.IntegrationTests: metadata per mode, read projection, write and
diff --git a/docs/plans/evidence/2026-11-13-717-t13/L1-L2-meta-warm.txt b/docs/plans/evidence/2026-11-13-717-t13/L1-L2-meta-warm.txt
new file mode 100644
index 000000000..fc071005a
--- /dev/null
+++ b/docs/plans/evidence/2026-11-13-717-t13/L1-L2-meta-warm.txt
@@ -0,0 +1,16 @@
+##### :14840
+endpoint opc.tcp://localhost:14840/OtOpcUa server=urn:OtOpcUa:central-1
+RedundancySupport i=3709: 2 [Good]
+ServerUriArray i=11314: [urn:OtOpcUa:central-1, urn:OtOpcUa:central-2] [Good]
+ServiceLevel i=2267: 250 [Good]
+ServerArray i=2254: [urn:OtOpcUa:central-1, urn:OtOpcUa:central-2] [Good]
+ServerRedundancy i=2296 TypeDefinition: NonTransparentRedundancyType i=2039
+Write ServerUriArray: BadNotWritable
+##### :14841
+endpoint opc.tcp://localhost:14841/OtOpcUa server=urn:OtOpcUa:central-2
+RedundancySupport i=3709: 2 [Good]
+ServerUriArray i=11314: [urn:OtOpcUa:central-2, urn:OtOpcUa:central-1] [Good]
+ServiceLevel i=2267: 90 [Good]
+ServerArray i=2254: [urn:OtOpcUa:central-2, urn:OtOpcUa:central-1] [Good]
+ServerRedundancy i=2296 TypeDefinition: NonTransparentRedundancyType i=2039
+Write ServerUriArray: BadNotWritable
diff --git a/docs/plans/evidence/2026-11-13-717-t13/L3-L10-probe-warm.txt b/docs/plans/evidence/2026-11-13-717-t13/L3-L10-probe-warm.txt
new file mode 100644
index 000000000..369712071
--- /dev/null
+++ b/docs/plans/evidence/2026-11-13-717-t13/L3-L10-probe-warm.txt
@@ -0,0 +1,32 @@
+##### Standby central-2 :14841
+09:02:27.873 probe opc.tcp://localhost:14841/OtOpcUa ServiceLevel=90
+ read raw calc-seconds: null [BadOutOfService] src=09:02:27.892
+ read uns calc-seconds: null [BadOutOfService] src=09:02:27.892
+ read uns vt-second: null [BadOutOfService] src=09:02:27.893
+ read raw calc-writable: null [BadOutOfService] src=09:02:27.893
+ write raw calc-writable=7: BadInvalidState
+ write uns calc-writable=7: BadInvalidState
+ HistoryRead raw calc-seconds: service=Good result=BadHistoryOperationUnsupported
+09:02:27.889 create item h=10 i=2253: Good id=2
+ ConditionRefresh call: Good
+ 09:02:28.146 event h=10(ev Server) type=RefreshStart msg="Condition refresh started for subscription 906693065." cond= retain= active= acked= time=09:02:28.059
+ 09:02:28.146 event h=10(ev Server) type=i=10637 msg="T13 even minute" cond=ns=3;s=AL-t13-minute retain=True active=True acked=False time=09:02:08.355
+ 09:02:28.146 event h=10(ev Server) type=RefreshEnd msg="Condition refresh completed for subscription 906693065." cond= retain= active= acked= time=09:02:28.059
+ Acknowledge on ns=3;s=AL-t13-minute: BadInvalidState
+ AddComment on ns=3;s=AL-t13-minute: BadInvalidState
+##### Active central-1 :14840
+09:02:31.598 probe opc.tcp://localhost:14840/OtOpcUa ServiceLevel=250
+ read raw calc-seconds: 32551 [Good] src=09:02:31.364
+ read uns calc-seconds: 32551 [Good] src=09:02:31.364
+ read uns vt-second: 31 [Good] src=09:02:31.376
+ read raw calc-writable: 0 [Good] src=09:02:05.071
+ write raw calc-writable=7: Good
+ write uns calc-writable=7: Good
+ HistoryRead raw calc-seconds: service=Good result=BadHistoryOperationUnsupported
+09:02:31.615 create item h=10 i=2253: Good id=2
+ ConditionRefresh call: Good
+ 09:02:31.898 event h=10(ev Server) type=RefreshStart msg="Condition refresh started for subscription 3744078708." cond= retain= active= acked= time=09:02:31.817
+ 09:02:31.898 event h=10(ev Server) type=i=10637 msg="T13 even minute" cond=ns=3;s=AL-t13-minute retain=True active=True acked=False time=09:02:08.351
+ 09:02:31.898 event h=10(ev Server) type=RefreshEnd msg="Condition refresh completed for subscription 3744078708." cond= retain= active= acked= time=09:02:31.817
+ Acknowledge on ns=3;s=AL-t13-minute: Good
+ AddComment on ns=3;s=AL-t13-minute: Good
diff --git a/docs/plans/evidence/2026-11-13-717-t13/RESULTS.md b/docs/plans/evidence/2026-11-13-717-t13/RESULTS.md
new file mode 100644
index 000000000..5e9380165
--- /dev/null
+++ b/docs/plans/evidence/2026-11-13-717-t13/RESULTS.md
@@ -0,0 +1,86 @@
+# Gitea #717 T13 live gate: Warm redundancy on an isolated docker pair (2026-11-13)
+
+**Result: every leg that applies passes on the fixed build. The gate found two product defects on the crash-failover
+path, both fixed in `c569f806` with tests (mutation-checked) and re-verified live.**
+
+## Rig
+
+An isolated two-node pair on the Mac, separate from the shared `otopcua-dev` rig, which was not touched.
+
+| Item | Value |
+|---|---|
+| Build | `origin/master` `0c19f26c` (runs 1 and 2), then `0c19f26c` + fix `c569f806` (runs 2b, 3 and 4). Published on the host and copied into a runtime-only `aspnet:10.0` image (`rig/Dockerfile`). |
+| Images | `otopcua-host:t13-0c19f26c` and `otopcua-host:t13-0c19f26c-fix`. Both were removed at teardown. |
+| Compose | project `otopcua-t13` (`rig/compose.t13.yml`) with its own network, its own SQL Server 2022 and its own volumes (`t13-mssql-data`, `t13-c1`, `t13-c2`). Ports: OPC UA `14840`/`14841`, HTTP `19210`/`19211`, SQL `14339`. |
+| Topology | `central-1` + `central-2`, roles `admin,driver,cluster-MAIN`, self-first seeds, per-node `ApplicationUri` with the partner in `PeerApplicationUris` (as in docker-dev). `OpcUa__Redundancy__Mode=Warm` on both nodes, and `Unset` for the last run. `PromoteWhilePrimaryUnreachable` is at its default (`true`). |
+| External contact | None. LDAP points at loopback (AdminUI login is disabled), the historian is off, and no driver reaches a device. |
+| Schema | `dotnet ef database update` from the host against `:14339`. |
+| Config | `rig/seed.sql` plus `rig/fixeq.sql`, then `POST /api/deployments`. The deploy was rejected once with `EquipmentIdNotDerived`; `fixeq.sql` renames the equipment to the canonical `EQ-3326c7a4728e`. The config contains: • a Calculation driver `t13-calc/Device1`, with `calc-seconds` (Int32, Read, a 1 s timer, value = UTC seconds-of-day) and `calc-writable` (ReadWrite); • UNS `t13/warm/t13-equipment`, with references to both tags; • VirtualTag `vt-second`; • scripted alarm `t13-even-minute` (OffNormal, Retain), active on even UTC minutes; • one API key `t13-operator` (principal `t13-op`) with a Cluster-scope grant (WriteOperate, AlarmAck and the rest). Deployment `5c90c82f`, sealed with acks from both nodes. |
+| Client | `harness/` is a raw OPC UA client on SDK 2.0.1. It uses raw Publish/Republish/TransferSubscriptions so sequence numbers are visible. `meta`/`probe` sessions are anonymous or API-key; `watch`, `timing*` and `transfer` sessions use the API-key `UserName` identity. |
+
+Logs: `logs/run1` (master, the first graceful + crash run), `logs/run2` (the first fix, crash only), `logs/run3`
+(final fix: graceful, crash, timings and transfer), `logs/run4-unset`. `logs/actions.log` holds the operator timeline.
+The `server-*.log` files are the containers' stdout. From run 2 onwards they carry millisecond timestamps.
+
+## Legs
+
+| Leg | Result | Evidence |
+|---|---|---|
+| L1 metadata | **PASS** | `L1-L2-meta-warm.txt`. Both nodes: `RedundancySupport=2` (Warm), `i=2296` type definition `NonTransparentRedundancyType` (`i=2039`). `ServerUriArray` lists self first: `[central-1, central-2]` on :14840 and `[central-2, central-1]` on :14841. A Write to it returns `BadNotWritable`. `ServerArray` is unchanged and lists the same URIs. |
+| L2 bands | **PASS** | Steady state: Primary 250, Standby 90. Boot reads on a rejoin were 1 (run 3) or 50 (run 1, the Standby-degraded band). |
+| L3 standby data | **PASS** | `L3-L10-probe-warm.txt`. Standby reads of the raw tag, its UNS reference, the VirtualTag and the writable tag all return `BadOutOfService`/null, while the Primary reads Good at the same moment. Standby subscriptions deliver one `BadOutOfService` per item and then silence. Acquisition continues: on promotion the current value arrives Good without a new driver publish (`run1/watch-c2.log` 09:06:00.371). |
+| L4 standby alarms | **PASS** | `run1/watch-c*.log` 09:02:40–09:05:54: the Primary delivered 3 transitions (09:03, 09:04, 09:05) on both the Server-object item and the equipment-folder item; the Standby delivered 0. Reconfirmed after the crash (run 3, 09:26:00). A ConditionRefresh on the Standby returns RefreshStart, the retained `T13 even minute` condition, RefreshEnd. |
+| Unsolicited promotion refresh | **PASS** | On every promotion, each live event item received RefreshStart, the retained condition, RefreshEnd, followed by new events (run 1 09:06:00.371; run 3 09:23:28.650 and 09:10:59/09:24:49 crash). |
+| L5 graceful failover (`docker stop` of the Primary, i.e. CoordinatedShutdown → cluster Leave) | **PASS** (see note A) | Run 3, final build: the old Primary demoted at 09:23:28.332, the same millisecond it was marked Leaving. Its watcher saw ServiceLevel 0, then `BadOutOfService` on all three tags, with the last Good at src .213 and no Good after the drop. The survivor promoted at .601–.609 in the spec order: RefreshStart/retained/RefreshEnd, then SL 250, then Good data. There was no dual-Active window (~270 ms with no Active node). |
+| Three demotion timings (first delivery after demotion) | **PASS** | `run3/timing1..3.txt`, each across a live graceful demotion (detected 96–127 ms after `docker stop`). **T1:** 4 Good samples queued with publishing disabled, then the client handle changed 1→99, then demotion. The first Publish carried 5× `BadOutOfService`/null for h99, while `i=2267` (h2) stayed Good, so it was not rewritten. Republish of the same sequence number returned the same projected payload. **T2:** `maxNotificationsPerPublish=1` with prebuilt messages (`MoreNotifications=true`). Message *a* (seq 2) was Good while Active. After demotion, Republish of the prebuilt never-sent seq 3 returned `BadMessageNotAvailable`, and Publish then delivered seq 3 projected; its Republish was also projected. **T3:** Good was delivered while Active. After demotion, Republish returned the original Good; after the ack it returned `BadMessageNotAvailable`. |
+| L10 writes / Part 9 methods on the Standby | **PASS** | `L3-L10-probe-warm.txt` (API-key session with WriteOperate and AlarmAck). Standby: Write on the raw and UNS `calc-writable` returns **`BadInvalidState`**, and so do Acknowledge and AddComment on the condition. Active, same session shape: the Write is Good (the optimistic path; the Calculation driver later reverts it), Acknowledge is Good and AddComment is Good. HistoryRead returns `BadHistoryOperationUnsupported` on **both** nodes because the rig has no historian, so it is not refused by role. |
+| L6 crash failover (`docker kill` of the Primary, `PromoteWhilePrimaryUnreachable` on) | **FAIL on `0c19f26c` → PASS after `c569f806`** (findings 1 and 2) | Run 3, final build: kill at 09:24:37.877; unreachable at 09:24:49.263; the survivor promoted at **09:24:49.275** (`unreachable-primary`, 12 ms after its own event). The measured gap from kill to Active is **11.4 s**, all of it the failure detector. The survivor stayed Active through auto-down's removal (09:25:05) with Good data throughout. ServiceLevel went 250 → 200 (Active-stale, because no singleton heartbeat arrived while the Primary was unreachable) → 250 once its own singleton published (09:25:20). |
+| L7 rejoin / no failback | **PASS** | After the graceful stop and after the crash, the restarted node came back Standby: the boot read was 1 or 50, then 90, and 250/255 never appeared. The Active node stayed at 250 throughout (`run1`, `run3` 09:24:00, 09:25:48). |
+| L11 transfer | **PASS** | `run3/transfer-c1.log`. Session A (API key) created a subscription on the **Standby**, and its items read `BadOutOfService`. Session B, with the same identity, called `TransferSubscriptions(sendInitialValues=true)` and got Good; A received `GoodSubscriptionTransferred`. B's initial values were projected (`BadOutOfService`), and B received no Good until promotion. The Primary was then stopped and the node promoted: B got SL 250 and Good at 09:29:16.621. The other node was restarted and this node was stopped: B got SL 0 and `BadOutOfService` for both tags (09:30:09.514) before the session ended. |
+| L9 `Mode=Unset` regression | **PASS** | `run4-unset/`, same pair and same fixed build, recreated with `OpcUa__Redundancy__Mode=Unset`. The levels are 250/240. `RedundancySupport=0` and `i=2296` is `ServerRedundancyType`, as the SDK default. The orphan `i=11314` reads Good `[]`. Both nodes serve Good data, the secondary accepts writes and AddComment (today's path), and the secondary delivers alarm events (09:33:00) with 0 `BadOutOfService` in 190 Good notifications. |
+| L8 `Mode=None` standalone | not run | It was not in this gate's scope. It is covered by `WarmRedundancyWireTests` (None metadata). |
+| L12 durable restore | not applicable | Durable subscriptions are not enabled (SDK default off), as recorded in #717. |
+
+## Findings
+
+**Finding 1: the crash survivor demoted when auto-down removed the Primary (fixed).** On `0c19f26c` (`run1`,
+09:10:47 kill), the survivor promoted at 09:10:59 because the Primary was unreachable. At 09:11:15 auto-down removed the
+Primary. The snapshot's Primary then left the unreachable set, so rule (3′) no longer held, and the survivor **demoted
+to Standby** (`reason=snapshot`, SL 50, `BadOutOfService` to every client). It was Active again only at 09:11:30, when
+its own singleton finished the hand-over retries and published. The result was a ~15 s outage immediately after a
+successful crash failover. **Fix:** a Primary that is Down, or that this node saw removed (and not back since), counts as
+gone (`WarmRoleAuthority.SnapshotPrimaryGone`, removal tracked from `MemberRemoved` in `OpcUaPublishActor`). A member
+that is only Leaving/Exiting, or that the node never saw, does not count. Re-verified in run 2 (09:17:53) and run 3
+(09:24:37): no demotion through the removal.
+
+**Finding 2: the Warm verdict read Akka's read view, which lags the event being handled (fixed).** `OpcUaPublishActor`
+re-evaluated on each member/reachability event, but it read `Cluster.State`/`SelfMember`. Those come from
+`ClusterReadView`, which is a *separate* subscriber to the same events. In run 2 the survivor stayed Standby **4.3 s**
+after its own `UnreachableMember` event (09:18:05.722 → 09:18:09.988); it promoted only on the next health tick. The
+same lag shows in run 1's graceful handover. The old Primary demoted only at `Exiting` (SL src 09:06:00.552), while the
+survivor was already Active from .316. That left a ~240 ms dual-Active window, in which the old Primary delivered a Good
+sample at .400. **Fix:** the Warm verdict uses a membership view built from the actor's own subscription, falling back to
+the read view only before the first event. Re-verified: promotion now comes 12 ms after the unreachable event (run 3),
+and the graceful demotion lands at the same millisecond as Leaving, with no overlap (run 3, 09:23:28.332).
+
+Tests (Runtime.Tests, 1058 passed, 3 skipped):
+
+- `WarmRoleAuthorityTests.A_snapshot_Primary_that_auto_down_removed_is_still_gone_and_the_survivor_stays_Active`, a
+ truth table that includes the Leaving/Exiting/never-seen negatives;
+- `OpcUaPublishActorWarmTests.The_crash_survivor_promotes_on_its_own_unreachable_event_and_stays_Active_through_the_removal`,
+ which uses synthetic events that the read view never sees, with a positive control: the Primary comes back and
+ demotes the node.
+
+Mutations: a read-view-only verdict fails the actor test at promotion; turning off the removed arm fails both tests.
+
+## Notes
+
+- **A. The old Primary's level on a graceful leave is 0, not 90.** `ServiceLevelCalculator.ComputeWarm` returns 0 for a
+ member that is not Up/Joining, so a leaving Primary drops 250 → 0. The plan's L5 text says "drops to 90". 0 is below
+ every band, and it is what the code specifies for a non-Up member. This is a wording difference, not a defect.
+- **B. The Active-stale band after a crash.** The survivor sits at 200 (Active, stale snapshot) for ~10–20 s until its
+ own singleton publishes. Data stays Good. This is expected from `ComputeWarm`, and clients still see it as the
+ higher-level node.
+- **C. The manual-failover button** (AdminUI) was not used. `docker stop` drives the same mechanism
+ (CoordinatedShutdown → cluster Leave), and it can be scripted with millisecond timing.
+- Both nodes ran the same build in every run. The pre-fix image was used only for runs 1 and 2.
diff --git a/docs/plans/evidence/2026-11-13-717-t13/harness/Program.cs b/docs/plans/evidence/2026-11-13-717-t13/harness/Program.cs
new file mode 100644
index 000000000..39e33cc14
--- /dev/null
+++ b/docs/plans/evidence/2026-11-13-717-t13/harness/Program.cs
@@ -0,0 +1,445 @@
+// #717 T13 live-gate harness: a raw OPC UA client (SDK 2.0.1) against the isolated otopcua-t13 pair.
+using System.Diagnostics;
+using System.Text;
+using System.Text.Json;
+using Opc.Ua;
+using Opc.Ua.Client;
+
+const string RawNs = "https://zb.com/otopcua/raw";
+const string UnsNs = "https://zb.com/otopcua/uns";
+const string Equip = "EQ-3326c7a4728e";
+var apiKey = File.ReadAllText(Environment.GetEnvironmentVariable("T13_APIKEY_FILE") ?? "../apikey.txt").Trim();
+
+var cmd = args[0];
+var url = args.Length > 1 ? args[1] : "opc.tcp://localhost:14840/OtOpcUa";
+using var cts = new CancellationTokenSource();
+Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
+var ct = cts.Token;
+
+switch (cmd)
+{
+ case "meta": await Meta(url); break;
+ case "browse": await Browse(url, args[2]); break;
+ case "watch": await Watch(url, args[2], args.Length > 3 && args[3] == "anon"); break;
+ case "probe": await Probe(url, args.Length > 2 && args[2] == "ack"); break;
+ case "timing1": await Timing1(url, args[2]); break;
+ case "timing2": await Timing2(url, args[2]); break;
+ case "timing3": await Timing3(url, args[2]); break;
+ case "transfer": await Transfer(url, args[2]); break;
+ default: throw new ArgumentException(cmd);
+}
+
+return;
+
+// ------------------------------------------------------------------ helpers
+
+static string Now() => DateTime.UtcNow.ToString("HH:mm:ss.fff");
+static RequestHeader H() => new() { Timestamp = DateTimeUtc.Now, TimeoutHint = 10_000 };
+static string Val(DataValue v) => $"{(v.WrappedValue.IsNull ? "null" : v.WrappedValue.AsBoxedObject(Variant.BoxingBehavior.Legacy)?.ToString())} [{StatusCode.LookupSymbolicId(v.StatusCode.Code) ?? v.StatusCode.ToString()}] src={((DateTime)v.SourceTimestamp):HH:mm:ss.fff}";
+static string Sc(StatusCode s) => StatusCode.LookupSymbolicId(s.Code) ?? s.ToString();
+
+async Task Connect(string endpointUrl, bool anonymous = false)
+{
+ var telemetry = DefaultTelemetry.Create(_ => { });
+ var pki = Path.Combine(Path.GetTempPath(), "t13-client-pki");
+ var config = new ApplicationConfiguration(telemetry)
+ {
+ ApplicationName = "T13Harness",
+ ApplicationUri = $"urn:T13Harness:{Guid.NewGuid():N}",
+ ApplicationType = ApplicationType.Client,
+ SecurityConfiguration = new SecurityConfiguration
+ {
+ ApplicationCertificate = new CertificateIdentifier { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(pki, "own") },
+ TrustedIssuerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(pki, "issuer") },
+ TrustedPeerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(pki, "trusted") },
+ RejectedCertificateStore = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(pki, "rejected") },
+ AutoAcceptUntrustedCertificates = true,
+ },
+ ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
+ };
+ await config.ValidateAsync(ApplicationType.Client, ct);
+ var endpoint = await CoreClientUtils.SelectEndpointAsync(config, endpointUrl, false, telemetry, ct);
+ var identity = anonymous ? new UserIdentity(new AnonymousIdentityToken()) : new UserIdentity("apikey", Encoding.UTF8.GetBytes(apiKey));
+ return await new DefaultSessionFactory(telemetry).CreateAsync(config,
+ new ConfiguredEndpoint(null, endpoint!, EndpointConfiguration.Create(config)),
+ false, false, "T13Harness", 60_000, identity, default, ct);
+}
+
+NodeId RawN(ISession s, string id) => new(id, (ushort)s.NamespaceUris.GetIndex(RawNs));
+NodeId UnsN(ISession s, string id) => new(id, (ushort)s.NamespaceUris.GetIndex(UnsNs));
+
+async Task Read(ISession s, NodeId n, uint attr = Attributes.Value)
+{
+ var r = await ((SessionClient)s).ReadAsync(H(), 0, TimestampsToReturn.Both, new[] { new ReadValueId { NodeId = n, AttributeId = attr } }, ct);
+ return r.Results[0];
+}
+
+async Task ReadSl(ISession s) => Convert.ToByte((await Read(s, VariableIds.Server_ServiceLevel)).WrappedValue.AsBoxedObject(Variant.BoxingBehavior.Legacy));
+
+static EventFilter EventFilter()
+{
+ var f = new EventFilter();
+ f.AddSelectClause(new NodeId(ObjectTypes.BaseEventType), QualifiedName.From(BrowseNames.EventId)); // 0
+ f.AddSelectClause(new NodeId(ObjectTypes.BaseEventType), QualifiedName.From(BrowseNames.EventType)); // 1
+ f.AddSelectClause(new NodeId(ObjectTypes.BaseEventType), QualifiedName.From(BrowseNames.Message)); // 2
+ f.AddSelectClause(new NodeId(ObjectTypes.BaseEventType), QualifiedName.From(BrowseNames.Time)); // 3
+ f.SelectClauses += new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.ConditionType, AttributeId = Attributes.NodeId }; // 4 ConditionId
+ f.AddSelectClause(ObjectTypeIds.ConditionType, QualifiedName.From(BrowseNames.Retain)); // 5
+ f.SelectClauses += new SimpleAttributeOperand(ObjectTypeIds.AlarmConditionType, [QualifiedName.From(BrowseNames.ActiveState), QualifiedName.From(BrowseNames.Id)]); // 6
+ f.SelectClauses += new SimpleAttributeOperand(ObjectTypeIds.AcknowledgeableConditionType, [QualifiedName.From(BrowseNames.AckedState), QualifiedName.From(BrowseNames.Id)]); // 7
+ f.AddSelectClause(new NodeId(ObjectTypes.BaseEventType), QualifiedName.From(BrowseNames.SourceName)); // 8
+ return f;
+}
+
+static string TypeName(NodeId t) =>
+ t == ObjectTypeIds.RefreshStartEventType ? "RefreshStart" :
+ t == ObjectTypeIds.RefreshEndEventType ? "RefreshEnd" :
+ t == ObjectTypeIds.GeneralModelChangeEventType ? "ModelChange" :
+ t.IsNull ? "?" : t.ToString();
+
+static string EventStr(ArrayOf f)
+{
+ var type = TypeName(f[1].GetNodeId());
+ var msg = f[2].GetLocalizedText().Text;
+ var cond = f[4].IsNull ? "" : f[4].AsBoxedObject(Variant.BoxingBehavior.Legacy)?.ToString();
+ return $"type={type} msg=\"{msg}\" cond={cond} retain={f[5].AsBoxedObject(Variant.BoxingBehavior.Legacy)} active={f[6].AsBoxedObject(Variant.BoxingBehavior.Legacy)} acked={f[7].AsBoxedObject(Variant.BoxingBehavior.Legacy)} time={(f[3].IsNull ? "" : ((DateTime)f[3].GetDateTime()).ToString("HH:mm:ss.fff"))}";
+}
+
+async Task AddItem(SessionClient c, uint sub, NodeId node, uint handle, uint queue = 10, MonitoringMode mode = MonitoringMode.Reporting, MonitoringFilter? filter = null, uint attr = Attributes.Value)
+{
+ var r = await c.CreateMonitoredItemsAsync(H(), sub, TimestampsToReturn.Both, new[]
+ {
+ new MonitoredItemCreateRequest
+ {
+ ItemToMonitor = new ReadValueId { NodeId = node, AttributeId = attr },
+ MonitoringMode = mode,
+ RequestedParameters = new MonitoringParameters
+ {
+ ClientHandle = handle, SamplingInterval = attr == Attributes.EventNotifier ? 0 : 100, QueueSize = queue, DiscardOldest = true,
+ Filter = filter is null ? ExtensionObject.Null : new ExtensionObject(filter),
+ },
+ },
+ }, ct);
+ Console.WriteLine($"{Now()} create item h={handle} {node}: {Sc(r.Results[0].StatusCode)} id={r.Results[0].MonitoredItemId}");
+ return r.Results[0].MonitoredItemId;
+}
+
+static List Lines(NotificationMessage m, IReadOnlyDictionary names)
+{
+ var list = new List();
+ for (var i = 0; i < m.NotificationData.Count; i++)
+ {
+ if (m.NotificationData[i].TryGetValue(out var d))
+ for (var j = 0; j < d.MonitoredItems.Count; j++)
+ list.Add($"data h={d.MonitoredItems[j].ClientHandle}({names.GetValueOrDefault(d.MonitoredItems[j].ClientHandle, "?")}) {Val(d.MonitoredItems[j].Value)}");
+ else if (m.NotificationData[i].TryGetValue(out var e))
+ foreach (var ev in e.Events) list.Add($"event h={ev.ClientHandle}({names.GetValueOrDefault(ev.ClientHandle, "?")}) {EventStr(ev.EventFields)}");
+ else if (m.NotificationData[i].TryGetValue(out var s))
+ list.Add($"status {Sc(s.Status)}");
+ }
+
+ return list;
+}
+
+static List<(uint Handle, DataValue Value)> DataChanges(NotificationMessage m)
+{
+ var list = new List<(uint, DataValue)>();
+ for (var i = 0; i < m.NotificationData.Count; i++)
+ if (m.NotificationData[i].TryGetValue(out var d))
+ for (var j = 0; j < d.MonitoredItems.Count; j++) list.Add((d.MonitoredItems[j].ClientHandle, d.MonitoredItems[j].Value));
+ return list;
+}
+
+async Task PublishData(SessionClient c, ArrayOf acks = default)
+{
+ for (var i = 0; i < 40; i++)
+ {
+ var r = await c.PublishAsync(H(), acks, ct);
+ acks = default;
+ if (r.NotificationMessage.NotificationData.Count > 0) return r;
+ }
+
+ throw new InvalidOperationException("no data message");
+}
+
+// Stops the container in the background and returns once this session reads a non-Active ServiceLevel.
+async Task<(Process Stop, DateTime Issued, DateTime Demoted, byte Sl)> StopAndWaitForDemotion(ISession s, string container)
+{
+ var issued = DateTime.UtcNow;
+ var p = Process.Start(new ProcessStartInfo("docker", $"stop -t 60 {container}") { RedirectStandardOutput = true, RedirectStandardError = true })!;
+ Console.WriteLine($"{Now()} docker stop {container} issued");
+ while (true)
+ {
+ var sl = await ReadSl(s);
+ if (sl < 200) { var t = DateTime.UtcNow; Console.WriteLine($"{Now()} demoted: ServiceLevel={sl} after {(t - issued).TotalMilliseconds:F0} ms"); return (p, issued, t, sl); }
+ await Task.Delay(10, ct);
+ }
+}
+
+// ------------------------------------------------------------------ commands
+
+async Task Meta(string u)
+{
+ using var s = await Connect(u, anonymous: true);
+ Console.WriteLine($"endpoint {s.Endpoint.EndpointUrl} server={s.Endpoint.Server.ApplicationUri}");
+ foreach (var (name, n) in new[] { ("RedundancySupport i=3709", VariableIds.Server_ServerRedundancy_RedundancySupport),
+ ("ServerUriArray i=11314", VariableIds.Server_ServerRedundancy_ServerUriArray),
+ ("ServiceLevel i=2267", VariableIds.Server_ServiceLevel),
+ ("ServerArray i=2254", VariableIds.Server_ServerArray) })
+ {
+ var v = await Read(s, n);
+ var o = v.WrappedValue.AsBoxedObject(Variant.BoxingBehavior.Legacy);
+ Console.WriteLine($"{name}: {(o is Array a ? "[" + string.Join(", ", a.Cast