diff --git a/docs/DesignDecisions.md b/docs/DesignDecisions.md
index e315ce0..6b60257 100644
--- a/docs/DesignDecisions.md
+++ b/docs/DesignDecisions.md
@@ -199,12 +199,50 @@ Consequences, and how this sits with the existing failover/reconcile design:
goes to the worker's console/stderr, which is captured on dev hosts but is
not a metric, not a dashboard tile, and not part of any session-status or
alarm-feed payload, so a production deployment can truncate indefinitely
- without anyone noticing. Surfacing truncation as a **structural** degraded
- status (a field on the alarm-provider mode/status surface the dashboard and
- `StreamAlarms` consumers already read) is filed as a follow-up; until it
- lands, the log line is the only signal. A galaxy that truncates persistently
+ without anyone noticing. The structural signal that fixes this landed
+ separately — see the next decision. A galaxy that truncates persistently
is a configuration problem: raise `MxGateway:Alarms:MaxAlarmsPerFetch`.
+### Alarms — truncation is reported per record on the public snapshot stream
+
+Decision (2026-08-17): the truncated-fetch verdict above is carried to clients as
+`QueryActiveAlarmsReplyPayload.snapshot_truncated` on the worker IPC reply and as
+`ActiveAlarmSnapshot.from_truncated_snapshot` on **every record** of the public
+`QueryActiveAlarms` stream, with a matching `IGatewayAlarmService.SnapshotTruncated`
+driving a dashboard banner. Both fields are additive proto3 booleans.
+
+A per-record boolean is an odd shape for what is set-level status, so the reason
+matters: `rpc QueryActiveAlarms(QueryActiveAlarmsRequest) returns (stream
+ActiveAlarmSnapshot)` returns a *bare* message stream. There is no envelope, no
+header message, and no trailing summary to hang a set-level field off. Adding one
+would mean either a new wrapper message (breaking every existing client's stream
+element type) or a trailing metadata convention (invisible to clients that stop
+reading early). Stamping the flag identically on each record is the only carrier
+that is additive on the wire: clients that ignore the field deserialize exactly
+as before. Consumers should read it as "the set this record belongs to may be
+incomplete", never as a statement about the record's own fidelity — that is what
+`degraded` / `source_provider` mean, and the two are independent. The reply
+payload carries the flag as well because a prefix filter (or an empty galaxy) can
+leave zero records, and a truncated fetch with nothing to report still has to say
+so.
+
+The **detection heuristic is unchanged**: `IsTruncatedFetch` remains
+`fetchedRecordCount >= maxAlarmsPerFetch`. The live probe run for this work could
+not verify whether `ALARM_RECORDS/@COUNT` reports the total active count or only
+the records in the reply (`docs/AlarmProbeFindings.md`), and an exact-looking
+signal derived from an unverified attribute is worse than an honest heuristic —
+it would read as precise while being wrong in the one direction that matters.
+Switching to `@COUNT` stays blocked on probe evidence.
+
+The flag is **not latched**. It is replaced by each fetch's verdict, so the first
+sub-cap fetch clears it, and `GatewayAlarmMonitor.ClearCache` drops it with the
+cache generation it describes. A caveat that never turns off is a caveat
+operators learn to ignore.
+
+This is gateway metadata about **our** fetch mechanics, not a claim about MXAccess
+behaviour, so it is not a parity deviation: no event is synthesized and no
+MXAccess-observable semantics change.
+
## Session-Resilience Epic Scope
Decision (2026-07-09, archreview TST-04; migrated here 2026-08-07 from the retired
diff --git a/docs/plans/2026-07-10-dashboard-session-acl-tst15.md b/docs/plans/2026-07-10-dashboard-session-acl-tst15.md
index c1e23d7..fc7d09b 100644
--- a/docs/plans/2026-07-10-dashboard-session-acl-tst15.md
+++ b/docs/plans/2026-07-10-dashboard-session-acl-tst15.md
@@ -295,3 +295,57 @@ to defer heavy revocation.
- **Staleness bound.** A revoked tag grant takes effect within one token lifetime
(≤5 min) for token-auth connections and immediately for a fresh cookie login.
```
+
+## 12. As-built notes (enforcement landed 2026-08)
+
+### 12.1 §4's "no second seam" no longer held — the ACL gates two
+
+This design was written against a dashboard whose only route to a session's event
+feed was the SignalR hub, which is why §4 concludes "gating at join is sufficient —
+there is no second seam to guard". The 2026-08 in-process feed refactor invalidated
+that premise: server-rendered pages stopped opening a loopback SignalR connection to
+`/hubs/events` and now read the mirror directly through
+`IDashboardSessionEventSubscriber.Subscribe(sessionId)` (`DashboardEventBroadcaster`
+implements both the publish and subscribe interfaces). `SessionDetailsPage` is that
+second consumer, and it never touches `SubscribeSession`, so a hub-only gate would
+have left the page as an ungated path to the same events.
+
+The shipped enforcement therefore puts the *same* `IDashboardSessionAcl` decision in
+front of both subscribe calls:
+
+- `EventsHub.SubscribeSession` — denies with `HubException("Not authorized for this
+ session.")` before the group join **and** before the `EventsHubViewerRegistry`
+ registration, so a denied caller neither receives events nor turns the mirror on.
+- `SessionDetailsPage` — resolves the circuit principal via
+ `AuthenticationStateProvider` and checks the ACL *before* `Subscribe(SessionId)`.
+ A denial creates no subscription, starts no pump, and registers no viewer; the
+ events panel renders "Not authorized for this session's events." in place of its
+ empty state. The gate wraps only whether the subscription is created — the page's
+ generation/`ReferenceEquals` guards, `MarkDisconnectedAsync`, and the
+ `DisposeAsync`/`DetachEventsAsync` coupling are untouched.
+
+Both seams remain subscribe-time-only. Session tags are immutable for the session's
+life (§3), so a joined group or a live in-process subscription cannot go stale, and
+no per-event check is needed on either path.
+
+### 12.2 Where the grant is stamped
+
+`zb:dashboardtag` claims are added at both principal-construction sites:
+`DashboardAuthenticator.CreatePrincipal` (cookie login, so a circuit carries its
+grant without a token round-trip) and `HubTokenService.Issue` (hub bearer). Both
+resolve the grant from the caller's `mxgateway:ldap_group` claims through
+`DashboardGroupTagMapping` + `Dashboard:GroupToTag` rather than copying tag claims
+already on the principal — re-resolving at mint is what makes the token's 5-minute
+lifetime an actual staleness bound on a changed grant, as §4 claims. Tags are
+stamped for Administrators too; they are simply moot, because the ACL's admin bypass
+is checked first.
+
+### 12.3 Deviations worth knowing
+
+- `CanViewSession` takes a **nullable** `ClaimsPrincipal`. `HubCallerContext.User` is
+ nullable, and null denies — the fail-closed reading.
+- The admin bypass additionally requires `Identity.IsAuthenticated`, matching
+ `DashboardSessionAdminService.CanManage`. A role claim on an unauthenticated
+ identity does not bypass.
+- Tag *values* are never logged at either seam; only the identifiers and the
+ allow/deny outcome are observable.
diff --git a/gateway.md b/gateway.md
index 1105639..8fc8278 100644
--- a/gateway.md
+++ b/gateway.md
@@ -240,6 +240,24 @@ monitoring (forced)") when subtag mode is the configured `Fallback:Mode=ForceSub
as a fault. Metrics: `mxgateway.alarms.provider_mode` gauge (1 = alarmmgr,
2 = subtag) and `mxgateway.alarms.provider_switches` counter.
+**Truncated-snapshot visibility:** `GetXmlCurrentAlarms2` caps its reply at
+`MxGateway:Alarms:MaxAlarmsPerFetch` and offers no confirmed "more available"
+flag, so a reply holding exactly the cap is treated as truncated. On such a
+fetch `WnWrapAlarmConsumer` merges rather than replaces its retained snapshot,
+which suppresses the absence-implies-Clear inference and keeps a capped poll
+from broadcasting Clears for alarms it simply had no room to mention. That
+suppression is reported structurally rather than only in a rate-limited worker
+warning: the `QueryActiveAlarms` reply payload carries `snapshot_truncated`,
+every `ActiveAlarmSnapshot` in it carries `from_truncated_snapshot`, and the
+dashboard Alarms tab shows a warning banner while the flag is set. The flag
+means "this active set may be incomplete", not "this record is unreliable" —
+it is independent of the subtag-fallback `degraded` field above. It is not
+latched: the first fetch that comes back under the cap is complete, restores
+absence authority, and clears it. Detection remains the record-count heuristic;
+the reply's `ALARM_RECORDS/@COUNT` attribute would make the test exact only if
+it reported the total active count rather than the records in the reply, which
+a live probe could not discriminate (see `docs/AlarmProbeFindings.md`).
+
Forced modes are available via `MxGateway:Alarms:Fallback:Mode`:
`ForceAlarmManager` disables failover; `ForceSubtag` forces the standby
on from startup; `Auto` (default) enables failover and failback. Watch-list
diff --git a/src/ZB.MOM.WW.MxGateway.Contracts/Generated/MxaccessGateway.cs b/src/ZB.MOM.WW.MxGateway.Contracts/Generated/MxaccessGateway.cs
index bbaa9ff..feadce8 100644
--- a/src/ZB.MOM.WW.MxGateway.Contracts/Generated/MxaccessGateway.cs
+++ b/src/ZB.MOM.WW.MxGateway.Contracts/Generated/MxaccessGateway.cs
@@ -285,248 +285,249 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
"ASgJEhcKD214YWNjZXNzX3Byb2dpZBgDIAEoCRIWCg5teGFjY2Vzc19jbHNp",
"ZBgEIAEoCSJAChBEcmFpbkV2ZW50c1JlcGx5EiwKBmV2ZW50cxgBIAMoCzIc",
"Lm14YWNjZXNzX2dhdGV3YXkudjEuTXhFdmVudCI1ChxBY2tub3dsZWRnZUFs",
- "YXJtUmVwbHlQYXlsb2FkEhUKDW5hdGl2ZV9zdGF0dXMYASABKAUiXAodUXVl",
+ "YXJtUmVwbHlQYXlsb2FkEhUKDW5hdGl2ZV9zdGF0dXMYASABKAUieAodUXVl",
"cnlBY3RpdmVBbGFybXNSZXBseVBheWxvYWQSOwoJc25hcHNob3RzGAEgAygL",
- "MigubXhhY2Nlc3NfZ2F0ZXdheS52MS5BY3RpdmVBbGFybVNuYXBzaG90Io8I",
- "CgdNeEV2ZW50EjIKBmZhbWlseRgBIAEoDjIiLm14YWNjZXNzX2dhdGV3YXku",
- "djEuTXhFdmVudEZhbWlseRISCgpzZXNzaW9uX2lkGAIgASgJEhUKDXNlcnZl",
- "cl9oYW5kbGUYAyABKAUSEwoLaXRlbV9oYW5kbGUYBCABKAUSKwoFdmFsdWUY",
- "BSABKAsyHC5teGFjY2Vzc19nYXRld2F5LnYxLk14VmFsdWUSDwoHcXVhbGl0",
- "eRgGIAEoBRI0ChBzb3VyY2VfdGltZXN0YW1wGAcgASgLMhouZ29vZ2xlLnBy",
- "b3RvYnVmLlRpbWVzdGFtcBI0CghzdGF0dXNlcxgIIAMoCzIiLm14YWNjZXNz",
- "X2dhdGV3YXkudjEuTXhTdGF0dXNQcm94eRIXCg93b3JrZXJfc2VxdWVuY2UY",
- "CSABKAQSNAoQd29ya2VyX3RpbWVzdGFtcBgKIAEoCzIaLmdvb2dsZS5wcm90",
- "b2J1Zi5UaW1lc3RhbXASPQoZZ2F0ZXdheV9yZWNlaXZlX3RpbWVzdGFtcBgL",
- "IAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASFAoHaHJlc3VsdBgM",
- "IAEoBUgBiAEBEhIKCnJhd19zdGF0dXMYDSABKAkSNwoKcmVwbGF5X2dhcBgO",
- "IAEoCzIeLm14YWNjZXNzX2dhdGV3YXkudjEuUmVwbGF5R2FwSAKIAQESQAoO",
- "b25fZGF0YV9jaGFuZ2UYFCABKAsyJi5teGFjY2Vzc19nYXRld2F5LnYxLk9u",
- "RGF0YUNoYW5nZUV2ZW50SAASRgoRb25fd3JpdGVfY29tcGxldGUYFSABKAsy",
- "KS5teGFjY2Vzc19nYXRld2F5LnYxLk9uV3JpdGVDb21wbGV0ZUV2ZW50SAAS",
- "SQoSb3BlcmF0aW9uX2NvbXBsZXRlGBYgASgLMisubXhhY2Nlc3NfZ2F0ZXdh",
- "eS52MS5PcGVyYXRpb25Db21wbGV0ZUV2ZW50SAASUQoXb25fYnVmZmVyZWRf",
- "ZGF0YV9jaGFuZ2UYFyABKAsyLi5teGFjY2Vzc19nYXRld2F5LnYxLk9uQnVm",
- "ZmVyZWREYXRhQ2hhbmdlRXZlbnRIABJKChNvbl9hbGFybV90cmFuc2l0aW9u",
- "GBggASgLMisubXhhY2Nlc3NfZ2F0ZXdheS52MS5PbkFsYXJtVHJhbnNpdGlv",
- "bkV2ZW50SAASXgoeb25fYWxhcm1fcHJvdmlkZXJfbW9kZV9jaGFuZ2VkGBkg",
- "ASgLMjQubXhhY2Nlc3NfZ2F0ZXdheS52MS5PbkFsYXJtUHJvdmlkZXJNb2Rl",
- "Q2hhbmdlZEV2ZW50SABCBgoEYm9keUIKCghfaHJlc3VsdEINCgtfcmVwbGF5",
- "X2dhcCJQCglSZXBsYXlHYXASIAoYcmVxdWVzdGVkX2FmdGVyX3NlcXVlbmNl",
- "GAEgASgEEiEKGW9sZGVzdF9hdmFpbGFibGVfc2VxdWVuY2UYAiABKAQiEwoR",
- "T25EYXRhQ2hhbmdlRXZlbnQiFgoUT25Xcml0ZUNvbXBsZXRlRXZlbnQiGAoW",
- "T3BlcmF0aW9uQ29tcGxldGVFdmVudCLUAQoZT25CdWZmZXJlZERhdGFDaGFu",
- "Z2VFdmVudBIyCglkYXRhX3R5cGUYASABKA4yHy5teGFjY2Vzc19nYXRld2F5",
- "LnYxLk14RGF0YVR5cGUSNAoOcXVhbGl0eV92YWx1ZXMYAiABKAsyHC5teGFj",
- "Y2Vzc19nYXRld2F5LnYxLk14QXJyYXkSNgoQdGltZXN0YW1wX3ZhbHVlcxgD",
- "IAEoCzIcLm14YWNjZXNzX2dhdGV3YXkudjEuTXhBcnJheRIVCg1yYXdfZGF0",
- "YV90eXBlGAQgASgFItAEChZPbkFsYXJtVHJhbnNpdGlvbkV2ZW50EhwKFGFs",
- "YXJtX2Z1bGxfcmVmZXJlbmNlGAEgASgJEh8KF3NvdXJjZV9vYmplY3RfcmVm",
- "ZXJlbmNlGAIgASgJEhcKD2FsYXJtX3R5cGVfbmFtZRgDIAEoCRJBCg90cmFu",
- "c2l0aW9uX2tpbmQYBCABKA4yKC5teGFjY2Vzc19nYXRld2F5LnYxLkFsYXJt",
- "VHJhbnNpdGlvbktpbmQSEAoIc2V2ZXJpdHkYBSABKAUSPAoYb3JpZ2luYWxf",
- "cmFpc2VfdGltZXN0YW1wGAYgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVz",
- "dGFtcBI4ChR0cmFuc2l0aW9uX3RpbWVzdGFtcBgHIAEoCzIaLmdvb2dsZS5w",
- "cm90b2J1Zi5UaW1lc3RhbXASFQoNb3BlcmF0b3JfdXNlchgIIAEoCRIYChBv",
- "cGVyYXRvcl9jb21tZW50GAkgASgJEhAKCGNhdGVnb3J5GAogASgJEhMKC2Rl",
- "c2NyaXB0aW9uGAsgASgJEjMKDWN1cnJlbnRfdmFsdWUYDCABKAsyHC5teGFj",
- "Y2Vzc19nYXRld2F5LnYxLk14VmFsdWUSMQoLbGltaXRfdmFsdWUYDSABKAsy",
- "HC5teGFjY2Vzc19nYXRld2F5LnYxLk14VmFsdWUSEAoIZGVncmFkZWQYDiAB",
- "KAgSPwoPc291cmNlX3Byb3ZpZGVyGA8gASgOMiYubXhhY2Nlc3NfZ2F0ZXdh",
- "eS52MS5BbGFybVByb3ZpZGVyTW9kZSKgAQofT25BbGFybVByb3ZpZGVyTW9k",
- "ZUNoYW5nZWRFdmVudBI0CgRtb2RlGAEgASgOMiYubXhhY2Nlc3NfZ2F0ZXdh",
- "eS52MS5BbGFybVByb3ZpZGVyTW9kZRIOCgZyZWFzb24YAiABKAkSDwoHaHJl",
- "c3VsdBgDIAEoBRImCgJhdBgEIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1l",
- "c3RhbXAi0AQKE0FjdGl2ZUFsYXJtU25hcHNob3QSHAoUYWxhcm1fZnVsbF9y",
- "ZWZlcmVuY2UYASABKAkSHwoXc291cmNlX29iamVjdF9yZWZlcmVuY2UYAiAB",
- "KAkSFwoPYWxhcm1fdHlwZV9uYW1lGAMgASgJEhAKCHNldmVyaXR5GAQgASgF",
- "EjwKGG9yaWdpbmFsX3JhaXNlX3RpbWVzdGFtcBgFIAEoCzIaLmdvb2dsZS5w",
- "cm90b2J1Zi5UaW1lc3RhbXASPwoNY3VycmVudF9zdGF0ZRgGIAEoDjIoLm14",
- "YWNjZXNzX2dhdGV3YXkudjEuQWxhcm1Db25kaXRpb25TdGF0ZRIQCghjYXRl",
- "Z29yeRgHIAEoCRITCgtkZXNjcmlwdGlvbhgIIAEoCRI9ChlsYXN0X3RyYW5z",
- "aXRpb25fdGltZXN0YW1wGAkgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVz",
- "dGFtcBIVCg1vcGVyYXRvcl91c2VyGAogASgJEhgKEG9wZXJhdG9yX2NvbW1l",
- "bnQYCyABKAkSMwoNY3VycmVudF92YWx1ZRgMIAEoCzIcLm14YWNjZXNzX2dh",
- "dGV3YXkudjEuTXhWYWx1ZRIxCgtsaW1pdF92YWx1ZRgNIAEoCzIcLm14YWNj",
- "ZXNzX2dhdGV3YXkudjEuTXhWYWx1ZRIQCghkZWdyYWRlZBgOIAEoCBI/Cg9z",
- "b3VyY2VfcHJvdmlkZXIYDyABKA4yJi5teGFjY2Vzc19nYXRld2F5LnYxLkFs",
- "YXJtUHJvdmlkZXJNb2RlIpABChdBY2tub3dsZWRnZUFsYXJtUmVxdWVzdBId",
- "ChVjbGllbnRfY29ycmVsYXRpb25faWQYAiABKAkSHAoUYWxhcm1fZnVsbF9y",
- "ZWZlcmVuY2UYAyABKAkSDwoHY29tbWVudBgEIAEoCRIVCg1vcGVyYXRvcl91",
- "c2VyGAUgASgJSgQIARACUgpzZXNzaW9uX2lkIvEBChVBY2tub3dsZWRnZUFs",
- "YXJtUmVwbHkSFgoOY29ycmVsYXRpb25faWQYAiABKAkSPAoPcHJvdG9jb2xf",
- "c3RhdHVzGAMgASgLMiMubXhhY2Nlc3NfZ2F0ZXdheS52MS5Qcm90b2NvbFN0",
- "YXR1cxIUCgdocmVzdWx0GAQgASgFSACIAQESMgoGc3RhdHVzGAUgASgLMiIu",
- "bXhhY2Nlc3NfZ2F0ZXdheS52MS5NeFN0YXR1c1Byb3h5EhoKEmRpYWdub3N0",
- "aWNfbWVzc2FnZRgGIAEoCUIKCghfaHJlc3VsdEoECAEQAlIKc2Vzc2lvbl9p",
- "ZCJRChNTdHJlYW1BbGFybXNSZXF1ZXN0Eh0KFWNsaWVudF9jb3JyZWxhdGlv",
- "bl9pZBgBIAEoCRIbChNhbGFybV9maWx0ZXJfcHJlZml4GAIgASgJIoQCChBB",
- "bGFybUZlZWRNZXNzYWdlEkAKDGFjdGl2ZV9hbGFybRgBIAEoCzIoLm14YWNj",
- "ZXNzX2dhdGV3YXkudjEuQWN0aXZlQWxhcm1TbmFwc2hvdEgAEhsKEXNuYXBz",
- "aG90X2NvbXBsZXRlGAIgASgISAASQQoKdHJhbnNpdGlvbhgDIAEoCzIrLm14",
- "YWNjZXNzX2dhdGV3YXkudjEuT25BbGFybVRyYW5zaXRpb25FdmVudEgAEkMK",
- "D3Byb3ZpZGVyX3N0YXR1cxgEIAEoCzIoLm14YWNjZXNzX2dhdGV3YXkudjEu",
- "QWxhcm1Qcm92aWRlclN0YXR1c0gAQgkKB3BheWxvYWQimAEKE0FsYXJtUHJv",
- "dmlkZXJTdGF0dXMSNAoEbW9kZRgBIAEoDjImLm14YWNjZXNzX2dhdGV3YXku",
- "djEuQWxhcm1Qcm92aWRlck1vZGUSEAoIZGVncmFkZWQYAiABKAgSDgoGcmVh",
- "c29uGAMgASgJEikKBXNpbmNlGAQgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRp",
- "bWVzdGFtcCLrAQoNTXhTdGF0dXNQcm94eRIPCgdzdWNjZXNzGAEgASgFEjcK",
- "CGNhdGVnb3J5GAIgASgOMiUubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeFN0YXR1",
- "c0NhdGVnb3J5EjgKC2RldGVjdGVkX2J5GAMgASgOMiMubXhhY2Nlc3NfZ2F0",
- "ZXdheS52MS5NeFN0YXR1c1NvdXJjZRIOCgZkZXRhaWwYBCABKAUSFAoMcmF3",
- "X2NhdGVnb3J5GAUgASgFEhcKD3Jhd19kZXRlY3RlZF9ieRgGIAEoBRIXCg9k",
- "aWFnbm9zdGljX3RleHQYByABKAki6QMKB014VmFsdWUSMgoJZGF0YV90eXBl",
- "GAEgASgOMh8ubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeERhdGFUeXBlEhQKDHZh",
- "cmlhbnRfdHlwZRgCIAEoCRIPCgdpc19udWxsGAMgASgIEhYKDnJhd19kaWFn",
- "bm9zdGljGAQgASgJEhUKDXJhd19kYXRhX3R5cGUYBSABKAUSFAoKYm9vbF92",
- "YWx1ZRgKIAEoCEgAEhUKC2ludDMyX3ZhbHVlGAsgASgFSAASFQoLaW50NjRf",
- "dmFsdWUYDCABKANIABIVCgtmbG9hdF92YWx1ZRgNIAEoAkgAEhYKDGRvdWJs",
- "ZV92YWx1ZRgOIAEoAUgAEhYKDHN0cmluZ192YWx1ZRgPIAEoCUgAEjUKD3Rp",
- "bWVzdGFtcF92YWx1ZRgQIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3Rh",
- "bXBIABIzCgthcnJheV92YWx1ZRgRIAEoCzIcLm14YWNjZXNzX2dhdGV3YXku",
- "djEuTXhBcnJheUgAEhMKCXJhd192YWx1ZRgSIAEoDEgAEkAKEnNwYXJzZV9h",
- "cnJheV92YWx1ZRgTIAEoCzIiLm14YWNjZXNzX2dhdGV3YXkudjEuTXhTcGFy",
- "c2VBcnJheUgAQgYKBGtpbmQi/gQKB014QXJyYXkSOgoRZWxlbWVudF9kYXRh",
- "X3R5cGUYASABKA4yHy5teGFjY2Vzc19nYXRld2F5LnYxLk14RGF0YVR5cGUS",
- "FAoMdmFyaWFudF90eXBlGAIgASgJEhIKCmRpbWVuc2lvbnMYAyADKA0SFgoO",
- "cmF3X2RpYWdub3N0aWMYBCABKAkSHQoVcmF3X2VsZW1lbnRfZGF0YV90eXBl",
- "GAUgASgFEjUKC2Jvb2xfdmFsdWVzGAogASgLMh4ubXhhY2Nlc3NfZ2F0ZXdh",
- "eS52MS5Cb29sQXJyYXlIABI3CgxpbnQzMl92YWx1ZXMYCyABKAsyHy5teGFj",
- "Y2Vzc19nYXRld2F5LnYxLkludDMyQXJyYXlIABI3CgxpbnQ2NF92YWx1ZXMY",
- "DCABKAsyHy5teGFjY2Vzc19nYXRld2F5LnYxLkludDY0QXJyYXlIABI3Cgxm",
- "bG9hdF92YWx1ZXMYDSABKAsyHy5teGFjY2Vzc19nYXRld2F5LnYxLkZsb2F0",
- "QXJyYXlIABI5Cg1kb3VibGVfdmFsdWVzGA4gASgLMiAubXhhY2Nlc3NfZ2F0",
- "ZXdheS52MS5Eb3VibGVBcnJheUgAEjkKDXN0cmluZ192YWx1ZXMYDyABKAsy",
- "IC5teGFjY2Vzc19nYXRld2F5LnYxLlN0cmluZ0FycmF5SAASPwoQdGltZXN0",
- "YW1wX3ZhbHVlcxgQIAEoCzIjLm14YWNjZXNzX2dhdGV3YXkudjEuVGltZXN0",
- "YW1wQXJyYXlIABIzCgpyYXdfdmFsdWVzGBEgASgLMh0ubXhhY2Nlc3NfZ2F0",
- "ZXdheS52MS5SYXdBcnJheUgAQggKBnZhbHVlcyKZAQoNTXhTcGFyc2VBcnJh",
- "eRI6ChFlbGVtZW50X2RhdGFfdHlwZRgBIAEoDjIfLm14YWNjZXNzX2dhdGV3",
- "YXkudjEuTXhEYXRhVHlwZRIUCgx0b3RhbF9sZW5ndGgYAiABKA0SNgoIZWxl",
- "bWVudHMYAyADKAsyJC5teGFjY2Vzc19nYXRld2F5LnYxLk14U3BhcnNlRWxl",
- "bWVudCJNCg9NeFNwYXJzZUVsZW1lbnQSDQoFaW5kZXgYASABKA0SKwoFdmFs",
- "dWUYAiABKAsyHC5teGFjY2Vzc19nYXRld2F5LnYxLk14VmFsdWUiGwoJQm9v",
- "bEFycmF5Eg4KBnZhbHVlcxgBIAMoCCIcCgpJbnQzMkFycmF5Eg4KBnZhbHVl",
- "cxgBIAMoBSIcCgpJbnQ2NEFycmF5Eg4KBnZhbHVlcxgBIAMoAyIcCgpGbG9h",
- "dEFycmF5Eg4KBnZhbHVlcxgBIAMoAiIdCgtEb3VibGVBcnJheRIOCgZ2YWx1",
- "ZXMYASADKAEiHQoLU3RyaW5nQXJyYXkSDgoGdmFsdWVzGAEgAygJIjwKDlRp",
- "bWVzdGFtcEFycmF5EioKBnZhbHVlcxgBIAMoCzIaLmdvb2dsZS5wcm90b2J1",
- "Zi5UaW1lc3RhbXAiGgoIUmF3QXJyYXkSDgoGdmFsdWVzGAEgAygMIlgKDlBy",
- "b3RvY29sU3RhdHVzEjUKBGNvZGUYASABKA4yJy5teGFjY2Vzc19nYXRld2F5",
- "LnYxLlByb3RvY29sU3RhdHVzQ29kZRIPCgdtZXNzYWdlGAIgASgJKp8LCg1N",
- "eENvbW1hbmRLaW5kEh8KG01YX0NPTU1BTkRfS0lORF9VTlNQRUNJRklFRBAA",
- "EhwKGE1YX0NPTU1BTkRfS0lORF9SRUdJU1RFUhABEh4KGk1YX0NPTU1BTkRf",
- "S0lORF9VTlJFR0lTVEVSEAISHAoYTVhfQ09NTUFORF9LSU5EX0FERF9JVEVN",
- "EAMSHQoZTVhfQ09NTUFORF9LSU5EX0FERF9JVEVNMhAEEh8KG01YX0NPTU1B",
- "TkRfS0lORF9SRU1PVkVfSVRFTRAFEhoKFk1YX0NPTU1BTkRfS0lORF9BRFZJ",
- "U0UQBhIdChlNWF9DT01NQU5EX0tJTkRfVU5fQURWSVNFEAcSJgoiTVhfQ09N",
- "TUFORF9LSU5EX0FEVklTRV9TVVBFUlZJU09SWRAIEiUKIU1YX0NPTU1BTkRf",
- "S0lORF9BRERfQlVGRkVSRURfSVRFTRAJEjAKLE1YX0NPTU1BTkRfS0lORF9T",
- "RVRfQlVGRkVSRURfVVBEQVRFX0lOVEVSVkFMEAoSGwoXTVhfQ09NTUFORF9L",
- "SU5EX1NVU1BFTkQQCxIcChhNWF9DT01NQU5EX0tJTkRfQUNUSVZBVEUQDBIZ",
- "ChVNWF9DT01NQU5EX0tJTkRfV1JJVEUQDRIaChZNWF9DT01NQU5EX0tJTkRf",
- "V1JJVEUyEA4SIQodTVhfQ09NTUFORF9LSU5EX1dSSVRFX1NFQ1VSRUQQDxIi",
- "Ch5NWF9DT01NQU5EX0tJTkRfV1JJVEVfU0VDVVJFRDIQEBIlCiFNWF9DT01N",
- "QU5EX0tJTkRfQVVUSEVOVElDQVRFX1VTRVIQERIoCiRNWF9DT01NQU5EX0tJ",
- "TkRfQVJDSEVTVFJBX1VTRVJfVE9fSUQQEhIhCh1NWF9DT01NQU5EX0tJTkRf",
- "QUREX0lURU1fQlVMSxATEiQKIE1YX0NPTU1BTkRfS0lORF9BRFZJU0VfSVRF",
- "TV9CVUxLEBQSJAogTVhfQ09NTUFORF9LSU5EX1JFTU9WRV9JVEVNX0JVTEsQ",
- "FRInCiNNWF9DT01NQU5EX0tJTkRfVU5fQURWSVNFX0lURU1fQlVMSxAWEiIK",
- "Hk1YX0NPTU1BTkRfS0lORF9TVUJTQ1JJQkVfQlVMSxAXEiQKIE1YX0NPTU1B",
- "TkRfS0lORF9VTlNVQlNDUklCRV9CVUxLEBgSJAogTVhfQ09NTUFORF9LSU5E",
- "X1NVQlNDUklCRV9BTEFSTVMQGRImCiJNWF9DT01NQU5EX0tJTkRfVU5TVUJT",
- "Q1JJQkVfQUxBUk1TEBoSJQohTVhfQ09NTUFORF9LSU5EX0FDS05PV0xFREdF",
- "X0FMQVJNEBsSJwojTVhfQ09NTUFORF9LSU5EX1FVRVJZX0FDVElWRV9BTEFS",
- "TVMQHBItCilNWF9DT01NQU5EX0tJTkRfQUNLTk9XTEVER0VfQUxBUk1fQllf",
- "TkFNRRAdEh4KGk1YX0NPTU1BTkRfS0lORF9XUklURV9CVUxLEB4SHwobTVhf",
- "Q09NTUFORF9LSU5EX1dSSVRFMl9CVUxLEB8SJgoiTVhfQ09NTUFORF9LSU5E",
- "X1dSSVRFX1NFQ1VSRURfQlVMSxAgEicKI01YX0NPTU1BTkRfS0lORF9XUklU",
- "RV9TRUNVUkVEMl9CVUxLECESHQoZTVhfQ09NTUFORF9LSU5EX1JFQURfQlVM",
- "SxAiEhgKFE1YX0NPTU1BTkRfS0lORF9QSU5HEGQSJQohTVhfQ09NTUFORF9L",
- "SU5EX0dFVF9TRVNTSU9OX1NUQVRFEGUSIwofTVhfQ09NTUFORF9LSU5EX0dF",
- "VF9XT1JLRVJfSU5GTxBmEiAKHE1YX0NPTU1BTkRfS0lORF9EUkFJTl9FVkVO",
- "VFMQZxIjCh9NWF9DT01NQU5EX0tJTkRfU0hVVERPV05fV09SS0VSEGgqegoR",
- "QWxhcm1Qcm92aWRlck1vZGUSIwofQUxBUk1fUFJPVklERVJfTU9ERV9VTlNQ",
- "RUNJRklFRBAAEiAKHEFMQVJNX1BST1ZJREVSX01PREVfQUxBUk1NR1IQARIe",
- "ChpBTEFSTV9QUk9WSURFUl9NT0RFX1NVQlRBRxACKq0CCg1NeEV2ZW50RmFt",
- "aWx5Eh8KG01YX0VWRU5UX0ZBTUlMWV9VTlNQRUNJRklFRBAAEiIKHk1YX0VW",
- "RU5UX0ZBTUlMWV9PTl9EQVRBX0NIQU5HRRABEiUKIU1YX0VWRU5UX0ZBTUlM",
- "WV9PTl9XUklURV9DT01QTEVURRACEiYKIk1YX0VWRU5UX0ZBTUlMWV9PUEVS",
- "QVRJT05fQ09NUExFVEUQAxIrCidNWF9FVkVOVF9GQU1JTFlfT05fQlVGRkVS",
- "RURfREFUQV9DSEFOR0UQBBInCiNNWF9FVkVOVF9GQU1JTFlfT05fQUxBUk1f",
- "VFJBTlNJVElPThAFEjIKLk1YX0VWRU5UX0ZBTUlMWV9PTl9BTEFSTV9QUk9W",
- "SURFUl9NT0RFX0NIQU5HRUQQBirKAQoTQWxhcm1UcmFuc2l0aW9uS2luZBIl",
- "CiFBTEFSTV9UUkFOU0lUSU9OX0tJTkRfVU5TUEVDSUZJRUQQABIfChtBTEFS",
- "TV9UUkFOU0lUSU9OX0tJTkRfUkFJU0UQARIlCiFBTEFSTV9UUkFOU0lUSU9O",
- "X0tJTkRfQUNLTk9XTEVER0UQAhIfChtBTEFSTV9UUkFOU0lUSU9OX0tJTkRf",
- "Q0xFQVIQAxIjCh9BTEFSTV9UUkFOU0lUSU9OX0tJTkRfUkVUUklHR0VSEAQq",
- "qgEKE0FsYXJtQ29uZGl0aW9uU3RhdGUSJQohQUxBUk1fQ09ORElUSU9OX1NU",
- "QVRFX1VOU1BFQ0lGSUVEEAASIAocQUxBUk1fQ09ORElUSU9OX1NUQVRFX0FD",
- "VElWRRABEiYKIkFMQVJNX0NPTkRJVElPTl9TVEFURV9BQ1RJVkVfQUNLRUQQ",
- "AhIiCh5BTEFSTV9DT05ESVRJT05fU1RBVEVfSU5BQ1RJVkUQAyqlAwoQTXhT",
- "dGF0dXNDYXRlZ29yeRIiCh5NWF9TVEFUVVNfQ0FURUdPUllfVU5TUEVDSUZJ",
- "RUQQABIeChpNWF9TVEFUVVNfQ0FURUdPUllfVU5LTk9XThABEhkKFU1YX1NU",
- "QVRVU19DQVRFR09SWV9PSxACEh4KGk1YX1NUQVRVU19DQVRFR09SWV9QRU5E",
- "SU5HEAMSHgoaTVhfU1RBVFVTX0NBVEVHT1JZX1dBUk5JTkcQBBIqCiZNWF9T",
- "VEFUVVNfQ0FURUdPUllfQ09NTVVOSUNBVElPTl9FUlJPUhAFEioKJk1YX1NU",
- "QVRVU19DQVRFR09SWV9DT05GSUdVUkFUSU9OX0VSUk9SEAYSKAokTVhfU1RB",
- "VFVTX0NBVEVHT1JZX09QRVJBVElPTkFMX0VSUk9SEAcSJQohTVhfU1RBVFVT",
- "X0NBVEVHT1JZX1NFQ1VSSVRZX0VSUk9SEAgSJQohTVhfU1RBVFVTX0NBVEVH",
- "T1JZX1NPRlRXQVJFX0VSUk9SEAkSIgoeTVhfU1RBVFVTX0NBVEVHT1JZX09U",
- "SEVSX0VSUk9SEAoqygIKDk14U3RhdHVzU291cmNlEiAKHE1YX1NUQVRVU19T",
- "T1VSQ0VfVU5TUEVDSUZJRUQQABIcChhNWF9TVEFUVVNfU09VUkNFX1VOS05P",
- "V04QARIjCh9NWF9TVEFUVVNfU09VUkNFX1JFUVVFU1RJTkdfTE1YEAISIwof",
- "TVhfU1RBVFVTX1NPVVJDRV9SRVNQT05ESU5HX0xNWBADEiMKH01YX1NUQVRV",
- "U19TT1VSQ0VfUkVRVUVTVElOR19OTVgQBBIjCh9NWF9TVEFUVVNfU09VUkNF",
- "X1JFU1BPTkRJTkdfTk1YEAUSMQotTVhfU1RBVFVTX1NPVVJDRV9SRVFVRVNU",
- "SU5HX0FVVE9NQVRJT05fT0JKRUNUEAYSMQotTVhfU1RBVFVTX1NPVVJDRV9S",
- "RVNQT05ESU5HX0FVVE9NQVRJT05fT0JKRUNUEAcq3QQKCk14RGF0YVR5cGUS",
- "HAoYTVhfREFUQV9UWVBFX1VOU1BFQ0lGSUVEEAASGAoUTVhfREFUQV9UWVBF",
- "X1VOS05PV04QARIYChRNWF9EQVRBX1RZUEVfTk9fREFUQRACEhgKFE1YX0RB",
- "VEFfVFlQRV9CT09MRUFOEAMSGAoUTVhfREFUQV9UWVBFX0lOVEVHRVIQBBIW",
- "ChJNWF9EQVRBX1RZUEVfRkxPQVQQBRIXChNNWF9EQVRBX1RZUEVfRE9VQkxF",
- "EAYSFwoTTVhfREFUQV9UWVBFX1NUUklORxAHEhUKEU1YX0RBVEFfVFlQRV9U",
- "SU1FEAgSHQoZTVhfREFUQV9UWVBFX0VMQVBTRURfVElNRRAJEh8KG01YX0RB",
- "VEFfVFlQRV9SRUZFUkVOQ0VfVFlQRRAKEhwKGE1YX0RBVEFfVFlQRV9TVEFU",
- "VVNfVFlQRRALEhUKEU1YX0RBVEFfVFlQRV9FTlVNEAwSLQopTVhfREFUQV9U",
- "WVBFX1NFQ1VSSVRZX0NMQVNTSUZJQ0FUSU9OX0VOVU0QDRIiCh5NWF9EQVRB",
- "X1RZUEVfREFUQV9RVUFMSVRZX1RZUEUQDhIfChtNWF9EQVRBX1RZUEVfUVVB",
- "TElGSUVEX0VOVU0QDxIhCh1NWF9EQVRBX1RZUEVfUVVBTElGSUVEX1NUUlVD",
- "VBAQEikKJU1YX0RBVEFfVFlQRV9JTlRFUk5BVElPTkFMSVpFRF9TVFJJTkcQ",
- "ERIbChdNWF9EQVRBX1RZUEVfQklHX1NUUklORxASEhQKEE1YX0RBVEFfVFlQ",
- "RV9FTkQQEyqjAwoSUHJvdG9jb2xTdGF0dXNDb2RlEiQKIFBST1RPQ09MX1NU",
- "QVRVU19DT0RFX1VOU1BFQ0lGSUVEEAASGwoXUFJPVE9DT0xfU1RBVFVTX0NP",
- "REVfT0sQARIoCiRQUk9UT0NPTF9TVEFUVVNfQ09ERV9JTlZBTElEX1JFUVVF",
- "U1QQAhIqCiZQUk9UT0NPTF9TVEFUVVNfQ09ERV9TRVNTSU9OX05PVF9GT1VO",
- "RBADEioKJlBST1RPQ09MX1NUQVRVU19DT0RFX1NFU1NJT05fTk9UX1JFQURZ",
- "EAQSKwonUFJPVE9DT0xfU1RBVFVTX0NPREVfV09SS0VSX1VOQVZBSUxBQkxF",
- "EAUSIAocUFJPVE9DT0xfU1RBVFVTX0NPREVfVElNRU9VVBAGEiEKHVBST1RP",
- "Q09MX1NUQVRVU19DT0RFX0NBTkNFTEVEEAcSKwonUFJPVE9DT0xfU1RBVFVT",
- "X0NPREVfUFJPVE9DT0xfVklPTEFUSU9OEAgSKQolUFJPVE9DT0xfU1RBVFVT",
- "X0NPREVfTVhBQ0NFU1NfRkFJTFVSRRAJKr8CCgxTZXNzaW9uU3RhdGUSHQoZ",
- "U0VTU0lPTl9TVEFURV9VTlNQRUNJRklFRBAAEhoKFlNFU1NJT05fU1RBVEVf",
- "Q1JFQVRJTkcQARIhCh1TRVNTSU9OX1NUQVRFX1NUQVJUSU5HX1dPUktFUhAC",
- "EiIKHlNFU1NJT05fU1RBVEVfV0FJVElOR19GT1JfUElQRRADEh0KGVNFU1NJ",
- "T05fU1RBVEVfSEFORFNIQUtJTkcQBBIlCiFTRVNTSU9OX1NUQVRFX0lOSVRJ",
- "QUxJWklOR19XT1JLRVIQBRIXChNTRVNTSU9OX1NUQVRFX1JFQURZEAYSGQoV",
- "U0VTU0lPTl9TVEFURV9DTE9TSU5HEAcSGAoUU0VTU0lPTl9TVEFURV9DTE9T",
- "RUQQCBIZChVTRVNTSU9OX1NUQVRFX0ZBVUxURUQQCTLDBQoPTXhBY2Nlc3NH",
- "YXRld2F5El0KC09wZW5TZXNzaW9uEicubXhhY2Nlc3NfZ2F0ZXdheS52MS5P",
- "cGVuU2Vzc2lvblJlcXVlc3QaJS5teGFjY2Vzc19nYXRld2F5LnYxLk9wZW5T",
- "ZXNzaW9uUmVwbHkSYAoMQ2xvc2VTZXNzaW9uEigubXhhY2Nlc3NfZ2F0ZXdh",
- "eS52MS5DbG9zZVNlc3Npb25SZXF1ZXN0GiYubXhhY2Nlc3NfZ2F0ZXdheS52",
- "MS5DbG9zZVNlc3Npb25SZXBseRJUCgZJbnZva2USJS5teGFjY2Vzc19nYXRl",
- "d2F5LnYxLk14Q29tbWFuZFJlcXVlc3QaIy5teGFjY2Vzc19nYXRld2F5LnYx",
- "Lk14Q29tbWFuZFJlcGx5ElgKDFN0cmVhbUV2ZW50cxIoLm14YWNjZXNzX2dh",
- "dGV3YXkudjEuU3RyZWFtRXZlbnRzUmVxdWVzdBocLm14YWNjZXNzX2dhdGV3",
- "YXkudjEuTXhFdmVudDABEmwKEEFja25vd2xlZGdlQWxhcm0SLC5teGFjY2Vz",
- "c19nYXRld2F5LnYxLkFja25vd2xlZGdlQWxhcm1SZXF1ZXN0GioubXhhY2Nl",
- "c3NfZ2F0ZXdheS52MS5BY2tub3dsZWRnZUFsYXJtUmVwbHkSYQoMU3RyZWFt",
- "QWxhcm1zEigubXhhY2Nlc3NfZ2F0ZXdheS52MS5TdHJlYW1BbGFybXNSZXF1",
- "ZXN0GiUubXhhY2Nlc3NfZ2F0ZXdheS52MS5BbGFybUZlZWRNZXNzYWdlMAES",
- "bgoRUXVlcnlBY3RpdmVBbGFybXMSLS5teGFjY2Vzc19nYXRld2F5LnYxLlF1",
- "ZXJ5QWN0aXZlQWxhcm1zUmVxdWVzdBooLm14YWNjZXNzX2dhdGV3YXkudjEu",
- "QWN0aXZlQWxhcm1TbmFwc2hvdDABQiaqAiNaQi5NT00uV1cuTXhHYXRld2F5",
- "LkNvbnRyYWN0cy5Qcm90b2IGcHJvdG8z"));
+ "MigubXhhY2Nlc3NfZ2F0ZXdheS52MS5BY3RpdmVBbGFybVNuYXBzaG90EhoK",
+ "EnNuYXBzaG90X3RydW5jYXRlZBgCIAEoCCKPCAoHTXhFdmVudBIyCgZmYW1p",
+ "bHkYASABKA4yIi5teGFjY2Vzc19nYXRld2F5LnYxLk14RXZlbnRGYW1pbHkS",
+ "EgoKc2Vzc2lvbl9pZBgCIAEoCRIVCg1zZXJ2ZXJfaGFuZGxlGAMgASgFEhMK",
+ "C2l0ZW1faGFuZGxlGAQgASgFEisKBXZhbHVlGAUgASgLMhwubXhhY2Nlc3Nf",
+ "Z2F0ZXdheS52MS5NeFZhbHVlEg8KB3F1YWxpdHkYBiABKAUSNAoQc291cmNl",
+ "X3RpbWVzdGFtcBgHIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAS",
+ "NAoIc3RhdHVzZXMYCCADKAsyIi5teGFjY2Vzc19nYXRld2F5LnYxLk14U3Rh",
+ "dHVzUHJveHkSFwoPd29ya2VyX3NlcXVlbmNlGAkgASgEEjQKEHdvcmtlcl90",
+ "aW1lc3RhbXAYCiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEj0K",
+ "GWdhdGV3YXlfcmVjZWl2ZV90aW1lc3RhbXAYCyABKAsyGi5nb29nbGUucHJv",
+ "dG9idWYuVGltZXN0YW1wEhQKB2hyZXN1bHQYDCABKAVIAYgBARISCgpyYXdf",
+ "c3RhdHVzGA0gASgJEjcKCnJlcGxheV9nYXAYDiABKAsyHi5teGFjY2Vzc19n",
+ "YXRld2F5LnYxLlJlcGxheUdhcEgCiAEBEkAKDm9uX2RhdGFfY2hhbmdlGBQg",
+ "ASgLMiYubXhhY2Nlc3NfZ2F0ZXdheS52MS5PbkRhdGFDaGFuZ2VFdmVudEgA",
+ "EkYKEW9uX3dyaXRlX2NvbXBsZXRlGBUgASgLMikubXhhY2Nlc3NfZ2F0ZXdh",
+ "eS52MS5PbldyaXRlQ29tcGxldGVFdmVudEgAEkkKEm9wZXJhdGlvbl9jb21w",
+ "bGV0ZRgWIAEoCzIrLm14YWNjZXNzX2dhdGV3YXkudjEuT3BlcmF0aW9uQ29t",
+ "cGxldGVFdmVudEgAElEKF29uX2J1ZmZlcmVkX2RhdGFfY2hhbmdlGBcgASgL",
+ "Mi4ubXhhY2Nlc3NfZ2F0ZXdheS52MS5PbkJ1ZmZlcmVkRGF0YUNoYW5nZUV2",
+ "ZW50SAASSgoTb25fYWxhcm1fdHJhbnNpdGlvbhgYIAEoCzIrLm14YWNjZXNz",
+ "X2dhdGV3YXkudjEuT25BbGFybVRyYW5zaXRpb25FdmVudEgAEl4KHm9uX2Fs",
+ "YXJtX3Byb3ZpZGVyX21vZGVfY2hhbmdlZBgZIAEoCzI0Lm14YWNjZXNzX2dh",
+ "dGV3YXkudjEuT25BbGFybVByb3ZpZGVyTW9kZUNoYW5nZWRFdmVudEgAQgYK",
+ "BGJvZHlCCgoIX2hyZXN1bHRCDQoLX3JlcGxheV9nYXAiUAoJUmVwbGF5R2Fw",
+ "EiAKGHJlcXVlc3RlZF9hZnRlcl9zZXF1ZW5jZRgBIAEoBBIhChlvbGRlc3Rf",
+ "YXZhaWxhYmxlX3NlcXVlbmNlGAIgASgEIhMKEU9uRGF0YUNoYW5nZUV2ZW50",
+ "IhYKFE9uV3JpdGVDb21wbGV0ZUV2ZW50IhgKFk9wZXJhdGlvbkNvbXBsZXRl",
+ "RXZlbnQi1AEKGU9uQnVmZmVyZWREYXRhQ2hhbmdlRXZlbnQSMgoJZGF0YV90",
+ "eXBlGAEgASgOMh8ubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeERhdGFUeXBlEjQK",
+ "DnF1YWxpdHlfdmFsdWVzGAIgASgLMhwubXhhY2Nlc3NfZ2F0ZXdheS52MS5N",
+ "eEFycmF5EjYKEHRpbWVzdGFtcF92YWx1ZXMYAyABKAsyHC5teGFjY2Vzc19n",
+ "YXRld2F5LnYxLk14QXJyYXkSFQoNcmF3X2RhdGFfdHlwZRgEIAEoBSLQBAoW",
+ "T25BbGFybVRyYW5zaXRpb25FdmVudBIcChRhbGFybV9mdWxsX3JlZmVyZW5j",
+ "ZRgBIAEoCRIfChdzb3VyY2Vfb2JqZWN0X3JlZmVyZW5jZRgCIAEoCRIXCg9h",
+ "bGFybV90eXBlX25hbWUYAyABKAkSQQoPdHJhbnNpdGlvbl9raW5kGAQgASgO",
+ "MigubXhhY2Nlc3NfZ2F0ZXdheS52MS5BbGFybVRyYW5zaXRpb25LaW5kEhAK",
+ "CHNldmVyaXR5GAUgASgFEjwKGG9yaWdpbmFsX3JhaXNlX3RpbWVzdGFtcBgG",
+ "IAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASOAoUdHJhbnNpdGlv",
+ "bl90aW1lc3RhbXAYByABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1w",
+ "EhUKDW9wZXJhdG9yX3VzZXIYCCABKAkSGAoQb3BlcmF0b3JfY29tbWVudBgJ",
+ "IAEoCRIQCghjYXRlZ29yeRgKIAEoCRITCgtkZXNjcmlwdGlvbhgLIAEoCRIz",
+ "Cg1jdXJyZW50X3ZhbHVlGAwgASgLMhwubXhhY2Nlc3NfZ2F0ZXdheS52MS5N",
+ "eFZhbHVlEjEKC2xpbWl0X3ZhbHVlGA0gASgLMhwubXhhY2Nlc3NfZ2F0ZXdh",
+ "eS52MS5NeFZhbHVlEhAKCGRlZ3JhZGVkGA4gASgIEj8KD3NvdXJjZV9wcm92",
+ "aWRlchgPIAEoDjImLm14YWNjZXNzX2dhdGV3YXkudjEuQWxhcm1Qcm92aWRl",
+ "ck1vZGUioAEKH09uQWxhcm1Qcm92aWRlck1vZGVDaGFuZ2VkRXZlbnQSNAoE",
+ "bW9kZRgBIAEoDjImLm14YWNjZXNzX2dhdGV3YXkudjEuQWxhcm1Qcm92aWRl",
+ "ck1vZGUSDgoGcmVhc29uGAIgASgJEg8KB2hyZXN1bHQYAyABKAUSJgoCYXQY",
+ "BCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIvEEChNBY3RpdmVB",
+ "bGFybVNuYXBzaG90EhwKFGFsYXJtX2Z1bGxfcmVmZXJlbmNlGAEgASgJEh8K",
+ "F3NvdXJjZV9vYmplY3RfcmVmZXJlbmNlGAIgASgJEhcKD2FsYXJtX3R5cGVf",
+ "bmFtZRgDIAEoCRIQCghzZXZlcml0eRgEIAEoBRI8ChhvcmlnaW5hbF9yYWlz",
+ "ZV90aW1lc3RhbXAYBSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1w",
+ "Ej8KDWN1cnJlbnRfc3RhdGUYBiABKA4yKC5teGFjY2Vzc19nYXRld2F5LnYx",
+ "LkFsYXJtQ29uZGl0aW9uU3RhdGUSEAoIY2F0ZWdvcnkYByABKAkSEwoLZGVz",
+ "Y3JpcHRpb24YCCABKAkSPQoZbGFzdF90cmFuc2l0aW9uX3RpbWVzdGFtcBgJ",
+ "IAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASFQoNb3BlcmF0b3Jf",
+ "dXNlchgKIAEoCRIYChBvcGVyYXRvcl9jb21tZW50GAsgASgJEjMKDWN1cnJl",
+ "bnRfdmFsdWUYDCABKAsyHC5teGFjY2Vzc19nYXRld2F5LnYxLk14VmFsdWUS",
+ "MQoLbGltaXRfdmFsdWUYDSABKAsyHC5teGFjY2Vzc19nYXRld2F5LnYxLk14",
+ "VmFsdWUSEAoIZGVncmFkZWQYDiABKAgSPwoPc291cmNlX3Byb3ZpZGVyGA8g",
+ "ASgOMiYubXhhY2Nlc3NfZ2F0ZXdheS52MS5BbGFybVByb3ZpZGVyTW9kZRIf",
+ "Chdmcm9tX3RydW5jYXRlZF9zbmFwc2hvdBgQIAEoCCKQAQoXQWNrbm93bGVk",
+ "Z2VBbGFybVJlcXVlc3QSHQoVY2xpZW50X2NvcnJlbGF0aW9uX2lkGAIgASgJ",
+ "EhwKFGFsYXJtX2Z1bGxfcmVmZXJlbmNlGAMgASgJEg8KB2NvbW1lbnQYBCAB",
+ "KAkSFQoNb3BlcmF0b3JfdXNlchgFIAEoCUoECAEQAlIKc2Vzc2lvbl9pZCLx",
+ "AQoVQWNrbm93bGVkZ2VBbGFybVJlcGx5EhYKDmNvcnJlbGF0aW9uX2lkGAIg",
+ "ASgJEjwKD3Byb3RvY29sX3N0YXR1cxgDIAEoCzIjLm14YWNjZXNzX2dhdGV3",
+ "YXkudjEuUHJvdG9jb2xTdGF0dXMSFAoHaHJlc3VsdBgEIAEoBUgAiAEBEjIK",
+ "BnN0YXR1cxgFIAEoCzIiLm14YWNjZXNzX2dhdGV3YXkudjEuTXhTdGF0dXNQ",
+ "cm94eRIaChJkaWFnbm9zdGljX21lc3NhZ2UYBiABKAlCCgoIX2hyZXN1bHRK",
+ "BAgBEAJSCnNlc3Npb25faWQiUQoTU3RyZWFtQWxhcm1zUmVxdWVzdBIdChVj",
+ "bGllbnRfY29ycmVsYXRpb25faWQYASABKAkSGwoTYWxhcm1fZmlsdGVyX3By",
+ "ZWZpeBgCIAEoCSKEAgoQQWxhcm1GZWVkTWVzc2FnZRJACgxhY3RpdmVfYWxh",
+ "cm0YASABKAsyKC5teGFjY2Vzc19nYXRld2F5LnYxLkFjdGl2ZUFsYXJtU25h",
+ "cHNob3RIABIbChFzbmFwc2hvdF9jb21wbGV0ZRgCIAEoCEgAEkEKCnRyYW5z",
+ "aXRpb24YAyABKAsyKy5teGFjY2Vzc19nYXRld2F5LnYxLk9uQWxhcm1UcmFu",
+ "c2l0aW9uRXZlbnRIABJDCg9wcm92aWRlcl9zdGF0dXMYBCABKAsyKC5teGFj",
+ "Y2Vzc19nYXRld2F5LnYxLkFsYXJtUHJvdmlkZXJTdGF0dXNIAEIJCgdwYXls",
+ "b2FkIpgBChNBbGFybVByb3ZpZGVyU3RhdHVzEjQKBG1vZGUYASABKA4yJi5t",
+ "eGFjY2Vzc19nYXRld2F5LnYxLkFsYXJtUHJvdmlkZXJNb2RlEhAKCGRlZ3Jh",
+ "ZGVkGAIgASgIEg4KBnJlYXNvbhgDIAEoCRIpCgVzaW5jZRgEIAEoCzIaLmdv",
+ "b2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAi6wEKDU14U3RhdHVzUHJveHkSDwoH",
+ "c3VjY2VzcxgBIAEoBRI3CghjYXRlZ29yeRgCIAEoDjIlLm14YWNjZXNzX2dh",
+ "dGV3YXkudjEuTXhTdGF0dXNDYXRlZ29yeRI4CgtkZXRlY3RlZF9ieRgDIAEo",
+ "DjIjLm14YWNjZXNzX2dhdGV3YXkudjEuTXhTdGF0dXNTb3VyY2USDgoGZGV0",
+ "YWlsGAQgASgFEhQKDHJhd19jYXRlZ29yeRgFIAEoBRIXCg9yYXdfZGV0ZWN0",
+ "ZWRfYnkYBiABKAUSFwoPZGlhZ25vc3RpY190ZXh0GAcgASgJIukDCgdNeFZh",
+ "bHVlEjIKCWRhdGFfdHlwZRgBIAEoDjIfLm14YWNjZXNzX2dhdGV3YXkudjEu",
+ "TXhEYXRhVHlwZRIUCgx2YXJpYW50X3R5cGUYAiABKAkSDwoHaXNfbnVsbBgD",
+ "IAEoCBIWCg5yYXdfZGlhZ25vc3RpYxgEIAEoCRIVCg1yYXdfZGF0YV90eXBl",
+ "GAUgASgFEhQKCmJvb2xfdmFsdWUYCiABKAhIABIVCgtpbnQzMl92YWx1ZRgL",
+ "IAEoBUgAEhUKC2ludDY0X3ZhbHVlGAwgASgDSAASFQoLZmxvYXRfdmFsdWUY",
+ "DSABKAJIABIWCgxkb3VibGVfdmFsdWUYDiABKAFIABIWCgxzdHJpbmdfdmFs",
+ "dWUYDyABKAlIABI1Cg90aW1lc3RhbXBfdmFsdWUYECABKAsyGi5nb29nbGUu",
+ "cHJvdG9idWYuVGltZXN0YW1wSAASMwoLYXJyYXlfdmFsdWUYESABKAsyHC5t",
+ "eGFjY2Vzc19nYXRld2F5LnYxLk14QXJyYXlIABITCglyYXdfdmFsdWUYEiAB",
+ "KAxIABJAChJzcGFyc2VfYXJyYXlfdmFsdWUYEyABKAsyIi5teGFjY2Vzc19n",
+ "YXRld2F5LnYxLk14U3BhcnNlQXJyYXlIAEIGCgRraW5kIv4ECgdNeEFycmF5",
+ "EjoKEWVsZW1lbnRfZGF0YV90eXBlGAEgASgOMh8ubXhhY2Nlc3NfZ2F0ZXdh",
+ "eS52MS5NeERhdGFUeXBlEhQKDHZhcmlhbnRfdHlwZRgCIAEoCRISCgpkaW1l",
+ "bnNpb25zGAMgAygNEhYKDnJhd19kaWFnbm9zdGljGAQgASgJEh0KFXJhd19l",
+ "bGVtZW50X2RhdGFfdHlwZRgFIAEoBRI1Cgtib29sX3ZhbHVlcxgKIAEoCzIe",
+ "Lm14YWNjZXNzX2dhdGV3YXkudjEuQm9vbEFycmF5SAASNwoMaW50MzJfdmFs",
+ "dWVzGAsgASgLMh8ubXhhY2Nlc3NfZ2F0ZXdheS52MS5JbnQzMkFycmF5SAAS",
+ "NwoMaW50NjRfdmFsdWVzGAwgASgLMh8ubXhhY2Nlc3NfZ2F0ZXdheS52MS5J",
+ "bnQ2NEFycmF5SAASNwoMZmxvYXRfdmFsdWVzGA0gASgLMh8ubXhhY2Nlc3Nf",
+ "Z2F0ZXdheS52MS5GbG9hdEFycmF5SAASOQoNZG91YmxlX3ZhbHVlcxgOIAEo",
+ "CzIgLm14YWNjZXNzX2dhdGV3YXkudjEuRG91YmxlQXJyYXlIABI5Cg1zdHJp",
+ "bmdfdmFsdWVzGA8gASgLMiAubXhhY2Nlc3NfZ2F0ZXdheS52MS5TdHJpbmdB",
+ "cnJheUgAEj8KEHRpbWVzdGFtcF92YWx1ZXMYECABKAsyIy5teGFjY2Vzc19n",
+ "YXRld2F5LnYxLlRpbWVzdGFtcEFycmF5SAASMwoKcmF3X3ZhbHVlcxgRIAEo",
+ "CzIdLm14YWNjZXNzX2dhdGV3YXkudjEuUmF3QXJyYXlIAEIICgZ2YWx1ZXMi",
+ "mQEKDU14U3BhcnNlQXJyYXkSOgoRZWxlbWVudF9kYXRhX3R5cGUYASABKA4y",
+ "Hy5teGFjY2Vzc19nYXRld2F5LnYxLk14RGF0YVR5cGUSFAoMdG90YWxfbGVu",
+ "Z3RoGAIgASgNEjYKCGVsZW1lbnRzGAMgAygLMiQubXhhY2Nlc3NfZ2F0ZXdh",
+ "eS52MS5NeFNwYXJzZUVsZW1lbnQiTQoPTXhTcGFyc2VFbGVtZW50Eg0KBWlu",
+ "ZGV4GAEgASgNEisKBXZhbHVlGAIgASgLMhwubXhhY2Nlc3NfZ2F0ZXdheS52",
+ "MS5NeFZhbHVlIhsKCUJvb2xBcnJheRIOCgZ2YWx1ZXMYASADKAgiHAoKSW50",
+ "MzJBcnJheRIOCgZ2YWx1ZXMYASADKAUiHAoKSW50NjRBcnJheRIOCgZ2YWx1",
+ "ZXMYASADKAMiHAoKRmxvYXRBcnJheRIOCgZ2YWx1ZXMYASADKAIiHQoLRG91",
+ "YmxlQXJyYXkSDgoGdmFsdWVzGAEgAygBIh0KC1N0cmluZ0FycmF5Eg4KBnZh",
+ "bHVlcxgBIAMoCSI8Cg5UaW1lc3RhbXBBcnJheRIqCgZ2YWx1ZXMYASADKAsy",
+ "Gi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIhoKCFJhd0FycmF5Eg4KBnZh",
+ "bHVlcxgBIAMoDCJYCg5Qcm90b2NvbFN0YXR1cxI1CgRjb2RlGAEgASgOMicu",
+ "bXhhY2Nlc3NfZ2F0ZXdheS52MS5Qcm90b2NvbFN0YXR1c0NvZGUSDwoHbWVz",
+ "c2FnZRgCIAEoCSqfCwoNTXhDb21tYW5kS2luZBIfChtNWF9DT01NQU5EX0tJ",
+ "TkRfVU5TUEVDSUZJRUQQABIcChhNWF9DT01NQU5EX0tJTkRfUkVHSVNURVIQ",
+ "ARIeChpNWF9DT01NQU5EX0tJTkRfVU5SRUdJU1RFUhACEhwKGE1YX0NPTU1B",
+ "TkRfS0lORF9BRERfSVRFTRADEh0KGU1YX0NPTU1BTkRfS0lORF9BRERfSVRF",
+ "TTIQBBIfChtNWF9DT01NQU5EX0tJTkRfUkVNT1ZFX0lURU0QBRIaChZNWF9D",
+ "T01NQU5EX0tJTkRfQURWSVNFEAYSHQoZTVhfQ09NTUFORF9LSU5EX1VOX0FE",
+ "VklTRRAHEiYKIk1YX0NPTU1BTkRfS0lORF9BRFZJU0VfU1VQRVJWSVNPUlkQ",
+ "CBIlCiFNWF9DT01NQU5EX0tJTkRfQUREX0JVRkZFUkVEX0lURU0QCRIwCixN",
+ "WF9DT01NQU5EX0tJTkRfU0VUX0JVRkZFUkVEX1VQREFURV9JTlRFUlZBTBAK",
+ "EhsKF01YX0NPTU1BTkRfS0lORF9TVVNQRU5EEAsSHAoYTVhfQ09NTUFORF9L",
+ "SU5EX0FDVElWQVRFEAwSGQoVTVhfQ09NTUFORF9LSU5EX1dSSVRFEA0SGgoW",
+ "TVhfQ09NTUFORF9LSU5EX1dSSVRFMhAOEiEKHU1YX0NPTU1BTkRfS0lORF9X",
+ "UklURV9TRUNVUkVEEA8SIgoeTVhfQ09NTUFORF9LSU5EX1dSSVRFX1NFQ1VS",
+ "RUQyEBASJQohTVhfQ09NTUFORF9LSU5EX0FVVEhFTlRJQ0FURV9VU0VSEBES",
+ "KAokTVhfQ09NTUFORF9LSU5EX0FSQ0hFU1RSQV9VU0VSX1RPX0lEEBISIQod",
+ "TVhfQ09NTUFORF9LSU5EX0FERF9JVEVNX0JVTEsQExIkCiBNWF9DT01NQU5E",
+ "X0tJTkRfQURWSVNFX0lURU1fQlVMSxAUEiQKIE1YX0NPTU1BTkRfS0lORF9S",
+ "RU1PVkVfSVRFTV9CVUxLEBUSJwojTVhfQ09NTUFORF9LSU5EX1VOX0FEVklT",
+ "RV9JVEVNX0JVTEsQFhIiCh5NWF9DT01NQU5EX0tJTkRfU1VCU0NSSUJFX0JV",
+ "TEsQFxIkCiBNWF9DT01NQU5EX0tJTkRfVU5TVUJTQ1JJQkVfQlVMSxAYEiQK",
+ "IE1YX0NPTU1BTkRfS0lORF9TVUJTQ1JJQkVfQUxBUk1TEBkSJgoiTVhfQ09N",
+ "TUFORF9LSU5EX1VOU1VCU0NSSUJFX0FMQVJNUxAaEiUKIU1YX0NPTU1BTkRf",
+ "S0lORF9BQ0tOT1dMRURHRV9BTEFSTRAbEicKI01YX0NPTU1BTkRfS0lORF9R",
+ "VUVSWV9BQ1RJVkVfQUxBUk1TEBwSLQopTVhfQ09NTUFORF9LSU5EX0FDS05P",
+ "V0xFREdFX0FMQVJNX0JZX05BTUUQHRIeChpNWF9DT01NQU5EX0tJTkRfV1JJ",
+ "VEVfQlVMSxAeEh8KG01YX0NPTU1BTkRfS0lORF9XUklURTJfQlVMSxAfEiYK",
+ "Ik1YX0NPTU1BTkRfS0lORF9XUklURV9TRUNVUkVEX0JVTEsQIBInCiNNWF9D",
+ "T01NQU5EX0tJTkRfV1JJVEVfU0VDVVJFRDJfQlVMSxAhEh0KGU1YX0NPTU1B",
+ "TkRfS0lORF9SRUFEX0JVTEsQIhIYChRNWF9DT01NQU5EX0tJTkRfUElORxBk",
+ "EiUKIU1YX0NPTU1BTkRfS0lORF9HRVRfU0VTU0lPTl9TVEFURRBlEiMKH01Y",
+ "X0NPTU1BTkRfS0lORF9HRVRfV09SS0VSX0lORk8QZhIgChxNWF9DT01NQU5E",
+ "X0tJTkRfRFJBSU5fRVZFTlRTEGcSIwofTVhfQ09NTUFORF9LSU5EX1NIVVRE",
+ "T1dOX1dPUktFUhBoKnoKEUFsYXJtUHJvdmlkZXJNb2RlEiMKH0FMQVJNX1BS",
+ "T1ZJREVSX01PREVfVU5TUEVDSUZJRUQQABIgChxBTEFSTV9QUk9WSURFUl9N",
+ "T0RFX0FMQVJNTUdSEAESHgoaQUxBUk1fUFJPVklERVJfTU9ERV9TVUJUQUcQ",
+ "AiqtAgoNTXhFdmVudEZhbWlseRIfChtNWF9FVkVOVF9GQU1JTFlfVU5TUEVD",
+ "SUZJRUQQABIiCh5NWF9FVkVOVF9GQU1JTFlfT05fREFUQV9DSEFOR0UQARIl",
+ "CiFNWF9FVkVOVF9GQU1JTFlfT05fV1JJVEVfQ09NUExFVEUQAhImCiJNWF9F",
+ "VkVOVF9GQU1JTFlfT1BFUkFUSU9OX0NPTVBMRVRFEAMSKwonTVhfRVZFTlRf",
+ "RkFNSUxZX09OX0JVRkZFUkVEX0RBVEFfQ0hBTkdFEAQSJwojTVhfRVZFTlRf",
+ "RkFNSUxZX09OX0FMQVJNX1RSQU5TSVRJT04QBRIyCi5NWF9FVkVOVF9GQU1J",
+ "TFlfT05fQUxBUk1fUFJPVklERVJfTU9ERV9DSEFOR0VEEAYqygEKE0FsYXJt",
+ "VHJhbnNpdGlvbktpbmQSJQohQUxBUk1fVFJBTlNJVElPTl9LSU5EX1VOU1BF",
+ "Q0lGSUVEEAASHwobQUxBUk1fVFJBTlNJVElPTl9LSU5EX1JBSVNFEAESJQoh",
+ "QUxBUk1fVFJBTlNJVElPTl9LSU5EX0FDS05PV0xFREdFEAISHwobQUxBUk1f",
+ "VFJBTlNJVElPTl9LSU5EX0NMRUFSEAMSIwofQUxBUk1fVFJBTlNJVElPTl9L",
+ "SU5EX1JFVFJJR0dFUhAEKqoBChNBbGFybUNvbmRpdGlvblN0YXRlEiUKIUFM",
+ "QVJNX0NPTkRJVElPTl9TVEFURV9VTlNQRUNJRklFRBAAEiAKHEFMQVJNX0NP",
+ "TkRJVElPTl9TVEFURV9BQ1RJVkUQARImCiJBTEFSTV9DT05ESVRJT05fU1RB",
+ "VEVfQUNUSVZFX0FDS0VEEAISIgoeQUxBUk1fQ09ORElUSU9OX1NUQVRFX0lO",
+ "QUNUSVZFEAMqpQMKEE14U3RhdHVzQ2F0ZWdvcnkSIgoeTVhfU1RBVFVTX0NB",
+ "VEVHT1JZX1VOU1BFQ0lGSUVEEAASHgoaTVhfU1RBVFVTX0NBVEVHT1JZX1VO",
+ "S05PV04QARIZChVNWF9TVEFUVVNfQ0FURUdPUllfT0sQAhIeChpNWF9TVEFU",
+ "VVNfQ0FURUdPUllfUEVORElORxADEh4KGk1YX1NUQVRVU19DQVRFR09SWV9X",
+ "QVJOSU5HEAQSKgomTVhfU1RBVFVTX0NBVEVHT1JZX0NPTU1VTklDQVRJT05f",
+ "RVJST1IQBRIqCiZNWF9TVEFUVVNfQ0FURUdPUllfQ09ORklHVVJBVElPTl9F",
+ "UlJPUhAGEigKJE1YX1NUQVRVU19DQVRFR09SWV9PUEVSQVRJT05BTF9FUlJP",
+ "UhAHEiUKIU1YX1NUQVRVU19DQVRFR09SWV9TRUNVUklUWV9FUlJPUhAIEiUK",
+ "IU1YX1NUQVRVU19DQVRFR09SWV9TT0ZUV0FSRV9FUlJPUhAJEiIKHk1YX1NU",
+ "QVRVU19DQVRFR09SWV9PVEhFUl9FUlJPUhAKKsoCCg5NeFN0YXR1c1NvdXJj",
+ "ZRIgChxNWF9TVEFUVVNfU09VUkNFX1VOU1BFQ0lGSUVEEAASHAoYTVhfU1RB",
+ "VFVTX1NPVVJDRV9VTktOT1dOEAESIwofTVhfU1RBVFVTX1NPVVJDRV9SRVFV",
+ "RVNUSU5HX0xNWBACEiMKH01YX1NUQVRVU19TT1VSQ0VfUkVTUE9ORElOR19M",
+ "TVgQAxIjCh9NWF9TVEFUVVNfU09VUkNFX1JFUVVFU1RJTkdfTk1YEAQSIwof",
+ "TVhfU1RBVFVTX1NPVVJDRV9SRVNQT05ESU5HX05NWBAFEjEKLU1YX1NUQVRV",
+ "U19TT1VSQ0VfUkVRVUVTVElOR19BVVRPTUFUSU9OX09CSkVDVBAGEjEKLU1Y",
+ "X1NUQVRVU19TT1VSQ0VfUkVTUE9ORElOR19BVVRPTUFUSU9OX09CSkVDVBAH",
+ "Kt0ECgpNeERhdGFUeXBlEhwKGE1YX0RBVEFfVFlQRV9VTlNQRUNJRklFRBAA",
+ "EhgKFE1YX0RBVEFfVFlQRV9VTktOT1dOEAESGAoUTVhfREFUQV9UWVBFX05P",
+ "X0RBVEEQAhIYChRNWF9EQVRBX1RZUEVfQk9PTEVBThADEhgKFE1YX0RBVEFf",
+ "VFlQRV9JTlRFR0VSEAQSFgoSTVhfREFUQV9UWVBFX0ZMT0FUEAUSFwoTTVhf",
+ "REFUQV9UWVBFX0RPVUJMRRAGEhcKE01YX0RBVEFfVFlQRV9TVFJJTkcQBxIV",
+ "ChFNWF9EQVRBX1RZUEVfVElNRRAIEh0KGU1YX0RBVEFfVFlQRV9FTEFQU0VE",
+ "X1RJTUUQCRIfChtNWF9EQVRBX1RZUEVfUkVGRVJFTkNFX1RZUEUQChIcChhN",
+ "WF9EQVRBX1RZUEVfU1RBVFVTX1RZUEUQCxIVChFNWF9EQVRBX1RZUEVfRU5V",
+ "TRAMEi0KKU1YX0RBVEFfVFlQRV9TRUNVUklUWV9DTEFTU0lGSUNBVElPTl9F",
+ "TlVNEA0SIgoeTVhfREFUQV9UWVBFX0RBVEFfUVVBTElUWV9UWVBFEA4SHwob",
+ "TVhfREFUQV9UWVBFX1FVQUxJRklFRF9FTlVNEA8SIQodTVhfREFUQV9UWVBF",
+ "X1FVQUxJRklFRF9TVFJVQ1QQEBIpCiVNWF9EQVRBX1RZUEVfSU5URVJOQVRJ",
+ "T05BTElaRURfU1RSSU5HEBESGwoXTVhfREFUQV9UWVBFX0JJR19TVFJJTkcQ",
+ "EhIUChBNWF9EQVRBX1RZUEVfRU5EEBMqowMKElByb3RvY29sU3RhdHVzQ29k",
+ "ZRIkCiBQUk9UT0NPTF9TVEFUVVNfQ09ERV9VTlNQRUNJRklFRBAAEhsKF1BS",
+ "T1RPQ09MX1NUQVRVU19DT0RFX09LEAESKAokUFJPVE9DT0xfU1RBVFVTX0NP",
+ "REVfSU5WQUxJRF9SRVFVRVNUEAISKgomUFJPVE9DT0xfU1RBVFVTX0NPREVf",
+ "U0VTU0lPTl9OT1RfRk9VTkQQAxIqCiZQUk9UT0NPTF9TVEFUVVNfQ09ERV9T",
+ "RVNTSU9OX05PVF9SRUFEWRAEEisKJ1BST1RPQ09MX1NUQVRVU19DT0RFX1dP",
+ "UktFUl9VTkFWQUlMQUJMRRAFEiAKHFBST1RPQ09MX1NUQVRVU19DT0RFX1RJ",
+ "TUVPVVQQBhIhCh1QUk9UT0NPTF9TVEFUVVNfQ09ERV9DQU5DRUxFRBAHEisK",
+ "J1BST1RPQ09MX1NUQVRVU19DT0RFX1BST1RPQ09MX1ZJT0xBVElPThAIEikK",
+ "JVBST1RPQ09MX1NUQVRVU19DT0RFX01YQUNDRVNTX0ZBSUxVUkUQCSq/AgoM",
+ "U2Vzc2lvblN0YXRlEh0KGVNFU1NJT05fU1RBVEVfVU5TUEVDSUZJRUQQABIa",
+ "ChZTRVNTSU9OX1NUQVRFX0NSRUFUSU5HEAESIQodU0VTU0lPTl9TVEFURV9T",
+ "VEFSVElOR19XT1JLRVIQAhIiCh5TRVNTSU9OX1NUQVRFX1dBSVRJTkdfRk9S",
+ "X1BJUEUQAxIdChlTRVNTSU9OX1NUQVRFX0hBTkRTSEFLSU5HEAQSJQohU0VT",
+ "U0lPTl9TVEFURV9JTklUSUFMSVpJTkdfV09SS0VSEAUSFwoTU0VTU0lPTl9T",
+ "VEFURV9SRUFEWRAGEhkKFVNFU1NJT05fU1RBVEVfQ0xPU0lORxAHEhgKFFNF",
+ "U1NJT05fU1RBVEVfQ0xPU0VEEAgSGQoVU0VTU0lPTl9TVEFURV9GQVVMVEVE",
+ "EAkywwUKD014QWNjZXNzR2F0ZXdheRJdCgtPcGVuU2Vzc2lvbhInLm14YWNj",
+ "ZXNzX2dhdGV3YXkudjEuT3BlblNlc3Npb25SZXF1ZXN0GiUubXhhY2Nlc3Nf",
+ "Z2F0ZXdheS52MS5PcGVuU2Vzc2lvblJlcGx5EmAKDENsb3NlU2Vzc2lvbhIo",
+ "Lm14YWNjZXNzX2dhdGV3YXkudjEuQ2xvc2VTZXNzaW9uUmVxdWVzdBomLm14",
+ "YWNjZXNzX2dhdGV3YXkudjEuQ2xvc2VTZXNzaW9uUmVwbHkSVAoGSW52b2tl",
+ "EiUubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeENvbW1hbmRSZXF1ZXN0GiMubXhh",
+ "Y2Nlc3NfZ2F0ZXdheS52MS5NeENvbW1hbmRSZXBseRJYCgxTdHJlYW1FdmVu",
+ "dHMSKC5teGFjY2Vzc19nYXRld2F5LnYxLlN0cmVhbUV2ZW50c1JlcXVlc3Qa",
+ "HC5teGFjY2Vzc19nYXRld2F5LnYxLk14RXZlbnQwARJsChBBY2tub3dsZWRn",
+ "ZUFsYXJtEiwubXhhY2Nlc3NfZ2F0ZXdheS52MS5BY2tub3dsZWRnZUFsYXJt",
+ "UmVxdWVzdBoqLm14YWNjZXNzX2dhdGV3YXkudjEuQWNrbm93bGVkZ2VBbGFy",
+ "bVJlcGx5EmEKDFN0cmVhbUFsYXJtcxIoLm14YWNjZXNzX2dhdGV3YXkudjEu",
+ "U3RyZWFtQWxhcm1zUmVxdWVzdBolLm14YWNjZXNzX2dhdGV3YXkudjEuQWxh",
+ "cm1GZWVkTWVzc2FnZTABEm4KEVF1ZXJ5QWN0aXZlQWxhcm1zEi0ubXhhY2Nl",
+ "c3NfZ2F0ZXdheS52MS5RdWVyeUFjdGl2ZUFsYXJtc1JlcXVlc3QaKC5teGFj",
+ "Y2Vzc19nYXRld2F5LnYxLkFjdGl2ZUFsYXJtU25hcHNob3QwAUImqgIjWkIu",
+ "TU9NLldXLk14R2F0ZXdheS5Db250cmFjdHMuUHJvdG9iBnByb3RvMw=="));
descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData,
new pbr::FileDescriptor[] { global::Google.Protobuf.WellKnownTypes.DurationReflection.Descriptor, global::Google.Protobuf.WellKnownTypes.TimestampReflection.Descriptor, },
new pbr::GeneratedClrTypeInfo(new[] {typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommandKind), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmProviderMode), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxEventFamily), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmTransitionKind), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmConditionState), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusCategory), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusSource), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxDataType), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatusCode), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.SessionState), }, null, new pbr::GeneratedClrTypeInfo[] {
@@ -602,7 +603,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerInfoReply), global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerInfoReply.Parser, new[]{ "WorkerProcessId", "WorkerVersion", "MxaccessProgid", "MxaccessClsid" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.DrainEventsReply), global::ZB.MOM.WW.MxGateway.Contracts.Proto.DrainEventsReply.Parser, new[]{ "Events" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmReplyPayload), global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmReplyPayload.Parser, new[]{ "NativeStatus" }, null, null, null, null),
- new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.QueryActiveAlarmsReplyPayload), global::ZB.MOM.WW.MxGateway.Contracts.Proto.QueryActiveAlarmsReplyPayload.Parser, new[]{ "Snapshots" }, null, null, null, null),
+ new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.QueryActiveAlarmsReplyPayload), global::ZB.MOM.WW.MxGateway.Contracts.Proto.QueryActiveAlarmsReplyPayload.Parser, new[]{ "Snapshots", "SnapshotTruncated" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxEvent), global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxEvent.Parser, new[]{ "Family", "SessionId", "ServerHandle", "ItemHandle", "Value", "Quality", "SourceTimestamp", "Statuses", "WorkerSequence", "WorkerTimestamp", "GatewayReceiveTimestamp", "Hresult", "RawStatus", "ReplayGap", "OnDataChange", "OnWriteComplete", "OperationComplete", "OnBufferedDataChange", "OnAlarmTransition", "OnAlarmProviderModeChanged" }, new[]{ "Body", "Hresult", "ReplayGap" }, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.ReplayGap), global::ZB.MOM.WW.MxGateway.Contracts.Proto.ReplayGap.Parser, new[]{ "RequestedAfterSequence", "OldestAvailableSequence" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnDataChangeEvent), global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnDataChangeEvent.Parser, null, null, null, null, null),
@@ -611,7 +612,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnBufferedDataChangeEvent), global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnBufferedDataChangeEvent.Parser, new[]{ "DataType", "QualityValues", "TimestampValues", "RawDataType" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnAlarmTransitionEvent), global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnAlarmTransitionEvent.Parser, new[]{ "AlarmFullReference", "SourceObjectReference", "AlarmTypeName", "TransitionKind", "Severity", "OriginalRaiseTimestamp", "TransitionTimestamp", "OperatorUser", "OperatorComment", "Category", "Description", "CurrentValue", "LimitValue", "Degraded", "SourceProvider" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnAlarmProviderModeChangedEvent), global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnAlarmProviderModeChangedEvent.Parser, new[]{ "Mode", "Reason", "Hresult", "At" }, null, null, null, null),
- new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.ActiveAlarmSnapshot), global::ZB.MOM.WW.MxGateway.Contracts.Proto.ActiveAlarmSnapshot.Parser, new[]{ "AlarmFullReference", "SourceObjectReference", "AlarmTypeName", "Severity", "OriginalRaiseTimestamp", "CurrentState", "Category", "Description", "LastTransitionTimestamp", "OperatorUser", "OperatorComment", "CurrentValue", "LimitValue", "Degraded", "SourceProvider" }, null, null, null, null),
+ new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.ActiveAlarmSnapshot), global::ZB.MOM.WW.MxGateway.Contracts.Proto.ActiveAlarmSnapshot.Parser, new[]{ "AlarmFullReference", "SourceObjectReference", "AlarmTypeName", "Severity", "OriginalRaiseTimestamp", "CurrentState", "Category", "Description", "LastTransitionTimestamp", "OperatorUser", "OperatorComment", "CurrentValue", "LimitValue", "Degraded", "SourceProvider", "FromTruncatedSnapshot" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmRequest), global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmRequest.Parser, new[]{ "ClientCorrelationId", "AlarmFullReference", "Comment", "OperatorUser" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmReply), global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmReply.Parser, new[]{ "CorrelationId", "ProtocolStatus", "Hresult", "Status", "DiagnosticMessage" }, new[]{ "Hresult" }, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.StreamAlarmsRequest), global::ZB.MOM.WW.MxGateway.Contracts.Proto.StreamAlarmsRequest.Parser, new[]{ "ClientCorrelationId", "AlarmFilterPrefix" }, null, null, null, null),
@@ -23224,6 +23225,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public QueryActiveAlarmsReplyPayload(QueryActiveAlarmsReplyPayload other) : this() {
snapshots_ = other.snapshots_.Clone();
+ snapshotTruncated_ = other.snapshotTruncated_;
_unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
}
@@ -23244,6 +23246,26 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
get { return snapshots_; }
}
+ /// Field number for the "snapshot_truncated" field.
+ public const int SnapshotTruncatedFieldNumber = 2;
+ private bool snapshotTruncated_;
+ ///
+ /// True when the provider fetch backing this reply came back holding the
+ /// per-fetch cap (MxGateway:Alarms:MaxAlarmsPerFetch). The reply may then omit
+ /// active alarms, and the worker suspends its absence-implies-Clear inference
+ /// for that poll — so a reference missing from `snapshots` is not evidence the
+ /// alarm cleared. Carried on the payload as well as per-record because a
+ /// truncated fetch that filters down to zero records still has to say so.
+ ///
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public bool SnapshotTruncated {
+ get { return snapshotTruncated_; }
+ set {
+ snapshotTruncated_ = value;
+ }
+ }
+
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public override bool Equals(object other) {
@@ -23260,6 +23282,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
return true;
}
if(!snapshots_.Equals(other.snapshots_)) return false;
+ if (SnapshotTruncated != other.SnapshotTruncated) return false;
return Equals(_unknownFields, other._unknownFields);
}
@@ -23268,6 +23291,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
public override int GetHashCode() {
int hash = 1;
hash ^= snapshots_.GetHashCode();
+ if (SnapshotTruncated != false) hash ^= SnapshotTruncated.GetHashCode();
if (_unknownFields != null) {
hash ^= _unknownFields.GetHashCode();
}
@@ -23287,6 +23311,10 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
output.WriteRawMessage(this);
#else
snapshots_.WriteTo(output, _repeated_snapshots_codec);
+ if (SnapshotTruncated != false) {
+ output.WriteRawTag(16);
+ output.WriteBool(SnapshotTruncated);
+ }
if (_unknownFields != null) {
_unknownFields.WriteTo(output);
}
@@ -23298,6 +23326,10 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) {
snapshots_.WriteTo(ref output, _repeated_snapshots_codec);
+ if (SnapshotTruncated != false) {
+ output.WriteRawTag(16);
+ output.WriteBool(SnapshotTruncated);
+ }
if (_unknownFields != null) {
_unknownFields.WriteTo(ref output);
}
@@ -23309,6 +23341,9 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
public int CalculateSize() {
int size = 0;
size += snapshots_.CalculateSize(_repeated_snapshots_codec);
+ if (SnapshotTruncated != false) {
+ size += 1 + 1;
+ }
if (_unknownFields != null) {
size += _unknownFields.CalculateSize();
}
@@ -23322,6 +23357,9 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
return;
}
snapshots_.Add(other.snapshots_);
+ if (other.SnapshotTruncated != false) {
+ SnapshotTruncated = other.SnapshotTruncated;
+ }
_unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
}
@@ -23345,6 +23383,10 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
snapshots_.AddEntriesFrom(input, _repeated_snapshots_codec);
break;
}
+ case 16: {
+ SnapshotTruncated = input.ReadBool();
+ break;
+ }
}
}
#endif
@@ -23368,6 +23410,10 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
snapshots_.AddEntriesFrom(ref input, _repeated_snapshots_codec);
break;
}
+ case 16: {
+ SnapshotTruncated = input.ReadBool();
+ break;
+ }
}
}
}
@@ -26732,6 +26778,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
limitValue_ = other.limitValue_ != null ? other.limitValue_.Clone() : null;
degraded_ = other.degraded_;
sourceProvider_ = other.sourceProvider_;
+ fromTruncatedSnapshot_ = other.fromTruncatedSnapshot_;
_unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
}
@@ -26944,6 +26991,29 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
}
}
+ /// Field number for the "from_truncated_snapshot" field.
+ public const int FromTruncatedSnapshotFieldNumber = 16;
+ private bool fromTruncatedSnapshot_;
+ ///
+ /// True when the provider fetch that produced this snapshot hit the per-fetch
+ /// cap: the snapshot set may omit active alarms, and the worker suspended its
+ /// absence-implies-Clear inference for that poll. Says nothing about THIS
+ /// record's fidelity — the record is as accurate as any other; it flags that
+ /// the set it belongs to is possibly incomplete. QueryActiveAlarms returns a
+ /// bare `stream ActiveAlarmSnapshot` with no envelope message, so a per-record
+ /// boolean is the only additive way to carry set-level degraded status on that
+ /// RPC. Distinct from `degraded`, which is about the subtag fallback provider.
+ /// Additive (proto3): clients that ignore it deserialize the stream unchanged.
+ ///
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public bool FromTruncatedSnapshot {
+ get { return fromTruncatedSnapshot_; }
+ set {
+ fromTruncatedSnapshot_ = value;
+ }
+ }
+
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public override bool Equals(object other) {
@@ -26974,6 +27044,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
if (!object.Equals(LimitValue, other.LimitValue)) return false;
if (Degraded != other.Degraded) return false;
if (SourceProvider != other.SourceProvider) return false;
+ if (FromTruncatedSnapshot != other.FromTruncatedSnapshot) return false;
return Equals(_unknownFields, other._unknownFields);
}
@@ -26996,6 +27067,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
if (limitValue_ != null) hash ^= LimitValue.GetHashCode();
if (Degraded != false) hash ^= Degraded.GetHashCode();
if (SourceProvider != global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmProviderMode.Unspecified) hash ^= SourceProvider.GetHashCode();
+ if (FromTruncatedSnapshot != false) hash ^= FromTruncatedSnapshot.GetHashCode();
if (_unknownFields != null) {
hash ^= _unknownFields.GetHashCode();
}
@@ -27074,6 +27146,10 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
output.WriteRawTag(120);
output.WriteEnum((int) SourceProvider);
}
+ if (FromTruncatedSnapshot != false) {
+ output.WriteRawTag(128, 1);
+ output.WriteBool(FromTruncatedSnapshot);
+ }
if (_unknownFields != null) {
_unknownFields.WriteTo(output);
}
@@ -27144,6 +27220,10 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
output.WriteRawTag(120);
output.WriteEnum((int) SourceProvider);
}
+ if (FromTruncatedSnapshot != false) {
+ output.WriteRawTag(128, 1);
+ output.WriteBool(FromTruncatedSnapshot);
+ }
if (_unknownFields != null) {
_unknownFields.WriteTo(ref output);
}
@@ -27199,6 +27279,9 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
if (SourceProvider != global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmProviderMode.Unspecified) {
size += 1 + pb::CodedOutputStream.ComputeEnumSize((int) SourceProvider);
}
+ if (FromTruncatedSnapshot != false) {
+ size += 2 + 1;
+ }
if (_unknownFields != null) {
size += _unknownFields.CalculateSize();
}
@@ -27268,6 +27351,9 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
if (other.SourceProvider != global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmProviderMode.Unspecified) {
SourceProvider = other.SourceProvider;
}
+ if (other.FromTruncatedSnapshot != false) {
+ FromTruncatedSnapshot = other.FromTruncatedSnapshot;
+ }
_unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
}
@@ -27359,6 +27445,10 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
SourceProvider = (global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmProviderMode) input.ReadEnum();
break;
}
+ case 128: {
+ FromTruncatedSnapshot = input.ReadBool();
+ break;
+ }
}
}
#endif
@@ -27450,6 +27540,10 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
SourceProvider = (global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmProviderMode) input.ReadEnum();
break;
}
+ case 128: {
+ FromTruncatedSnapshot = input.ReadBool();
+ break;
+ }
}
}
}
diff --git a/src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto b/src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto
index db5fb4f..0ee3fb2 100644
--- a/src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto
+++ b/src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto
@@ -726,6 +726,13 @@ message AcknowledgeAlarmReplyPayload {
// stream.
message QueryActiveAlarmsReplyPayload {
repeated ActiveAlarmSnapshot snapshots = 1;
+ // True when the provider fetch backing this reply came back holding the
+ // per-fetch cap (MxGateway:Alarms:MaxAlarmsPerFetch). The reply may then omit
+ // active alarms, and the worker suspends its absence-implies-Clear inference
+ // for that poll — so a reference missing from `snapshots` is not evidence the
+ // alarm cleared. Carried on the payload as well as per-record because a
+ // truncated fetch that filters down to zero records still has to say so.
+ bool snapshot_truncated = 2;
}
message MxEvent {
@@ -932,6 +939,16 @@ message ActiveAlarmSnapshot {
// OnAlarmTransitionEvent.source_provider; always ALARMMGR or SUBTAG on the
// wire (never UNSPECIFIED).
AlarmProviderMode source_provider = 15;
+ // True when the provider fetch that produced this snapshot hit the per-fetch
+ // cap: the snapshot set may omit active alarms, and the worker suspended its
+ // absence-implies-Clear inference for that poll. Says nothing about THIS
+ // record's fidelity — the record is as accurate as any other; it flags that
+ // the set it belongs to is possibly incomplete. QueryActiveAlarms returns a
+ // bare `stream ActiveAlarmSnapshot` with no envelope message, so a per-record
+ // boolean is the only additive way to carry set-level degraded status on that
+ // RPC. Distinct from `degraded`, which is about the subtag fallback provider.
+ // Additive (proto3): clients that ignore it deserialize the stream unchanged.
+ bool from_truncated_snapshot = 16;
}
enum AlarmConditionState {
diff --git a/src/ZB.MOM.WW.MxGateway.IntegrationTests/DashboardLdapLiveTests.cs b/src/ZB.MOM.WW.MxGateway.IntegrationTests/DashboardLdapLiveTests.cs
index 5ee6788..f470396 100644
--- a/src/ZB.MOM.WW.MxGateway.IntegrationTests/DashboardLdapLiveTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.IntegrationTests/DashboardLdapLiveTests.cs
@@ -170,6 +170,7 @@ public sealed class DashboardLdapLiveTests
return new DashboardAuthenticator(
new LdapAuthService(ldapOptions),
new DashboardGroupRoleMapper(Options.Create(gatewayOptions)),
+ Options.Create(gatewayOptions),
NullLogger.Instance);
}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs b/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs
index b29d00a..8ce372e 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs
@@ -57,6 +57,11 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
private string _providerReason = string.Empty;
private DateTimeOffset _providerSince = DateTimeOffset.UtcNow;
+ // Whether the worker's most recent reconcile fetch was capped, guarded by _sync.
+ // Written only by ApplyReconcile, so it always describes the same pass that
+ // produced the current _alarms generation.
+ private bool _snapshotTruncated;
+
private volatile GatewayAlarmMonitorState _state = GatewayAlarmMonitorState.Disabled;
private volatile string? _lastError;
private GatewaySession? _session;
@@ -110,6 +115,12 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
}
}
+ ///
+ public bool SnapshotTruncated
+ {
+ get { lock (_sync) { return _snapshotTruncated; } }
+ }
+
///
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
@@ -416,7 +427,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
QueryActiveAlarmsReplyPayload? payload = reply.Reply.QueryActiveAlarms;
if (payload is not null)
{
- ApplyReconcile(payload.Snapshots);
+ ApplyReconcile(payload.Snapshots, payload.SnapshotTruncated);
}
}
@@ -610,7 +621,13 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
// suppressed. The dedup fires only on a positive marker match, so the contract stays
// at-least-once: consumers must still treat alarm state idempotently — apply a transition as
// "set the alarm to this state", never as an increment or a toggle.
- private void ApplyReconcile(IEnumerable snapshots)
+ //
+ // Truncation (`snapshotTruncated`) needs no special handling here, and that is worth saying
+ // because the obvious worry — a capped fetch reading as a wave of Clears — is answered one
+ // level down. The worker merges rather than replaces its retained snapshot on a capped fetch,
+ // so the set arriving here still carries the alarms the capped reply had no room to mention.
+ // The flag is therefore only recorded, for the operator-facing completeness caveat.
+ private void ApplyReconcile(IEnumerable snapshots, bool snapshotTruncated)
{
Dictionary next = new(StringComparer.Ordinal);
foreach (ActiveAlarmSnapshot snapshot in snapshots)
@@ -669,6 +686,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
_alarms[incoming.Key] = incoming.Value;
}
+ _snapshotTruncated = snapshotTruncated;
_currentAlarmsProjection = null;
}
}
@@ -716,6 +734,10 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
lock (_sync)
{
_alarms.Clear();
+ // The truncation verdict describes the cache generation being discarded, so it goes
+ // with it. Carrying it across a monitor restart would caveat an empty set as "may be
+ // incomplete" on evidence from a session that no longer exists.
+ _snapshotTruncated = false;
_currentAlarmsProjection = null;
}
}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Alarms/IGatewayAlarmService.cs b/src/ZB.MOM.WW.MxGateway.Server/Alarms/IGatewayAlarmService.cs
index 7231ba4..fd596bd 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Alarms/IGatewayAlarmService.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Alarms/IGatewayAlarmService.cs
@@ -38,6 +38,16 @@ public interface IGatewayAlarmService
/// A point-in-time copy of the current active-alarm set.
IReadOnlyList CurrentAlarms { get; }
+ ///
+ /// True when the worker's most recent reconcile fetch hit the provider's
+ /// per-fetch cap, so may be missing active
+ /// alarms. The monitor is otherwise healthy — this is not a fault, it is
+ /// a completeness caveat, which is why it is separate from
+ /// and . Cleared by the first
+ /// reconcile whose fetch comes back under the cap.
+ ///
+ bool SnapshotTruncated { get; }
+
///
/// Attaches to the central alarm feed. The returned stream yields one
/// per currently-active alarm, then a
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/AlarmsPage.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/AlarmsPage.razor
index 634b4ba..0cabe58 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/AlarmsPage.razor
+++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/AlarmsPage.razor
@@ -34,6 +34,17 @@
Alarm query failed: @_queryError
}
+@* Warning, not danger: the rows below are all real and the monitor is healthy — only the
+ completeness of the set is in doubt, so this must not read as "alarms are broken". *@
+@if (_snapshotTruncated)
+{
+
+ Alarm snapshot may be incomplete — the provider returned a capped fetch, so alarms beyond
+ the cap are not listed. Alarms already known stay listed rather than clearing. Raise
+ MxGateway:Alarms:MaxAlarmsPerFetch or narrow the subscription if this persists.
+
Waiting for events. The dashboard subscribes to this session's events directly, so
@@ -175,6 +180,10 @@ else
private CancellationTokenSource? _eventPumpCancellation;
private Task? _eventPumpTask;
private bool _eventsConnected;
+ // Renders the denial message in place of the events panel's empty state. Starts true so the
+ // panel reads as "waiting" until the gate has actually been evaluated for a session id;
+ // AttachEventsAsync is the only writer, and it writes on the renderer's dispatcher.
+ private bool _eventsAuthorized = true;
private string? _subscribedSessionId;
private readonly LinkedList _recentEvents = new();
@@ -203,7 +212,7 @@ else
// renderer's dispatcher so the new subscription is published to
// _eventSubscription from the same thread the pump's guard reads it on.
await DetachEventsAsync();
- AttachEvents();
+ await AttachEventsAsync();
}
}
@@ -288,19 +297,34 @@ else
// IDashboardEventBroadcaster, and the subscription registers with
// EventsHubViewerRegistry, so the "nobody is watching" gate keeps working for
// both audiences.
- // ACL posture is unchanged from the hub path: any dashboard Viewer may watch
- // any session (SEC-25 tracks the per-session ACL for both seams).
- private void AttachEvents()
+ // ACL posture matches the hub path exactly: IDashboardSessionAcl gates this seam with the
+ // same decision EventsHub.SubscribeSession applies (SEC-25 / TST-15). The gate wraps only
+ // whether a subscription is created at all — the generation guards, the pump, and the detach
+ // coupling below it are untouched, so a denied page holds no subscription to leak and never
+ // registers a viewer, which keeps the broadcaster's mirror off for that session.
+ private async Task AttachEventsAsync()
{
if (string.IsNullOrWhiteSpace(SessionId))
{
return;
}
+ // Deliberately no ConfigureAwait(false): the decision and everything it publishes must
+ // land back on the renderer's dispatcher, which is where the fields below are owned.
+ AuthenticationState authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
+
+ _subscribedSessionId = SessionId;
+ _eventsAuthorized = SessionAcl.CanViewSession(authenticationState.User, SessionId);
+
+ if (!_eventsAuthorized)
+ {
+ // No subscription, no pump, no viewer registration — the panel renders the denial.
+ return;
+ }
+
_eventSubscription = EventSubscriber.Subscribe(SessionId);
_eventPumpCancellation = new CancellationTokenSource();
_eventsConnected = true;
- _subscribedSessionId = SessionId;
// Deliberately not awaited: the pump runs for as long as the page watches this
// session and is cancelled and drained by DetachEventsAsync.
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardActiveAlarm.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardActiveAlarm.cs
index 90708e2..fe3a76f 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardActiveAlarm.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardActiveAlarm.cs
@@ -57,7 +57,14 @@ public sealed record DashboardActiveAlarm(
/// The active alarms, or an empty list on error.
/// A diagnostic message when the query failed; otherwise null.
/// The worker process id backing the dashboard session, when available.
+///
+/// True when the provider fetch behind hit its per-fetch cap, so the
+/// list may be missing active alarms. Distinct from : the query
+/// succeeded and every row shown is real — only the set's completeness is in doubt, which the
+/// page states as a caveat rather than a failure.
+///
public sealed record DashboardAlarmQueryResult(
IReadOnlyList Alarms,
string? Error,
- int? WorkerProcessId);
+ int? WorkerProcessId,
+ bool SnapshotTruncated = false);
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticationDefaults.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticationDefaults.cs
index f00caf3..7f00a59 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticationDefaults.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticationDefaults.cs
@@ -36,6 +36,16 @@ public static class DashboardAuthenticationDefaults
public const string LdapGroupClaimType = "mxgateway:ldap_group";
public const string KeyPrefixClaimType = "mxgateway:key_prefix";
+ ///
+ /// Claim carrying one dashboard event-visibility tag the caller is granted (SEC-25). Stamped
+ /// at cookie login by and at hub-token mint by
+ /// , both resolving the caller's LDAP groups through
+ /// MxGateway:Dashboard:GroupToTag; read by . A
+ /// principal carrying none of these claims is an empty-grant Viewer, which is the fail-closed
+ /// default. Visibility only — it never grants data access.
+ ///
+ public const string DashboardTagClaimType = "zb:dashboardtag";
+
///
/// Dashboard auth cookie name used when the cookie is not guaranteed to be Secure
/// (RequireHttpsCookie=false → )
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticator.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticator.cs
index 4ad86fe..8250990 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticator.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticator.cs
@@ -1,7 +1,9 @@
using System.Security.Claims;
+using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.Auth.Abstractions.Roles;
using ZB.MOM.WW.Auth.AspNetCore;
+using ZB.MOM.WW.MxGateway.Server.Configuration;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
@@ -17,10 +19,15 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
///
/// Shared LDAP bind-then-search provider.
/// Maps LDAP groups to dashboard roles.
+///
+/// Gateway options supplying MxGateway:Dashboard:GroupToTag, the map that turns the user's
+/// LDAP groups into the dashboard visibility tags stamped on the cookie principal (SEC-25).
+///
/// Logger for diagnostic, credential-free login outcomes.
public sealed class DashboardAuthenticator(
ILdapAuthService ldapAuthService,
IGroupRoleMapper roleMapper,
+ IOptions options,
ILogger logger) : IDashboardAuthenticator
{
private const string GenericFailureMessage = "The username or password is invalid, or the user is not authorized.";
@@ -70,7 +77,8 @@ public sealed class DashboardAuthenticator(
ldapResult.Username,
ldapResult.DisplayName,
ldapResult.Groups,
- roles));
+ roles,
+ options.Value.Dashboard.GroupToTag));
}
///
@@ -97,12 +105,23 @@ public sealed class DashboardAuthenticator(
/// is role-based), so the shape change is non-breaking for dashboard consumers.
///
/// The dashboard roles resolved from .
+ ///
+ /// The configured Dashboard:GroupToTag map. The tags it grants are stamped as
+ /// claims so a
+ /// cookie-authenticated circuit carries its grant without a hub-token round-trip — the
+ /// session-details page's in-process subscribe seam reads exactly these claims.
+ ///
private static ClaimsPrincipal CreatePrincipal(
string username,
string displayName,
IEnumerable groups,
- IEnumerable roles)
+ IEnumerable roles,
+ IReadOnlyDictionary groupToTag)
{
+ // Materialized because the groups are read twice below (group claims and tag mapping) and
+ // the source is only guaranteed to be enumerable.
+ string[] groupNames = groups as string[] ?? [.. groups];
+
List claims =
[
// Keep NameIdentifier so any existing read-site that uses it continues to work.
@@ -120,9 +139,14 @@ public sealed class DashboardAuthenticator(
// Groups are short RDN names from ILdapAuthService (see param doc above), so
// this claim value is the short group name, not the original DN.
// LdapGroupClaimType is MxGateway-specific ("mxgateway:ldap_group") — no ZbClaimType for groups.
- claims.AddRange(groups.Select(group => new Claim(
+ claims.AddRange(groupNames.Select(group => new Claim(
DashboardAuthenticationDefaults.LdapGroupClaimType,
group)));
+ // Dashboard event-visibility tags (SEC-25). Visibility only — never a data-access grant —
+ // and never logged: only the decision, never the tag values, reaches diagnostics.
+ claims.AddRange(DashboardGroupTagMapping
+ .MapGroupsToTags(groupNames, groupToTag)
+ .Select(tag => new Claim(DashboardAuthenticationDefaults.DashboardTagClaimType, tag)));
ClaimsIdentity claimsIdentity = new(
claims,
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardLiveDataService.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardLiveDataService.cs
index ec2aa2e..6e7e2c2 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardLiveDataService.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardLiveDataService.cs
@@ -127,7 +127,11 @@ public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsync
? null
: _alarmService.LastError ?? $"Alarm monitor is {_alarmService.State}.";
- return Task.FromResult(new DashboardAlarmQueryResult(alarms, error, _alarmService.WorkerProcessId));
+ return Task.FromResult(new DashboardAlarmQueryResult(
+ alarms,
+ error,
+ _alarmService.WorkerProcessId,
+ _alarmService.SnapshotTruncated));
}
// Promotes every already-advised tag in this read to the front of the recency
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs
index 83ef2d1..f9367d9 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs
@@ -45,6 +45,10 @@ public static class DashboardServiceCollectionExtensions
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ // Singleton and stateless: it reads the session registry and options per call, and is
+ // consulted from both subscribe seams (the EventsHub join and the session-details page's
+ // in-process subscription).
+ services.AddSingleton();
// Singleton, and the only consumer scope left is HubTokenAuthenticationHandler plus
// the /hubs/token endpoint: server-rendered pages read the in-process feeds, so
// nothing in this process builds a hub connection or needs a token for one.
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSessionAcl.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSessionAcl.cs
new file mode 100644
index 0000000..6a28aec
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSessionAcl.cs
@@ -0,0 +1,85 @@
+using System.Security.Claims;
+using Microsoft.Extensions.Options;
+using ZB.MOM.WW.MxGateway.Server.Configuration;
+using ZB.MOM.WW.MxGateway.Server.Sessions;
+
+namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
+
+///
+/// Tag-intersection implementation of . Fails closed on
+/// every branch: an unknown session, an empty tag grant, and an untagged session under the
+/// default all deny.
+///
+///
+///
+/// Decision order (first match wins):
+///
+///
+/// Authenticated caller in → allow. Admin
+/// already reaches every destructive surface, so event-metadata visibility is strictly weaker.
+/// Session not present in → deny. No subscription
+/// is created for a phantom id.
+/// Session carries no tags → allow only when
+/// MxGateway:Dashboard:UntaggedSessionVisibility is
+/// .
+/// Otherwise allow iff the session's tags intersect the caller's granted tags
+/// (ordinal-ignore-case).
+///
+///
+/// Granted tags are read from the caller's
+/// claims, stamped at login () or at hub-token mint
+/// (). A principal with no such claims — anonymous localhost included —
+/// is an empty-grant Viewer.
+///
+///
+/// This sits on the subscribe path, not the per-event path, and must stay cheap enough to
+/// keep it there: the only per-call work is the claim scan plus a session-registry lookup, with no
+/// intermediate collection built. A per-event re-check is deliberately not needed — a joined SignalR
+/// group and an in-process subscription are both per-session, and
+/// is immutable for the session's life, so the decision taken at subscribe time cannot go stale
+/// while the subscription lives.
+///
+///
+/// Registry the session id is resolved against.
+/// Gateway options supplying Dashboard:UntaggedSessionVisibility.
+public sealed class DashboardSessionAcl(
+ ISessionManager sessionManager,
+ IOptions options) : IDashboardSessionAcl
+{
+ ///
+ public bool CanViewSession(ClaimsPrincipal? principal, string sessionId)
+ {
+ if (principal is null || string.IsNullOrWhiteSpace(sessionId))
+ {
+ return false;
+ }
+
+ if (principal.Identity?.IsAuthenticated == true && principal.IsInRole(DashboardRoles.Admin))
+ {
+ return true;
+ }
+
+ if (!sessionManager.TryGetSession(sessionId, out GatewaySession? session))
+ {
+ return false;
+ }
+
+ if (session.Tags.Count == 0)
+ {
+ return options.Value.Dashboard.UntaggedSessionVisibility == UntaggedSessionVisibility.AllViewers;
+ }
+
+ // Session.Tags is an ordinal-ignore-case set, so the containment test carries the
+ // comparison; scanning the claims (rather than materializing the grant) keeps this
+ // allocation-free beyond the claim enumerator.
+ foreach (Claim tagClaim in principal.FindAll(DashboardAuthenticationDefaults.DashboardTagClaimType))
+ {
+ if (session.Tags.Contains(tagClaim.Value))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/HubTokenService.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/HubTokenService.cs
index 1ea957b..5985374 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/HubTokenService.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/HubTokenService.cs
@@ -2,14 +2,16 @@ using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.AspNetCore.DataProtection;
+using Microsoft.Extensions.Options;
+using ZB.MOM.WW.MxGateway.Server.Configuration;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
///
/// Mints and validates short-lived bearer tokens for SignalR hub connections.
-/// The token is a data-protected JSON payload containing the user's name and
-/// role claims. Validity is enforced by the data-protection time-limited
-/// protector; no separate signing keys are configured.
+/// The token is a data-protected JSON payload containing the user's name, role
+/// claims, and granted dashboard visibility tags. Validity is enforced by the
+/// data-protection time-limited protector; no separate signing keys are configured.
///
///
/// This service is registered as a singleton in
@@ -32,24 +34,33 @@ public sealed class HubTokenService
// Hub bearer tokens are single-purpose, data-protection-encrypted, and NOT server-side
// revocable. A short lifetime bounds the exposure window of a token captured from a proxy
// or log after logout (the cookie is cleared on logout, but outstanding tokens are not), and
- // bounds how long a stale role set survives a role change. Five minutes is transparent to
- // clients that re-fetch from /hubs/token on every (re)connect, which is what a remote hub
- // consumer is expected to do; see docs/GatewayDashboardDesign.md. Heavier jti-denylist
- // revocation is deliberately deferred until per-session hub ACLs land, when tokens gain
- // session binding.
+ // bounds how long a stale role set survives a role change. It now bounds a stale *tag* grant
+ // the same way (SEC-25): the token carries the tags resolved from the caller's LDAP groups at
+ // mint time, so revoking a GroupToTag entry takes effect for token-authenticated hub
+ // connections within one lifetime — the natural place the deferred "tokens gain session
+ // binding" note landed. Five minutes is transparent to clients that re-fetch from /hubs/token
+ // on every (re)connect, which is what a remote hub consumer is expected to do; see
+ // docs/GatewayDashboardDesign.md. Heavier jti-denylist revocation stays deferred.
internal static readonly TimeSpan TokenLifetime = TimeSpan.FromMinutes(5);
private readonly ITimeLimitedDataProtector _protector;
+ private readonly IOptions _options;
/// Initializes a new instance of the HubTokenService with a data protection provider.
/// The data protection provider for token encryption.
- public HubTokenService(IDataProtectionProvider dataProtection)
+ ///
+ /// Gateway options supplying MxGateway:Dashboard:GroupToTag, the map used to resolve the
+ /// caller's granted visibility tags at mint time.
+ ///
+ public HubTokenService(IDataProtectionProvider dataProtection, IOptions options)
{
ArgumentNullException.ThrowIfNull(dataProtection);
+ ArgumentNullException.ThrowIfNull(options);
_protector = dataProtection.CreateProtector(ProtectorPurpose).ToTimeLimitedDataProtector();
+ _options = options;
}
- /// Issues a bearer token carrying the user's identity and roles.
+ /// Issues a bearer token carrying the user's identity, roles, and granted tags.
/// The claims principal representing the user.
/// The data-protected bearer token string.
public string Issue(ClaimsPrincipal user) => Issue(user, TokenLifetime);
@@ -65,10 +76,20 @@ public sealed class HubTokenService
internal string Issue(ClaimsPrincipal user, TimeSpan lifetime)
{
ArgumentNullException.ThrowIfNull(user);
+
+ // Resolved from the caller's LDAP-group claims rather than copied from any tag claims the
+ // principal already carries: re-resolving is what makes the 5-minute lifetime an actual
+ // staleness bound on the grant. Tags are stamped for every caller — an Administrator
+ // bypasses the ACL, so theirs are simply moot rather than a special case here.
+ IReadOnlySet grantedTags = DashboardGroupTagMapping.MapGroupsToTags(
+ user.FindAll(DashboardAuthenticationDefaults.LdapGroupClaimType).Select(c => c.Value),
+ _options.Value.Dashboard.GroupToTag);
+
HubTokenPayload payload = new(
user.Identity?.Name,
user.FindFirstValue(ClaimTypes.NameIdentifier),
- [.. user.FindAll(ClaimTypes.Role).Select(c => c.Value)]);
+ [.. user.FindAll(ClaimTypes.Role).Select(c => c.Value)],
+ [.. grantedTags]);
return _protector.Protect(JsonSerializer.Serialize(payload), lifetime);
}
@@ -107,6 +128,12 @@ public sealed class HubTokenService
}
claims.AddRange((payload.Roles ?? []).Select(r => new Claim(ClaimTypes.Role, r)));
+ // Rehydrated alongside the roles so the reconstructed principal is what
+ // IDashboardSessionAcl reads on the hub path — a token minted before the tag field
+ // existed (or by a caller with no grant) simply yields an empty grant, which denies.
+ claims.AddRange((payload.Tags ?? []).Select(t => new Claim(
+ DashboardAuthenticationDefaults.DashboardTagClaimType,
+ t)));
ClaimsIdentity identity = new(
claims,
@@ -121,5 +148,5 @@ public sealed class HubTokenService
}
}
- private sealed record HubTokenPayload(string? Name, string? NameIdentifier, string[]? Roles);
+ private sealed record HubTokenPayload(string? Name, string? NameIdentifier, string[]? Roles, string[]? Tags);
}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/EventsHub.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/EventsHub.cs
index 53c5c1c..601fabc 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/EventsHub.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/EventsHub.cs
@@ -15,8 +15,11 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
/// registry to skip all mirror work for sessions nobody is watching.
///
/// Registry tracking which sessions have live subscribers.
+/// Per-session visibility gate consulted before any group join.
[Authorize(Policy = DashboardAuthenticationDefaults.HubClientsPolicy)]
-public sealed class EventsHub(EventsHubViewerRegistry viewerRegistry) : Hub
+public sealed class EventsHub(
+ EventsHubViewerRegistry viewerRegistry,
+ IDashboardSessionAcl sessionAcl) : Hub
{
/// Method name used to push individual MxEvent values to clients.
public const string EventMessage = "MxEvent";
@@ -33,27 +36,21 @@ public sealed class EventsHub(EventsHubViewerRegistry viewerRegistry) : Hub
/// client.
///
///
- /// In v1 the hub-level
- /// (HubClientsPolicy) only checks that the caller carries one of
- /// the dashboard roles (Admin or Viewer); both roles may subscribe to
- /// any session id they choose. This is acceptable today because (a) the
- /// dashboard's per-session views show non-secret session metadata that
- /// any authenticated dashboard user can already see, and (b) tag values
- /// are stripped from the mirrored events by
- /// when
- /// MxGateway:Dashboard:ShowTagValues is false (the default), so the
- /// most sensitive payload cannot leak through this seam regardless of the
- /// still-missing ACL. The per-session ACL that gates the gRPC
- /// StreamEvents RPC is intentionally not yet mirrored here.
- /// TODO(per-session-acl): tracked as remediation roadmap item 12
- /// (SEC-25). Once a role/scope is introduced that scopes a Viewer to a
- /// specific session or tenant, add a session-access check at this seam —
- /// either inline (consult the per-user allowed-session set on
- /// Context.User claims / Context.Items) or via a dedicated
- /// authorization policy applied to the hub method itself.
+ /// The hub-level (HubClientsPolicy)
+ /// only checks that the caller carries one of the dashboard roles, which by
+ /// itself would let any Viewer subscribe to any session id they name. The
+ /// per-session decision is 's
+ /// (SEC-25 / TST-15): Administrators see every session, a Viewer sees a
+ /// session only when its tags intersect their granted tags, and an unknown
+ /// session id is denied. A denied caller is not joined to the group and is
+ /// not registered with , so the mirror
+ /// stays off for a session nobody is legitimately watching. The same ACL
+ /// gates the in-process seam used by the session-details page, so neither
+ /// path is the weaker one.
///
/// Session id to subscribe the caller to.
/// A task representing the subscription operation.
+ /// The caller may not observe this session.
public Task SubscribeSession(string sessionId)
{
if (string.IsNullOrWhiteSpace(sessionId))
@@ -61,6 +58,13 @@ public sealed class EventsHub(EventsHubViewerRegistry viewerRegistry) : Hub
return Task.CompletedTask;
}
+ if (!sessionAcl.CanViewSession(Context.User, sessionId))
+ {
+ // Surfaced rather than swallowed so a client can tell "denied" from "no events yet".
+ // The message names neither the session's tags nor the caller's grant.
+ throw new HubException("Not authorized for this session.");
+ }
+
// Register before joining the group: the reverse order would leave a window
// in which this connection is a group member but the broadcaster's gate still
// reports the session unwatched, silently dropping events it should receive.
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardSessionAcl.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardSessionAcl.cs
new file mode 100644
index 0000000..5ae4f87
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardSessionAcl.cs
@@ -0,0 +1,33 @@
+using System.Security.Claims;
+
+namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
+
+///
+/// Decides whether a dashboard principal may observe one session's mirrored
+/// event stream (SEC-25 / TST-15). Consulted at every subscribe seam: the
+/// SignalR EventsHub.SubscribeSession join and the in-process
+/// IDashboardSessionEventSubscriber.Subscribe used by the
+/// session-details page.
+///
+///
+/// The dashboard authenticates LDAP users while sessions are owned by API keys —
+/// two disjoint identity domains — so the bridge is the session tag: a
+/// session inherits its owning key's tags, and a dashboard group grants tags via
+/// MxGateway:Dashboard:GroupToTag. See
+/// docs/plans/2026-07-10-dashboard-session-acl-tst15.md.
+///
+public interface IDashboardSessionAcl
+{
+ ///
+ /// Returns whether may observe the events of the
+ /// session identified by .
+ ///
+ ///
+ /// The dashboard caller. , unauthenticated, or claim-less
+ /// principals (including the anonymous-localhost path) are treated as Viewers
+ /// holding an empty tag grant.
+ ///
+ /// Session id the caller wants to observe.
+ /// when the caller may observe the session; otherwise .
+ bool CanViewSession(ClaimsPrincipal? principal, string sessionId);
+}
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmTruncationSignalTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmTruncationSignalTests.cs
new file mode 100644
index 0000000..e8f8772
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmTruncationSignalTests.cs
@@ -0,0 +1,329 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Runtime.CompilerServices;
+using System.Threading.Channels;
+using Microsoft.Extensions.Logging.Abstractions;
+using ZB.MOM.WW.MxGateway.Contracts.Proto;
+using ZB.MOM.WW.MxGateway.Server.Alarms;
+using ZB.MOM.WW.MxGateway.Server.Configuration;
+using ZB.MOM.WW.MxGateway.Server.Grpc;
+using ZB.MOM.WW.MxGateway.Server.Metrics;
+using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
+using ZB.MOM.WW.MxGateway.Server.Sessions;
+using ZB.MOM.WW.MxGateway.Tests.TestSupport;
+
+namespace ZB.MOM.WW.MxGateway.Tests.Alarms;
+
+///
+/// Carries the worker's truncated-fetch verdict across the gateway: worker
+/// reply payload → → the public
+/// QueryActiveAlarms stream.
+///
+///
+///
+/// The truncation guard itself (the worker merging rather than replacing
+/// a capped snapshot) is already covered in the worker suite. What was
+/// missing is that the guard is silent: a capped fetch suppresses
+/// absence-implies-Clear inference and says so only in a rate-limited
+/// stderr warning, so a consumer of the alarm surface could not tell a
+/// complete active set from a capped one. These tests pin the structural
+/// signal that replaces the guesswork.
+///
+///
+/// The load-bearing assertion is the false one
+/// ().
+/// "Truncated reply sets the flag" would also pass against a field
+/// hard-wired to true; only the complete-reply case proves the flag is
+/// actually derived from the worker's verdict.
+///
+///
+public sealed class AlarmTruncationSignalTests
+{
+ private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(15);
+
+ ///
+ /// A capped worker reply sets the monitor's completeness caveat and
+ /// stamps every cached snapshot, so both the dashboard (which reads the
+ /// service flag) and the RPC (which reads the records) can surface it.
+ ///
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task Reconcile_WithTruncatedWorkerReply_SurfacesTheFlagOnMonitorAndSnapshots()
+ {
+ using GatewayMetrics metrics = new();
+ StubSessionManager sessions = new()
+ {
+ SnapshotTruncated = true,
+ Snapshots = [NewSnapshot("Galaxy!Area.Tank01.Level.HiHi", fromTruncatedSnapshot: true)],
+ };
+ using GatewayAlarmMonitor monitor = CreateMonitor(sessions, metrics);
+
+ using CancellationTokenSource cts = new();
+ await monitor.StartAsync(cts.Token);
+ await sessions.WaitForReconcileAsync(WaitTimeout);
+ await WaitUntilAsync(() => monitor.CurrentAlarms.Count == 1, WaitTimeout);
+
+ Assert.True(monitor.SnapshotTruncated);
+ ActiveAlarmSnapshot cached = Assert.Single(monitor.CurrentAlarms);
+ Assert.True(cached.FromTruncatedSnapshot);
+ // Truncation is about the completeness of the SET, not the fidelity of
+ // the record — the subtag-fallback flag must stay independent of it.
+ Assert.False(cached.Degraded);
+
+ await cts.CancelAsync();
+ await monitor.StopAsync(CancellationToken.None);
+ }
+
+ ///
+ /// The public QueryActiveAlarms stream carries the per-record flag
+ /// through untouched. That RPC returns a bare
+ /// stream ActiveAlarmSnapshot with no envelope message, so the
+ /// per-record boolean is the only place set-level degraded status can
+ /// ride — if the service ever starts re-projecting records instead of
+ /// forwarding them, this is what catches the dropped field.
+ ///
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task QueryActiveAlarms_WithTruncatedSnapshot_StreamsTheFlagToTheClient()
+ {
+ FakeGatewayAlarmService alarms = new()
+ {
+ SnapshotTruncated = true,
+ CurrentAlarms = [NewSnapshot("Galaxy!Area.Tank01.Level.HiHi", fromTruncatedSnapshot: true)],
+ };
+ MxAccessGatewayService service = CreateService(alarms);
+ RecordingServerStreamWriter sink = new();
+
+ await service.QueryActiveAlarms(
+ new QueryActiveAlarmsRequest(),
+ sink,
+ new TestServerCallContext());
+
+ ActiveAlarmSnapshot streamed = Assert.Single(sink.Messages);
+ Assert.True(streamed.FromTruncatedSnapshot);
+ Assert.Equal("Galaxy!Area.Tank01.Level.HiHi", streamed.AlarmFullReference);
+ }
+
+ ///
+ /// The control. A complete worker reply must leave both the monitor flag
+ /// and the streamed records unset — otherwise every snapshot would read
+ /// as possibly-incomplete and the signal would carry no information.
+ ///
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task QueryActiveAlarms_WithCompleteWorkerReply_LeavesFlagUnset()
+ {
+ using GatewayMetrics metrics = new();
+ StubSessionManager sessions = new()
+ {
+ SnapshotTruncated = false,
+ Snapshots = [NewSnapshot("Galaxy!Area.Tank01.Level.HiHi", fromTruncatedSnapshot: false)],
+ };
+ using GatewayAlarmMonitor monitor = CreateMonitor(sessions, metrics);
+
+ using CancellationTokenSource cts = new();
+ await monitor.StartAsync(cts.Token);
+ await sessions.WaitForReconcileAsync(WaitTimeout);
+ await WaitUntilAsync(() => monitor.CurrentAlarms.Count == 1, WaitTimeout);
+
+ Assert.False(monitor.SnapshotTruncated);
+
+ MxAccessGatewayService service = CreateService(new FakeGatewayAlarmService
+ {
+ SnapshotTruncated = monitor.SnapshotTruncated,
+ CurrentAlarms = monitor.CurrentAlarms,
+ });
+ RecordingServerStreamWriter sink = new();
+
+ await service.QueryActiveAlarms(
+ new QueryActiveAlarmsRequest(),
+ sink,
+ new TestServerCallContext());
+
+ Assert.False(Assert.Single(sink.Messages).FromTruncatedSnapshot);
+
+ await cts.CancelAsync();
+ await monitor.StopAsync(CancellationToken.None);
+ }
+
+ private static ActiveAlarmSnapshot NewSnapshot(string reference, bool fromTruncatedSnapshot)
+ {
+ return new ActiveAlarmSnapshot
+ {
+ AlarmFullReference = reference,
+ SourceObjectReference = "Tank01.Level",
+ AlarmTypeName = "HiHi",
+ Category = "Area",
+ Severity = 500,
+ CurrentState = AlarmConditionState.Active,
+ SourceProvider = AlarmProviderMode.Alarmmgr,
+ FromTruncatedSnapshot = fromTruncatedSnapshot,
+ };
+ }
+
+ private static GatewayAlarmMonitor CreateMonitor(StubSessionManager sessions, GatewayMetrics metrics)
+ {
+ AlarmsOptions options = new()
+ {
+ Enabled = true,
+ SubscriptionExpression = @"\\NODE\Galaxy!Area",
+ };
+ return new GatewayAlarmMonitor(
+ sessions,
+ new StubWatchListResolver(),
+ metrics,
+ Microsoft.Extensions.Options.Options.Create(new GatewayOptions { Alarms = options }),
+ NullLogger.Instance);
+ }
+
+ private static MxAccessGatewayService CreateService(FakeGatewayAlarmService alarms)
+ {
+ StubSessionManager sessions = new();
+ return new MxAccessGatewayService(
+ sessions,
+ new GatewayRequestIdentityAccessor(),
+ new AllowAllConstraintEnforcer(),
+ new MxAccessGrpcRequestValidator(),
+ new MxAccessGrpcMapper(),
+ new StubEventStreamService(),
+ new GatewayMetrics(),
+ NullLogger.Instance,
+ alarms);
+ }
+
+ private static async Task WaitUntilAsync(Func condition, TimeSpan timeout)
+ {
+ DateTime deadline = DateTime.UtcNow + timeout;
+ while (DateTime.UtcNow < deadline)
+ {
+ if (condition())
+ {
+ return;
+ }
+
+ await Task.Delay(25);
+ }
+
+ throw new TimeoutException("Condition was not met in time.");
+ }
+
+ /// that resolves an empty watch-list.
+ private sealed class StubWatchListResolver : IAlarmWatchListResolver
+ {
+ ///
+ public Task> ResolveAsync(
+ AlarmsOptions options,
+ CancellationToken cancellationToken = default) =>
+ Task.FromResult>([]);
+ }
+
+ ///
+ /// Minimal that answers the monitor's
+ /// QueryActiveAlarms with a scripted reply payload — the seam this suite
+ /// drives the truncation verdict through.
+ ///
+ private sealed class StubSessionManager : ISessionManager
+ {
+ private readonly Channel _events = Channel.CreateUnbounded();
+ private readonly TaskCompletionSource _reconciled =
+ new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ /// Gets or sets the truncation verdict the scripted reply carries.
+ public bool SnapshotTruncated { get; init; }
+
+ /// Gets or sets the snapshots the scripted reply carries.
+ public IReadOnlyList Snapshots { get; init; } = [];
+
+ /// Completes once the monitor has issued its first QueryActiveAlarms.
+ /// The maximum time to wait.
+ /// A task that represents the asynchronous operation.
+ public Task WaitForReconcileAsync(TimeSpan timeout) => _reconciled.Task.WaitAsync(timeout);
+
+ ///
+ public Task OpenSessionAsync(
+ SessionOpenRequest request,
+ string? clientIdentity,
+ string? ownerKeyId,
+ CancellationToken cancellationToken)
+ {
+ GatewaySession session = new(
+ Guid.NewGuid().ToString("N"),
+ "Galaxy",
+ "pipe-test",
+ "nonce-test",
+ clientIdentity,
+ null,
+ null,
+ TimeSpan.FromSeconds(30),
+ TimeSpan.FromSeconds(30),
+ TimeSpan.FromSeconds(30),
+ DateTimeOffset.UtcNow);
+ session.AttachWorkerClient(new ChannelWorkerClient(session.SessionId, _events.Reader));
+ session.MarkReady();
+ return Task.FromResult(session);
+ }
+
+ ///
+ public Task InvokeAsync(
+ string sessionId,
+ WorkerCommand command,
+ CancellationToken cancellationToken)
+ {
+ MxCommandReply reply = new()
+ {
+ ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
+ };
+
+ if (command.Command?.Kind == MxCommandKind.QueryActiveAlarms)
+ {
+ QueryActiveAlarmsReplyPayload payload = new() { SnapshotTruncated = SnapshotTruncated };
+ payload.Snapshots.AddRange(Snapshots.Select(snapshot => snapshot.Clone()));
+ reply.QueryActiveAlarms = payload;
+ _reconciled.TrySetResult();
+ }
+
+ return Task.FromResult(new WorkerCommandReply { Reply = reply });
+ }
+
+ ///
+ public bool TryGetSession(string sessionId, [MaybeNullWhen(false)] out GatewaySession session)
+ {
+ session = null;
+ return false;
+ }
+
+ ///
+ public Task CloseSessionAsync(string sessionId, CancellationToken cancellationToken)
+ {
+ _events.Writer.TryComplete();
+ return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
+ }
+
+ ///
+ public Task KillWorkerAsync(string sessionId, string reason, CancellationToken cancellationToken) =>
+ Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
+
+ ///
+ public Task CloseExpiredLeasesAsync(DateTimeOffset now, CancellationToken cancellationToken) =>
+ Task.FromResult(0);
+
+ ///
+ public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+ }
+
+ ///
+ /// stub — QueryActiveAlarms never
+ /// touches the event path, but the service constructor requires one.
+ ///
+ private sealed class StubEventStreamService : IEventStreamService
+ {
+ ///
+ public async IAsyncEnumerable StreamEventsAsync(
+ StreamEventsRequest request,
+ string? callerKeyId,
+ [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ await Task.CompletedTask.ConfigureAwait(false);
+ yield break;
+ }
+ }
+}
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/AlarmsPageTruncationBannerTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/AlarmsPageTruncationBannerTests.cs
new file mode 100644
index 0000000..ebb4e54
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/AlarmsPageTruncationBannerTests.cs
@@ -0,0 +1,105 @@
+using Microsoft.AspNetCore.Components.Web.HtmlRendering;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using ZB.MOM.WW.MxGateway.Server.Alarms;
+using ZB.MOM.WW.MxGateway.Server.Configuration;
+using ZB.MOM.WW.MxGateway.Server.Dashboard;
+using ZB.MOM.WW.MxGateway.Server.Dashboard.Components.Pages;
+using ZB.MOM.WW.MxGateway.Tests.TestSupport;
+using HtmlRenderer = Microsoft.AspNetCore.Components.Web.HtmlRenderer;
+
+namespace ZB.MOM.WW.MxGateway.Tests.Dashboard;
+
+///
+/// Renders and asserts the truncated-snapshot
+/// caveat banner appears exactly when the alarm query reports a capped
+/// provider fetch.
+///
+///
+///
+/// The absence assertion is the load-bearing one: a banner that renders
+/// unconditionally would satisfy the positive case while telling every
+/// operator, on every normal day, that the alarm list might be missing
+/// alarms. A caveat that is always on is a caveat nobody reads.
+///
+///
+/// Static rendering via the framework's , as in
+/// SecretsNavRenderTests — the assertion is about markup the server
+/// emits, so no component-testing dependency is warranted. The page's
+/// poll loop runs its first pass inline during OnInitialized
+/// (the stub query completes synchronously), so the rendered markup
+/// already reflects the query result.
+///
+///
+public sealed class AlarmsPageTruncationBannerTests
+{
+ private const string BannerMarker = "Alarm snapshot may be incomplete";
+
+ /// A capped provider fetch puts the completeness caveat on the page.
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task AlarmsPage_WhenSnapshotTruncated_RendersTheCaveatBanner()
+ {
+ string html = await RenderAsync(snapshotTruncated: true);
+
+ Assert.Contains(BannerMarker, html, StringComparison.Ordinal);
+ }
+
+ ///
+ /// The proof the banner is gated. A complete fetch must render no caveat
+ /// at all, while the page itself still renders — the alarm-table heading
+ /// is the control that keeps this from passing over a blank page.
+ ///
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task AlarmsPage_WhenSnapshotComplete_OmitsTheCaveatBanner()
+ {
+ string html = await RenderAsync(snapshotTruncated: false);
+
+ Assert.DoesNotContain(BannerMarker, html, StringComparison.Ordinal);
+ Assert.Contains("Active Alarms", html, StringComparison.Ordinal);
+ }
+
+ private static async Task RenderAsync(bool snapshotTruncated)
+ {
+ ServiceCollection services = new();
+ services.AddLogging();
+ services.AddSingleton(
+ new StubLiveDataService(snapshotTruncated));
+ services.AddSingleton(
+ new FakeGatewayAlarmService { SnapshotTruncated = snapshotTruncated });
+ services.AddSingleton>(
+ Options.Create(new GatewayOptions { Alarms = new AlarmsOptions { Enabled = true } }));
+
+ await using ServiceProvider provider = services.BuildServiceProvider();
+ await using HtmlRenderer renderer = new(
+ provider,
+ provider.GetRequiredService());
+
+ return await renderer.Dispatcher.InvokeAsync(async () =>
+ {
+ HtmlRootComponent output = await renderer.RenderComponentAsync();
+ return output.ToHtmlString();
+ });
+ }
+
+ // Answers the page's 3-second poll synchronously, so the first pass completes
+ // inline inside OnInitialized and the rendered markup reflects it.
+ private sealed class StubLiveDataService(bool snapshotTruncated) : IDashboardLiveDataService
+ {
+ ///
+ public Task ReadAsync(
+ IReadOnlyCollection tagAddresses,
+ CancellationToken cancellationToken) =>
+ Task.FromResult(DashboardLiveReadResult.Empty);
+
+ ///
+ public Task QueryAlarmsAsync(CancellationToken cancellationToken) =>
+ Task.FromResult(new DashboardAlarmQueryResult(
+ Alarms: [],
+ Error: null,
+ WorkerProcessId: null,
+ SnapshotTruncated: snapshotTruncated));
+ }
+}
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardAuthenticatorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardAuthenticatorTests.cs
index 08e87b1..8773038 100644
--- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardAuthenticatorTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardAuthenticatorTests.cs
@@ -291,6 +291,7 @@ public sealed class DashboardAuthenticatorTests
return new DashboardAuthenticator(
ldapAuthService,
roleMapper,
+ Options.Create(options),
NullLogger.Instance);
}
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSessionAclTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSessionAclTests.cs
new file mode 100644
index 0000000..ff8ddba
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSessionAclTests.cs
@@ -0,0 +1,264 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Security.Claims;
+using Microsoft.Extensions.Options;
+using ZB.MOM.WW.MxGateway.Contracts.Proto;
+using ZB.MOM.WW.MxGateway.Server.Configuration;
+using ZB.MOM.WW.MxGateway.Server.Dashboard;
+using ZB.MOM.WW.MxGateway.Server.Sessions;
+
+namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
+
+///
+/// Covers , the single decision both dashboard subscribe seams
+/// consult (SEC-25 / TST-15).
+///
+///
+/// Every branch is asserted in its denying direction as well as its allowing one, because the
+/// pre-ACL behaviour was "allow everything": an assertion that a permitted caller is permitted
+/// cannot distinguish a working gate from no gate at all.
+///
+public sealed class DashboardSessionAclTests
+{
+ private const string TaggedSessionId = "session-tagged";
+ private const string UntaggedSessionId = "session-untagged";
+
+ /// An Administrator bypasses the tag check entirely, including for a tag they hold none of.
+ [Fact]
+ public void CanViewSession_Administrator_BypassesTagCheck()
+ {
+ DashboardSessionAcl acl = CreateAcl();
+
+ Assert.True(acl.CanViewSession(Principal(roles: [DashboardRoles.Admin]), TaggedSessionId));
+ Assert.True(acl.CanViewSession(Principal(roles: [DashboardRoles.Admin]), UntaggedSessionId));
+ }
+
+ ///
+ /// The admin bypass is what keeps Dashboard:DisableLogin auto-login (which stamps both
+ /// roles and no tags) working exactly as before this change.
+ ///
+ [Fact]
+ public void CanViewSession_AutoLoginStyleBothRolesNoTags_Allowed()
+ {
+ DashboardSessionAcl acl = CreateAcl();
+
+ Assert.True(acl.CanViewSession(
+ Principal(roles: [DashboardRoles.Admin, DashboardRoles.Viewer]),
+ TaggedSessionId));
+ }
+
+ ///
+ /// An unknown session id is denied even for a caller holding every configured tag: no
+ /// subscription is created for a session the registry does not have.
+ ///
+ [Fact]
+ public void CanViewSession_UnknownSession_Denied()
+ {
+ DashboardSessionAcl acl = CreateAcl();
+
+ Assert.False(acl.CanViewSession(
+ Principal(roles: [DashboardRoles.Viewer], tags: ["team-a", "team-b"]),
+ "session-does-not-exist"));
+ }
+
+ /// A blank session id is denied without consulting anything.
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void CanViewSession_BlankSessionId_Denied(string sessionId)
+ {
+ DashboardSessionAcl acl = CreateAcl();
+
+ Assert.False(acl.CanViewSession(Principal(roles: [DashboardRoles.Admin]), sessionId));
+ }
+
+ /// A null principal denies — the fail-closed reading of an unauthenticated hub context.
+ [Fact]
+ public void CanViewSession_NullPrincipal_Denied()
+ {
+ DashboardSessionAcl acl = CreateAcl();
+
+ Assert.False(acl.CanViewSession(null, UntaggedSessionId));
+ }
+
+ ///
+ /// Untagged sessions follow Dashboard:UntaggedSessionVisibility: hidden from Viewers
+ /// under the shipped default, visible under
+ /// the opt-in .
+ ///
+ /// The configured untagged-session visibility.
+ /// Whether a tagless Viewer may observe the untagged session.
+ [Theory]
+ [InlineData(UntaggedSessionVisibility.AdminOnly, false)]
+ [InlineData(UntaggedSessionVisibility.AllViewers, true)]
+ public void CanViewSession_UntaggedSession_FollowsConfiguredVisibility(
+ UntaggedSessionVisibility visibility,
+ bool expected)
+ {
+ DashboardSessionAcl acl = CreateAcl(visibility);
+
+ Assert.Equal(
+ expected,
+ acl.CanViewSession(Principal(roles: [DashboardRoles.Viewer]), UntaggedSessionId));
+ }
+
+ ///
+ /// A Viewer whose grant intersects the session's tags is allowed; the comparison is
+ /// ordinal-ignore-case, matching the session's tag set and the config map.
+ ///
+ /// The single tag the Viewer holds.
+ [Theory]
+ [InlineData("team-a")]
+ [InlineData("TEAM-A")]
+ public void CanViewSession_ViewerGrantIntersectsSessionTags_Allowed(string grantedTag)
+ {
+ DashboardSessionAcl acl = CreateAcl();
+
+ Assert.True(acl.CanViewSession(
+ Principal(roles: [DashboardRoles.Viewer], tags: [grantedTag]),
+ TaggedSessionId));
+ }
+
+ /// A Viewer holding only another tenant's tag is denied — the load-bearing negative.
+ [Fact]
+ public void CanViewSession_ViewerGrantDisjointFromSessionTags_Denied()
+ {
+ DashboardSessionAcl acl = CreateAcl();
+
+ Assert.False(acl.CanViewSession(
+ Principal(roles: [DashboardRoles.Viewer], tags: ["team-b"]),
+ TaggedSessionId));
+ }
+
+ ///
+ /// A principal carrying no tag claims — the anonymous-localhost / empty-grant Viewer of
+ /// SEC-02 — sees a tagged session never, and an untagged one only when the operator opted
+ /// into .
+ ///
+ [Fact]
+ public void CanViewSession_NoTagClaims_IsEmptyGrantViewer()
+ {
+ ClaimsPrincipal anonymous = new(new ClaimsIdentity());
+
+ Assert.False(CreateAcl().CanViewSession(anonymous, TaggedSessionId));
+ Assert.False(CreateAcl(UntaggedSessionVisibility.AdminOnly).CanViewSession(anonymous, UntaggedSessionId));
+ Assert.True(CreateAcl(UntaggedSessionVisibility.AllViewers).CanViewSession(anonymous, UntaggedSessionId));
+ }
+
+ ///
+ /// An unauthenticated principal that nonetheless carries an Administrator role claim does not
+ /// get the bypass: the bypass requires a real authenticated identity, as elsewhere in the
+ /// dashboard (DashboardSessionAdminService.CanManage).
+ ///
+ [Fact]
+ public void CanViewSession_UnauthenticatedAdminRoleClaim_DoesNotBypass()
+ {
+ // No authentication type => IsAuthenticated is false.
+ ClaimsPrincipal principal = new(new ClaimsIdentity(
+ [new Claim(ClaimTypes.Role, DashboardRoles.Admin)],
+ authenticationType: null,
+ nameType: ClaimTypes.Name,
+ roleType: ClaimTypes.Role));
+
+ Assert.False(CreateAcl().CanViewSession(principal, TaggedSessionId));
+ }
+
+ private static DashboardSessionAcl CreateAcl(
+ UntaggedSessionVisibility visibility = UntaggedSessionVisibility.AdminOnly)
+ {
+ GatewayOptions options = new()
+ {
+ Dashboard = new DashboardOptions { UntaggedSessionVisibility = visibility },
+ };
+
+ return new DashboardSessionAcl(
+ new TwoSessionManager(
+ CreateSession(TaggedSessionId, ["team-a"]),
+ CreateSession(UntaggedSessionId, tags: null)),
+ Options.Create(options));
+ }
+
+ private static ClaimsPrincipal Principal(string[] roles, string[]? tags = null)
+ {
+ List claims = [new Claim(ClaimTypes.Name, "viewer-user")];
+ claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
+ claims.AddRange((tags ?? []).Select(tag => new Claim(
+ DashboardAuthenticationDefaults.DashboardTagClaimType,
+ tag)));
+
+ return new ClaimsPrincipal(new ClaimsIdentity(
+ claims,
+ authenticationType: "test",
+ nameType: ClaimTypes.Name,
+ roleType: ClaimTypes.Role));
+ }
+
+ private static GatewaySession CreateSession(string sessionId, string[]? tags)
+ {
+ return new GatewaySession(
+ sessionId: sessionId,
+ backendName: "backend",
+ pipeName: $"pipe-{sessionId}",
+ nonce: "nonce",
+ clientIdentity: "client",
+ ownerKeyId: "key-1",
+ clientSessionName: "client-session",
+ clientCorrelationId: "correlation",
+ commandTimeout: TimeSpan.FromSeconds(5),
+ startupTimeout: TimeSpan.FromSeconds(5),
+ shutdownTimeout: TimeSpan.FromSeconds(5),
+ leaseDuration: TimeSpan.FromMinutes(30),
+ openedAt: DateTimeOffset.UnixEpoch,
+ ownerDashboardTags: tags);
+ }
+
+ /// Registry double serving exactly the two sessions the ACL cases need.
+ private sealed class TwoSessionManager(GatewaySession tagged, GatewaySession untagged) : ISessionManager
+ {
+ ///
+ public Task OpenSessionAsync(
+ SessionOpenRequest request,
+ string? clientIdentity,
+ string? ownerKeyId,
+ CancellationToken cancellationToken) => Task.FromResult(tagged);
+
+ ///
+ public bool TryGetSession(string sessionId, [MaybeNullWhen(false)] out GatewaySession session)
+ {
+ session = sessionId switch
+ {
+ TaggedSessionId => tagged,
+ UntaggedSessionId => untagged,
+ _ => null,
+ };
+
+ return session is not null;
+ }
+
+ ///
+ public Task InvokeAsync(
+ string sessionId,
+ WorkerCommand command,
+ CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply());
+
+ ///
+ public Task CloseSessionAsync(
+ string sessionId,
+ CancellationToken cancellationToken) =>
+ Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
+
+ ///
+ public Task KillWorkerAsync(
+ string sessionId,
+ string reason,
+ CancellationToken cancellationToken) =>
+ Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
+
+ ///
+ public Task CloseExpiredLeasesAsync(
+ DateTimeOffset now,
+ CancellationToken cancellationToken) => Task.FromResult(0);
+
+ ///
+ public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+ }
+}
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/EventsHubTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/EventsHubTests.cs
new file mode 100644
index 0000000..b0f2b83
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/EventsHubTests.cs
@@ -0,0 +1,175 @@
+using System.Security.Claims;
+using Microsoft.AspNetCore.Http.Features;
+using Microsoft.AspNetCore.SignalR;
+using ZB.MOM.WW.MxGateway.Server.Dashboard;
+using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
+
+namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
+
+///
+/// Covers the ACL gate on (SEC-25 / TST-15).
+///
+///
+/// The denial assertions are the load-bearing ones — before the gate existed every caller was
+/// joined, so "an allowed caller is joined" is indistinguishable from no gate. They assert the
+/// absence of BOTH effects of a join: the SignalR group membership and the viewer registration
+/// that turns the broadcaster's mirror on for the session. Leaving either behind would keep the
+/// event clone running for a caller who may not observe it.
+///
+public sealed class EventsHubTests
+{
+ private const string SessionId = "session-1";
+ private const string ConnectionId = "connection-1";
+
+ private static readonly ClaimsPrincipal TestPrincipal = new(new ClaimsIdentity(
+ [new Claim(ClaimTypes.Name, "viewer-user")],
+ authenticationType: "test"));
+
+ /// An allowed caller joins the group and registers as a viewer.
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task SubscribeSession_WhenAclAllows_JoinsGroupAndRegistersViewer()
+ {
+ EventsHubViewerRegistry registry = new();
+ RecordingGroupManager groups = new();
+ EventsHub hub = CreateHub(registry, groups, allow: true);
+
+ await hub.SubscribeSession(SessionId);
+
+ Assert.Equal([(ConnectionId, EventsHub.GroupName(SessionId))], groups.Added);
+ Assert.True(registry.HasViewers(SessionId));
+ }
+
+ ///
+ /// A denied caller gets a , is not joined, and is not registered.
+ ///
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task SubscribeSession_WhenAclDenies_ThrowsAndDoesNotJoin()
+ {
+ EventsHubViewerRegistry registry = new();
+ RecordingGroupManager groups = new();
+ EventsHub hub = CreateHub(registry, groups, allow: false);
+
+ HubException error = await Assert.ThrowsAsync(() => hub.SubscribeSession(SessionId));
+
+ Assert.Equal("Not authorized for this session.", error.Message);
+ Assert.Empty(groups.Added);
+ Assert.False(registry.HasViewers(SessionId));
+ }
+
+ ///
+ /// A blank session id is still a no-op rather than a denial, so a client that sends one is not
+ /// told it lacks authorization for a session it never named.
+ ///
+ /// The blank session id supplied by the caller.
+ /// A task that represents the asynchronous operation.
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ public async Task SubscribeSession_BlankSessionId_IsNoOp(string sessionId)
+ {
+ EventsHubViewerRegistry registry = new();
+ RecordingGroupManager groups = new();
+ EventsHub hub = CreateHub(registry, groups, allow: false);
+
+ await hub.SubscribeSession(sessionId);
+
+ Assert.Empty(groups.Added);
+ }
+
+ /// The ACL is asked about the session the caller named, with the caller's own principal.
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task SubscribeSession_AsksAclAboutTheRequestedSession()
+ {
+ StubSessionAcl acl = new(allow: true);
+ EventsHub hub = new(new EventsHubViewerRegistry(), acl)
+ {
+ Groups = new RecordingGroupManager(),
+ Context = new StubHubCallerContext(ConnectionId, TestPrincipal),
+ };
+
+ await hub.SubscribeSession(SessionId);
+
+ Assert.Equal(SessionId, acl.LastSessionId);
+ Assert.Same(TestPrincipal, acl.LastPrincipal);
+ }
+
+ private static EventsHub CreateHub(
+ EventsHubViewerRegistry registry,
+ RecordingGroupManager groups,
+ bool allow)
+ {
+ return new EventsHub(registry, new StubSessionAcl(allow))
+ {
+ Groups = groups,
+ Context = new StubHubCallerContext(ConnectionId, TestPrincipal),
+ };
+ }
+
+ private sealed class StubSessionAcl(bool allow) : IDashboardSessionAcl
+ {
+ /// Gets the principal passed to the most recent call.
+ public ClaimsPrincipal? LastPrincipal { get; private set; }
+
+ /// Gets the session id passed to the most recent call.
+ public string? LastSessionId { get; private set; }
+
+ ///
+ public bool CanViewSession(ClaimsPrincipal? principal, string sessionId)
+ {
+ LastPrincipal = principal;
+ LastSessionId = sessionId;
+
+ return allow;
+ }
+ }
+
+ private sealed class RecordingGroupManager : IGroupManager
+ {
+ /// Gets the (connection id, group name) pairs added, in order.
+ public List<(string ConnectionId, string GroupName)> Added { get; } = [];
+
+ ///
+ public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default)
+ {
+ Added.Add((connectionId, groupName));
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ public Task RemoveFromGroupAsync(
+ string connectionId,
+ string groupName,
+ CancellationToken cancellationToken = default) => Task.CompletedTask;
+ }
+
+ private sealed class StubHubCallerContext(string connectionId, ClaimsPrincipal user) : HubCallerContext
+ {
+ ///
+ public override string ConnectionId { get; } = connectionId;
+
+ ///
+ public override string? UserIdentifier => User?.Identity?.Name;
+
+ ///
+ public override ClaimsPrincipal? User { get; } = user;
+
+ ///
+ public override IDictionary