From af9f185d32b3785c2428cc081f2a5501c7ae6c97 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 17 Aug 2026 03:41:59 -0400 Subject: [PATCH 01/17] =?UTF-8?q?docs(plans):=20deferred-closeout=20plan?= =?UTF-8?q?=20=E2=80=94=20SEC-25,=20truncation=20signal,=20lock-parking,?= =?UTF-8?q?=20dead=20chain,=20alarm=20probes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/2026-08-17-deferred-closeout.md | 398 ++++++++++++++++++ ...2026-08-17-deferred-closeout.md.tasks.json | 18 + 2 files changed, 416 insertions(+) create mode 100644 docs/plans/2026-08-17-deferred-closeout.md create mode 100644 docs/plans/2026-08-17-deferred-closeout.md.tasks.json diff --git a/docs/plans/2026-08-17-deferred-closeout.md b/docs/plans/2026-08-17-deferred-closeout.md new file mode 100644 index 0000000..817295d --- /dev/null +++ b/docs/plans/2026-08-17-deferred-closeout.md @@ -0,0 +1,398 @@ +# Deferred Closeout Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development to implement this plan task-by-task (Opus implementers per user instruction; reviewer chain per each task's Classification). + +**Goal:** Close every item the two perf-remediation plans left recorded-but-open: the dead +`ISessionManager.ReadEventsAsync` chain, the frame-writer lock-parking latency follow-up, +SEC-25 (per-session dashboard event ACL, design already approved in +`docs/plans/2026-07-10-dashboard-session-acl-tst15.md`), the structural alarm-truncation +degraded-status signal (proto change), and the wnwrap live-alarm probes (GUID identity, +`ALARM_RECORDS/@COUNT` semantics) that need windev state. + +**Architecture:** Same two-phase posture as the prior plans. Gateway-side work builds and +tests on macOS via `NonWindows.slnx`; worker-side work (frame writer, alarm consumer, +worker command executor) is edited on the Mac and verified on windev. **This plan touches +`.proto` contracts** (Task 8) — contracts regeneration and all five clients rebuild are in +scope (Task 9), unlike the prior two plans. The windev probe task (Task 3) is gated on +external state (live alarms) and may legitimately end "blocked — recorded". + +**Tech stack:** .NET 10 gateway / .NET Framework 4.8 x86 worker / protobuf contracts / +five language clients / Blazor Server dashboard / GLAuth LDAP. + +**Branch:** `feat/deferred-closeout` off `main` (`ac3f04f`). + +--- + +## Ground rules for every implementer subagent + +- NEVER run `git stash`, `git reset`, `git clean`, or `git checkout `. Commit + with explicit pathspecs only — never `git add -A` / `git commit -a`. +- Build/test mutual exclusion: before any `dotnet build`/`dotnet test`, acquire the lock via + `mkdir /private/tmp/claude-501/-Users-dohertj2-Desktop-MxAccessGateway/f36938ae-bbca-4245-b5c9-fac512d69e22/scratchpad/buildlock` + (retry with backoff while it fails); `rmdir` it on ALL exit paths, including failures. +- `TreatWarningsAsErrors=true`, `Nullable=enable` — new warnings break the build; fix, don't suppress. +- Follow `docs/style-guides/CSharpStyleGuide.md`: file-scoped namespaces, `sealed` by + default, `Async` suffix, MXAccess-aligned names. +- Update affected docs in the same commit as the source change. +- MXAccess parity is the contract; never synthesize events. +- Never log secrets, API keys, credentials, or tag values. +- The `Files:` block is the scope contract. If the task can't be done inside it, that's a + plan defect — surface it, don't silently expand scope. +- On macOS build `NonWindows.slnx`; the x86 Worker and full `slnx` only build on windev. + +--- + +## Task 1: Remove the dead `ISessionManager.ReadEventsAsync` chain + +**Classification:** standard +**Estimated implement time:** ~6 min +**Parallelizable with:** Task 2, Task 3, Task 5 + +**Files:** +- Modify: `src/ZB.MOM.WW.MxGateway.Server/Sessions/ISessionManager.cs:42` (remove member) +- Modify: `src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionManager.cs:191-197` (remove implementation) +- Modify: `src/ZB.MOM.WW.MxGateway.Server/Sessions/GatewaySession.cs` (~:1517-1545 remove `ReadEventsAsync`; ~:767-776 rewrite the "keep the two bodies in step" comment on `MapWorkerEventsAsync` — with the twin gone it now claims the single worker-event read path directly) +- Modify: every test fake implementing `ISessionManager` (grep `ISessionManager` under `src/ZB.MOM.WW.MxGateway.Tests/` — the as-built note in `docs/plans/2026-08-15-deferred-remediation.md` estimated ~15 touches; remove the member from each fake and any tests that exercised it *through the interface*) +- Modify: `docs/plans/2026-08-15-deferred-remediation.md` as-built note "Task 3 — ReadEventsAsync retained" (append one line: removed by this plan, date) + +**Spec:** `ISessionManager.ReadEventsAsync` has zero production call sites — only test fakes +implement and exercise it. Remove the interface member, `SessionManager`'s forwarder, and +`GatewaySession.ReadEventsAsync` (verify with grep first that `MapWorkerEventsAsync` and the +fakes are truly the only remaining references — if a production caller appears, STOP and +report; do not force it). **`IWorkerClient.ReadEventsAsync` / `WorkerClient.ReadEventsAsync` +stay** — that is the live worker-channel claim; most grep hits are that member, read carefully. +Tests that existed solely to exercise the pass-through die with it; tests that used a fake's +`ReadEventsAsync` as a convenience seam get rewired to the distributor path or removed if +redundant — judgment call, state it in the commit body. + +**Steps:** grep callers → edit → `dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx` → +`dotnet test src/ZB.MOM.WW.MxGateway.Tests/... --filter "FullyQualifiedName~GatewaySession"`, +`~SessionManager`, `~EventStreamService` → commit +`refactor(sessions): remove the dead ISessionManager.ReadEventsAsync chain`. + +--- + +## Task 2: Frame-writer lock-parking — awaited control-frame completion unparked from the winner's pass + +**Classification:** high-risk +**Estimated implement time:** ~10 min +**Parallelizable with:** Task 1, Task 3, Task 5 + +**Files:** +- Modify: `src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerFrameWriter.cs` +- Test: `src/ZB.MOM.WW.MxGateway.Worker.Tests/Ipc/` frame-writer tests (windev-run; write them now) +- Modify: `docs/WorkerFrameProtocol.md` (completion-latency contract paragraph) +- Modify: `docs/plans/2026-08-15-deferred-remediation.md` as-built note "Task 10" (append: lock-parking closed by this plan) + +**Spec:** Today `WriteAsync` enqueues the frame, then unconditionally contends for +`_writeLock`; a caller that loses the race stays parked in `WaitAsync` until the winning +drainer releases the lock — even though the winner writes *and flushes* the loser's control +frame mid-pass and completes its per-frame completion source at that moment +(`WorkerFrameWriter.cs:112-116` documents this parking as the deliberate residual). Close it: +after enqueueing, the caller awaits **its own frame's completion** racing the lock +acquisition — when the completion resolves first (frame written+flushed by the winner), the +caller returns immediately; its abandoned lock-wait must not leak drain responsibility. +Shape (implementer refines, invariants below are the contract): + +1. Await `Task.WhenAny(frame.Completion.Task, lockWaitTask)`. +2. Completion first → detach: register a continuation on `lockWaitTask` that, on acquisition, + drains any queued frames if present and releases — the lock is never acquired-and-dropped, + and a frame enqueued between the winner's last dequeue and its release still gets drained + (the existing "drain everything you can see, then release" loop already covers most of + this; the continuation is the backstop for the abandoned waiter). +3. Lock first → drain as today. +4. Cancellation during the combined wait keeps the existing tombstone semantics + (`WorkerFrameWriter.cs:104-110`): tombstone only if a drainer hasn't claimed the frame; + a claimed frame completes normally and cancellation is *not* surfaced for it. + +**Invariants that must hold (write a test for each):** every enqueued frame is eventually +written or tombstoned (no stranded frame when the completion-first path abandons its lock +wait); completions still resolve only after write+flush; batch API (`WriteBatchAsync`) +semantics unchanged; no double-drain / double-release; a control-frame caller racing a long +event batch observes its completion before the batch drain finishes (the latency win this +task exists for — assert with a gated slow-stream fake). + +**Steps:** edit → macOS `dotnet build` of the shared-source projects is NOT possible for the +worker — verify compile on windev (`dotnet build src\ZB.MOM.WW.MxGateway.Worker\... -p:Platform=x86`) +via `ssh windev` before commit if feasible, else mark the commit "edited, windev-pending" and +Task 10/11 gates it → commit +`perf(worker): unpark awaited control-frame writers from the winning drain pass`. + +--- + +## Task 3: windev live-alarm probes — GUID identity and `ALARM_RECORDS/@COUNT` semantics + +**Classification:** standard (investigation; no gateway code — findings doc only) +**Estimated implement time:** ~15 min wall (timeboxed; may end "blocked") +**Parallelizable with:** Task 1, Task 2, Task 4, Task 5 + +**Files:** +- Read (on windev): `src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/WnWrapConsumerProbeTests.cs` (Skip-gated probe; flip `Skip=null` locally on windev, never commit the flip) +- Create: `docs/AlarmProbeFindings.md` (findings record, or the explicit "blocked" record) +- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs` — comments ONLY, and only if a finding confirms/refutes a documented assumption (no behavior change in this task) + +**Spec:** Two questions only a live rig answers: +1. **GUID identity semantics:** is the alarm record GUID stable for one alarm *instance* + across polls and across state transitions (UNACK→ACK→RTN)? `ComputeTransitions` keys the + diff on GUID (`latestSnapshot: Dictionary`); if wnwrap mints a new GUID on a + state change, a transition would read as clear+new instead. +2. **`ALARM_RECORDS/@COUNT`:** when the fetch is capped (`maxAlmCnt` < actives), does the + reply's `COUNT` attribute carry the *total* active count (a usable "more available" + signal) or just the records-in-reply count? If it carries the total, + `IsTruncatedFetch` can become exact instead of the ≥cap heuristic — feed that finding to Task 8. + +**Steps:** +1. `ssh windev` (lands in PowerShell; CI clone `C:\build\mxaccessgw-ci`; first build after a + pull may fail on stale Contracts obj — clear `src\ZB.MOM.WW.MxGateway.Contracts\obj,bin` + and rebuild, it is not a regression). Pull the branch. +2. Determine whether live alarms exist or can be raised: check provider state; try driving a + known alarmed attribute over its limit via a gateway write or a Galaxy test object. + Timebox 15 minutes. If no alarm can be made active: write `docs/AlarmProbeFindings.md` + recording exactly what was tried and that both questions remain open, commit, STOP. +3. With ≥1 live alarm: run the probe (`WnWrapConsumerProbeTests`, Skip flipped locally, cap + `maxAlmCnt` low, e.g. 1–2, to force truncation), ack the alarm, let it RTN, capture the + XML across the transitions. Record: GUID per state, `COUNT` vs records-in-reply under the + forced cap. +4. Write findings + implications (for `ComputeTransitions` and `IsTruncatedFetch`) into + `docs/AlarmProbeFindings.md`; adjust `WnWrapAlarmConsumer` comments where an assumption is + now confirmed/refuted. Any *behavioral* fix the findings demand is reported to the + orchestrator (feeds Task 8, or a follow-on task if it's transition-identity surgery) — not + done here. +5. Commit `docs(alarms): wnwrap live-probe findings — GUID identity, ALARM_RECORDS COUNT` . + +--- + +## Task 4: SEC-25 groundwork — `DashboardTags` on the API key, `Tags` on the session + +**Classification:** high-risk +**Estimated implement time:** ~8 min +**Parallelizable with:** Task 2, Task 3, Task 5 (NOT Task 1 — both touch `GatewaySession.cs` and session fakes; run after Task 1 lands) + +**Files:** +- Modify: `src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyConstraints.cs` (add `IReadOnlyList DashboardTags`, default empty) +- Modify: `src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyConstraintSerializer.cs` (round-trip the new field; absent-in-JSON → empty — old rows keep deserializing) +- Modify: `src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAdminCommandLineParser.cs` + `ApiKeyAdminCliRunner.cs` + `ApiKeyAdminCommand.cs` + `ApiKeyAdminListedKey.cs` (CLI `--dashboard-tags team-a,team-b` on create/update; shown in list output) +- Modify: `src/ZB.MOM.WW.MxGateway.Server/Sessions/GatewaySession.cs` (new `public IReadOnlySet Tags { get; }`, set at construction from the owner key's effective constraints; empty = untagged) +- Modify: `src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionManager.cs` (`OpenSession` path copies `ApiKeyIdentity.EffectiveConstraints.DashboardTags` onto the session) +- Test: serializer round-trip incl. legacy-JSON-without-field; CLI parse; session tag inheritance via the fake-worker harness +- Modify: `docs/Authorization.md` (`DashboardTags` is dashboard-visibility-only, never a data-access constraint) + +**Spec:** Implements §3 of `docs/plans/2026-07-10-dashboard-session-acl-tst15.md`. The §9 +open call is settled per the design's own recommendation: **the tag rides in the existing +`ApiKeyConstraints` JSON blob — no SQLite schema migration.** Tags are immutable per session, +assigned once at `OpenSession` from the owning key. No enforcement in this task — the field +and its plumbing only. Case handling: preserve tag strings as entered; comparisons later +(Task 6) are ordinal-ignore-case — note that on the property doc. + +**Steps:** edit → build NonWindows.slnx → targeted tests (`~ApiKeyConstraint`, `~ApiKeyAdmin`, +`~SessionManager`) → commit +`feat(security): DashboardTags on API-key constraints; sessions inherit owner tags (SEC-25 groundwork)`. + +--- + +## Task 5: SEC-25 config — `GroupToTag`, `UntaggedSessionVisibility`, validator, mapper + +**Classification:** standard +**Estimated implement time:** ~6 min +**Parallelizable with:** Task 1, Task 2, Task 3, Task 4 + +**Files:** +- Modify: `src/ZB.MOM.WW.MxGateway.Server/Configuration/` — `DashboardOptions` (`Dictionary GroupToTag` ordinal-ignore-case; `UntaggedSessionVisibility` enum `AdminOnly`(default)`|AllViewers`) + `GatewayOptionsValidator` (keys non-empty, tag arrays non-null/non-empty entries, enum known — mirror the `GroupToRole` validation shape) +- Create: group→tag mapper as a sibling of `DashboardGroupRoleMapping` (union of tags over the principal's LDAP groups; unknown group → nothing) +- Test: validator accept/reject cases; mapper union/unknown-group/case-insensitivity +- Modify: `docs/GatewayConfiguration.md` (`Dashboard:GroupToTag`, `Dashboard:UntaggedSessionVisibility` rows) + +**Spec:** §3.2 of the design doc, exactly. No coupling to `GroupToRole` — a group may appear +in one, the other, or both. + +**Steps:** edit → build → `--filter "FullyQualifiedName~GatewayOptionsValidator"` + mapper +tests → commit `feat(dashboard): GroupToTag / UntaggedSessionVisibility config (SEC-25)`. + +--- + +## Task 6: SEC-25 enforcement — ACL service, token/cookie tag claims, both subscribe seams gated + +**Classification:** high-risk +**Estimated implement time:** ~10 min +**Parallelizable with:** none (needs Tasks 4 + 5) + +**Files:** +- Create: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/` `IDashboardSessionAcl` + implementation (`CanViewSession(ClaimsPrincipal, string sessionId)`; DI singleton) +- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/HubTokenService.cs` (payload gains `string[]? Tags`; mint stamps resolved granted tags as `zb:dashboardtag` claims; validate rehydrates them) +- Modify: dashboard cookie principal creation (`DashboardAuthenticator.CreatePrincipal` per the design doc — grep for the exact site) so cookie-authenticated circuits carry the same tag claims +- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/EventsHub.cs:42-60` (`SubscribeSession` checks the ACL; deny → `HubException`, no group join; **remove the `TODO(per-session-acl)`**) +- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionDetailsPage.razor` (in-process seam gate: resolve the circuit principal via `AuthenticationStateProvider`, check `IDashboardSessionAcl.CanViewSession` **before** `Subscribe(SessionId)`; deny → render "Not authorized for this session's events." in place of the event panel, no subscription created) +- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs` (register the ACL) +- Test: `DashboardSessionAclTests` (admin bypass; session-not-found deny; untagged under both visibility values; tag intersection match/non-match; principal-with-no-claims = empty-grant Viewer); `HubTokenService` tag round-trip; `EventsHub` deny-does-not-join; SessionDetailsPage gate (deny renders banner, allow subscribes — follow the existing bUnit/component test idiom in `Gateway/Dashboard/`) +- Modify: `docs/plans/2026-07-10-dashboard-session-acl-tst15.md` — add an as-built note: the 2026-08 in-process feed refactor created a **second seam** (`IDashboardSessionEventSubscriber` used by SessionDetailsPage); "gating at join is sufficient — no second seam" (§4) no longer held, so the same ACL now gates both. + +**Spec:** §§2, 4, 4.1 of the design doc, with one deviation the doc predates: the dashboard +now also consumes session events **in-process** (SessionDetailsPage → `Subscribe(sessionId)`), +so enforcement lands in two places — the hub `SubscribeSession` (remote surface) and the +page-side check before the in-process subscribe. ACL decision order: Admin role → allow; +session unknown → deny; untagged → `UntaggedSessionVisibility == AllViewers`; else +`session.Tags ∩ grantedTags ≠ ∅` (ordinal-ignore-case). Anonymous localhost = Viewer with +empty grant (§4.1) — this *tightens* SEC-02's loopback posture by design; `DisableLogin` +auto-login carries both roles → admin bypass, unchanged. Fail closed everywhere. Never log +tag grants alongside credentials. + +**Steps:** edit → build → `--filter "~DashboardSessionAcl"`, `~EventsHub`, `~HubTokenService`, +`~SessionDetailsPage` → commit +`feat(dashboard): per-session event ACL on both subscribe seams (SEC-25 / TST-15)`. + +--- + +## Task 7: SEC-25 closure — live-LDAP tests, docs sweep, design-doc status + +**Classification:** standard +**Estimated implement time:** ~7 min +**Parallelizable with:** Task 8, Task 9 + +**Files:** +- Test: extend `DashboardLdapLiveTests` (gated `MXGATEWAY_RUN_LIVE_LDAP_TESTS=1`): map the existing GLAuth groups via config — `gw-viewer`'s group granted `team-a` through `Dashboard:GroupToTag`, a `team-a`-tagged session allows, a `team-b`-tagged session denies; `multi-role` (Admin) allows both. **Use the existing GLAuth users/groups (`glauth.md`) — no GLAuth server change.** +- Modify: `docs/Sessions.md` (session-tag model, dashboard event visibility), `gateway.md` dashboard section, `CLAUDE.md` dashboard-auth paragraph (Viewer is tag-scoped; Admin sees all; anonymous localhost = empty-grant Viewer), `glauth.md` (test-grant mapping used by the live tests — config-side only), `docs/GatewayDashboardDesign.md` (remove/replace the two "SEC-25 outstanding" passages at ~:277 and ~:699 — the ACL now exists; describe it) +- Modify: `docs/plans/2026-07-10-dashboard-session-acl-tst15.md` header — Status: Implemented (this plan, date) +- Modify: `archreview` tracking rows for TST-15/SEC-25 if a `Not started` row exists (mark Done with commit ref) + +**Spec:** §§7–8 of the design doc. Live tests are opt-in-gated exactly like the existing +`LiveLdapFactAttribute` suite; they must skip cleanly where GLAuth is unreachable. + +**Steps:** edit → build → run unit portions + (if GLAuth reachable from this Mac, +`MXGATEWAY_RUN_LIVE_LDAP_TESTS=1` run; else record skipped per `docs/GatewayTesting.md`) → +commit `test(dashboard)+docs: SEC-25 live-LDAP ACL coverage; design marked implemented`. + +--- + +## Task 8: Alarm-truncation degraded-status signal — proto + worker + gateway + dashboard + +**Classification:** high-risk +**Estimated implement time:** ~10 min +**Parallelizable with:** Task 7 (after Task 3's findings are in hand, or Task 3 recorded "blocked") + +**Files:** +- Modify: `src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto` — `ActiveAlarmSnapshot` gains `bool from_truncated_snapshot` (next free field number); `QueryActiveAlarmsReplyPayload` gains `bool snapshot_truncated`. Additive only; comment style per the file. +- Regenerate: `dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj` (never hand-edit `Generated/`) +- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs` (expose the retained truncated state, e.g. `bool LastSnapshotTruncated`, maintained where `ApplySnapshotUpdate` already receives `truncated`) + `IMxAccessAlarmConsumer.cs` + the `QueryActiveAlarms` reply builder in `MxAccessCommandExecutor.cs`/`AlarmDispatcher.cs` (stamp both new fields) +- Modify: `src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs` + `IGatewayAlarmService.cs` (+ its implementation) — propagate degraded state to the dashboard snapshot model (`DashboardActiveAlarm.cs` or a sibling flag on the alarm snapshot the service exposes) +- Modify: `src/ZB.MOM.WW.MxGateway.Server/Grpc/MxAccessGatewayService.cs` — the public `QueryActiveAlarms` stream carries `from_truncated_snapshot` through +- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/AlarmsPage.razor` — degraded banner ("Alarm snapshot may be incomplete — provider returned a capped fetch") driven by the service flag; reuse the existing alert-banner idiom, do not disturb the poll-loop structure landed in `dcbec97` +- Test: worker XML tests (`WnWrapAlarmConsumerXmlTests` idiom) for the exposed state; gateway fake-worker test that a truncated reply surfaces the flag end-to-end; AlarmsPage banner test +- Modify: `gateway.md` (alarm surface paragraph), `docs/DesignDecisions.md` (one entry: why per-record flag + reply-payload flag, wire-compatibility, absence-inference already suppressed worker-side since the truncation cliff fix) + +**Spec:** The truncation cliff fix (perf plan Task 23) made transitions safe but *silent*: +only a worker-stderr warning says the snapshot is degraded. Give it a structural signal: +worker stamps truncation state into the `QueryActiveAlarms` reply; gateway propagates it to +the public stream (per-record flag — the RPC returns a bare `stream ActiveAlarmSnapshot` +with no envelope, so a per-record boolean is the only additive carrier) and to the dashboard +alarm service; AlarmsPage shows the degraded banner. **If Task 3 found that +`ALARM_RECORDS/@COUNT` carries the true total, also replace the `IsTruncatedFetch` ≥cap +heuristic with the exact comparison in the same commit** (parse `@COUNT`, compare to records +delivered; keep the heuristic as fallback when the attribute is absent) — cite the findings +doc. If Task 3 was blocked, keep the heuristic untouched and say so in the commit body. +MXAccess parity note: this flag describes *our* fetch mechanics, not provider behavior — it +is additive gateway metadata, not a parity deviation. + +**Steps:** proto edit → regenerate → worker+gateway edits → build NonWindows.slnx + gateway +tests (`~Alarm`, `~AlarmsPage`) — worker compile/tests defer to the Task 11 windev gate → +commit `feat(alarms): structural degraded-status signal for truncated alarm snapshots`. + +--- + +## Task 9: Client regeneration + rebuild for the new alarm fields + +**Classification:** standard +**Estimated implement time:** ~8 min +**Parallelizable with:** Task 7 (needs Task 8's proto committed) + +**Files:** +- Regenerate per each client's README: `clients/dotnet`, `clients/python`, `clients/rust`, `clients/java`, Go (`clients/go` / `mxgw-go` layout — follow its README) +- Modify: each client's README alarm section IF it documents the snapshot shape (one line: `from_truncated_snapshot` means the provider fetch was capped and absent-implies-cleared inference was suspended) +- Test: `dotnet build clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx` + tests; `python -m pytest` in `clients/python`; `cargo fmt && cargo check --workspace && cargo test --workspace && cargo clippy --all-targets -- -D warnings` in `clients/rust`; `gradle test` in `clients/java`; `gofmt`/`go build ./...`/`go test ./...` in `clients/go` + +**Spec:** Additive proto fields — codegen carries them; no typed wrapper work unless a client +already wraps `ActiveAlarmSnapshot` in a typed model (then add the field there too, following +how `ReplayGap` surfacing was done per-client). All five clients must build and test green. +Use the build lock around each build/test. + +**Steps:** regenerate → build/test each client → commit +`chore(clients): regenerate for alarm truncation fields; READMEs note the degraded flag`. + +--- + +## Task 10: Phase gate — full gateway suite on macOS + +**Classification:** high-risk (gate) +**Estimated implement time:** ~5 min wall +**Parallelizable with:** none (after Tasks 1, 4–9) + +Full `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj` (with the +build lock) + `dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx`. Any failure: fix +forward with a scoped commit, re-run the failed filter, then re-run the full suite once. + +--- + +## Task 11: windev gate — full Windows verification + +**Classification:** high-risk (gate) +**Estimated implement time:** ~15 min wall +**Parallelizable with:** none (last verification) + +On windev (`ssh windev`, PowerShell, clone `C:\build\mxaccessgw-ci`; expect the stale +Contracts-obj first-build quirk — clear `src\ZB.MOM.WW.MxGateway.Contracts\obj,bin` and +rebuild, not a regression): + +```powershell +git pull # the feat/deferred-closeout branch +dotnet build src\ZB.MOM.WW.MxGateway.slnx +dotnet build src\ZB.MOM.WW.MxGateway.Worker\ZB.MOM.WW.MxGateway.Worker.csproj -p:Platform=x86 +dotnet test src\ZB.MOM.WW.MxGateway.Worker.Tests\ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86 +dotnet test src\ZB.MOM.WW.MxGateway.Tests\ZB.MOM.WW.MxGateway.Tests.csproj +``` + +This is the first verification of Task 2's writer restructure and Task 8's worker-side +changes — expect iteration; fix on the Mac, commit, re-run the failed leg. Live MXAccess +smoke (`MXGATEWAY_RUN_LIVE_MXACCESS_TESTS=1`) if provider state allows; else record why +skipped. + +--- + +## Task 12: Wrap-up — closure notes, umbrella check, final review + +**Classification:** small +**Estimated implement time:** ~5 min +**Parallelizable with:** none (last) + +- Append closure lines to `docs/plans/2026-08-15-deferred-remediation.md`'s out-of-scope + table (each row: resolved by this plan + date, or — for the probe row if Task 3 blocked — + "probes attempted , blocked on live alarms, findings doc records the attempt"). +- Check `../scadaproj/CLAUDE.md`'s MxAccessGateway entry: the proto *contents* changed + (additive fields) — update only if the index records a fact that changed (expected: no + change needed; verify, don't assume). +- Update `.tasks.json`; update auto-memory (`perf-remediation-branch.md` or successor). +- Dispatch the final integration code review (Opus) over `git diff main..feat/deferred-closeout`. + Merge remains the user's decision. + +--- + +## Explicitly out of scope + +| Item | Why | +|---|---| +| gRPC "all-sessions admin scope" (epic Task 16's gRPC half) | Stays with the session-resilience epic; the design doc itself scopes it out of TST-15. | +| Session-list row filtering by tag on SessionsPage | The approved design gates *event subscription* only; list-metadata visibility is a separate call. | +| jti-denylist token revocation | Rejected in the design (§10.3); 5-min token lifetime bounds grant staleness. | +| Behavioral rework of `ComputeTransitions` identity if the GUID probe refutes stability | Needs its own reviewed design against real captures; Task 3 records the evidence, a follow-on plan acts on it. | +| `MxAccessWriteCompletionCache` clone | Consciously kept (prior plan Task 12.5); unchanged posture. | + +--- + +## Execution notes for the orchestrator + +- Branch `feat/deferred-closeout` off `main` before Task 1. +- Opus implementers per user instruction; reviewer chain per Classification + (high-risk = spec-reviewer serial then code-reviewer; standard = parallel pair; small = code-reviewer only). +- Waves: **Wave 1:** 1, 2, 3, 5 · **Wave 2:** 4 (after 1) · **Wave 3:** 6 (after 4+5), 8 (after 3 findings/blocked) · + **Wave 4:** 7, 9 · then 10 → 11 → 12. Per-task `Parallelizable with` fields are the contract. +- Each implementer gets its full task text + the ground rules block. `Files:` is the scope contract. +- Task 3 and Task 11 run against windev over `ssh windev` (PowerShell); psbridge is available + as fallback transport. diff --git a/docs/plans/2026-08-17-deferred-closeout.md.tasks.json b/docs/plans/2026-08-17-deferred-closeout.md.tasks.json new file mode 100644 index 0000000..84d07f6 --- /dev/null +++ b/docs/plans/2026-08-17-deferred-closeout.md.tasks.json @@ -0,0 +1,18 @@ +{ + "planPath": "docs/plans/2026-08-17-deferred-closeout.md", + "tasks": [ + { "id": 1, "subject": "Task 1: Remove dead ISessionManager.ReadEventsAsync chain", "status": "pending" }, + { "id": 2, "subject": "Task 2: Frame-writer lock-parking — unpark awaited control-frame completion", "status": "pending" }, + { "id": 3, "subject": "Task 3: windev live-alarm probes — GUID identity, ALARM_RECORDS/@COUNT", "status": "pending" }, + { "id": 4, "subject": "Task 4: SEC-25 groundwork — DashboardTags on key, Tags on session", "status": "pending", "blockedBy": [1] }, + { "id": 5, "subject": "Task 5: SEC-25 config — GroupToTag, UntaggedSessionVisibility, validator, mapper", "status": "pending" }, + { "id": 6, "subject": "Task 6: SEC-25 enforcement — ACL, token/cookie tag claims, both seams gated", "status": "pending", "blockedBy": [4, 5] }, + { "id": 7, "subject": "Task 7: SEC-25 closure — live-LDAP tests, docs sweep, design-doc status", "status": "pending", "blockedBy": [6] }, + { "id": 8, "subject": "Task 8: Alarm-truncation degraded-status signal — proto + worker + gateway + dashboard", "status": "pending", "blockedBy": [3] }, + { "id": 9, "subject": "Task 9: Client regeneration + rebuild for new alarm fields", "status": "pending", "blockedBy": [8] }, + { "id": 10, "subject": "Task 10: Phase gate — full gateway suite on macOS", "status": "pending", "blockedBy": [1, 4, 5, 6, 7, 8, 9] }, + { "id": 11, "subject": "Task 11: windev gate — full Windows verification", "status": "pending", "blockedBy": [2, 10] }, + { "id": 12, "subject": "Task 12: Wrap-up — closure notes, umbrella check, final review", "status": "pending", "blockedBy": [11] } + ], + "lastUpdated": "2026-08-17T00:00:00Z" +} From fa9eb0c0b441d75b96284506df9dc8d989dd0f5b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 17 Aug 2026 03:46:41 -0400 Subject: [PATCH 02/17] refactor(sessions): remove the dead ISessionManager.ReadEventsAsync chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ISessionManager.ReadEventsAsync had zero production call sites: the worker event channel is drained once by GatewaySession.MapWorkerEventsAsync (the distributor pump), and every consumer — gRPC subscribers, the dashboard mirror, the alarm monitor — attaches to the distributor. The interface member, SessionManager's forwarder, and GatewaySession.ReadEventsAsync are gone; IWorkerClient/WorkerClient.ReadEventsAsync is untouched, it is the live worker-channel claim. No test was removed or rewired: nothing invoked the member through the interface. Nine ISessionManager test fakes carried a required-member stub (seven threw NotSupportedException or yielded nothing; EventStreamServiceTests and GatewaySessionDashboardMirrorTests forwarded to the session; the two MxAccessGatewayService fakes yielded their Events list) — all nine stubs were deleted. The MxAccessGatewayService suites' streaming tests already run through FakeEventStreamService, which reads the same Events list, so their coverage is unchanged; only the now-inaccurate doc comments on Events / LastReadEventsSessionId were reworded. The MapWorkerEventsAsync comment no longer describes a twin to keep in step; it now states the single-reader claim directly. docs/Sessions.md drops ReadEventsAsync from the SessionManager member list and from the Run-state prose. The 2026-08-15 deferred-remediation as-built note records the removal. --- docs/Sessions.md | 4 +- docs/plans/2026-08-15-deferred-remediation.md | 3 +- .../Sessions/GatewaySession.cs | 39 +++---------------- .../Sessions/ISessionManager.cs | 8 ---- .../Sessions/SessionManager.cs | 10 ----- .../Alarms/AlarmFailoverEndToEndTests.cs | 5 --- .../GatewayAlarmMonitorAttachOrderTests.cs | 5 --- .../GatewayAlarmMonitorProviderModeTests.cs | 5 --- .../DashboardLiveDataServiceTests.cs | 4 -- .../DashboardSessionAdminServiceTests.cs | 8 ---- .../Gateway/Grpc/EventStreamServiceTests.cs | 8 ---- .../MxAccessGatewayServiceConstraintTests.cs | 13 ------- .../Grpc/MxAccessGatewayServiceTests.cs | 20 ++-------- .../GatewaySessionDashboardMirrorTests.cs | 5 --- ...atewayGrpcAuthorizationInterceptorTests.cs | 8 ---- 15 files changed, 12 insertions(+), 133 deletions(-) diff --git a/docs/Sessions.md b/docs/Sessions.md index 072110b..b77dce5 100644 --- a/docs/Sessions.md +++ b/docs/Sessions.md @@ -49,7 +49,7 @@ public void TransitionTo(SessionState nextState) ### SessionManager (ISessionManager) -`SessionManager` is the orchestrator. It exposes `OpenSessionAsync`, `TryGetSession`, `InvokeAsync`, `ReadEventsAsync`, `CloseSessionAsync`, `KillWorkerAsync`, `CloseExpiredLeasesAsync`, and `ShutdownAsync`. It composes `ISessionRegistry`, `ISessionWorkerClientFactory`, `GatewayMetrics`, and `GatewayOptions`. +`SessionManager` is the orchestrator. It exposes `OpenSessionAsync`, `TryGetSession`, `InvokeAsync`, `CloseSessionAsync`, `KillWorkerAsync`, `CloseExpiredLeasesAsync`, and `ShutdownAsync`. It composes `ISessionRegistry`, `ISessionWorkerClientFactory`, `GatewayMetrics`, and `GatewayOptions`. `CloseSessionAsync` and `KillWorkerAsync` are both end-of-life paths but differ in what they offer the worker: @@ -191,7 +191,7 @@ The order — fault, deregister, dispose, release slot, record metric, log, reth ### Run -While `Ready`, callers reach the worker through `SessionManager.InvokeAsync` or `ReadEventsAsync`. Both delegate to `GatewaySession`, which checks the state under lock and updates `LastClientActivityAt` on every invocation. `GatewaySession` also exposes typed bulk helpers (`AddItemBulkAsync`, `SubscribeBulkAsync`, etc.) that wrap `WorkerCommand` round-trips and translate non-`Ok` `ProtocolStatus` replies into `SessionManagerException` with `SessionNotReady`. +While `Ready`, callers reach the worker through `SessionManager.InvokeAsync`, which delegates to `GatewaySession`, which checks the state under lock and updates `LastClientActivityAt` on every invocation. Events do not travel this path: every consumer attaches to the session's `SessionEventDistributor` instead (see below), so the manager exposes no event-read member. `GatewaySession` also exposes typed bulk helpers (`AddItemBulkAsync`, `SubscribeBulkAsync`, etc.) that wrap `WorkerCommand` round-trips and translate non-`Ok` `ProtocolStatus` replies into `SessionManagerException` with `SessionNotReady`. Event streaming uses `AttachEventSubscriber` which returns a disposable lease. When `allowMultipleSubscribers` is false (single-subscriber mode) a second attach throws `EventSubscriberAlreadyActive`; this prevents two gRPC streams from racing on the same worker event channel. When it is true, up to `MaxEventSubscribersPerSession` concurrent external subscribers are allowed and the next attach throws `EventSubscriberLimitReached`. The count-check-and-increment is atomic under the session lock, so concurrent attaches can never exceed the cap. The gateway-owned internal dashboard mirror subscriber is registered directly on the distributor and does not count toward the cap. Active event subscribers keep the session lease from expiring until the stream is disposed. diff --git a/docs/plans/2026-08-15-deferred-remediation.md b/docs/plans/2026-08-15-deferred-remediation.md index fb42808..a22e610 100644 --- a/docs/plans/2026-08-15-deferred-remediation.md +++ b/docs/plans/2026-08-15-deferred-remediation.md @@ -321,7 +321,8 @@ is worth keeping, this is the record. through `ISessionManager.ReadEventsAsync`. That interface member itself has zero production call sites — only test fakes implement and exercise it. Deleting it is a mechanical but wide change (~15 test-fake touches), so it is recorded as a follow-up -rather than done here. +rather than done here. Removed by `docs/plans/2026-08-17-deferred-closeout.md` Task 1, +2026-08-17. **Task 5 — dashboard event feed, two review rounds.** Review caught two races that the first cut did not have. First, subscription lifetime: subscriptions are now diff --git a/src/ZB.MOM.WW.MxGateway.Server/Sessions/GatewaySession.cs b/src/ZB.MOM.WW.MxGateway.Server/Sessions/GatewaySession.cs index ac426a4..f7c8808 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Sessions/GatewaySession.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Sessions/GatewaySession.cs @@ -765,13 +765,11 @@ public sealed class GatewaySession // distributor guarantees a single consumer) and maps each frame to the public MxEvent, // preserving worker order. Mirrors the former ProduceEventsAsync mapping exactly. // - // This deliberately duplicates the three lines of ReadEventsAsync rather than enumerating - // it: every worker event crosses this source, and routing it through a second pure - // pass-through iterator cost two extra MoveNextAsync state-machine hops per event for no - // semantic value. ReadEventsAsync stays for ISessionManager.ReadEventsAsync; keep the two - // bodies in step. Only one of them may run per attach — WorkerClient.ReadEventsAsync - // single-reader-claims the event channel and throws on a second consumer — and on the - // distributor path that one consumer is this method. + // This is the session's only reader of the worker event channel: every gateway consumer — + // gRPC subscribers, the dashboard mirror, the alarm monitor — attaches to the distributor + // this source feeds. WorkerClient.ReadEventsAsync single-reader-claims that channel and + // throws on a second consumer, so any future path that drains it directly fails loudly + // rather than splitting events. private async IAsyncEnumerable MapWorkerEventsAsync( [EnumeratorCancellation] CancellationToken cancellationToken) { @@ -1522,33 +1520,6 @@ public sealed class GatewaySession cancellationToken); } - /// - /// Reads events from the worker as an asynchronous enumerable stream. - /// - /// Token to cancel the asynchronous operation. - /// - /// Backs ISessionManager.ReadEventsAsync. The distributor does not come - /// through here — MapWorkerEventsAsync inlines this body to save a per-event - /// iterator hop, so changes made here belong there too. The two are mutually exclusive - /// per attach: claims the worker event - /// channel for a single reader and throws on the second consumer. - /// - /// An asynchronous stream of worker events. - public async IAsyncEnumerable ReadEventsAsync( - [EnumeratorCancellation] CancellationToken cancellationToken) - { - IWorkerClient workerClient = await GetReadyWorkerClientAsync(cancellationToken).ConfigureAwait(false); - TouchClientActivity(_eventStreaming.TimeProvider.GetUtcNow()); - - await foreach (WorkerEvent workerEvent in workerClient - .ReadEventsAsync(cancellationToken) - .WithCancellation(cancellationToken) - .ConfigureAwait(false)) - { - yield return workerEvent; - } - } - /// /// Closes the session and shuts down the worker process. /// diff --git a/src/ZB.MOM.WW.MxGateway.Server/Sessions/ISessionManager.cs b/src/ZB.MOM.WW.MxGateway.Server/Sessions/ISessionManager.cs index 7a9b749..9cb0191 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Sessions/ISessionManager.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Sessions/ISessionManager.cs @@ -35,14 +35,6 @@ public interface ISessionManager WorkerCommand command, CancellationToken cancellationToken); - /// Reads events streamed from the worker for the specified session. - /// Identifier of the session. - /// Token to cancel the asynchronous operation. - /// Events emitted by the worker. - IAsyncEnumerable ReadEventsAsync( - string sessionId, - CancellationToken cancellationToken); - /// Closes a session and terminates its worker process. /// Identifier of the session to close. /// Token to cancel the asynchronous operation. diff --git a/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionManager.cs b/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionManager.cs index e369141..0cf27b6 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionManager.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionManager.cs @@ -187,16 +187,6 @@ public sealed class SessionManager : ISessionManager } } - /// - public IAsyncEnumerable ReadEventsAsync( - string sessionId, - CancellationToken cancellationToken) - { - GatewaySession session = GetRequiredSession(sessionId); - - return session.ReadEventsAsync(cancellationToken); - } - /// public async Task CloseSessionAsync( string sessionId, diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmFailoverEndToEndTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmFailoverEndToEndTests.cs index dd0813e..bf9a0d0 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmFailoverEndToEndTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmFailoverEndToEndTests.cs @@ -464,11 +464,6 @@ public sealed class AlarmFailoverEndToEndTests return Task.FromResult(new WorkerCommandReply { Reply = reply }); } - /// - public IAsyncEnumerable ReadEventsAsync( - string sessionId, - CancellationToken cancellationToken) => throw new NotSupportedException(); - /// public bool TryGetSession(string sessionId, [MaybeNullWhen(false)] out GatewaySession session) { diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorAttachOrderTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorAttachOrderTests.cs index 969645b..956cf14 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorAttachOrderTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorAttachOrderTests.cs @@ -618,11 +618,6 @@ public sealed class GatewayAlarmMonitorAttachOrderTests return new WorkerCommandReply { Reply = reply }; } - /// - public IAsyncEnumerable ReadEventsAsync( - string sessionId, - CancellationToken cancellationToken) => throw new NotSupportedException(); - /// public bool TryGetSession(string sessionId, [MaybeNullWhen(false)] out GatewaySession session) { diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorProviderModeTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorProviderModeTests.cs index eef1fa3..3b3f8fb 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorProviderModeTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorProviderModeTests.cs @@ -777,11 +777,6 @@ public sealed class GatewayAlarmMonitorProviderModeTests return Task.FromResult(new WorkerCommandReply { Reply = reply }); } - /// - public IAsyncEnumerable ReadEventsAsync( - string sessionId, - CancellationToken cancellationToken) => throw new NotSupportedException(); - /// public bool TryGetSession(string sessionId, [MaybeNullWhen(false)] out GatewaySession session) { diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardLiveDataServiceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardLiveDataServiceTests.cs index 3059458..d03b4d9 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardLiveDataServiceTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardLiveDataServiceTests.cs @@ -261,10 +261,6 @@ public sealed class DashboardLiveDataServiceTests CancellationToken cancellationToken) => throw new NotSupportedException(); - /// - public IAsyncEnumerable ReadEventsAsync(string sessionId, CancellationToken cancellationToken) => - throw new NotSupportedException(); - /// public Task KillWorkerAsync( string sessionId, diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSessionAdminServiceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSessionAdminServiceTests.cs index b0cdf77..2482c1a 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSessionAdminServiceTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSessionAdminServiceTests.cs @@ -370,14 +370,6 @@ public sealed class DashboardSessionAdminServiceTests throw new NotSupportedException(); } - /// - public IAsyncEnumerable ReadEventsAsync( - string sessionId, - CancellationToken cancellationToken) - { - throw new NotSupportedException(); - } - /// public Task CloseSessionAsync( string sessionId, diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/EventStreamServiceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/EventStreamServiceTests.cs index 5695f44..177e6e9 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/EventStreamServiceTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/EventStreamServiceTests.cs @@ -761,14 +761,6 @@ public sealed class EventStreamServiceTests return Task.FromResult(new WorkerCommandReply()); } - /// - public IAsyncEnumerable ReadEventsAsync( - string sessionId, - CancellationToken cancellationToken) - { - return _sessions[sessionId].ReadEventsAsync(cancellationToken); - } - /// public Task CloseSessionAsync( string sessionId, diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceConstraintTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceConstraintTests.cs index 885bffb..304a9a0 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceConstraintTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceConstraintTests.cs @@ -935,19 +935,6 @@ public sealed class MxAccessGatewayServiceConstraintTests return Task.FromResult(InvokeReply); } - /// - public async IAsyncEnumerable ReadEventsAsync( - string sessionId, - [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) - { - foreach (WorkerEvent ev in Events) - { - cancellationToken.ThrowIfCancellationRequested(); - await Task.Yield(); - yield return ev; - } - } - /// public Task CloseSessionAsync( string sessionId, diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs index 5ad05a8..cfc06a3 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs @@ -517,7 +517,7 @@ public sealed class MxAccessGatewayServiceTests /// The last owner key id passed to OpenSessionAsync. public string? LastOwnerKeyId { get; private set; } - /// The last session ID passed to ReadEventsAsync. + /// The last session ID the event stream service was asked to stream. public string? LastReadEventsSessionId { get; private set; } /// The last worker command passed to InvokeAsync. @@ -540,10 +540,10 @@ public sealed class MxAccessGatewayServiceTests /// The number of times InvokeAsync was called. public int InvokeCount { get; private set; } - /// The events to return from ReadEventsAsync. + /// The events the fake event stream service replays for this manager. public List Events { get; } = []; - /// Records the session ID passed to ReadEventsAsync. + /// Records the session ID the event stream service was asked to stream. /// Identifier of the session. public void RecordReadEventsSessionId(string sessionId) { @@ -602,20 +602,6 @@ public sealed class MxAccessGatewayServiceTests return Task.FromResult(InvokeReply); } - /// - public async IAsyncEnumerable ReadEventsAsync( - string sessionId, - [EnumeratorCancellation] CancellationToken cancellationToken) - { - LastReadEventsSessionId = sessionId; - foreach (WorkerEvent workerEvent in Events) - { - cancellationToken.ThrowIfCancellationRequested(); - await Task.Yield(); - yield return workerEvent; - } - } - /// public Task CloseSessionAsync( string sessionId, diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/GatewaySessionDashboardMirrorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/GatewaySessionDashboardMirrorTests.cs index b103a29..1d54933 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/GatewaySessionDashboardMirrorTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/GatewaySessionDashboardMirrorTests.cs @@ -347,11 +347,6 @@ public sealed class GatewaySessionDashboardMirrorTests WorkerCommand command, CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply()); - /// - public IAsyncEnumerable ReadEventsAsync( - string sessionId, - CancellationToken cancellationToken) => session.ReadEventsAsync(cancellationToken); - /// public Task CloseSessionAsync( string sessionId, diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs index 1808e54..b9327a6 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs @@ -848,14 +848,6 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests return Task.FromResult(new WorkerCommandReply()); } - /// - public IAsyncEnumerable ReadEventsAsync( - string sessionId, - CancellationToken cancellationToken) - { - return AsyncEnumerable.Empty(); - } - /// public Task CloseSessionAsync( string sessionId, From c79aaaf9eb0b9753e3f2a28d2e48a15269c2c58a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 17 Aug 2026 03:46:56 -0400 Subject: [PATCH 03/17] feat(dashboard): GroupToTag / UntaggedSessionVisibility config (SEC-25) Groundwork for the per-session dashboard event ACL (docs/plans/2026-07-10-dashboard-session-acl-tst15.md 3.2): a dashboard group can now grant visibility tags, and untagged sessions default to AdminOnly. Enforcement lands with the EventsHub ACL; nothing consumes the grant yet. GroupToTag is deliberately uncoupled from GroupToRole - a group may appear in either map, both, or neither - and is validated for shape only. Tags gate dashboard event visibility, never data access. --- docs/GatewayConfiguration.md | 12 +- .../Configuration/DashboardOptions.cs | 16 ++ .../Configuration/GatewayOptionsValidator.cs | 30 ++++ .../UntaggedSessionVisibility.cs | 22 +++ .../Dashboard/DashboardGroupTagMapping.cs | 59 ++++++++ .../GatewayOptionsValidatorTests.cs | 138 ++++++++++++++++++ .../DashboardGroupTagMappingTests.cs | 113 ++++++++++++++ 7 files changed, 389 insertions(+), 1 deletion(-) create mode 100644 src/ZB.MOM.WW.MxGateway.Server/Configuration/UntaggedSessionVisibility.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardGroupTagMapping.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardGroupTagMappingTests.cs diff --git a/docs/GatewayConfiguration.md b/docs/GatewayConfiguration.md index 210454f..378f955 100644 --- a/docs/GatewayConfiguration.md +++ b/docs/GatewayConfiguration.md @@ -60,7 +60,11 @@ paths, timeouts, queue sizes, enum values, or protocol values are invalid. "GroupToRole": { "GwAdmin": "Admin", "GwReader": "Viewer" - } + }, + "GroupToTag": { + "GwReader": [ "team-a" ] + }, + "UntaggedSessionVisibility": "AdminOnly" }, "Protocol": { "WorkerProtocolVersion": 1, @@ -190,6 +194,8 @@ events (a "gap") and must re-snapshot; whatever is still retained is replayed. | `MxGateway:Dashboard:RecentSessionLimit` | `200` | Maximum number of session summaries projected into each dashboard snapshot. | | `MxGateway:Dashboard:ShowTagValues` | `false` | Controls whether tag values reach the dashboard's SignalR events hub mirror. `false` (default): `DashboardEventBroadcaster` blanks tag values from a deep-cloned copy of each `MxEvent` before it reaches any hub subscriber — event metadata (tag reference, quality, status, timestamps) still renders; see `docs/GatewayDashboardDesign.md`'s `EventsHub` row for the mechanism. Security-relevant because the per-session hub ACL that would scope a Viewer to specific sessions does not exist yet: with no per-session scoping, this redaction is currently the only thing standing between a low-trust Viewer and other sessions' tag values, so setting this `true` exposes every session's tag values to every authenticated dashboard viewer. The flag gates only the SignalR hub mirror — it does **not** cover the `/browse` live-value display, which remains a separate, still-open residual. | | `MxGateway:Dashboard:GroupToRole` | _(empty)_ | LDAP group → dashboard role mapping. Keys are LDAP group names (short CN or full DN — leading-RDN match). Values must be `Admin` (read/write, API-key CRUD) or `Viewer` (read-only). A user whose LDAP groups don't intersect this map cannot sign in; with no mapping at all, only the loopback bypass admits anyone. | +| `MxGateway:Dashboard:GroupToTag` | _(empty)_ | LDAP group → dashboard visibility tags. Keys follow the same convention as `GroupToRole` (short CN or full DN — leading-RDN match, case-insensitive); values are tag lists. A dashboard user's granted tag set is the union over the groups they belong to; an unmapped group contributes nothing. **Visibility only:** tags scope which sessions' event streams a Viewer may observe on the dashboard — they never grant or deny data access, which stays with the API key's scopes and constraints. Independent of `GroupToRole`: a group may appear in either map, both, or neither. Empty (the default) means Viewers hold no tags, so under the default `UntaggedSessionVisibility` they observe no session's events. | +| `MxGateway:Dashboard:UntaggedSessionVisibility` | `AdminOnly` | Who may observe a session that carries no tags (its owning API key declared none). `AdminOnly` (default, fail-closed) restricts untagged sessions to dashboard Administrators. `AllViewers` shows them to every Viewer — opt-in for a single-tenant deployment that wants the pre-tag behaviour. Administrators always see every session regardless of tags. | | `MxGateway:Dashboard:DisableLogin` | `false` | Dev/test only. When `true`, replaces the cookie authentication handler with `DashboardAutoLoginAuthenticationHandler`, which auto-authenticates every dashboard request — including requests from remote browsers, not just loopback — as `AutoLoginUser` holding both `Administrator` and `Viewer` roles. No login form, LDAP bind, or cookie is involved. A loud one-time startup warning is logged. Differs from `AllowAnonymousLocalhost`: `DisableLogin` mints a real authenticated principal (so role-gated write affordances appear), whereas `AllowAnonymousLocalhost` satisfies the authorization requirement on loopback only without minting a principal (write affordances stay hidden). Never enable in production. **Production hard-stop (SEC-04):** when the host runs in the `Production` environment and `DisableLogin` is `true`, startup validation fails and the process aborts — the flag is only accepted outside Production, where the one-time startup warning still fires. | | `MxGateway:Dashboard:AutoLoginUser` | `(null)` | Username stamped on the synthetic principal when `DisableLogin` is `true`. Default `(null)` — a null or blank value falls back to `multi-role`. Has no effect when `DisableLogin` is `false`. | @@ -198,6 +204,10 @@ and `RecentSessionLimit` must be greater than or equal to zero. `GroupToRole` values are validated at startup; invalid role names fail validation. Emptiness is allowed (a closed deployment that admits no LDAP users) but practical deployments populate at least one Admin group. +`GroupToTag` is validated for shape only — non-blank group keys, non-null tag +lists, non-blank tags — and is not cross-checked against `GroupToRole`, because +role grants and visibility grants are deliberately separate concerns. +`UntaggedSessionVisibility` must be `AdminOnly` or `AllViewers`. ### Authorization policies diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/DashboardOptions.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/DashboardOptions.cs index a0f888c..b3d995d 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/DashboardOptions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/DashboardOptions.cs @@ -67,4 +67,20 @@ public sealed class DashboardOptions /// Users with no matching group are rejected at login. /// public Dictionary GroupToRole { get; init; } = new(StringComparer.OrdinalIgnoreCase); + + /// + /// LDAP group → dashboard visibility tags. A dashboard user's granted tag set + /// is the union over the groups they belong to; a session is observable on the + /// events hub when its tags intersect that grant. Independent of + /// — a group may appear in either map, both, or + /// neither. Visibility only: tags never gate data access. + /// + public Dictionary GroupToTag { get; init; } = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Who may observe a session whose owning API key carries no dashboard tags. + /// Defaults to + /// so an upgrade tightens rather than loosens. + /// + public UntaggedSessionVisibility UntaggedSessionVisibility { get; init; } = UntaggedSessionVisibility.AdminOnly; } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs index eadeea3..e9a7b1f 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs @@ -410,6 +410,36 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase entry in options.GroupToTag) + { + if (string.IsNullOrWhiteSpace(entry.Key)) + { + builder.Add("MxGateway:Dashboard:GroupToTag keys (LDAP group names) must be non-blank."); + } + + if (entry.Value is null) + { + builder.Add($"MxGateway:Dashboard:GroupToTag['{entry.Key}'] must be a list of tags, not null."); + continue; + } + + if (Array.Exists(entry.Value, string.IsNullOrWhiteSpace)) + { + builder.Add($"MxGateway:Dashboard:GroupToTag['{entry.Key}'] tags must be non-blank."); + } + } + + if (!Enum.IsDefined(options.UntaggedSessionVisibility)) + { + builder.Add( + $"MxGateway:Dashboard:UntaggedSessionVisibility must be '{nameof(UntaggedSessionVisibility.AdminOnly)}' " + + $"or '{nameof(UntaggedSessionVisibility.AllViewers)}'."); + } + AddIfNotPositive( options.SnapshotIntervalMilliseconds, "MxGateway:Dashboard:SnapshotIntervalMilliseconds must be greater than zero.", diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/UntaggedSessionVisibility.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/UntaggedSessionVisibility.cs new file mode 100644 index 0000000..342a111 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/UntaggedSessionVisibility.cs @@ -0,0 +1,22 @@ +namespace ZB.MOM.WW.MxGateway.Server.Configuration; + +/// +/// Who may observe the dashboard event stream of a session that carries no +/// dashboard tags. Tags gate dashboard event VISIBILITY only; they never widen +/// or narrow data access. +/// +public enum UntaggedSessionVisibility +{ + /// + /// Default. An untagged session is visible only to a dashboard Administrator. + /// Fails closed: a deployment that has not populated + /// shows Viewers nothing. + /// + AdminOnly, + + /// + /// An untagged session is visible to every dashboard Viewer. Opt-in for a + /// genuinely single-tenant deployment that wants the pre-ACL behaviour. + /// + AllViewers +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardGroupTagMapping.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardGroupTagMapping.cs new file mode 100644 index 0000000..dc54fd7 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardGroupTagMapping.cs @@ -0,0 +1,59 @@ +namespace ZB.MOM.WW.MxGateway.Server.Dashboard; + +/// +/// Single source of truth for mapping a user's LDAP groups to the dashboard +/// visibility tags they are granted (MxGateway:Dashboard:GroupToTag). +/// Sibling of and deliberately follows +/// the same group-matching rules (full DN first, leading-RDN fallback, +/// case-insensitive) so operators write one kind of group key for both maps. +/// Tags gate dashboard event VISIBILITY only; they are never a data-access +/// constraint. +/// +internal static class DashboardGroupTagMapping +{ + /// + /// Maps the user's LDAP groups to the union of the tags those groups grant. + /// A group with no entry in the map contributes nothing; duplicate tags + /// across groups collapse (case-insensitively). Returns an empty set when no + /// group matches — an empty grant, which the ACL treats as "sees no tagged + /// session". + /// + /// The collection of LDAP groups the user belongs to. + /// The mapping from group names to granted tags. + /// The distinct tags granted across all of the user's groups. + internal static IReadOnlySet MapGroupsToTags( + IEnumerable groups, + IReadOnlyDictionary groupToTag) + { + HashSet tags = new(StringComparer.OrdinalIgnoreCase); + if (groupToTag.Count == 0) + { + return tags; + } + + foreach (string group in groups) + { + string normalizedGroup = group.Trim(); + + if (!groupToTag.TryGetValue(normalizedGroup, out string[]? granted) + && !groupToTag.TryGetValue( + DashboardGroupRoleMapping.ExtractFirstRdnValue(normalizedGroup), + out granted)) + { + continue; + } + + if (granted is null) + { + continue; + } + + foreach (string tag in granted) + { + tags.Add(tag); + } + } + + return tags; + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs index 2878517..89e4581 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs @@ -884,6 +884,144 @@ public sealed class GatewayOptionsValidatorTests Assert.True(result.Succeeded); } + /// Verifies a populated GroupToTag map with well-formed tags passes validation. + [Fact] + public void Validate_Succeeds_WhenGroupToTagWellFormed() + { + GatewayOptions options = CloneWithDashboard( + ValidOptions(), + new DashboardOptions + { + GroupToTag = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["GwViewer"] = ["team-a"], + ["TeamBViewers"] = ["team-b", "team-c"], + }, + }); + ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); + Assert.True(result.Succeeded); + } + + /// + /// Verifies GroupToTag is not coupled to GroupToRole: a group that grants a tag + /// but no role (and vice versa) is a legal configuration. + /// + [Fact] + public void Validate_Succeeds_WhenGroupToTagAndGroupToRoleShareNoGroups() + { + GatewayOptions options = CloneWithDashboard( + ValidOptions(), + new DashboardOptions + { + GroupToRole = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["GwAdmin"] = "Administrator", + }, + GroupToTag = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["TeamBViewers"] = ["team-b"], + }, + }); + ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); + Assert.True(result.Succeeded); + } + + /// Verifies a blank GroupToTag key (LDAP group name) fails validation. + [Fact] + public void Validate_Fails_WhenGroupToTagKeyIsBlank() + { + GatewayOptions options = CloneWithDashboard( + ValidOptions(), + new DashboardOptions + { + GroupToTag = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [" "] = ["team-a"], + }, + }); + ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); + Assert.True(result.Failed); + Assert.Contains( + result.Failures!, + f => f.Contains("MxGateway:Dashboard:GroupToTag") && f.Contains("non-blank")); + } + + /// Verifies a blank tag entry fails validation. + [Fact] + public void Validate_Fails_WhenGroupToTagContainsBlankTag() + { + GatewayOptions options = CloneWithDashboard( + ValidOptions(), + new DashboardOptions + { + GroupToTag = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["GwViewer"] = ["team-a", " "], + }, + }); + ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); + Assert.True(result.Failed); + Assert.Contains( + result.Failures!, + f => f.Contains("MxGateway:Dashboard:GroupToTag['GwViewer']") && f.Contains("non-blank")); + } + + /// Verifies a null tag list (e.g. "GwViewer": null in JSON) fails validation. + [Fact] + public void Validate_Fails_WhenGroupToTagValueIsNull() + { + GatewayOptions options = CloneWithDashboard( + ValidOptions(), + new DashboardOptions + { + GroupToTag = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["GwViewer"] = null!, + }, + }); + ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); + Assert.True(result.Failed); + Assert.Contains( + result.Failures!, + f => f.Contains("MxGateway:Dashboard:GroupToTag['GwViewer']") && f.Contains("null")); + } + + /// Verifies both defined values pass validation. + /// The visibility value under test. + [Theory] + [InlineData(UntaggedSessionVisibility.AdminOnly)] + [InlineData(UntaggedSessionVisibility.AllViewers)] + public void Validate_Succeeds_ForDefinedUntaggedSessionVisibility(UntaggedSessionVisibility visibility) + { + GatewayOptions options = CloneWithDashboard( + ValidOptions(), + new DashboardOptions { UntaggedSessionVisibility = visibility }); + ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); + Assert.True(result.Succeeded); + } + + /// Verifies an out-of-range fails validation. + [Fact] + public void Validate_Fails_WhenUntaggedSessionVisibilityUndefined() + { + GatewayOptions options = CloneWithDashboard( + ValidOptions(), + new DashboardOptions { UntaggedSessionVisibility = (UntaggedSessionVisibility)42 }); + ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); + Assert.True(result.Failed); + Assert.Contains( + result.Failures!, + f => f.Contains("MxGateway:Dashboard:UntaggedSessionVisibility")); + } + + /// Verifies the shipped default for untagged sessions is the strict AdminOnly. + [Fact] + public void DashboardOptions_UntaggedSessionVisibility_DefaultsToAdminOnly() + { + Assert.Equal(UntaggedSessionVisibility.AdminOnly, new DashboardOptions().UntaggedSessionVisibility); + Assert.Empty(new DashboardOptions().GroupToTag); + } + /// Verifies plaintext LDAP transport (None) aborts startup in Production. [Fact] public void Validate_Fails_WhenLdapTransportNoneInProduction() diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardGroupTagMappingTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardGroupTagMappingTests.cs new file mode 100644 index 0000000..d547deb --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardGroupTagMappingTests.cs @@ -0,0 +1,113 @@ +using ZB.MOM.WW.MxGateway.Server.Dashboard; + +namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard; + +/// +/// Tests for , the LDAP-group → dashboard +/// visibility-tag grant. Group matching must follow the same rules as +/// (full DN first, leading-RDN fallback, +/// case-insensitive), and the grant is the union across the user's groups. +/// +public sealed class DashboardGroupTagMappingTests +{ + private static Dictionary StandardMapping() => new(StringComparer.OrdinalIgnoreCase) + { + ["GwViewer"] = ["team-a"], + ["TeamBViewers"] = ["team-b", "team-c"], + }; + + /// Verifies full-DN match, leading-RDN fallback, case-insensitivity, and unmapped → empty. + /// The LDAP group name or distinguished name. + /// The expected single granted tag, or null if no match. + [Theory] + [InlineData("GwViewer", "team-a")] + [InlineData("gwviewer", "team-a")] + [InlineData("ou=GwViewer,ou=groups,dc=zb,dc=local", "team-a")] + [InlineData("OtherGroup", null)] + public void MapGroupsToTags_ResolvesByShortNameAndDistinguishedName(string ldapGroup, string? expectedTag) + { + IReadOnlySet tags = DashboardGroupTagMapping.MapGroupsToTags([ldapGroup], StandardMapping()); + + if (expectedTag is null) + { + Assert.Empty(tags); + } + else + { + Assert.Equal(expectedTag, Assert.Single(tags)); + } + } + + /// Verifies the grant is the union of every matching group's tags. + [Fact] + public void MapGroupsToTags_MultipleGroups_UnionsTags() + { + IReadOnlySet tags = DashboardGroupTagMapping.MapGroupsToTags( + ["GwViewer", "TeamBViewers"], + StandardMapping()); + + string[] ordered = [.. tags.OrderBy(t => t, StringComparer.Ordinal)]; + Assert.Equal(["team-a", "team-b", "team-c"], ordered); + } + + /// Verifies an unknown group contributes nothing to a grant its siblings still produce. + [Fact] + public void MapGroupsToTags_UnknownGroup_ContributesNothing() + { + IReadOnlySet tags = DashboardGroupTagMapping.MapGroupsToTags( + ["GwViewer", "NotInTheMap"], + StandardMapping()); + + Assert.Equal("team-a", Assert.Single(tags)); + } + + /// Verifies the same tag granted by two groups, differing only in case, collapses to one entry. + [Fact] + public void MapGroupsToTags_DuplicateTagsAcrossGroups_DedupedCaseInsensitively() + { + Dictionary mapping = new(StringComparer.OrdinalIgnoreCase) + { + ["GroupOne"] = ["team-a"], + ["GroupTwo"] = ["TEAM-A"], + }; + + IReadOnlySet tags = DashboardGroupTagMapping.MapGroupsToTags(["GroupOne", "GroupTwo"], mapping); + + Assert.Single(tags); + Assert.Contains("team-a", tags); + Assert.Contains("TEAM-A", tags); + } + + /// Verifies an empty map yields an empty grant — no Viewer sees a tagged session. + [Fact] + public void MapGroupsToTags_EmptyMapping_ReturnsNoTags() + { + IReadOnlySet tags = DashboardGroupTagMapping.MapGroupsToTags( + ["GwViewer"], + new Dictionary(StringComparer.OrdinalIgnoreCase)); + + Assert.Empty(tags); + } + + /// + /// The tag grant is independent of the role map: a group present only in + /// GroupToTag still grants its tags. Asserted here because the two maps are + /// deliberately uncoupled in validation as well. + /// + [Fact] + public void MapGroupsToTags_GroupAbsentFromRoleMap_StillGrantsTags() + { + Dictionary groupToRole = new(StringComparer.OrdinalIgnoreCase) + { + ["GwAdmin"] = DashboardRoles.Admin, + }; + + IReadOnlyList roles = DashboardGroupRoleMapping.MapGroupsToRoles(["TeamBViewers"], groupToRole); + IReadOnlySet tags = DashboardGroupTagMapping.MapGroupsToTags(["TeamBViewers"], StandardMapping()); + + string[] ordered = [.. tags.OrderBy(t => t, StringComparer.Ordinal)]; + + Assert.Empty(roles); + Assert.Equal(["team-b", "team-c"], ordered); + } +} From 9130994736de60de2e30d2bf2f08b8a989458c8a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 17 Aug 2026 03:55:05 -0400 Subject: [PATCH 04/17] perf(worker): unpark awaited control-frame writers from the winning drain pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WriteAsync enqueued its frame and then contended unconditionally for the write lock, so a caller that lost the race stayed in WaitAsync until the winning drainer released — even though that winner writes, flushes, and completes the loser's control frame at the control-to-event class boundary, part-way through its pass. The boundary flush made the delivery point honest; the awaited task was still charged for the whole event backlog it had just been flushed ahead of. WriteAsync now awaits its own frame's completion racing the lock acquisition. Completion first: the caller returns at its frame's delivery point and the outstanding acquisition is detached, not dropped — a continuation drains whatever is queued and releases, so the lock is never acquired and silently held and a frame enqueued between the previous drainer's last dequeue and its release is still written. Lock first: drain as before. Cancellation keeps the WRK-22 tombstone semantics exactly, and a wait cancelled after the caller has already detached releases nothing (SemaphoreSlim hands no count to a wait it cancels), so no count leaks and no queued frame is stranded. A token that fires after the frame's completion won the race changes nothing — the frame was delivered. WriteBatchAsync deliberately keeps the plain wait-then-drain shape: its last completion resolves at the end-of-pass flush anyway. Three tests: the latency win (a control caller returning while the winning WriteBatchAsync event burst is demonstrably still blocked mid-pass), a mixed-priority concurrency soak pinning exactly-once writes and a single drainer, and the cancel-after-detach corner (a wrongly released count would surface as the drainer's own Release throwing SemaphoreFullException). edited on macOS, windev verification pending (plan Task 11). Verified here by compiling and running WorkerFrameWriter plus the writer suite against net10.0 in a scratch harness: 31/31 pass, and the two behaviour-pinning tests fail against the pre-change parked implementation. --- docs/WorkerFrameProtocol.md | 34 ++- docs/plans/2026-08-15-deferred-remediation.md | 3 + .../Ipc/WorkerFrameProtocolTests.cs | 203 +++++++++++++++++- .../Ipc/WorkerFrameWriter.cs | 146 +++++++++++-- 4 files changed, 354 insertions(+), 32 deletions(-) diff --git a/docs/WorkerFrameProtocol.md b/docs/WorkerFrameProtocol.md index e2f25b9..ebff8d7 100644 --- a/docs/WorkerFrameProtocol.md +++ b/docs/WorkerFrameProtocol.md @@ -171,13 +171,33 @@ oversized event) surfaces from the batch's awaited completions as that frame's `WorkerFrameProtocolException`; the remaining completions are still observed so none faults unobserved. -The completion is the frame's delivery point, not necessarily the instant its -caller returns. A caller that loses the race for the write lock only observes -its own completion after the winning drainer releases the lock, so its return -remains bounded by that drain pass even though its control frame was flushed -and completed at the class boundary inside it. The boundary flush is what -makes the delivery point honest; unparking a lock-race loser from the winner's -pass would be a separate change to the enqueue-then-contend shape. +The completion is the frame's delivery point, and a `WriteAsync` caller now +returns at it. The boundary flush alone only made the delivery point honest: +a caller that lost the race for the write lock still sat in the lock wait +until the winning drainer released it, so its awaited task was charged for the +whole event backlog its control frame had just been flushed ahead of. To close +that, `WriteAsync` awaits its own frame's completion *racing* the lock +acquisition instead of the acquisition alone. Whichever settles first decides: + +- **Completion first** — the winning drainer wrote and flushed this frame at + the class boundary, so the caller returns immediately. The lock acquisition + it leaves outstanding is *detached*, not dropped: a continuation drains + whatever is queued and then releases, so the lock is never acquired and + silently held, and a frame enqueued between the previous drainer's last + dequeue and its release is still written by someone. Draining an empty queue + is a no-op, so the common case is acquire-nothing-release. +- **Lock first** — the caller drains the pass itself, exactly as before. +- **Cancellation** — the wait ends without the lock (`SemaphoreSlim` hands no + count to a wait it cancels, so the detached continuation releases nothing on + that path) and the tombstone rules below apply unchanged. A token that fires + *after* the frame's completion won the race changes nothing: the frame was + delivered, and the caller returns normally. + +`WriteBatchAsync` deliberately keeps the plain wait-then-drain shape. A batch +caller's result is its whole set of completions and the last of those resolves +at the end-of-pass flush — the instant before the drainer releases the lock — +so racing the acquisition would buy it nothing while adding one detached +acquisition per call. Cancellation of a `WriteAsync`/`WriteBatchAsync` call that is still waiting for the write lock when its token fires tombstones the queued frame: the diff --git a/docs/plans/2026-08-15-deferred-remediation.md b/docs/plans/2026-08-15-deferred-remediation.md index a22e610..4da7f6c 100644 --- a/docs/plans/2026-08-15-deferred-remediation.md +++ b/docs/plans/2026-08-15-deferred-remediation.md @@ -349,6 +349,9 @@ boundary, so the priority class governs the frame's delivery point rather than o its byte order. Getting the awaited-latency win too requires unparking the lock-race loser from the winner's pass — a change to the write-lock shape, recorded as a follow-up. One extra `FlushFileBuffers` per mixed pass is the accepted cost. +That lock-parking was closed by `docs/plans/2026-08-17-deferred-closeout.md` +Task 2, 2026-08-17: `WriteAsync` races its own frame's completion against the +lock acquisition and detaches the wait it abandons. **Task 11 — teardown ordering and unconditional fault observation.** Teardown disposes the session-owned transport first, then observes the read that dispose abandoned. diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Ipc/WorkerFrameProtocolTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Ipc/WorkerFrameProtocolTests.cs index a58fed6..9426325 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Ipc/WorkerFrameProtocolTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Ipc/WorkerFrameProtocolTests.cs @@ -428,10 +428,10 @@ public sealed class WorkerFrameProtocolTests /// control run has already run, so the heartbeat or reply is on the pipe rather than waiting behind /// the batch. Exactly two flushes for the pass — one per class run, not one per control frame. /// - /// The assertion is on the flush, not on the queued callers' returned tasks, because those tasks are - /// still gated by the write lock they lost to the drainer (see the latency contract on - /// WorkerFrameWriter.WriteAsync): the completion resolves at the boundary flush, but a - /// lock-race loser observes it only once the drainer releases the lock. + /// The assertion here is on the flush and on the event callers, whose delivery point is + /// still the end-of-pass flush. That the control frame's own caller returns at the boundary — the + /// lock-parking this pass used to impose on it — is the separate subject of + /// . /// /// /// A task that represents the asynchronous operation. @@ -912,6 +912,201 @@ public sealed class WorkerFrameProtocolTests Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerEvent, f4.BodyCase); } + /// + /// Frame-writer lock-parking, closed. The class-boundary flush made a control frame's delivery + /// point honest, but the caller that lost the write-lock race still could not observe it: it + /// sat in WaitAsync until the winning drainer released the lock, so its awaited task was + /// charged for the whole event backlog the boundary flush had just jumped it ahead of. The caller + /// now races its own frame's completion against the lock acquisition, so it returns at the boundary. + /// + /// The winner here is a WriteBatchAsync event burst — the production hot path, the event + /// drain loop's own call — gated so the pass is stopped inside the batch, after the boundary flush + /// that delivered the control frame and long before the pass ends. The control caller returning + /// while the drainer is demonstrably still blocked mid-batch is the whole property; under the parked + /// shape this await could not return until ReleaseSecondGateWrite, so a regression shows up + /// as AwaitWithTimeoutAsync's rather than as a hang. + /// + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task WriteAsync_ControlFrameLosingLockRaceToEventBatch_ReturnsAtItsOwnCompletion() + { + WorkerFrameProtocolOptions options = CreateOptions(); + // Write 1 (the batch's first event) gates the pass open; write 3 is the first event written + // after the control frame's boundary flush, so blocking it stops the drain with the control + // frame delivered and the rest of the batch still unwritten. + using GatedWriteStream stream = new(secondGateWriteIndex: 3); + WorkerFrameWriter writer = new(stream, options); + + WorkerEnvelope[] batch = new[] + { + CreateEventEnvelope(workerSequence: 1), + CreateEventEnvelope(workerSequence: 2), + CreateEventEnvelope(workerSequence: 3), + CreateEventEnvelope(workerSequence: 4), + }; + + Task batchWrite = writer.WriteBatchAsync(batch, WorkerFrameWritePriority.Event); + await AwaitWithTimeoutAsync(stream.FirstWriteStarted); + + // The control frame loses the lock race to the batch and is drained by it. + Task controlWrite = writer.WriteAsync(CreateShutdownAckEnvelope(), WorkerFrameWritePriority.Control); + await Task.Delay(50); + + stream.ReleaseFirstWrite(); + await AwaitWithTimeoutAsync(stream.SecondGateWriteStarted); + + // The boundary flush ran and the drain is now blocked inside the batch behind it. + Assert.Equal(1, stream.FlushCount); + Assert.False(batchWrite.IsCompleted); + + // The latency win: the loser returns here, with the winner's pass still in flight. + await AwaitWithTimeoutAsync(controlWrite); + Assert.False(batchWrite.IsCompleted); + + stream.ReleaseSecondGateWrite(); + await AwaitWithTimeoutAsync(batchWrite); + + // One flush per class run, unchanged: the control run's boundary flush, then the batch's. + Assert.Equal(2, stream.FlushCount); + + // Detaching the abandoned lock wait strands nothing: every frame is on the wire exactly once, + // in drain order, with contiguous write-time sequences. + stream.Position = 0; + WorkerFrameReader reader = new(stream, options); + WorkerEnvelope frame1 = await reader.ReadAsync(); + WorkerEnvelope frame2 = await reader.ReadAsync(); + WorkerEnvelope frame3 = await reader.ReadAsync(); + WorkerEnvelope frame4 = await reader.ReadAsync(); + WorkerEnvelope frame5 = await reader.ReadAsync(); + Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerEvent, frame1.BodyCase); + Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerShutdownAck, frame2.BodyCase); + Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerEvent, frame3.BodyCase); + Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerEvent, frame4.BodyCase); + Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerEvent, frame5.BodyCase); + Assert.Equal( + new ulong[] { 1, 2, 3, 4, 5 }, + new[] { frame1.Sequence, frame2.Sequence, frame3.Sequence, frame4.Sequence, frame5.Sequence }); + Assert.Equal(stream.Length, stream.Position); + } + + /// + /// Frame-writer lock-parking, the "nothing is stranded" half. A caller that returns on its + /// completion abandons a live write-lock acquisition; that acquisition still carries drain + /// responsibility, because a frame can be enqueued after the winning drainer's last dequeue and + /// before its release. Under heavy mixed-priority concurrency — every caller racing its completion + /// against the lock, so detached acquisitions pile up — every frame must still be written exactly + /// once, and the write lock must still admit exactly one drainer: a double-drain would interleave + /// two passes over the same stream, and a double-release would either do that or throw + /// out of a later drain. Contiguous 1..N sequences with no + /// duplicates and no trailing bytes is the observable form of both. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task WriteAsync_UnderMixedPriorityConcurrency_WritesEveryQueuedFrameExactlyOnce() + { + const int perClass = 40; + WorkerFrameProtocolOptions options = CreateOptions(); + using MemoryStream stream = new(); + WorkerFrameWriter writer = new(stream, options); + + Task[] writes = new Task[perClass * 2]; + for (int index = 0; index < perClass; index++) + { + writes[index * 2] = writer.WriteAsync(CreateGatewayHelloEnvelope(), WorkerFrameWritePriority.Control); + writes[(index * 2) + 1] = writer.WriteAsync(CreateEventEnvelope(), WorkerFrameWritePriority.Event); + } + + await AwaitWithTimeoutAsync(Task.WhenAll(writes)); + + // A detached acquisition drains whatever it finds and releases; this write goes through the + // same lock afterwards, so it can only succeed if the lock was left in a usable state. + await AwaitWithTimeoutAsync( + writer.WriteAsync(CreateShutdownAckEnvelope(), WorkerFrameWritePriority.Control)); + + const int total = (perClass * 2) + 1; + int controlCount = 0; + int eventCount = 0; + stream.Position = 0; + WorkerFrameReader reader = new(stream, options); + for (int index = 0; index < total; index++) + { + WorkerEnvelope frame = await reader.ReadAsync(); + Assert.Equal((ulong)(index + 1), frame.Sequence); + if (frame.BodyCase == WorkerEnvelope.BodyOneofCase.WorkerEvent) + { + eventCount++; + } + else + { + controlCount++; + } + } + + Assert.Equal(perClass + 1, controlCount); + Assert.Equal(perClass, eventCount); + // No frame was written twice and none was left queued. + Assert.Equal(stream.Length, stream.Position); + } + + /// + /// Frame-writer lock-parking, the cancellation corner. A caller that returned on its completion + /// leaves a live lock acquisition behind; if its token then fires, that acquisition is cancelled + /// after the caller is long gone. hands no count to a wait it cancels, + /// so the detached continuation must release nothing on that path — releasing there would push the + /// count past the maximum and make the drainer's own Release throw + /// , which is exactly what awaiting the drainer's write here + /// detects. The late cancellation must also not retroactively cancel the call that already + /// returned, nor tombstone a frame that is already on the wire. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task WriteAsync_TokenCancelledAfterCompletionFirstReturn_LeavesTheWriteLockIntact() + { + WorkerFrameProtocolOptions options = CreateOptions(); + using GatedWriteStream stream = new(secondGateWriteIndex: 3); + WorkerFrameWriter writer = new(stream, options); + + // The drainer holds the lock, blocked writing its own control frame. + Task firstControl = writer.WriteAsync(CreateGatewayHelloEnvelope(), WorkerFrameWritePriority.Control); + await AwaitWithTimeoutAsync(stream.FirstWriteStarted); + + using CancellationTokenSource cts = new(); + Task lateControl = writer.WriteAsync(CreateShutdownAckEnvelope(), WorkerFrameWritePriority.Control, cts.Token); + Task eventWrite = writer.WriteAsync(CreateEventEnvelope(), WorkerFrameWritePriority.Event); + await Task.Delay(50); + + // The drain writes both control frames, flushes them at the class boundary, then blocks on the + // event write — so the cancellable caller returns on its completion with its acquisition live. + stream.ReleaseFirstWrite(); + await AwaitWithTimeoutAsync(stream.SecondGateWriteStarted); + await AwaitWithTimeoutAsync(lateControl); + + // Cancel the acquisition nobody is waiting on any more. + cts.Cancel(); + + stream.ReleaseSecondGateWrite(); + + // A count released on the cancelled path would surface here, as the drainer's release throwing. + await AwaitWithTimeoutAsync(Task.WhenAll(firstControl, eventWrite)); + + // The lock is still usable, and the cancelled token did not recall the delivered frame. + await AwaitWithTimeoutAsync( + writer.WriteAsync(CreateGatewayHelloEnvelope(), WorkerFrameWritePriority.Control)); + + stream.Position = 0; + WorkerFrameReader reader = new(stream, options); + WorkerEnvelope frame1 = await reader.ReadAsync(); + WorkerEnvelope frame2 = await reader.ReadAsync(); + WorkerEnvelope frame3 = await reader.ReadAsync(); + WorkerEnvelope frame4 = await reader.ReadAsync(); + Assert.Equal(WorkerEnvelope.BodyOneofCase.GatewayHello, frame1.BodyCase); + Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerShutdownAck, frame2.BodyCase); + Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerEvent, frame3.BodyCase); + Assert.Equal(WorkerEnvelope.BodyOneofCase.GatewayHello, frame4.BodyCase); + Assert.Equal(stream.Length, stream.Position); + } + private static WorkerEnvelope CreateGatewayHelloEnvelope(ulong sequence = 1) { return new WorkerEnvelope diff --git a/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerFrameWriter.cs b/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerFrameWriter.cs index d94b118..c8a1e9a 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerFrameWriter.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerFrameWriter.cs @@ -16,9 +16,12 @@ namespace ZB.MOM.WW.MxGateway.Worker.Ipc; /// holds the lock drains every queued frame, control frames first, so a reply, fault, or heartbeat is /// never delayed behind an event backlog — neither in the bytes it writes nor in the flush that /// delivers them, because the drain flushes at every control-to-event boundary rather than only at the -/// end of the pass. The envelope Sequence is stamped by the draining lock-holder at the moment -/// of writing, so the on-wire order and the stamped sequence always agree even under concurrent callers -/// and priority reordering. +/// end of the pass. A caller that loses the lock race does not wait for the winner's pass to end: it +/// awaits its own frame's completion racing its lock acquisition, so it returns at the boundary flush +/// that delivered its frame and the acquisition it walks away from is detached rather than dropped +/// (see ). The envelope Sequence is stamped by the draining +/// lock-holder at the moment of writing, so the on-wire order and the stamped sequence always agree +/// even under concurrent callers and priority reordering. /// public sealed class WorkerFrameWriter { @@ -111,10 +114,12 @@ public sealed class WorkerFrameWriter /// Latency contract: a control frame's bytes are written, flushed, and its completion /// resolved before the events a drain pass writes after it — the delivery point of a heartbeat, /// reply, fault, or shutdown ack is never charged for the event backlog behind it. The returned - /// task can still be later than that instant for a caller that lost the write-lock race: it only - /// observes its completion after the winning drainer releases the lock, so its own return remains - /// bounded by that pass. That parking is deliberate — the alternative is to race the lock wait - /// against the completion, which buys nothing for the frame's delivery. + /// task resolves at that same instant even for a caller that lost the write-lock race, because + /// this call awaits its own frame's completion racing the lock acquisition rather than the lock + /// alone: the completion resolving first returns the caller immediately and hands the outstanding + /// acquisition to , which keeps its drain responsibility. Without that, + /// a control frame delivered at a class boundary still reported back only after the winning + /// drainer finished writing and flushing the entire event backlog behind it. /// /// public async Task WriteAsync( @@ -140,20 +145,39 @@ public sealed class WorkerFrameWriter } } - // Contend for the single writer: whoever wins drains every currently-queued frame in priority - // order, so this frame is written by this call or by a concurrent caller that got the lock - // first. Either way it completes via its own TaskCompletionSource. - try + // Contend for the single writer, but race that contention against this frame's own completion. + // Whoever wins the lock drains every currently-queued frame in priority order, so this frame is + // written by this call or by a concurrent caller that got the lock first — and in the latter + // case the winner writes, flushes, and completes it at the control-to-event boundary, part-way + // through its pass. Racing the two is what lets this call return at that instant instead of at + // the winner's release; the acquisition it then walks away from is detached, never dropped, so + // no drain responsibility leaves with it. + Task lockWait = _writeLock.WaitAsync(cancellationToken); + Task completion = frame.Completion.Task; + await Task.WhenAny(completion, lockWait).ConfigureAwait(false); + + if (lockWait.Status != TaskStatus.RanToCompletion) { - await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - // Tombstone the queued frame so DequeueNext skips it — but only if a draining lock-holder - // has not already claimed it. If it is claimed it is mid-write and cannot be recalled; the - // caller still observes cancellation while the frame reaches the wire (documented above). - TombstoneIfUnclaimed(frame, cancellationToken); - throw; + // This call does not hold the lock: either the completion won the race and the acquisition + // is still outstanding, or the wait ended without the lock because the token fired. Hand the + // wait over either way — an acquisition nobody is waiting on must still drain and release, + // and a wait that ends without the lock must still have its outcome observed. + DetachLockWait(lockWait); + + if (!completion.IsCompleted) + { + // The frame was not delivered, so the wait must have ended in cancellation. Tombstone + // the queued frame so DequeueNext skips it — but only if a draining lock-holder has not + // already claimed it. If it is claimed it is mid-write and cannot be recalled; the + // caller still observes cancellation while the frame reaches the wire (documented + // above). Rethrow from the wait rather than from the completion, so a claimed frame's + // canceller is not held behind the very write it is abandoning. + TombstoneIfUnclaimed(frame, cancellationToken); + await lockWait.ConfigureAwait(false); + } + + await completion.ConfigureAwait(false); + return; } try @@ -165,7 +189,7 @@ public sealed class WorkerFrameWriter _writeLock.Release(); } - await frame.Completion.Task.ConfigureAwait(false); + await completion.ConfigureAwait(false); } /// @@ -195,6 +219,13 @@ public sealed class WorkerFrameWriter /// ; frames /// the cancelled caller abandons (claimed mid-write, or already faulted) get a fault-observing /// continuation so a later write failure never raises an unobserved-task exception (NEXT-04). + /// + /// This path deliberately keeps the plain wait-then-drain shape rather than the single-frame + /// path's completion-versus-lock race. A batch caller's result is its whole set of completions, + /// and the last of those resolves at the end-of-pass flush — the instant before the drainer + /// releases the lock — so racing the acquisition would buy a batch caller nothing while adding a + /// detached acquisition per call. Semantics here are unchanged by that race. + /// /// public async Task WriteBatchAsync( IReadOnlyList envelopes, @@ -319,6 +350,79 @@ public sealed class WorkerFrameWriter TaskScheduler.Default); } + /// + /// Keeps a write-lock acquisition whose caller has stopped waiting for it — its frame was + /// delivered inside the winning drainer's pass, or its token fired — from losing the drain + /// responsibility that comes with the lock. The wait is never simply dropped: if it goes on to + /// acquire the lock, the continuation drains whatever is queued and then releases, so the lock + /// is never acquired and silently held, and a frame enqueued between the previous drainer's + /// last and its release is still written by someone. A wait that ends + /// without the lock took no semaphore count and so has nothing to release. + /// + /// The continuation is queued to the thread pool rather than run inline, because it starts a + /// drain pass: running that synchronously would charge whichever thread called Release + /// for the next caller's writes. + /// + /// + /// Outstanding write-lock acquisition the caller has walked away from. + private void DetachLockWait(Task lockWait) + { + _ = lockWait.ContinueWith( + OnDetachedLockWaitSettled, + CancellationToken.None, + TaskContinuationOptions.None, + TaskScheduler.Default); + } + + /// + /// Settles a detached write-lock acquisition: drain and release if it took the lock, otherwise + /// observe its outcome and stop. Acquisition and cancellation are mutually exclusive — + /// never hands a count to a wait it cancels — so neither branch can + /// leak a count, and neither can strand a queued frame: a cancelled wait never held the lock, + /// so whatever is queued is still owned by the next caller to acquire it. + /// + /// Settled write-lock acquisition task. + private void OnDetachedLockWaitSettled(Task lockWait) + { + if (lockWait.Status != TaskStatus.RanToCompletion) + { + // Cancelled by the originating caller's token, or — only pathologically, a disposed + // semaphore — faulted. No count was taken, so there is nothing to release and no drain to + // inherit. Touch Exception so a fault on a task nobody awaits any more cannot surface as an + // unobserved-task exception. + _ = lockWait.Exception; + return; + } + + _ = DrainDetachedAsync(); + } + + /// + /// Runs a drain pass under a write lock this writer acquired on behalf of a caller that has + /// already returned, then releases it. An empty queue makes + /// a no-op, so the common case is acquire-drain-nothing-release; the pass earns its keep for a + /// frame enqueued after the previous drainer's last dequeue but before its release. + /// + /// A task that completes once the drain pass has ended and the lock has been released. + private async Task DrainDetachedAsync() + { + try + { + await DrainQueuedFramesAsync().ConfigureAwait(false); + } + catch (Exception) + { + // DrainQueuedFramesAsync routes every write and flush failure onto the affected frames' + // completions, so nothing is expected to escape it. If anything ever does, there is no + // caller left on this pass to receive it and letting it out would only raise an + // unobserved-task exception. The release below is the part that must not be skipped. + } + finally + { + _writeLock.Release(); + } + } + // Runs only under _writeLock. Drains control frames before event frames, stamping and writing each. // The stream write itself is not cancellable: a frame is written atomically or fails, never left // half-written on the pipe because a caller gave up waiting. From a212e145acea6e5477945f713ce4fd8ca4a09a86 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 17 Aug 2026 03:58:14 -0400 Subject: [PATCH 05/17] feat(security): DashboardTags on API-key constraints; sessions inherit owner tags (SEC-25 groundwork) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a dashboard event-visibility tag to ApiKeyConstraints, riding in the existing constraints JSON blob so no auth-store schema migration is needed (design docs/plans/2026-07-10-dashboard-session-acl-tst15.md sections 3/3.1, open call settled per its own recommendation). The tag is visibility-only: no read, write, browse, or subscribe path consults it, and HasRead/HasWriteConstraints ignore it. GatewaySession gains an immutable, ordinal-ignore-case Tags set stamped at construction from the owning API key, forwarded by MxAccessGatewayService.OpenSession from the resolved ApiKeyIdentity — never from the wire request, so a client cannot label its own session with another tenant's tag. ISessionManager gains a tag-carrying OpenSessionAsync overload whose default implementation forwards to the tagless one, so an implementation that does not model tags opens an untagged (least visible) session. apikey create-key gains --dashboard-tags team-a,team-b (repeatable, trimmed, de-duplicated; an empty segment is rejected rather than dropped) and list-keys prints the tags column. No enforcement yet — the EventsHub ACL that consumes the tag is a later change. --- docs/Authentication.md | 17 ++- docs/Authorization.md | 24 ++++ .../Grpc/MxAccessGatewayService.cs | 8 +- .../Authentication/ApiKeyAdminCliRunner.cs | 7 +- .../ApiKeyAdminCommandLineParser.cs | 40 ++++++- .../ApiKeyConstraintSerializer.cs | 5 + .../Authentication/ApiKeyConstraints.cs | 40 ++++++- .../Sessions/GatewaySession.cs | 30 ++++- .../Sessions/ISessionManager.cs | 27 +++++ .../Sessions/SessionManager.cs | 17 ++- .../Grpc/MxAccessGatewayServiceTests.cs | 55 +++++++++ .../Gateway/Sessions/SessionManagerTests.cs | 49 ++++++++ .../ApiKeyAdminCommandLineParserTests.cs | 58 +++++++++ .../ApiKeyConstraintSerializerTests.cs | 110 ++++++++++++++++++ 14 files changed, 477 insertions(+), 10 deletions(-) create mode 100644 src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyConstraintSerializerTests.cs diff --git a/docs/Authentication.md b/docs/Authentication.md index b7692d2..6b05974 100644 --- a/docs/Authentication.md +++ b/docs/Authentication.md @@ -263,6 +263,7 @@ mxgateway apikey init-db mxgateway apikey create-key --key-id ops.alice --display-name "Alice (ops)" --scopes read,write mxgateway apikey create-key --key-id area1.reader --display-name "Area 1 reader" --scopes invoke:read,metadata:read --read-subtree "Area1/*" --browse-subtree "Area1/*" mxgateway apikey create-key --key-id ops.temp --display-name "Temp contractor" --scopes invoke:read --expires 90d +mxgateway apikey create-key --key-id team-a.svc --display-name "Team A service" --scopes session:open,invoke:read --dashboard-tags team-a mxgateway apikey create-key --key-id ops.audit --display-name "Audit window" --scopes metadata:read --expires 2027-01-01T00:00:00Z mxgateway apikey list-keys --json mxgateway apikey revoke-key --key-id ops.alice @@ -272,8 +273,20 @@ mxgateway apikey rotate-key --key-id ops.alice Constraint flags are optional. `--read-subtree`, `--write-subtree`, `--read-tag-glob`, `--write-tag-glob`, and `--browse-subtree` are repeatable. `--max-write-classification` accepts one integer. `--read-alarm-only` and -`--read-historized-only` are boolean flags. Existing rows with null constraints -remain fully unconstrained after migration. +`--read-historized-only` are boolean flags. `--dashboard-tags` takes a +comma-separated list (`--dashboard-tags team-a,team-b`) and is repeatable; its +segments are trimmed and de-duplicated ordinal-ignore-case, and an empty segment +is rejected rather than dropped so a stray comma cannot silently persist a grant +the operator did not write. Existing rows with null constraints remain fully +unconstrained after migration; rows written before `--dashboard-tags` existed +deserialize as untagged, unchanged in every other respect. + +`--dashboard-tags` is *not* a data-access constraint — it only labels the key for +dashboard event visibility, and sessions the key opens inherit it. See +[Authorization](./Authorization.md#constraint-enforcement). + +`list-keys` prints the tags as a trailing tab-separated column (`-` when +untagged); the values are operator-chosen labels, not key material. Key ids are restricted by the parser to ASCII letters, digits, periods, and hyphens so they remain safe to embed in the token format and in URL paths used by diff --git a/docs/Authorization.md b/docs/Authorization.md index abb8dc3..2762fa5 100644 --- a/docs/Authorization.md +++ b/docs/Authorization.md @@ -178,6 +178,30 @@ Supported constraints are: | `browse_subtrees` | Contained-path globs used to filter Galaxy browse results and deploy-event counts. | | `read_alarm_only` | Read/subscription commands must target objects with alarm-bearing attributes. | | `read_historized_only` | Read/subscription commands must target objects with historized attributes. | +| `dashboard_tags` | Dashboard event-visibility tags. **Not a data-access constraint** — see below. | + +`dashboard_tags` is the one member of the blob that constrains nothing on the +gRPC data path. No read, write, browse, or subscribe check consults it, and +`HasReadConstraints` / `HasWriteConstraints` deliberately ignore it: adding a tag +neither widens nor narrows what a key may read or write. It rides in the same +serialized blob only to avoid an auth-store schema migration +(`docs/plans/2026-07-10-dashboard-session-acl-tst15.md` §3.1). + +Its sole purpose is dashboard event visibility. A session records the tags of the +API key that opened it (`GatewaySession.Tags`, immutable for the session's life, +compared ordinal-ignore-case). The tags come from the owning key, never from the +client's `OpenSession` request, so a client cannot label its own session with +another tenant's tag. A key with no tags opens untagged sessions. + +Tags are set at key creation with +`apikey create-key --dashboard-tags team-a,team-b` (repeatable; segments are +trimmed and de-duplicated ordinal-ignore-case). Keys created from the dashboard +API Keys page are currently always untagged. + +The tag is carried end to end today; the dashboard ACL that consumes it — scoping +a Viewer's `EventsHub` subscriptions to the sessions their LDAP groups are +granted — is a separate change. Until it lands, the tag affects nothing at +runtime. Glob matching is anchored, case-insensitive, and supports `*` and `?`. Subtree and tag glob lists are alternatives: matching either list allows that diff --git a/src/ZB.MOM.WW.MxGateway.Server/Grpc/MxAccessGatewayService.cs b/src/ZB.MOM.WW.MxGateway.Server/Grpc/MxAccessGatewayService.cs index fa77d2c..ced3554 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Grpc/MxAccessGatewayService.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Grpc/MxAccessGatewayService.cs @@ -32,11 +32,17 @@ public sealed class MxAccessGatewayService( try { requestValidator.ValidateOpenSession(request); + + // The session's owner id and its dashboard-visibility tags both come from the resolved + // API key identity, never from the request: the key is the tenant principal, so a + // client cannot label its own session with another tenant's tag (SEC-25). + ApiKeyIdentity? owner = identityAccessor.Current; GatewaySession session = await sessionManager .OpenSessionAsync( SessionOpenRequest.FromContract(request), ResolveClientIdentity(), - identityAccessor.Current?.KeyId, + owner?.KeyId, + owner?.EffectiveConstraints.DashboardTags, context.CancellationToken) .ConfigureAwait(false); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAdminCliRunner.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAdminCliRunner.cs index e852397..0a8cefc 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAdminCliRunner.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAdminCliRunner.cs @@ -143,8 +143,13 @@ public sealed class ApiKeyAdminCliRunner(ApiKeyAdminCommands commands) string expiry = key.ExpiresUtc is { } expires ? expires.ToUniversalTime().ToString("u", System.Globalization.CultureInfo.InvariantCulture) : "-"; + // Dashboard tags are operator-facing labels, not key material, so they are safe to + // print alongside the scopes; "-" keeps the column aligned for an untagged key. + string dashboardTags = key.Constraints.DashboardTags.Count > 0 + ? string.Join(',', key.Constraints.DashboardTags) + : "-"; await output.WriteLineAsync( - $"{key.KeyId}\t{key.DisplayName}\t{status}\t{expiry}\t{string.Join(',', key.Scopes)}") + $"{key.KeyId}\t{key.DisplayName}\t{status}\t{expiry}\t{string.Join(',', key.Scopes)}\t{dashboardTags}") .ConfigureAwait(false); } } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAdminCommandLineParser.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAdminCommandLineParser.cs index 041094f..607efd6 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAdminCommandLineParser.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAdminCommandLineParser.cs @@ -233,7 +233,45 @@ public static class ApiKeyAdminCommandLineParser MaxWriteClassification: ParseNullableInt(GetOption(options, "max-write-classification")), BrowseSubtrees: GetOptions(options, "browse-subtree"), ReadAlarmOnly: HasFlag(options, "read-alarm-only"), - ReadHistorizedOnly: HasFlag(options, "read-historized-only")); + ReadHistorizedOnly: HasFlag(options, "read-historized-only")) + { + DashboardTags = ParseDashboardTags(options), + }; + } + + // --dashboard-tags takes a comma-separated list ("team-a,team-b"); repeating the flag unions + // its values. Segments are trimmed and de-duplicated ordinal-ignore-case, matching how the + // enforcement site compares them. An empty segment is rejected rather than dropped: a stray + // comma otherwise silently persists a grant the operator did not mean to write. + private static IReadOnlyList ParseDashboardTags(Dictionary> options) + { + if (!options.TryGetValue("dashboard-tags", out List? values)) + { + return Array.Empty(); + } + + List tags = []; + HashSet seen = new(StringComparer.OrdinalIgnoreCase); + + foreach (string? raw in values) + { + foreach (string segment in (raw ?? string.Empty).Split(',')) + { + string tag = segment.Trim(); + if (tag.Length == 0) + { + throw new FormatException( + "--dashboard-tags must be a comma-separated list of non-empty tags."); + } + + if (seen.Add(tag)) + { + tags.Add(tag); + } + } + } + + return tags.Count == 0 ? Array.Empty() : tags; } // Parses the optional --expires value into an absolute UTC expiry. Accepts a relative diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyConstraintSerializer.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyConstraintSerializer.cs index 73b5575..c0a5418 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyConstraintSerializer.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyConstraintSerializer.cs @@ -22,6 +22,11 @@ public static class ApiKeyConstraintSerializer /// Deserializes API key constraints from JSON, or returns empty constraints if JSON is null or whitespace. /// The JSON string to deserialize. /// The deserialized constraints, or when is null/whitespace. + /// + /// Members absent from the JSON take their default: rows persisted before + /// existed carry no dashboard_tags + /// member and deserialize to an untagged key, unchanged in every other respect. + /// public static ApiKeyConstraints Deserialize(string? json) { if (string.IsNullOrWhiteSpace(json)) diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyConstraints.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyConstraints.cs index 6f0f1ee..1cf8418 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyConstraints.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyConstraints.cs @@ -10,6 +10,38 @@ public sealed record ApiKeyConstraints( bool ReadAlarmOnly, bool ReadHistorizedOnly) { + private readonly IReadOnlyList _dashboardTags = Array.Empty(); + + /// + /// Gets the dashboard event-visibility tags granted to this key (SEC-25). + /// + /// + /// + /// This is dashboard event-visibility only. It is never a + /// data-access constraint: no read, write, browse, or subscribe path consults it, and + /// adding a tag neither widens nor narrows what the key may read or write. Sessions + /// opened by the key inherit these tags (GatewaySession.Tags), and a dashboard + /// Viewer may observe a session's mirrored event metadata only when their granted tags + /// intersect the session's. It rides in the same serialized constraints blob purely to + /// avoid an auth-store schema migration — see + /// docs/plans/2026-07-10-dashboard-session-acl-tst15.md §3.1. + /// + /// + /// Tag values are stored exactly as supplied; comparisons are ordinal-ignore-case at the + /// enforcement site, so Team-A and team-a name the same tag. An empty list + /// means untagged. + /// + /// + public IReadOnlyList DashboardTags + { + get => _dashboardTags; + + // Defensive copy: the tag set is a security-relevant grant, so the record must not alias a + // caller-owned list that could be mutated after construction. A null or empty value (an old + // persisted row has no dashboard_tags member at all) normalizes to untagged. + init => _dashboardTags = value is { Count: > 0 } ? [.. value] : Array.Empty(); + } + /// Gets an empty constraints instance with no restrictions. public static ApiKeyConstraints Empty { get; } = new( ReadSubtrees: Array.Empty(), @@ -22,6 +54,11 @@ public sealed record ApiKeyConstraints( ReadHistorizedOnly: false); /// Gets a value indicating whether the constraints are empty (no restrictions). + /// + /// counts here even though it restricts nothing: an empty + /// instance is not persisted at all (ApiKeyConstraintSerializer.Serialize returns + /// null), so a key whose only per-key policy is a dashboard tag must still round-trip. + /// public bool IsEmpty => ReadSubtrees.Count == 0 && WriteSubtrees.Count == 0 @@ -30,7 +67,8 @@ public sealed record ApiKeyConstraints( && MaxWriteClassification is null && BrowseSubtrees.Count == 0 && !ReadAlarmOnly - && !ReadHistorizedOnly; + && !ReadHistorizedOnly + && DashboardTags.Count == 0; /// Gets a value indicating whether any read constraints are defined. public bool HasReadConstraints => diff --git a/src/ZB.MOM.WW.MxGateway.Server/Sessions/GatewaySession.cs b/src/ZB.MOM.WW.MxGateway.Server/Sessions/GatewaySession.cs index f7c8808..5802ff9 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Sessions/GatewaySession.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Sessions/GatewaySession.cs @@ -1,3 +1,4 @@ +using System.Collections.Frozen; using System.Diagnostics; using System.Runtime.CompilerServices; using Microsoft.Extensions.Logging; @@ -12,6 +13,10 @@ namespace ZB.MOM.WW.MxGateway.Server.Sessions; public sealed class GatewaySession { + // Shared untagged sentinel: most sessions carry no dashboard tags. Frozen so the exposed set + // cannot be mutated by a cast — the tag set is a visibility grant, not a scratch collection. + private static readonly IReadOnlySet EmptyTags = FrozenSet.Empty; + private readonly object _syncRoot = new(); private readonly SemaphoreSlim _closeLock = new(1, 1); private readonly SessionEventStreaming _eventStreaming; @@ -149,6 +154,12 @@ public sealed class GatewaySession /// using 's clock so the timer /// is unit-testable. /// + /// + /// Dashboard event-visibility tags inherited from the owning API key (SEC-25). Copied into + /// the immutable set; or empty means untagged. + /// The tags come from the owner key, never from the client's wire request, so a client + /// cannot label its own session with another tenant's tag. + /// public GatewaySession( string sessionId, string backendName, @@ -167,7 +178,8 @@ public sealed class GatewaySession TimeSpan detachGrace = default, TimeSpan workerReadyWaitTimeout = default, ArrayAddressNormalizer? addressNormalizer = null, - TimeSpan faultedGrace = default) + TimeSpan faultedGrace = default, + IReadOnlyList? ownerDashboardTags = null) { if (string.IsNullOrWhiteSpace(sessionId)) { @@ -195,6 +207,9 @@ public sealed class GatewaySession Nonce = nonce; ClientIdentity = clientIdentity; OwnerKeyId = ownerKeyId; + Tags = ownerDashboardTags is { Count: > 0 } + ? ownerDashboardTags.ToFrozenSet(StringComparer.OrdinalIgnoreCase) + : EmptyTags; ClientSessionName = clientSessionName; ClientCorrelationId = clientCorrelationId; CommandTimeout = commandTimeout; @@ -241,6 +256,19 @@ public sealed class GatewaySession /// public string? OwnerKeyId { get; } + /// + /// Gets the dashboard event-visibility tags this session inherited from its owning API key + /// (SEC-25). An empty set means untagged. + /// + /// + /// Immutable for the session's life — assigned once at construction from the owner key's + /// ApiKeyConstraints.DashboardTags — so a dashboard subscription decided at join time + /// never has to be re-evaluated. The set compares ordinal-ignore-case. These tags gate + /// nothing on the gRPC data path; they exist only so the dashboard can scope which sessions' + /// mirrored event metadata a Viewer may observe. + /// + public IReadOnlySet Tags { get; } + /// /// Gets the client-supplied session name. /// diff --git a/src/ZB.MOM.WW.MxGateway.Server/Sessions/ISessionManager.cs b/src/ZB.MOM.WW.MxGateway.Server/Sessions/ISessionManager.cs index 9cb0191..0470f5e 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Sessions/ISessionManager.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Sessions/ISessionManager.cs @@ -17,6 +17,33 @@ public interface ISessionManager string? ownerKeyId, CancellationToken cancellationToken); + /// + /// Opens a new gateway session, stamping the owning API key's dashboard event-visibility + /// tags onto it (SEC-25). + /// + /// Request payload. + /// Client identity string. + /// API key identifier of the caller creating the session. + /// + /// The owner key's ApiKeyConstraints.DashboardTags. Null or empty opens an untagged + /// session. Never sourced from the client's wire request — see + /// docs/plans/2026-07-10-dashboard-session-acl-tst15.md §3.1. + /// + /// Token to cancel the asynchronous operation. + /// The newly opened session. + /// + /// The default implementation forwards to the tagless overload, so an implementation that + /// does not model tags (unit-test fakes) opens an untagged session. That is the + /// fail-closed direction: untagged sessions are the least dashboard-visible ones. + /// + Task OpenSessionAsync( + SessionOpenRequest request, + string? clientIdentity, + string? ownerKeyId, + IReadOnlyList? ownerDashboardTags, + CancellationToken cancellationToken) + => OpenSessionAsync(request, clientIdentity, ownerKeyId, cancellationToken); + /// Attempts to retrieve a session by ID. /// Identifier of the session. /// The retrieved session, if found. diff --git a/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionManager.cs b/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionManager.cs index 0cf27b6..a58aec0 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionManager.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionManager.cs @@ -87,11 +87,20 @@ public sealed class SessionManager : ISessionManager _sessionSlots = new SemaphoreSlim(_options.Sessions.MaxSessions, _options.Sessions.MaxSessions); } + /// + public Task OpenSessionAsync( + SessionOpenRequest request, + string? clientIdentity, + string? ownerKeyId, + CancellationToken cancellationToken) + => OpenSessionAsync(request, clientIdentity, ownerKeyId, ownerDashboardTags: null, cancellationToken); + /// public async Task OpenSessionAsync( SessionOpenRequest request, string? clientIdentity, string? ownerKeyId, + IReadOnlyList? ownerDashboardTags, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); @@ -101,7 +110,7 @@ public sealed class SessionManager : ISessionManager bool sessionOpenedRecorded = false; try { - session = CreateSession(request, clientIdentity, ownerKeyId); + session = CreateSession(request, clientIdentity, ownerKeyId, ownerDashboardTags); if (!_registry.TryAdd(session)) { throw new SessionManagerException( @@ -494,7 +503,8 @@ public sealed class SessionManager : ISessionManager private GatewaySession CreateSession( SessionOpenRequest request, string? clientIdentity, - string? ownerKeyId) + string? ownerKeyId, + IReadOnlyList? ownerDashboardTags) { string sessionUid = Guid.NewGuid().ToString("N"); string sessionId = $"session-{sessionUid}"; @@ -541,7 +551,8 @@ public sealed class SessionManager : ISessionManager TimeSpan.FromSeconds(Math.Max(0, _options.Sessions.DetachGraceSeconds)), TimeSpan.FromMilliseconds(Math.Max(0, _options.Sessions.WorkerReadyWaitTimeoutMs)), _addressNormalizer, - TimeSpan.FromSeconds(Math.Max(0, _options.Sessions.FaultedGraceSeconds))); + TimeSpan.FromSeconds(Math.Max(0, _options.Sessions.FaultedGraceSeconds)), + ownerDashboardTags); } private static string CreateClientCorrelationId( diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs index cfc06a3..6a82b5d 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs @@ -50,6 +50,45 @@ public sealed class MxAccessGatewayServiceTests Assert.Equal("operator-session", sessionManager.LastOpenRequest?.ClientSessionName); } + /// + /// Verifies OpenSession forwards the calling key's dashboard-visibility tags, so the + /// session's tags are derived from the owning API key rather than the wire request (SEC-25). + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task OpenSession_WithTaggedKey_ForwardsOwnerDashboardTags() + { + GatewayRequestIdentityAccessor identityAccessor = new(); + FakeSessionManager sessionManager = new(); + MxAccessGatewayService service = CreateService(sessionManager, identityAccessor); + ApiKeyIdentity identity = CreateIdentity() with + { + Constraints = ApiKeyConstraints.Empty with { DashboardTags = ["team-a"] }, + }; + + using IDisposable identityScope = identityAccessor.Push(identity); + await service.OpenSession(new OpenSessionRequest(), new TestServerCallContext()); + + Assert.Equal(["team-a"], sessionManager.LastOwnerDashboardTags); + } + + /// + /// Verifies an unauthenticated OpenSession (no resolved key identity) opens an untagged + /// session — the fail-closed state for dashboard event visibility. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task OpenSession_WithoutIdentity_ForwardsNoDashboardTags() + { + FakeSessionManager sessionManager = new(); + MxAccessGatewayService service = CreateService(sessionManager, new GatewayRequestIdentityAccessor()); + + await service.OpenSession(new OpenSessionRequest(), new TestServerCallContext()); + + Assert.Null(sessionManager.LastOwnerDashboardTags); + Assert.Null(sessionManager.LastOwnerKeyId); + } + /// /// Verifies that Invoke maps a genuinely missing session to NotFound via the /// service's own ResolveSession lookup. No InvokeException is @@ -517,6 +556,9 @@ public sealed class MxAccessGatewayServiceTests /// The last owner key id passed to OpenSessionAsync. public string? LastOwnerKeyId { get; private set; } + /// The last owner dashboard tags passed to OpenSessionAsync. + public IReadOnlyList? LastOwnerDashboardTags { get; private set; } + /// The last session ID the event stream service was asked to stream. public string? LastReadEventsSessionId { get; private set; } @@ -564,6 +606,19 @@ public sealed class MxAccessGatewayServiceTests return Task.FromResult(OpenSessionResult ?? CreateSession("session-1", processId: 1234)); } + /// + public Task OpenSessionAsync( + SessionOpenRequest request, + string? clientIdentity, + string? ownerKeyId, + IReadOnlyList? ownerDashboardTags, + CancellationToken cancellationToken) + { + LastOwnerDashboardTags = ownerDashboardTags; + + return OpenSessionAsync(request, clientIdentity, ownerKeyId, cancellationToken); + } + /// public bool TryGetSession( string sessionId, diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/SessionManagerTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/SessionManagerTests.cs index f4e16f1..55c2cb6 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/SessionManagerTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/SessionManagerTests.cs @@ -109,6 +109,55 @@ public sealed class SessionManagerTests Assert.Null(session.OwnerKeyId); } + /// + /// Verifies a session inherits the owning API key's dashboard-visibility tags (SEC-25), + /// compared ordinal-ignore-case so a differently cased grant still matches. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task OpenSessionAsync_WithOwnerDashboardTags_CopiesTagsOntoSession() + { + SessionManager manager = CreateManager(new FakeSessionWorkerClientFactory(new FakeWorkerClient())); + + GatewaySession session = await manager.OpenSessionAsync( + CreateOpenRequest(), + clientIdentity: "MyKey Display", + ownerKeyId: "key-abc123", + ownerDashboardTags: ["team-a", "team-b"], + CancellationToken.None); + + Assert.Equal(["team-a", "team-b"], session.Tags.OrderBy(tag => tag, StringComparer.Ordinal)); + Assert.Contains("TEAM-A", session.Tags); + } + + /// + /// Verifies a session opened by a key with no dashboard tags is untagged, which is the + /// fail-closed state for dashboard event visibility. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task OpenSessionAsync_WithoutOwnerDashboardTags_LeavesSessionUntagged() + { + SessionManager manager = CreateManager(new FakeSessionWorkerClientFactory(new FakeWorkerClient())); + + GatewaySession session = await manager.OpenSessionAsync( + CreateOpenRequest(), + clientIdentity: "MyKey Display", + ownerKeyId: "key-abc123", + ownerDashboardTags: null, + CancellationToken.None); + + Assert.Empty(session.Tags); + + GatewaySession tagless = await manager.OpenSessionAsync( + CreateOpenRequest(), + "client-1", + ownerKeyId: null, + CancellationToken.None); + + Assert.Empty(tagless.Tags); + } + /// Verifies that opening a session sets the initial lease expiry from the configured default lease. /// A task that represents the asynchronous operation. [Fact] diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyAdminCommandLineParserTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyAdminCommandLineParserTests.cs index f7b94bb..467e7d0 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyAdminCommandLineParserTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyAdminCommandLineParserTests.cs @@ -196,6 +196,64 @@ public sealed class ApiKeyAdminCommandLineParserTests Assert.True(constraints.ReadHistorizedOnly); } + /// + /// Verifies --dashboard-tags parses a comma-separated list, trimming segments and unioning + /// repeated occurrences of the flag without duplicating a tag that differs only by case. + /// + [Fact] + public void Parse_CreateKeyCommand_WithDashboardTags_ParsesTrimmedTagList() + { + ApiKeyAdminParseResult result = ApiKeyAdminCommandLineParser.Parse( + [ + "apikey", + "create-key", + "--key-id", + "operator01", + "--display-name", + "Operator", + "--dashboard-tags", + " team-a , team-b ", + "--dashboard-tags", + "TEAM-A,team-c" + ]); + + Assert.True(result.IsApiKeyCommand); + Assert.Null(result.Error); + Assert.NotNull(result.Command); + Assert.Equal(["team-a", "team-b", "team-c"], result.Command.Constraints.DashboardTags); + } + + /// Verifies a create-key command without --dashboard-tags leaves the key untagged. + [Fact] + public void Parse_CreateKeyCommand_WithoutDashboardTags_LeavesKeyUntagged() + { + ApiKeyAdminParseResult result = ApiKeyAdminCommandLineParser.Parse( + ["apikey", "create-key", "--key-id", "operator01", "--display-name", "Operator"]); + + Assert.NotNull(result.Command); + Assert.Empty(result.Command.Constraints.DashboardTags); + } + + /// + /// Verifies an empty tag segment is rejected rather than dropped: a stray comma must not + /// silently persist a grant the operator did not write. + /// + [Theory] + [InlineData("team-a,,team-b")] + [InlineData("team-a, ")] + [InlineData("")] + public void Parse_CreateKeyCommand_WithEmptyDashboardTag_Fails(string tags) + { + ApiKeyAdminParseResult result = ApiKeyAdminCommandLineParser.Parse( + ["apikey", "create-key", "--key-id", "operator01", "--display-name", "Operator", + $"--dashboard-tags={tags}"]); + + Assert.True(result.IsApiKeyCommand); + Assert.Null(result.Command); + Assert.NotNull(result.Error); + Assert.Contains("--dashboard-tags", result.Error, StringComparison.Ordinal); + } + /// Verifies that create-key command without display name returns error. [Fact] public void Parse_CreateKeyWithoutDisplayName_ReturnsError() diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyConstraintSerializerTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyConstraintSerializerTests.cs new file mode 100644 index 0000000..4084f41 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyConstraintSerializerTests.cs @@ -0,0 +1,110 @@ +using ZB.MOM.WW.MxGateway.Server.Security.Authentication; + +namespace ZB.MOM.WW.MxGateway.Tests.Security.Authentication; + +public sealed class ApiKeyConstraintSerializerTests +{ + /// Verifies that dashboard tags survive a serialize/deserialize round trip. + [Fact] + public void RoundTrip_WithDashboardTags_PreservesTags() + { + ApiKeyConstraints constraints = ApiKeyConstraints.Empty with + { + ReadSubtrees = ["Area1/*"], + DashboardTags = ["team-a", "team-b"], + }; + + string? json = ApiKeyConstraintSerializer.Serialize(constraints); + + Assert.NotNull(json); + Assert.Contains("dashboard_tags", json, StringComparison.Ordinal); + + ApiKeyConstraints restored = ApiKeyConstraintSerializer.Deserialize(json); + + Assert.Equal(["team-a", "team-b"], restored.DashboardTags); + Assert.Equal(["Area1/*"], restored.ReadSubtrees); + } + + /// + /// Verifies a key whose only per-key policy is a dashboard tag is still persisted: the + /// serializer drops empty constraints entirely, so the tag must count as non-empty. + /// + [Fact] + public void Serialize_WithOnlyDashboardTags_IsNotTreatedAsEmpty() + { + ApiKeyConstraints constraints = ApiKeyConstraints.Empty with { DashboardTags = ["team-a"] }; + + Assert.False(constraints.IsEmpty); + Assert.NotNull(ApiKeyConstraintSerializer.Serialize(constraints)); + } + + /// Verifies that dashboard tags never register as read or write (data-access) constraints. + [Fact] + public void DashboardTags_AreNotDataAccessConstraints() + { + ApiKeyConstraints constraints = ApiKeyConstraints.Empty with { DashboardTags = ["team-a"] }; + + Assert.False(constraints.HasReadConstraints); + Assert.False(constraints.HasWriteConstraints); + } + + /// + /// Verifies a row persisted before the dashboard-tag field existed still deserializes, with + /// every pre-existing constraint intact and an untagged (empty, never null) tag list. + /// + [Fact] + public void Deserialize_LegacyJsonWithoutDashboardTags_YieldsUntaggedConstraints() + { + const string LegacyJson = """ + { + "read_subtrees": ["Area1/*"], + "write_subtrees": [], + "read_tag_globs": [], + "write_tag_globs": ["Pump_*"], + "max_write_classification": 2, + "browse_subtrees": ["Area1/*"], + "read_alarm_only": true, + "read_historized_only": false + } + """; + + ApiKeyConstraints constraints = ApiKeyConstraintSerializer.Deserialize(LegacyJson); + + Assert.Empty(constraints.DashboardTags); + Assert.Equal(["Area1/*"], constraints.ReadSubtrees); + Assert.Equal(["Pump_*"], constraints.WriteTagGlobs); + Assert.Equal(2, constraints.MaxWriteClassification); + Assert.Equal(["Area1/*"], constraints.BrowseSubtrees); + Assert.True(constraints.ReadAlarmOnly); + Assert.False(constraints.ReadHistorizedOnly); + } + + /// Verifies an explicit JSON null for the tag list normalizes to untagged rather than null. + [Fact] + public void Deserialize_ExplicitNullDashboardTags_YieldsEmptyList() + { + const string Json = """ + { + "read_subtrees": [], + "write_subtrees": [], + "read_tag_globs": [], + "write_tag_globs": [], + "max_write_classification": null, + "browse_subtrees": [], + "read_alarm_only": false, + "read_historized_only": false, + "dashboard_tags": null + } + """; + + Assert.Empty(ApiKeyConstraintSerializer.Deserialize(Json).DashboardTags); + } + + /// Verifies null or whitespace constraint JSON deserializes to the untagged empty instance. + [Fact] + public void Deserialize_NullJson_YieldsEmptyConstraints() + { + Assert.Same(ApiKeyConstraints.Empty, ApiKeyConstraintSerializer.Deserialize(null)); + Assert.Empty(ApiKeyConstraints.Empty.DashboardTags); + } +} From ce5d8ae7c2e84763e02951808b3340ab77d6c55d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 17 Aug 2026 04:03:39 -0400 Subject: [PATCH 06/17] =?UTF-8?q?docs(alarms):=20wnwrap=20live-probe=20fin?= =?UTF-8?q?dings=20=E2=80=94=20GUID=20identity,=20ALARM=5FRECORDS=20COUNT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both questions stay open, and the reason is the finding: the dev rig's alarm UDAs reject a plain MXAccess Write with SecurityError/detail=1008 from the responding automation object, so no alarm instance can be created to follow through an acknowledge and no population can be built to overflow a capped fetch. The rig is otherwise live — objects deployed and on scan, wnwrap subscribed, GetXmlCurrentAlarms2 returning well-formed XML — which is what makes the blocker specific and the unblock (engine-side script, or AuthenticateUser + WriteSecured, or reclassifying the UDAs) actionable. Comment-only changes in WnWrapAlarmConsumer: scope the GUID-identity claim to the leg live capture actually covers, and record that ALARM_RECORDS/@COUNT exists as a candidate exact truncation signal but is deliberately not trusted because its semantics under a capped reply are unverified. No behavior change. --- docs/AlarmProbeFindings.md | 123 ++++++++++++++++++ .../MxAccess/WnWrapAlarmConsumer.cs | 39 ++++-- 2 files changed, 152 insertions(+), 10 deletions(-) create mode 100644 docs/AlarmProbeFindings.md diff --git a/docs/AlarmProbeFindings.md b/docs/AlarmProbeFindings.md new file mode 100644 index 0000000..f67e360 --- /dev/null +++ b/docs/AlarmProbeFindings.md @@ -0,0 +1,123 @@ +# Alarm Probe Findings + +`WnWrapAlarmConsumer` rests on two assumptions that no unit test can settle, because both +are properties of AVEVA's alarm provider rather than of our code: + +1. **GUID identity.** The snapshot diff in `ComputeTransitions` keys on the alarm record's + `GUID`. If wnwrap mints a fresh GUID when an alarm changes state, a single + `UNACK_ALM → ACK_ALM` transition reads as one alarm disappearing and a different one + appearing — a spurious clear plus a spurious raise on every acknowledge. +2. **`ALARM_RECORDS/@COUNT` semantics.** `IsTruncatedFetch` treats a reply holding exactly + `maxAlmCnt` records as truncated, because `GetXmlCurrentAlarms2` exposes no explicit + "more available" flag. If the reply's `COUNT` attribute carries the *total* active count + rather than the records-in-reply count, truncation detection can become exact instead of + conservative, and the bounded staleness `ApplySnapshotUpdate` accepts goes away. + +This document records what a live probe run against the dev rig (`DESKTOP-6JL3KKO`, +2026-08-17) could and could not establish, so the next attempt starts from the blocker +rather than rediscovering it. + +## Outcome + +| Question | Status | +|---|---| +| GUID stable across polls and `ALM → RTN` | Answered — yes, by the 2026-05-01 capture in `AlarmClientDiscovery.md` | +| GUID stable across `UNACK → ACK`, and across clear-then-re-raise | **Open** | +| `COUNT` = total active vs records-in-reply under a capped fetch | **Open** | + +Both open questions are blocked by the same thing: the rig has no active alarm and cannot +be driven into one over MXAccess, so there is no alarm instance whose GUID can be followed +through an acknowledge and no population large enough to overflow a capped fetch. + +## Why The Rig Cannot Raise An Alarm + +The rig is otherwise healthy, which is what makes the blocker specific rather than a +general "nothing works": + +- `aaEngine`, `alarmmgr`, `NmxSvc`, and `wnwrapServerEx` are all running. +- `TestArea` (area of `TestMachine_001`…`_003`) and the objects themselves are deployed + (`deployed_version` non-null in the `ZB` Galaxy Repository) and on scan — the probe's + advised `ScanState` subtags report true, and every advised alarm attribute delivers an + initial value, so the MXAccess read path is live. +- The wnwrap consumer subscribes cleanly: `InitializeConsumer`, `RegisterConsumer`, + `Subscribe(\\DESKTOP-6JL3KKO\Galaxy!TestArea)`, and `SetXmlAlarmQuery` all return 0, and + `GetXmlCurrentAlarms2` returns well-formed XML on every poll. + +What fails is the *write* that would set the alarm condition. Every `Write` to the alarm +UDAs completes with a security failure: + +``` +WRITE-COMPLETE hLMX=1 hItem=1 statuses=[success=0 category=SecurityError detectedBy=RespondingAutomationObject detail=1008 text=] +``` + +The status comes back from the responding automation object, not from the proxy, so the +request reaches the engine and the engine refuses it. The advised value confirms the +refusal is total rather than transient: neither the alarm UDA nor its `.InAlarm` /`.Acked` +subtags report any change after a write attempt, across six write attempts in one session +(raise, clear, re-raise, cleanup). The attributes carry a security classification that a +plain `Write` cannot satisfy. + +The 2026-05-01 capture that answered the `ALM → RTN` leg did not hit this, because the +alarm condition was driven from *inside* the engine by a System Platform script rather than +from an external MXAccess client. That script is not running now, and the values sat idle +for the whole probe session. + +### Unblocking + +Any one of these makes both questions answerable, in rough order of cost: + +- Re-enable the System Platform script that flips `TestMachine_001.TestAlarm001` + (referenced throughout `AlarmClientDiscovery.md`). It writes from inside the engine, so + the attribute's security classification does not apply. +- Drive the write through `AuthenticateUser` + `WriteSecured` with a Galaxy account + permitted on that classification. The worker already implements both verbs; the probe + used plain `Write`, which is the wrong verb for a secured attribute. +- Reclassify the test UDAs to free access in the IDE and redeploy `TestMachine_001`…`_003`. + +Three separate objects are wired to the same alarm UDA name, so once writes land, a +`maxAlmCnt` of 1 or 2 forces truncation against three active alarms and answers the `COUNT` +question in the same run. + +## Evidence + +Snapshot payload, identical at every cap (1, 2, and 1024) and at every poll across the +~100-second session: + +```xml + +``` + +Two things follow from the empty case alone. `COUNT` is present on the root element in +every reply, so the attribute exists as a candidate signal rather than something wnwrap +omits. And `COUNT` agrees with the element count here — but trivially, since both are zero, +which is exactly the case that cannot discriminate the two hypotheses. + +The probe used for the run was a throwaway file in the windev CI clone +(`C:\build\mxaccessgw-ci`), deleted afterwards; the clone is back to a clean tree at +`origin/main`. Nothing in this repository changed to run it. The reusable, Skip-gated +harness it was modelled on is +`src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/WnWrapConsumerProbeTests.cs`. + +## Implications + +### Transition identity + +`ComputeTransitions` keying on GUID is safe for the raise and clear legs, which is the +evidence `AlarmClientDiscovery.md` already carries. The acknowledge leg — the one where a +re-minted GUID would corrupt the feed, because an ack is the state change most likely to +create a new record in a provider that models acknowledgement as a separate event — is +still assumed rather than observed. Nothing here justifies changing the diff, but the +assumption should not be described in code as established. + +### Truncation detection + +`IsTruncatedFetch` stays as written. Tightening it to an exact test requires knowing that +`COUNT` reports the total, and this run cannot show that. The conservative rule keeps its +justification: at the cap, treating a complete fetch as truncated costs one poll of +staleness, while treating a truncated fetch as complete broadcasts clears for every alarm +past the cap. + +The one substantive correction is to the phrasing rather than the logic. The reply is not +featureless — it carries a `COUNT` attribute the parser currently ignores. Whether that +attribute is a usable "more available" signal is unverified, not absent, and the comments +in `WnWrapAlarmConsumer` now say so. diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs index b9f44f4..041e901 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs @@ -402,11 +402,15 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer Dictionary next = ParseSnapshotXml(xml, out int fetchedRecordCount); // TRUNCATION CLIFF. GetXmlCurrentAlarms2 caps its reply at maxAlmCnt - // and gives no "there is more" flag, so a reply holding exactly the - // cap is indistinguishable from a galaxy that happens to have exactly - // that many active alarms. Treat the ambiguous case as truncated: the - // false-positive cost is a snapshot that stays stale for one poll, the - // false-negative cost is every alarm past the cap reading as cleared. + // and gives no *verified* "there is more" flag, so a reply holding + // exactly the cap is indistinguishable from a galaxy that happens to + // have exactly that many active alarms. Treat the ambiguous case as + // truncated: the false-positive cost is a snapshot that stays stale for + // one poll, the false-negative cost is every alarm past the cap reading + // as cleared. (The reply's ALARM_RECORDS/@COUNT attribute is a + // candidate exact signal, but only if it reports the total rather than + // the records in the reply — untested on a live rig, see + // docs/AlarmProbeFindings.md.) bool truncated = IsTruncatedFetch(fetchedRecordCount, maxAlarmsPerFetch); IReadOnlyList transitions; @@ -436,11 +440,15 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer /// Decides whether a fetch that came back holding /// records hit the cap. /// GetXmlCurrentAlarms2 caps its reply at maxAlmCnt and - /// offers no "more available" flag, so a reply at exactly the cap is - /// indistinguishable from a galaxy that happens to hold exactly that - /// many active alarms; both are treated as truncated. Exposed as - /// internal static so the rule is unit-testable without the - /// wnwrapConsumer COM object. + /// offers no confirmed "more available" flag, so a reply at exactly the + /// cap is indistinguishable from a galaxy that happens to hold exactly + /// that many active alarms; both are treated as truncated. The reply + /// root carries an ALARM_RECORDS/@COUNT attribute that would make + /// the test exact if it reported the total active count rather than the + /// records in this reply; a live probe could not discriminate the two + /// (see docs/AlarmProbeFindings.md), so the count is deliberately + /// not trusted here. Exposed as internal static so the rule is + /// unit-testable without the wnwrapConsumer COM object. /// /// ALARM records the reply carried. /// The cap that was passed to the fetch. @@ -599,6 +607,17 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer /// stays correct for the alarms it does carry — first sightings /// and state changes are computed from presence alone. /// + /// + /// Every rule above assumes the GUID identifies the alarm + /// instance rather than the state it is in: a re-minted + /// GUID would read as the old alarm vanishing and a new one + /// appearing, i.e. a spurious Clear plus a spurious Raise. Live + /// capture confirms stability across the active→returned leg only + /// (docs/AlarmClientDiscovery.md); the acknowledge leg and + /// re-raise-after-clear are assumed, not observed, because the dev + /// rig's alarm attributes reject unauthenticated writes — see + /// docs/AlarmProbeFindings.md. + /// /// /// The snapshot from the previous poll (or empty on first call). /// The snapshot just parsed from GetXmlCurrentAlarms2. From b8b7b69ba0512fe0a08a346d6b0c9f0b777f24fe Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 17 Aug 2026 04:07:44 -0400 Subject: [PATCH 07/17] fix(worker): observe detached drain faults per NEXT-04 discipline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both tasks the detached-lock-wait path starts and discards now carry the file's fault-observing continuation, factored out of ObserveAbandonedFault as ObserveFault so the idiom has one definition. DrainDetachedAsync swallows the drain but its _writeLock.Release() sits in a finally outside that catch, so a Release that ever throws — a SemaphoreFullException from some future double-release regression — had no awaiter and would have surfaced on net48 as TaskScheduler.UnobservedTaskException at finalization instead of an attributable failure. Same for a throw out of OnDetachedLockWaitSettled. Review's Minor (distinguishing a cancelled from a faulted lock wait before tombstoning) is deliberately not taken: a faulted WaitAsync is unreachable here — nothing disposes _writeLock — so the branch would be untestable new logic whose only effect is internal state, the caller already receiving the fault itself from the rethrow. Recorded as a comment at the site instead. edited on macOS, windev verification pending (plan Task 11). Re-ran the net10 scratch harness over WorkerFrameWriter and the writer suite: 0 warnings, 31/31 pass. --- .../Ipc/WorkerFrameWriter.cs | 38 ++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerFrameWriter.cs b/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerFrameWriter.cs index c8a1e9a..1c1c90d 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerFrameWriter.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerFrameWriter.cs @@ -172,6 +172,12 @@ public sealed class WorkerFrameWriter // caller still observes cancellation while the frame reaches the wire (documented // above). Rethrow from the wait rather than from the completion, so a claimed frame's // canceller is not held behind the very write it is abandoning. + // + // The wait ending without the lock IS cancellation in every reachable case — nothing + // disposes _writeLock — so the tombstone's TrySetCanceled is honest. A hypothetical + // faulted wait would take this same path and label the frame cancelled instead of + // faulted; that is internal state only, since the await below rethrows the fault itself + // to the caller. TombstoneIfUnclaimed(frame, cancellationToken); await lockWait.ConfigureAwait(false); } @@ -343,8 +349,23 @@ public sealed class WorkerFrameWriter /// Frame whose completion may fault without an awaiter. private static void ObserveAbandonedFault(PendingFrame frame) { - _ = frame.Completion.Task.ContinueWith( - task => _ = task.Exception, + ObserveFault(frame.Completion.Task); + } + + /// + /// Attaches the NEXT-04 fault-observing continuation to a task this writer starts and then + /// discards. Every such task must carry one: with no awaiter, a fault would otherwise reach + /// nobody and resurface as at finalization + /// — a detached, unattributable failure long after the code that caused it. The continuation + /// runs only on the faulted path and only touches , so it can + /// never fault itself; it runs inline because an already-faulted task would otherwise pay a + /// scheduling hop to do nothing. + /// + /// Discarded task whose fault would otherwise go unobserved. + private static void ObserveFault(Task task) + { + _ = task.ContinueWith( + faulted => _ = faulted.Exception, CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); @@ -367,11 +388,14 @@ public sealed class WorkerFrameWriter /// Outstanding write-lock acquisition the caller has walked away from. private void DetachLockWait(Task lockWait) { - _ = lockWait.ContinueWith( + // The continuation task is discarded, so it takes the NEXT-04 fault observer: nothing awaits + // it, and a throw out of OnDetachedLockWaitSettled would otherwise be an unobserved-task + // exception raised at finalization rather than an attributable failure here. + ObserveFault(lockWait.ContinueWith( OnDetachedLockWaitSettled, CancellationToken.None, TaskContinuationOptions.None, - TaskScheduler.Default); + TaskScheduler.Default)); } /// @@ -394,7 +418,11 @@ public sealed class WorkerFrameWriter return; } - _ = DrainDetachedAsync(); + // Discarded, so it takes the NEXT-04 fault observer too. DrainDetachedAsync swallows the drain + // itself, but its release sits in a finally outside that catch: a Release that ever throws (a + // SemaphoreFullException from some future double-release regression, say) must fail somewhere + // attributable rather than at finalization. + ObserveFault(DrainDetachedAsync()); } /// From 693a78db7dee85a2c09dd3ebb874eeb02294e4d7 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 17 Aug 2026 04:18:34 -0400 Subject: [PATCH 08/17] feat(alarms): structural degraded-status signal for truncated alarm snapshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The truncation-cliff fix made alarm transitions truncation-safe but silent: when GetXmlCurrentAlarms2 returns exactly maxAlmCnt records the worker suppresses absence-implies-Clear inference and says so only in a rate-limited stderr warning. No client and no operator could tell a complete active set from a capped one. Two additive proto3 booleans carry the verdict out: - QueryActiveAlarmsReplyPayload.snapshot_truncated = 2 (worker IPC reply) - ActiveAlarmSnapshot.from_truncated_snapshot = 16 (per record) The per-record field is not an aesthetic choice. QueryActiveAlarms returns a bare `stream ActiveAlarmSnapshot` with no envelope, header, or trailer, so a per-record boolean is the only carrier that stays wire-compatible; an envelope message would change every existing client's stream element type. The reply payload states it too because a prefix filter can leave zero records and a truncated fetch with nothing to report still has to say so. The flag means "this set may be incomplete", never "this record is unreliable" — it is independent of the subtag-fallback `degraded` field. Detection is deliberately UNCHANGED: IsTruncatedFetch remains `fetchedRecordCount >= maxAlarmsPerFetch`. The live probe (docs/AlarmProbeFindings.md, ce5d8ae) could not verify whether ALARM_RECORDS/@COUNT reports the total active count or only the records in the reply, so @COUNT is not parsed for detection; switching to it stays blocked on probe evidence. The probe's comment annotations in WnWrapAlarmConsumer.cs are preserved. Reset semantics: not latched. WnWrapAlarmConsumer.FoldFetch replaces the verdict on every poll under the same lock as the snapshot merge, so the first sub-cap fetch clears it; GatewayAlarmMonitor.ClearCache drops it with the cache generation it describes. A caveat that never turns off is one operators learn to ignore. Flow: WnWrapAlarmConsumer.LastSnapshotTruncated -> AlarmDispatcher (stamps every record) / IAlarmCommandHandler (payload) -> MxAccessCommandExecutor reply -> GatewayAlarmMonitor._snapshotTruncated -> IGatewayAlarmService.SnapshotTruncated -> DashboardAlarmQueryResult -> AlarmsPage warning banner (render-side only; the poll loop and DisposeAsync drain are untouched). The public QueryActiveAlarms RPC forwards worker snapshots unmodified, so the per-record flag needed no mapper change — a test pins that. Parity: this describes OUR fetch mechanics — additive gateway metadata — not MXAccess provider behavior. No event is synthesized and no MXAccess-observable semantics change, so it is not a parity deviation. Tests: worker LastSnapshotTruncated set/reset/consecutive-burst (windev-run); gateway end-to-end truncated reply -> monitor -> public stream, with the complete-reply control as the load-bearing assertion; AlarmsPage banner present/absent. Docs: gateway.md alarm surface, docs/DesignDecisions.md entry. --- docs/DesignDecisions.md | 46 +- .../2026-07-10-dashboard-session-acl-tst15.md | 54 ++ gateway.md | 18 + .../Generated/MxaccessGateway.cs | 580 ++++++++++-------- .../Protos/mxaccess_gateway.proto | 17 + .../DashboardLdapLiveTests.cs | 1 + .../Alarms/GatewayAlarmMonitor.cs | 26 +- .../Alarms/IGatewayAlarmService.cs | 10 + .../Components/Pages/AlarmsPage.razor | 13 + .../Components/Pages/SessionDetailsPage.razor | 36 +- .../Dashboard/DashboardActiveAlarm.cs | 9 +- .../DashboardAuthenticationDefaults.cs | 10 + .../Dashboard/DashboardAuthenticator.cs | 30 +- .../Dashboard/DashboardLiveDataService.cs | 6 +- .../DashboardServiceCollectionExtensions.cs | 4 + .../Dashboard/DashboardSessionAcl.cs | 85 +++ .../Dashboard/HubTokenService.cs | 51 +- .../Dashboard/Hubs/EventsHub.cs | 42 +- .../Dashboard/IDashboardSessionAcl.cs | 33 + .../Alarms/AlarmTruncationSignalTests.cs | 329 ++++++++++ .../AlarmsPageTruncationBannerTests.cs | 105 ++++ .../Dashboard/DashboardAuthenticatorTests.cs | 1 + .../Dashboard/DashboardSessionAclTests.cs | 264 ++++++++ .../Gateway/Dashboard/EventsHubTests.cs | 175 ++++++ .../Gateway/Dashboard/HubTokenServiceTests.cs | 99 ++- .../SessionDetailsPageEventAclTests.cs | 218 +++++++ .../TestSupport/FakeGatewayAlarmService.cs | 3 + .../MxAccess/AlarmCommandExecutorTests.cs | 3 + .../MxAccess/AlarmCommandHandlerTests.cs | 3 + .../MxAccess/AlarmDispatcherTests.cs | 3 + .../MxAccess/FailoverAlarmConsumerTests.cs | 6 + .../MxAccess/MxAccessStaSessionTests.cs | 3 + .../MxAccess/WnWrapAlarmConsumerXmlTests.cs | 82 +++ .../MxAccess/AlarmCommandHandler.cs | 19 + .../MxAccess/AlarmDispatcher.cs | 22 +- .../MxAccess/FailoverAlarmConsumer.cs | 11 + .../MxAccess/IAlarmCommandHandler.cs | 10 + .../MxAccess/IMxAccessAlarmConsumer.cs | 14 + .../MxAccess/MxAccessCommandExecutor.cs | 4 + .../MxAccess/SubtagAlarmConsumer.cs | 10 + .../MxAccess/WnWrapAlarmConsumer.cs | 71 ++- 41 files changed, 2217 insertions(+), 309 deletions(-) create mode 100644 src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSessionAcl.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardSessionAcl.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmTruncationSignalTests.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Tests/Dashboard/AlarmsPageTruncationBannerTests.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSessionAclTests.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/EventsHubTests.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/SessionDetailsPageEventAclTests.cs 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. +
+} +
@@ -156,6 +167,7 @@ @code { private readonly List _alarms = []; private string? _queryError; + private bool _snapshotTruncated; private int? _workerPid; private DateTimeOffset? _lastRefresh; private int _unackedCount; @@ -386,6 +398,7 @@ { DashboardAlarmQueryResult result = await LiveData.QueryAlarmsAsync(_cts.Token); _queryError = result.Error; + _snapshotTruncated = result.SnapshotTruncated; _workerPid = result.WorkerProcessId; _lastRefresh = DateTimeOffset.UtcNow; _alarms.Clear(); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionDetailsPage.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionDetailsPage.razor index af26880..0546c68 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionDetailsPage.razor +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionDetailsPage.razor @@ -10,6 +10,7 @@ @inject AuthenticationStateProvider AuthenticationStateProvider @inject IDashboardSessionAdminService SessionAdminService @inject IDashboardSessionEventSubscriber EventSubscriber +@inject IDashboardSessionAcl SessionAcl Dashboard Session @@ -114,7 +115,11 @@ else @(_eventsConnected ? "live" : "offline") - @if (_recentEvents.Count == 0) + @if (!_eventsAuthorized) + { +
Not authorized for this session's events.
+ } + else if (_recentEvents.Count == 0) {
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 Items { get; } = new Dictionary(); + + /// + public override IFeatureCollection Features { get; } = new FeatureCollection(); + + /// + public override CancellationToken ConnectionAborted => CancellationToken.None; + + /// + public override void Abort() + { + // Nothing to abort in a unit-constructed context. + } + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/HubTokenServiceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/HubTokenServiceTests.cs index 502cd9d..43a390c 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/HubTokenServiceTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/HubTokenServiceTests.cs @@ -1,5 +1,7 @@ using System.Security.Claims; using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.MxGateway.Server.Configuration; using ZB.MOM.WW.MxGateway.Server.Dashboard; namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard; @@ -19,7 +21,7 @@ public sealed class HubTokenServiceTests [Fact] public void Validate_TokenWithNullNameAndNullNameIdentifier_ReturnsNull() { - HubTokenService service = new(new EphemeralDataProtectionProvider()); + HubTokenService service = CreateService(); // Issue from a principal with NO Name claim and NO NameIdentifier // claim. The Issue method's payload will then carry @@ -43,7 +45,7 @@ public sealed class HubTokenServiceTests [Fact] public void Validate_TokenWithName_ReturnsAuthenticatedPrincipal() { - HubTokenService service = new(new EphemeralDataProtectionProvider()); + HubTokenService service = CreateService(); ClaimsIdentity identity = new( [ @@ -72,7 +74,7 @@ public sealed class HubTokenServiceTests [Fact] public void Validate_TokenWithOnlyNameIdentifier_ReturnsPrincipal() { - HubTokenService service = new(new EphemeralDataProtectionProvider()); + HubTokenService service = CreateService(); ClaimsIdentity identity = new( [ @@ -93,7 +95,7 @@ public sealed class HubTokenServiceTests [Fact] public void Validate_NullToken_ReturnsNull() { - HubTokenService service = new(new EphemeralDataProtectionProvider()); + HubTokenService service = CreateService(); Assert.Null(service.Validate(null)); } @@ -102,7 +104,7 @@ public sealed class HubTokenServiceTests [Fact] public void Validate_EmptyToken_ReturnsNull() { - HubTokenService service = new(new EphemeralDataProtectionProvider()); + HubTokenService service = CreateService(); Assert.Null(service.Validate(string.Empty)); } @@ -111,7 +113,7 @@ public sealed class HubTokenServiceTests [Fact] public void Validate_GarbageToken_ReturnsNull() { - HubTokenService service = new(new EphemeralDataProtectionProvider()); + HubTokenService service = CreateService(); Assert.Null(service.Validate("this-is-not-a-protected-payload")); } @@ -123,7 +125,7 @@ public sealed class HubTokenServiceTests [Fact] public void IssueThenValidate_FreshToken_RoundTripsIdentityAndRoles() { - HubTokenService service = new(new EphemeralDataProtectionProvider()); + HubTokenService service = CreateService(); ClaimsIdentity identity = new( [ new Claim(ClaimTypes.Name, "bob"), @@ -163,7 +165,7 @@ public sealed class HubTokenServiceTests [Fact] public void Validate_ExpiredToken_ReturnsNull() { - HubTokenService service = new(new EphemeralDataProtectionProvider()); + HubTokenService service = CreateService(); ClaimsIdentity identity = new( [new Claim(ClaimTypes.Name, "carol")], authenticationType: "test"); @@ -174,4 +176,85 @@ public sealed class HubTokenServiceTests Assert.Null(service.Validate(expiredToken)); } + + /// + /// The dashboard visibility grant (SEC-25) survives the mint/validate round-trip: tags are + /// resolved from the caller's LDAP-group claims through Dashboard:GroupToTag at + /// and rehydrated as + /// claims on the principal + /// reconstructs — which is the principal + /// IDashboardSessionAcl reads on the hub path. + /// + [Fact] + public void IssueThenValidate_ResolvesAndRoundTripsGrantedTags() + { + HubTokenService service = CreateService(new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["GwViewer"] = ["team-a"], + ["TeamBViewers"] = ["team-b"], + }); + + ClaimsIdentity identity = new( + [ + new Claim(ClaimTypes.Name, "dana"), + new Claim(ClaimTypes.Role, DashboardRoles.Viewer), + new Claim(DashboardAuthenticationDefaults.LdapGroupClaimType, "GwViewer"), + new Claim(DashboardAuthenticationDefaults.LdapGroupClaimType, "TeamBViewers"), + ], + authenticationType: "test", + nameType: ClaimTypes.Name, + roleType: ClaimTypes.Role); + + ClaimsPrincipal? result = service.Validate(service.Issue(new ClaimsPrincipal(identity))); + + Assert.NotNull(result); + Assert.Equal( + ["team-a", "team-b"], + result.FindAll(DashboardAuthenticationDefaults.DashboardTagClaimType) + .Select(c => c.Value) + .Order(StringComparer.Ordinal)); + } + + /// + /// A caller whose groups map to nothing mints a token with no tags, and validating it yields a + /// principal carrying no tag claims — the empty grant the ACL denies tagged sessions on. This + /// is also the shape of every token minted before the tag field existed (the payload field + /// deserializes to null), so the fail-closed direction is covered for both. + /// + [Fact] + public void IssueThenValidate_WithNoMatchingGroups_ProducesEmptyGrant() + { + HubTokenService service = CreateService(new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["SomeOtherGroup"] = ["team-a"], + }); + + ClaimsIdentity identity = new( + [ + new Claim(ClaimTypes.Name, "erin"), + new Claim(ClaimTypes.Role, DashboardRoles.Viewer), + new Claim(DashboardAuthenticationDefaults.LdapGroupClaimType, "GwViewer"), + ], + authenticationType: "test", + nameType: ClaimTypes.Name, + roleType: ClaimTypes.Role); + + ClaimsPrincipal? result = service.Validate(service.Issue(new ClaimsPrincipal(identity))); + + Assert.NotNull(result); + Assert.Empty(result.FindAll(DashboardAuthenticationDefaults.DashboardTagClaimType)); + } + + private static HubTokenService CreateService(Dictionary? groupToTag = null) + { + GatewayOptions options = new() + { + Dashboard = new DashboardOptions + { + GroupToTag = groupToTag ?? new Dictionary(StringComparer.OrdinalIgnoreCase), + }, + }; + + return new HubTokenService(new EphemeralDataProtectionProvider(), Options.Create(options)); + } } diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/SessionDetailsPageEventAclTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/SessionDetailsPageEventAclTests.cs new file mode 100644 index 0000000..7b500ef --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/SessionDetailsPageEventAclTests.cs @@ -0,0 +1,218 @@ +using System.Runtime.CompilerServices; +using System.Security.Claims; +using System.Threading.Channels; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.Web.HtmlRendering; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Server.Dashboard; +using ZB.MOM.WW.MxGateway.Server.Dashboard.Components.Pages; +using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs; + +namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard; + +/// +/// Covers the ACL gate on the session-details page's in-process subscribe seam (SEC-25 / TST-15). +/// +/// +/// +/// The 2026-08 in-process feed refactor gave the dashboard a second way to subscribe to a +/// session's events — , used by this page — so +/// gating the hub join alone would leave the page as an ungated path to the same feed. The +/// denial assertion here is the one that proves the second seam is closed: it asserts that +/// is never called, not merely that the +/// panel renders differently. +/// +/// +/// Rendered through the framework's static , the same idiom +/// SecretsNavRenderTests uses — no component-testing package, because the assertions are +/// about the emitted markup and the calls the lifecycle makes, not about interactivity. +/// +/// +public sealed class SessionDetailsPageEventAclTests +{ + private const string SessionId = "session-1"; + // Matched without the trailing possessive so the assertion does not depend on how the + // renderer escapes the apostrophe. + private const string DeniedMessage = "Not authorized for this session"; + private const string WaitingMarker = "Waiting for events."; + + /// A denied caller gets the message and no subscription is opened. + /// A task that represents the asynchronous operation. + [Fact] + public async Task Page_WhenAclDenies_RendersMessageAndDoesNotSubscribe() + { + RecordingEventSubscriber subscriber = new(); + + string html = await RenderAsync(subscriber, allow: false); + + Assert.Contains(DeniedMessage, html, StringComparison.Ordinal); + Assert.DoesNotContain(WaitingMarker, html, StringComparison.Ordinal); + Assert.Empty(subscriber.SubscribedSessionIds); + } + + /// + /// The control for the denial above: an allowed caller subscribes and sees the ordinary + /// waiting state. Without this, a page that failed to render its events panel at all would + /// satisfy the "no subscription" assertion and the suite would report a working gate over a + /// broken panel. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task Page_WhenAclAllows_SubscribesAndRendersWaitingState() + { + RecordingEventSubscriber subscriber = new(); + + string html = await RenderAsync(subscriber, allow: true); + + Assert.Equal([SessionId], subscriber.SubscribedSessionIds); + Assert.Contains(WaitingMarker, html, StringComparison.Ordinal); + Assert.DoesNotContain(DeniedMessage, html, StringComparison.Ordinal); + } + + private static async Task RenderAsync(RecordingEventSubscriber subscriber, bool allow) + { + ServiceCollection services = new(); + services.AddLogging(); + services.AddSingleton(new StubSnapshotService()); + services.AddSingleton(new IdleSnapshotFeed()); + services.AddSingleton(new NonManagingSessionAdminService()); + services.AddSingleton(subscriber); + services.AddSingleton(new StubSessionAcl(allow)); + services.AddSingleton(new StubAuthenticationStateProvider()); + + 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( + ParameterView.FromDictionary(new Dictionary + { + [nameof(SessionDetailsPage.SessionId)] = SessionId, + })); + + return output.ToHtmlString(); + }); + } + + private sealed class StubSessionAcl(bool allow) : IDashboardSessionAcl + { + /// + public bool CanViewSession(ClaimsPrincipal? principal, string sessionId) => allow; + } + + private sealed class RecordingEventSubscriber : IDashboardSessionEventSubscriber + { + /// Gets the session ids was called with, in order. + public List SubscribedSessionIds { get; } = []; + + /// + public IDashboardEventSubscription Subscribe(string sessionId) + { + SubscribedSessionIds.Add(sessionId); + + return new IdleSubscription(); + } + + // A subscription whose channel never yields and never completes, so the page's pump parks + // exactly as it would against a quiet session. + private sealed class IdleSubscription : IDashboardEventSubscription + { + private readonly Channel _channel = Channel.CreateUnbounded(); + + /// + public ChannelReader Reader => _channel.Reader; + + /// + public void Dispose() => _channel.Writer.TryComplete(); + } + } + + private sealed class StubSnapshotService : IDashboardSnapshotService + { + /// + public DashboardSnapshot GetSnapshot() => new( + GeneratedAt: DateTimeOffset.UnixEpoch, + GatewayStartedAt: DateTimeOffset.UnixEpoch, + GatewayUptime: TimeSpan.Zero, + GatewayStatus: "Healthy", + GatewayVersion: "test", + Sessions: + [ + new DashboardSessionSummary( + SessionId: SessionId, + BackendName: "backend", + State: SessionState.Ready, + ClientIdentity: "client", + ClientSessionName: "client-session", + ClientCorrelationId: "correlation", + OpenedAt: DateTimeOffset.UnixEpoch, + LastClientActivityAt: DateTimeOffset.UnixEpoch, + LeaseExpiresAt: null, + WorkerProcessId: null, + WorkerState: null, + LastWorkerHeartbeatAt: null, + EventsReceived: 0, + LastFault: null), + ], + Workers: [], + Metrics: [], + Faults: [], + ApiKeys: [], + Configuration: null!, + Galaxy: null!); + + /// + public IAsyncEnumerable WatchSnapshotsAsync(CancellationToken cancellationToken) => + new IdleSnapshotFeed().WatchAsync(cancellationToken); + } + + // Parks until the page is disposed, so the base page's watch loop neither spins nor pushes a + // second snapshot mid-assertion. + private sealed class IdleSnapshotFeed : IDashboardSnapshotFeed + { + /// + public async IAsyncEnumerable WatchAsync( + [EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false); + + yield break; + } + } + + private sealed class NonManagingSessionAdminService : IDashboardSessionAdminService + { + /// + public bool CanManage(ClaimsPrincipal user) => false; + + /// + public Task CloseSessionAsync( + ClaimsPrincipal user, + string sessionId, + CancellationToken cancellationToken) => + Task.FromResult(DashboardSessionAdminResult.Fail("not supported")); + + /// + public Task KillWorkerAsync( + ClaimsPrincipal user, + string sessionId, + CancellationToken cancellationToken) => + Task.FromResult(DashboardSessionAdminResult.Fail("not supported")); + } + + private sealed class StubAuthenticationStateProvider : AuthenticationStateProvider + { + /// + public override Task GetAuthenticationStateAsync() => + Task.FromResult(new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity( + [new Claim(ClaimTypes.Name, "viewer-user"), new Claim(ClaimTypes.Role, DashboardRoles.Viewer)], + authenticationType: "test", + nameType: ClaimTypes.Name, + roleType: ClaimTypes.Role)))); + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/TestSupport/FakeGatewayAlarmService.cs b/src/ZB.MOM.WW.MxGateway.Tests/TestSupport/FakeGatewayAlarmService.cs index e0e1674..369b672 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/TestSupport/FakeGatewayAlarmService.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/TestSupport/FakeGatewayAlarmService.cs @@ -24,6 +24,9 @@ public sealed class FakeGatewayAlarmService : IGatewayAlarmService /// public IReadOnlyList CurrentAlarms { get; set; } = []; + /// + public bool SnapshotTruncated { get; set; } + /// public async IAsyncEnumerable StreamAsync( string? alarmFilterPrefix, diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandExecutorTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandExecutorTests.cs index bef898a..a2df0ef 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandExecutorTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandExecutorTests.cs @@ -371,6 +371,9 @@ public sealed class AlarmCommandExecutorTests /// Gets the last alarm filter prefix. public string? LastFilterPrefix { get; private set; } + /// Gets or sets the truncation verdict the executor stamps onto the reply payload. + public bool LastSnapshotTruncated { get; set; } + /// public void Subscribe(SubscribeAlarmsCommand command, string sessionId) { diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs index 5e95c0e..d44a07e 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs @@ -473,6 +473,9 @@ public sealed class AlarmCommandHandlerTests /// public IReadOnlyList SnapshotActiveAlarms() => SnapshotResult; + /// Gets or sets the truncation verdict reported for the last fetch. + public bool LastSnapshotTruncated { get; set; } + /// Gets the number of times polled. public int PollCount { get; private set; } diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs index 4106c79..7b0474a 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs @@ -438,6 +438,9 @@ public sealed class AlarmDispatcherTests return SnapshotResult; } + /// Gets or sets the truncation verdict the dispatcher stamps onto snapshots. + public bool LastSnapshotTruncated { get; set; } + /// Gets the count of poll operations. public int PollCount { get; private set; } diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/FailoverAlarmConsumerTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/FailoverAlarmConsumerTests.cs index 71f5f8d..3625ab9 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/FailoverAlarmConsumerTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/FailoverAlarmConsumerTests.cs @@ -80,6 +80,9 @@ public sealed class FailoverAlarmConsumerTests /// public IReadOnlyList SnapshotActiveAlarms() => Array.Empty(); + /// Gets or sets the truncation verdict this child reports, so delegation is observable. + public bool LastSnapshotTruncated { get; set; } + /// public void Dispose() { } @@ -132,6 +135,9 @@ public sealed class FailoverAlarmConsumerTests return Array.Empty(); } + /// Gets or sets the truncation verdict this child reports, so delegation is observable. + public bool LastSnapshotTruncated { get; set; } + /// public void Dispose() { } diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs index aace1d7..0e7fd07 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs @@ -643,6 +643,9 @@ public sealed class MxAccessStaSessionTests get { lock (gate) return lastPollThreadId; } } + /// Gets or sets the truncation verdict reported for the last fetch. + public bool LastSnapshotTruncated { get; set; } + /// public void Subscribe(SubscribeAlarmsCommand command, string sessionId) { diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs index 7dd9448..28c4293 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs @@ -688,6 +688,88 @@ public sealed class WnWrapAlarmConsumerXmlTests Assert.Equal(MxAlarmStateKind.UnackAlm, record.State); } + // ------------------------------------------------------------------------- + // Degraded-status signal. The truncation guard above keeps a capped fetch + // from broadcasting phantom Clears, but it does so silently: the retained + // snapshot simply stops shrinking. LastSnapshotTruncated is what makes that + // suppression visible to the QueryActiveAlarms reply and, through it, the + // dashboard banner — so its set/reset behaviour is the contract, not detail. + // ------------------------------------------------------------------------- + + /// + /// A capped fetch sets the retained truncation verdict. Without this the + /// signal never leaves the consumer and the reply builder stamps a + /// complete-looking snapshot over a capped one. + /// + [Fact] + public void FoldFetch_WhenFetchTruncated_SetsLastSnapshotTruncated() + { + const int Cap = 8; + using WnWrapAlarmConsumer consumer = new WnWrapAlarmConsumer(Cap); + + Assert.False(consumer.LastSnapshotTruncated); + + Dictionary next = + WnWrapAlarmConsumer.ParseSnapshotXml(BuildAlarmXml(Cap), out int fetchedRecordCount); + Assert.True(WnWrapAlarmConsumer.IsTruncatedFetch(fetchedRecordCount, Cap)); + + consumer.FoldFetch(next, truncated: true, out int retainedCount); + + Assert.True(consumer.LastSnapshotTruncated); + Assert.Equal(Cap, retainedCount); + } + + /// + /// THE reset test. A sub-cap fetch is complete, so it restores absence + /// authority and must clear the verdict. Latching it instead would leave + /// the operator banner asserting "snapshot may be incomplete" forever + /// after a single burst above the cap, which trains operators to ignore + /// it — the opposite of what the signal is for. + /// + [Fact] + public void FoldFetch_AfterTruncatedFetch_SubCapFetchClearsLastSnapshotTruncated() + { + const int Cap = 8; + using WnWrapAlarmConsumer consumer = new WnWrapAlarmConsumer(Cap); + + Dictionary capped = + WnWrapAlarmConsumer.ParseSnapshotXml(BuildAlarmXml(Cap), out _); + consumer.FoldFetch(capped, truncated: true, out _); + Assert.True(consumer.LastSnapshotTruncated); + + Dictionary complete = + WnWrapAlarmConsumer.ParseSnapshotXml(BuildAlarmXml(Cap - 1), out int fetchedRecordCount); + Assert.False(WnWrapAlarmConsumer.IsTruncatedFetch(fetchedRecordCount, Cap)); + + consumer.FoldFetch(complete, truncated: false, out int retainedCount); + + Assert.False(consumer.LastSnapshotTruncated); + // The complete fetch also replaced the snapshot wholesale, which is what + // makes it authoritative about absence — pinned here so a future change + // cannot clear the verdict while keeping the merge semantics. + Assert.Equal(Cap - 1, retainedCount); + } + + /// + /// Consecutive capped fetches keep the verdict set. It is per-fetch state, + /// not an edge-triggered one-shot: an operator arriving mid-burst must + /// still see the caveat. + /// + [Fact] + public void FoldFetch_WithConsecutiveTruncatedFetches_KeepsLastSnapshotTruncatedSet() + { + const int Cap = 8; + using WnWrapAlarmConsumer consumer = new WnWrapAlarmConsumer(Cap); + + for (int pass = 0; pass < 3; pass++) + { + Dictionary capped = + WnWrapAlarmConsumer.ParseSnapshotXml(BuildAlarmXml(Cap), out _); + consumer.FoldFetch(capped, truncated: true, out _); + Assert.True(consumer.LastSnapshotTruncated); + } + } + private static MxAlarmSnapshotRecord NewRecord(Guid guid, MxAlarmStateKind state) { return new MxAlarmSnapshotRecord diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs index de60fce..534b9fa 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs @@ -342,6 +342,25 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler return filtered; } + /// + /// + /// Deliberately does not go through GetDispatcherOrThrow: an + /// unsubscribed handler has performed no fetch, and "no fetch" is not + /// truncated. Throwing here would turn a status read into a command + /// failure on a path the reply builder takes after the snapshot has + /// already been produced. + /// + public bool LastSnapshotTruncated + { + get + { + if (disposed) return false; + AlarmDispatcher? d; + lock (syncRoot) d = dispatcher; + return d is not null && d.LastSnapshotTruncated; + } + } + /// public void PollOnce() { diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs index accc2da..c5906c9 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs @@ -158,16 +158,28 @@ public sealed class AlarmDispatcher : IDisposable public IReadOnlyList SnapshotActiveAlarms() { if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher)); + // Read the truncation verdict before the snapshot, so a poll landing + // between the two can only widen the warning (a stale "truncated" over a + // complete snapshot), never narrow it into a false all-clear. + bool truncated = consumer.LastSnapshotTruncated; IReadOnlyList records = consumer.SnapshotActiveAlarms(); if (records.Count == 0) return Array.Empty(); List snapshots = new List(records.Count); foreach (MxAlarmSnapshotRecord record in records) { - snapshots.Add(MapToSnapshot(record)); + snapshots.Add(MapToSnapshot(record, truncated)); } return snapshots; } + /// + /// Whether the consumer's most recent fetch hit the per-fetch cap, so + /// the set returns may omit actives. + /// Stamped onto the QueryActiveAlarms reply payload, which is the only + /// carrier when the snapshot filters down to zero records. + /// + public bool LastSnapshotTruncated => !disposed && consumer.LastSnapshotTruncated; + private void OnTransition(object? sender, MxAlarmTransitionEvent transition) { if (disposed) return; @@ -196,7 +208,7 @@ public sealed class AlarmDispatcher : IDisposable degraded: record.Degraded); } - private static ActiveAlarmSnapshot MapToSnapshot(MxAlarmSnapshotRecord record) + private static ActiveAlarmSnapshot MapToSnapshot(MxAlarmSnapshotRecord record, bool truncated) { ActiveAlarmSnapshot snapshot = new ActiveAlarmSnapshot { @@ -212,6 +224,12 @@ public sealed class AlarmDispatcher : IDisposable Description = string.Empty, Degraded = record.Degraded, SourceProvider = record.Degraded ? AlarmProviderMode.Subtag : AlarmProviderMode.Alarmmgr, + // Set-level status, stamped identically on every record of the + // snapshot: QueryActiveAlarms streams bare ActiveAlarmSnapshot + // messages with no envelope to hang it off. Independent of + // Degraded above — that is about this record's provider, this is + // about whether the set it belongs to is complete. + FromTruncatedSnapshot = truncated, }; if (record.TransitionTimestampUtc != DateTime.MinValue) { diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/FailoverAlarmConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/FailoverAlarmConsumer.cs index 89896d2..9c69645 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/FailoverAlarmConsumer.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/FailoverAlarmConsumer.cs @@ -266,6 +266,17 @@ public sealed class FailoverAlarmConsumer : IMxAccessAlarmConsumer return ActiveChild.SnapshotActiveAlarms(); } + /// + /// + /// Delegated to the active child, matching + /// : the flag describes the snapshot + /// the same child produced, so reading it off the standby would pair a + /// verdict with a snapshot it does not belong to. A failover to the + /// subtag standby therefore reports not-truncated — correctly, since + /// that child performs no capped fetch. + /// + public bool LastSnapshotTruncated => !disposed && ActiveChild.LastSnapshotTruncated; + private IMxAccessAlarmConsumer ActiveChild => active == Active.Primary ? primary : standby; /// diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmCommandHandler.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmCommandHandler.cs index fa97078..bd8b6a9 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmCommandHandler.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmCommandHandler.cs @@ -74,6 +74,16 @@ public interface IAlarmCommandHandler : IDisposable /// The currently active alarms matching the filter. IReadOnlyList QueryActive(string? alarmFilterPrefix); + /// + /// Whether the consumer's most recent fetch hit the per-fetch cap, so + /// the set draws from may omit active alarms. + /// Stamped on the QueryActiveAlarms reply payload — the only carrier + /// once a prefix filter (or an empty galaxy) leaves zero records to + /// carry the per-record flag. when there is no + /// active subscription: no fetch has happened, so nothing is capped. + /// + bool LastSnapshotTruncated { get; } + /// /// Drives a single poll of the underlying alarm consumer on the /// caller's thread. This is a no-op when there is no active diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs index af7d1be..71f30e8 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs @@ -36,6 +36,20 @@ public interface IMxAccessAlarmConsumer : IDisposable /// event EventHandler? AlarmTransitionEmitted; + /// + /// Whether the most recent fetch that reached the retained snapshot came + /// back holding the per-fetch cap. While this is + /// the snapshot returned by is + /// authoritative about presence only: the provider may hold actives it + /// had no room to report, and the consumer has suspended the + /// absence-implies-Clear inference. Not latched — the first sub-cap fetch + /// after a run of capped ones clears it, because that fetch is complete + /// and the snapshot it produced is again authoritative about absence. + /// Consumers with no per-fetch cap (the subtag fallback, which is + /// event-driven) always report . + /// + bool LastSnapshotTruncated { get; } + /// /// Initializes the AVEVA alarm-client connection, registers as a /// consumer, and subscribes to the supplied alarm-provider expression. diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs index 15019f4..f1ada4b 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs @@ -977,6 +977,10 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor command.Command.QueryActiveAlarmsCommand.AlarmFilterPrefix); QueryActiveAlarmsReplyPayload payload = new QueryActiveAlarmsReplyPayload(); payload.Snapshots.AddRange(snapshots); + // Set-level degraded status: the snapshot may omit actives because + // the provider fetch hit its cap. The records carry the same flag, + // but a prefix filter can leave none, so the payload states it too. + payload.SnapshotTruncated = alarmCommandHandler.LastSnapshotTruncated; MxCommandReply reply = CreateOkReply(command); reply.QueryActiveAlarms = payload; return reply; diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmConsumer.cs index c02b519..754a36d 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmConsumer.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmConsumer.cs @@ -45,6 +45,16 @@ public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer /// Fires once per synthesized alarm-state transition. public event EventHandler? AlarmTransitionEmitted; + /// + /// + /// Always . Subtag mode is advise-driven over a + /// fixed watch list — there is no bulk fetch and therefore no per-fetch + /// cap to hit. Subtag snapshots are lower-fidelity in other ways, which + /// MxAlarmSnapshotRecord.Degraded already reports; truncation is + /// not one of them. + /// + public bool LastSnapshotTruncated => false; + /// /// Initializes the consumer over a subtag source and a watch list of /// alarm targets. diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs index 041e901..0f07764 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs @@ -93,6 +93,7 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer private long lastTruncationWarningMilliseconds = -TruncationWarningIntervalMilliseconds; private long truncatedFetchCount; + private bool lastSnapshotTruncated; private wwAlarmConsumerClass? client; private wwAlarmConsumerClass? ackClient; private bool subscribed; @@ -125,6 +126,23 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer : DefaultMaxAlarmsPerFetch; } + /// + /// COM-free construction, for exercising the retained-snapshot state + /// machine ( / + /// / ) on a machine without AVEVA + /// installed. throws and + /// no-ops on an instance built this way — both need the wnwrap coclass, + /// which cannot be instantiated on the macOS/Linux test matrix. Internal + /// rather than public so it cannot be reached from production wiring. + /// + /// Maximum alarms per fetch call. + internal WnWrapAlarmConsumer(int maxAlarmsPerFetch) + { + this.maxAlarmsPerFetch = maxAlarmsPerFetch > 0 + ? maxAlarmsPerFetch + : DefaultMaxAlarmsPerFetch; + } + /// /// Resolves the per-fetch cap from the launcher-provided environment /// variable. A missing, unparseable, or out-of-range value falls back @@ -373,6 +391,18 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer } } + /// + /// + /// Read without the disposed guard + /// carries: this is degraded-status metadata a reply builder stamps + /// alongside a snapshot, and throwing from it would fail a query whose + /// snapshot half succeeded. + /// + public bool LastSnapshotTruncated + { + get { lock (syncRoot) { return lastSnapshotTruncated; } } + } + /// /// Sink for the rate-limited truncated-fetch warning. Defaults to /// , the stream @@ -413,14 +443,8 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer // docs/AlarmProbeFindings.md.) bool truncated = IsTruncatedFetch(fetchedRecordCount, maxAlarmsPerFetch); - IReadOnlyList transitions; - int retainedCount; - lock (syncRoot) - { - transitions = ComputeTransitions(latestSnapshot, next); - ApplySnapshotUpdate(latestSnapshot, next, truncated); - retainedCount = latestSnapshot.Count; - } + IReadOnlyList transitions = + FoldFetch(next, truncated, out int retainedCount); if (truncated) { @@ -436,6 +460,37 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer } } + /// + /// Folds one fetch into the retained state under a single lock: the + /// transition diff, the snapshot merge/replace, and the truncation + /// verdict move together. Splitting them would let a concurrent + /// / + /// pair read a capped snapshot alongside the previous poll's "complete" + /// verdict — precisely the false all-clear the signal exists to prevent. + /// The verdict is replaced, never latched: a sub-cap fetch is complete + /// and restores absence authority, so leaving the flag set would strand + /// the operator banner on after a single burst. + /// + /// The snapshot just parsed from the fetch. + /// Whether the fetch hit the per-fetch cap. + /// Size of the retained snapshot after the fold. + /// The transitions the fetch implies. + internal IReadOnlyList FoldFetch( + Dictionary next, + bool truncated, + out int retainedCount) + { + lock (syncRoot) + { + IReadOnlyList transitions = + ComputeTransitions(latestSnapshot, next); + ApplySnapshotUpdate(latestSnapshot, next, truncated); + lastSnapshotTruncated = truncated; + retainedCount = latestSnapshot.Count; + return transitions; + } + } + /// /// Decides whether a fetch that came back holding /// records hit the cap. From 7ec0b3594c7d938bff50f0b557c71b3729355e7d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 17 Aug 2026 04:35:59 -0400 Subject: [PATCH 09/17] fix(dashboard): close AttachEventsAsync re-entrancy window; pin ACL decision-table corners (SEC-25 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the per-session event ACL. Part of that change rode into 693a78d via a concurrent agent's pathspec-less commit; this commit carries the review fixes and uses pathspecs on the commit itself so it cannot recur in either direction. Gating the page's subscribe seam made AttachEvents asynchronous — it awaits the authentication state — and that await is a suspension point the synchronous version did not have. On a rapid A -> B navigation the suspended A continuation resumes after B's parameter set has run to completion, re-reads the live SessionId (now B's), and attaches B a SECOND time. The ACL is not bypassed — the newer attach already cleared that same session — but the fields holding B's first subscription are overwritten in place, so nothing ever disposes it: its EventsHubViewerRegistry entry is never released, which keeps the mirror cloning events for a session the page is no longer watching through that handle, and its pump is never cancelled. A resource leak the ACL work introduced. OnParametersSetAsync now claims a monotonic _attachGeneration synchronously, before its first await, and AttachEventsAsync re-checks it after the await and before any field write or Subscribe call. A stale attach returns rather than detaching: it owns nothing, and tearing down there would destroy the newer attach's subscription. DetachEventsAsync needs no such guard — it captures and nulls the live fields synchronously before it awaits, so a resumed detach only unwinds what it already took ownership of. Same dispatcher-owned identity idea as the existing ReferenceEquals guards in PumpEventsAsync and MarkDisconnectedAsync, one level up. The interleaving is not expressible with the static HtmlRenderer idiom the other page tests use: it renders a root component once and exposes no parameter-update seam. The new test therefore adds a minimal Renderer subclass whose only job is to mount a component and drive a second SetParametersAsync into it while the first is parked on a gated AuthenticationStateProvider. That subclass is the lone reason for a narrowly scoped BL0006 suppression, justified in place: it is test-only scaffolding that never ships, and the cost of the warning coming true is a compile break in one test file on an SDK bump. Confirmed non-vacuous by mutation — with the generation check disabled the test goes red on the doubled subscription and the two passing ACL tests stay green. Two decision-table corners are now pinned rather than implied. Admin x nonexistent session id resolves to ALLOW, because the admin bypass is evaluated before the registry lookup; a plausible "look the session up first, it reads better" refactor would flip it, so a test documents the ordering. EventsHub's remarks said "an unknown session id is denied" without qualification, which read as universal; they now state that the bypass is checked first and every rule below it is a non-Admin rule. HubTokenServiceTests gains the truly-absent-field case: a hand-built payload JSON with no Tags key at all, protected through the same purpose, which is the shape every in-flight token has across the deploy that introduces the field. The existing test covered present-but-empty, which does not exercise the null coalesce that stands between a legacy token and a crash on the hub auth path. ProtectorPurpose became internal so the test cannot drift from the real purpose string. Tag-count cardinality cap considered and recorded as a deliberate non-goal. Build 0 warnings / 0 errors; 48 filtered (ACL/hub/token/page) and 257 dashboard tests pass. --- .../2026-07-10-dashboard-session-acl-tst15.md | 17 ++ .../Components/Pages/SessionDetailsPage.razor | 33 ++- .../Dashboard/HubTokenService.cs | 6 +- .../Dashboard/Hubs/EventsHub.cs | 11 +- .../Dashboard/DashboardSessionAclTests.cs | 20 +- .../Gateway/Dashboard/HubTokenServiceTests.cs | 38 +++- .../SessionDetailsPageEventAclTests.cs | 190 +++++++++++++++++- 7 files changed, 299 insertions(+), 16 deletions(-) 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 fc7d09b..ceff06c 100644 --- a/docs/plans/2026-07-10-dashboard-session-acl-tst15.md +++ b/docs/plans/2026-07-10-dashboard-session-acl-tst15.md @@ -349,3 +349,20 @@ is checked first. identity does not bypass. - Tag *values* are never logged at either seam; only the identifiers and the allow/deny outcome are observable. +- **The page gate needed a re-entrancy guard.** Making `AttachEvents` async (it now + awaits the authentication state) introduced a suspension point the synchronous + version did not have, and with it a window: on a rapid A → B navigation the + suspended A continuation resumes, re-reads the live `SessionId` — now B's — and + attaches B a second time, overwriting the fields that hold B's first subscription. + That subscription is then unreachable: never disposed, its `EventsHubViewerRegistry` + entry never released (so the mirror keeps cloning events for it), its pump never + cancelled. Not an ACL bypass — the newer attach had already cleared the same session + — but a resource leak the ACL work created. `OnParametersSetAsync` now claims a + monotonic `_attachGeneration` synchronously before its first await, and + `AttachEventsAsync` re-checks it after the await and before any field write or + `Subscribe`; a stale attach returns without attaching (it owns nothing, and tearing + down would destroy the newer attach's subscription). `DetachEventsAsync` needs no + guard: it captures and nulls the live fields synchronously before it awaits. +- The admin-bypass-before-lookup ordering means an Administrator naming a session id + the registry does not have is **allowed**, not denied. Deliberate, and pinned by a + test so a "look the session up first" refactor cannot flip it silently. diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionDetailsPage.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionDetailsPage.razor index 0546c68..7e3c2d2 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionDetailsPage.razor +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionDetailsPage.razor @@ -185,6 +185,16 @@ else // AttachEventsAsync is the only writer, and it writes on the renderer's dispatcher. private bool _eventsAuthorized = true; private string? _subscribedSessionId; + // Identifies the attach currently entitled to publish subscription state. Bumped + // synchronously by OnParametersSetAsync before it awaits anything, so a suspended + // AttachEventsAsync continuation can tell that a newer parameter set overtook it — the + // same dispatcher-owned identity idea as the ReferenceEquals guards in PumpEventsAsync + // and MarkDisconnectedAsync, one level up. Without it, the await on the authentication + // state opens a window in which a rapid A -> B navigation lets the stale continuation + // re-read the live SessionId and attach B a second time, orphaning B's first + // subscription (never disposed, its viewer registration never released, its pump never + // cancelled) behind the fields it overwrites. + private int _attachGeneration; private readonly LinkedList _recentEvents = new(); private bool CanManage { get; set; } @@ -208,11 +218,17 @@ else { if (!string.Equals(_subscribedSessionId, SessionId, StringComparison.Ordinal)) { + // Claimed before the first await, so every attach that follows carries a token + // that a later parameter set can invalidate. DetachEventsAsync needs no such + // guard: it captures and nulls the live fields synchronously before it awaits, + // so a resumed detach only unwinds what it already took ownership of. + int generation = ++_attachGeneration; + // Deliberately no ConfigureAwait(false): the resumption must stay on the // renderer's dispatcher so the new subscription is published to // _eventSubscription from the same thread the pump's guard reads it on. await DetachEventsAsync(); - await AttachEventsAsync(); + await AttachEventsAsync(generation); } } @@ -302,7 +318,7 @@ else // 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() + private async Task AttachEventsAsync(int generation) { if (string.IsNullOrWhiteSpace(SessionId)) { @@ -313,6 +329,19 @@ else // land back on the renderer's dispatcher, which is where the fields below are owned. AuthenticationState authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync(); + // Checked before ANY field write and before Subscribe, because both are the damage: a + // newer parameter set may have run start-to-finish while this continuation was parked, + // and SessionId now reads as ITS session. Attaching here would not bypass the ACL (the + // newer attach already cleared the same session), but it would strand the live + // subscription — overwritten in place, so nothing ever disposes it or releases its + // viewer registration, and the mirror stays on for a session nobody is watching. A + // stale attach owns nothing, so it returns rather than detaching: tearing down here + // would destroy the newer attach's subscription. + if (generation != _attachGeneration) + { + return; + } + _subscribedSessionId = SessionId; _eventsAuthorized = SessionAcl.CanViewSession(authenticationState.User, SessionId); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/HubTokenService.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/HubTokenService.cs index 5985374..d9b16b5 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/HubTokenService.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/HubTokenService.cs @@ -29,7 +29,11 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard; /// public sealed class HubTokenService { - private const string ProtectorPurpose = "ZB.MOM.WW.MxGateway.Dashboard.HubToken.v1"; + // Internal rather than private so a test can protect a hand-built payload through the same + // purpose and assert how Validate reads a payload shape this class no longer mints (a token + // predating the Tags field). Copying the literal into the test instead would let the two + // drift and silently turn that test into an assertion about an unrelated protector. + internal const string ProtectorPurpose = "ZB.MOM.WW.MxGateway.Dashboard.HubToken.v1"; // 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 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 601fabc..700a279 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/EventsHub.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/EventsHub.cs @@ -40,9 +40,14 @@ public sealed class EventsHub( /// 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 + /// (SEC-25 / TST-15). The admin bypass is evaluated first, so an + /// Administrator joins any session id they name; every check below it + /// applies to non-Admin callers only. For those: a Viewer sees a session + /// only when its tags intersect their granted tags, an untagged session + /// follows Dashboard:UntaggedSessionVisibility, and a session id the + /// registry does not have is denied outright — the phantom-id denial is + /// therefore a non-Admin rule, not a universal one. + /// 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 diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSessionAclTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSessionAclTests.cs index ff8ddba..91411d8 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSessionAclTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSessionAclTests.cs @@ -47,8 +47,24 @@ public sealed class DashboardSessionAclTests } /// - /// 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. + /// The decision table's order is load-bearing at exactly one corner: an Administrator naming + /// a session id the registry does not have is ALLOWED, because the admin bypass is checked + /// before the lookup. Pinned deliberately — reordering the two checks (a plausible "look the + /// session up first, it reads better" refactor) would flip this to a denial and quietly change + /// what an Administrator's hub join does for a session that closed a moment ago. + /// + [Fact] + public void CanViewSession_AdministratorAndUnknownSession_Allowed() + { + DashboardSessionAcl acl = CreateAcl(); + + Assert.True(acl.CanViewSession(Principal(roles: [DashboardRoles.Admin]), "session-does-not-exist")); + } + + /// + /// An unknown session id is denied for a non-Admin even when they hold every configured tag: + /// no subscription is created for a session the registry does not have. The Administrator + /// counterpart above is the deliberate exception. /// [Fact] public void CanViewSession_UnknownSession_Denied() diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/HubTokenServiceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/HubTokenServiceTests.cs index 43a390c..7846fd3 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/HubTokenServiceTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/HubTokenServiceTests.cs @@ -245,7 +245,39 @@ public sealed class HubTokenServiceTests Assert.Empty(result.FindAll(DashboardAuthenticationDefaults.DashboardTagClaimType)); } - private static HubTokenService CreateService(Dictionary? groupToTag = null) + /// + /// A token minted before the payload carried tags at all still validates, and yields an empty + /// grant rather than throwing or rejecting. Distinct from the empty-grant test above, which + /// exercises a Tags key that is present and empty: this one protects a hand-built + /// payload with the key genuinely ABSENT, which is the shape every in-flight token has across + /// the deploy that introduces the field. Deserialization leaves the field null, and the + /// null-coalesce in Validate is the only thing standing between that and a crash on + /// the hub's authentication path. + /// + [Fact] + public void Validate_TokenMintedBeforeTagsFieldExisted_YieldsEmptyGrant() + { + EphemeralDataProtectionProvider dataProtection = new(); + HubTokenService service = CreateService(dataProtection: dataProtection); + + // The pre-field payload shape, verbatim: no "Tags" key anywhere. + const string LegacyPayload = """{"Name":"frank","NameIdentifier":"frank-id","Roles":["Viewer"]}"""; + string legacyToken = dataProtection + .CreateProtector(HubTokenService.ProtectorPurpose) + .ToTimeLimitedDataProtector() + .Protect(LegacyPayload, HubTokenService.TokenLifetime); + + ClaimsPrincipal? result = service.Validate(legacyToken); + + Assert.NotNull(result); + Assert.Equal("frank", result.Identity?.Name); + Assert.True(result.IsInRole(DashboardRoles.Viewer)); + Assert.Empty(result.FindAll(DashboardAuthenticationDefaults.DashboardTagClaimType)); + } + + private static HubTokenService CreateService( + Dictionary? groupToTag = null, + IDataProtectionProvider? dataProtection = null) { GatewayOptions options = new() { @@ -255,6 +287,8 @@ public sealed class HubTokenServiceTests }, }; - return new HubTokenService(new EphemeralDataProtectionProvider(), Options.Create(options)); + return new HubTokenService( + dataProtection ?? new EphemeralDataProtectionProvider(), + Options.Create(options)); } } diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/SessionDetailsPageEventAclTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/SessionDetailsPageEventAclTests.cs index 7b500ef..1480d26 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/SessionDetailsPageEventAclTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/SessionDetailsPageEventAclTests.cs @@ -3,6 +3,7 @@ using System.Security.Claims; using System.Threading.Channels; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Components.RenderTree; using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.Web.HtmlRendering; using Microsoft.Extensions.DependencyInjection; @@ -73,7 +74,72 @@ public sealed class SessionDetailsPageEventAclTests Assert.DoesNotContain(DeniedMessage, html, StringComparison.Ordinal); } - private static async Task RenderAsync(RecordingEventSubscriber subscriber, bool allow) + /// + /// The re-entrancy guard on AttachEventsAsync: a rapid A -> B navigation must leave + /// exactly one live subscription, not two. + /// + /// + /// + /// Gating the ACL made attach asynchronous — it awaits the authentication state — and that + /// await is a suspension point the synchronous version did not have. The interleaving this + /// test forces is the one that window admits: A's attach parks on the auth state, B's whole + /// parameter set runs to completion behind it, and only then does A resume. A now reads + /// SessionId as B's and, ungurarded, subscribes to B a SECOND time — overwriting the + /// fields holding B's first subscription, which is then unreachable: never disposed, its + /// EventsHubViewerRegistry entry never released (so the mirror keeps cloning events + /// for it), its pump never cancelled. + /// + /// + /// The assertion is deliberately about subscription COUNT and disposal rather than about the + /// ACL: the guard is a resource-lifecycle fix, and the second attach was never an + /// authorization bypass — B had already been cleared by the newer attach. + /// + /// + /// This case needs a renderer that can re-set parameters on the SAME component instance, which + /// the static used by the tests above cannot do — it renders a root + /// component once and exposes no parameter-update seam. Hence the minimal + /// below, which is the smallest thing that can express + /// a second SetParametersAsync while the first is still suspended. + /// + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task Page_WhenNavigationOvertakesASuspendedAttach_LeavesOneSubscription() + { + RecordingEventSubscriber subscriber = new(); + // Call 1 is OnInitializedAsync's CanManage lookup; call 2 is the first session's attach, + // which is the one that must be caught mid-flight. + GatedAuthenticationStateProvider auth = new(gateOnCall: 2); + + ServiceCollection services = BuildServices(subscriber, allow: true, authenticationStateProvider: auth); + await using ServiceProvider provider = services.BuildServiceProvider(); + await using ParameterDrivingRenderer renderer = new(provider, provider.GetRequiredService()); + + SessionDetailsPage page = await renderer.MountAsync(); + + // Not awaited: it parks inside the first attach, which is the whole point. + Task first = renderer.SetParametersAsync(page, "session-a"); + await auth.Entered.WaitAsync(TestTimeout); + + // The overtaking navigation completes end to end while the first attach is suspended. + await renderer.SetParametersAsync(page, "session-b").WaitAsync(TestTimeout); + + auth.Release(); + await first.WaitAsync(TestTimeout); + + Assert.Empty(renderer.Exceptions); + // Without the generation guard this is ["session-b", "session-b"] and the first of the two + // is stranded — the exact leak the guard exists to prevent. + Assert.Equal(["session-b"], subscriber.SubscribedSessionIds); + Assert.Empty(subscriber.UndisposedAfterReplacement); + } + + private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(30); + + private static ServiceCollection BuildServices( + RecordingEventSubscriber subscriber, + bool allow, + AuthenticationStateProvider? authenticationStateProvider = null) { ServiceCollection services = new(); services.AddLogging(); @@ -82,9 +148,15 @@ public sealed class SessionDetailsPageEventAclTests services.AddSingleton(new NonManagingSessionAdminService()); services.AddSingleton(subscriber); services.AddSingleton(new StubSessionAcl(allow)); - services.AddSingleton(new StubAuthenticationStateProvider()); + services.AddSingleton( + authenticationStateProvider ?? new StubAuthenticationStateProvider()); - await using ServiceProvider provider = services.BuildServiceProvider(); + return services; + } + + private static async Task RenderAsync(RecordingEventSubscriber subscriber, bool allow) + { + await using ServiceProvider provider = BuildServices(subscriber, allow).BuildServiceProvider(); await using HtmlRenderer renderer = new(provider, provider.GetRequiredService()); return await renderer.Dispatcher.InvokeAsync(async () => @@ -107,31 +179,137 @@ public sealed class SessionDetailsPageEventAclTests private sealed class RecordingEventSubscriber : IDashboardSessionEventSubscriber { + private readonly List _handedOut = []; + /// Gets the session ids was called with, in order. public List SubscribedSessionIds { get; } = []; + /// + /// Gets the subscriptions that were superseded by a later one and never disposed — the + /// signature of a stranded subscription, whose viewer registration is never released. The + /// most recent subscription is excluded because the page legitimately still holds it. + /// + public IReadOnlyList UndisposedAfterReplacement => + [.. _handedOut.SkipLast(1).Where(subscription => !subscription.IsDisposed)]; + /// public IDashboardEventSubscription Subscribe(string sessionId) { SubscribedSessionIds.Add(sessionId); + IdleSubscription subscription = new(); + _handedOut.Add(subscription); - return new IdleSubscription(); + return subscription; } // A subscription whose channel never yields and never completes, so the page's pump parks // exactly as it would against a quiet session. - private sealed class IdleSubscription : IDashboardEventSubscription + internal sealed class IdleSubscription : IDashboardEventSubscription { private readonly Channel _channel = Channel.CreateUnbounded(); + /// Gets a value indicating whether the page released this subscription. + public bool IsDisposed { get; private set; } + /// public ChannelReader Reader => _channel.Reader; /// - public void Dispose() => _channel.Writer.TryComplete(); + public void Dispose() + { + IsDisposed = true; + _channel.Writer.TryComplete(); + } } } + // Gates one nominated call so a test can suspend an attach exactly where the ACL check made it + // asynchronous, and let a second parameter set overtake it. + private sealed class GatedAuthenticationStateProvider(int gateOnCall) : AuthenticationStateProvider + { + private readonly TaskCompletionSource _entered = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _calls; + + /// Completes once the gated call has been entered and is parked. + public Task Entered => _entered.Task; + + /// Lets the parked call finish. + public void Release() => _release.TrySetResult(); + + /// + public override async Task GetAuthenticationStateAsync() + { + if (Interlocked.Increment(ref _calls) == gateOnCall) + { + _entered.TrySetResult(); + await _release.Task.ConfigureAwait(false); + } + + return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity( + [new Claim(ClaimTypes.Name, "viewer-user"), new Claim(ClaimTypes.Role, DashboardRoles.Viewer)], + authenticationType: "test", + nameType: ClaimTypes.Name, + roleType: ClaimTypes.Role))); + } + } + + // The smallest renderer that can drive a SECOND parameter set into an already-mounted + // component instance. HtmlRenderer renders a root component once and offers no such seam, so + // the interleaving under test is inexpressible with it; everything here is plumbing around + // Renderer's protected mount/parameter surface, with no behaviour of its own. + // + // BL0006 warns that Microsoft.AspNetCore.Components.RenderTree is not for use outside the + // Blazor framework because those types may change between releases. Suppressed here and only + // here: this is test-only scaffolding (the same thing component-testing packages do), it never + // ships, and the cost of the warning coming true is a compile break in one test file on an SDK + // bump — not a production defect. Production code must keep honouring BL0006. +#pragma warning disable BL0006 + private sealed class ParameterDrivingRenderer(IServiceProvider services, ILoggerFactory loggerFactory) + : Renderer(services, loggerFactory) + { + /// Gets exceptions the renderer surfaced, so a test never passes over a swallowed fault. + public List Exceptions { get; } = []; + + /// + public override Dispatcher Dispatcher { get; } = Dispatcher.CreateDefault(); + + /// Instantiates the component with DI-injected properties and attaches it as a root. + /// Component type to mount. + /// The mounted component instance. + public Task MountAsync() + where TComponent : IComponent + { + return Dispatcher.InvokeAsync(() => + { + TComponent component = (TComponent)InstantiateComponent(typeof(TComponent)); + AssignRootComponentId(component); + + return component; + }); + } + + /// Sets the session-id parameter on an already-mounted page. + /// The mounted page. + /// Session id to render. + /// The task the component's parameter-set lifecycle returns. + public Task SetParametersAsync(IComponent component, string sessionId) + { + return Dispatcher.InvokeAsync(() => component.SetParametersAsync( + ParameterView.FromDictionary(new Dictionary + { + [nameof(SessionDetailsPage.SessionId)] = sessionId, + }))); + } + + /// + protected override void HandleException(Exception exception) => Exceptions.Add(exception); + + /// + protected override Task UpdateDisplayAsync(in RenderBatch renderBatch) => Task.CompletedTask; + } +#pragma warning restore BL0006 + private sealed class StubSnapshotService : IDashboardSnapshotService { /// From b9fb0dd720b500f3cd9c1514ac37a485ad642469 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 17 Aug 2026 04:39:33 -0400 Subject: [PATCH 10/17] fix(alarms): atomic snapshot+truncation read; direct tests for the flag plumbing (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found AlarmDispatcher.SnapshotActiveAlarms reading the snapshot and the truncation verdict through two independent lock acquisitions, defended by a comment claiming read-order made a race "widen only, never narrow". That claim was false: a not-truncated -> truncated poll landing between the two reads pairs a stale false with a capped snapshot, which is exactly the false all-clear the feature exists to prevent. It was safe only because AlarmCommandHandler STA-serializes consumer calls — an accident of the call graph, not an invariant. Made the invariant structural instead of documented. IMxAccessAlarmConsumer now exposes ONE accessor, `IReadOnlyList SnapshotActiveAlarms( out bool truncated)`, which implementations must satisfy from a single acquisition of the lock guarding the retained snapshot — mirroring the write side, where FoldFetch already updates snapshot and verdict together. The separate LastSnapshotTruncated property is gone from every layer, so there is no second read left to pair badly. `out` over a result struct follows the file's established idiom (FoldFetch, ParseSnapshotXml). The same threading applies one level up: IAlarmCommandHandler.QueryActive now carries `out bool snapshotTruncated`, so MxAccessCommandExecutor stamps the reply payload from the value the records were stamped with rather than reading the state a second time. Direct tests for the three hops that were only covered end-to-end: - AlarmDispatcherTests: truncated consumer snapshot stamps FromTruncatedSnapshot on every mapped record, with a complete-snapshot control, plus an assertion that the independent per-record Degraded flag is not dragged along. - AlarmCommandHandlerTests: the verdict delegates through the dispatcher (Theory over both values), and survives a prefix filter that removes every record — the case the per-record flag cannot cover. - AlarmCommandExecutorTests: the reply payload's SnapshotTruncated comes from the handler (Theory over both values), including the zero-record case. The WnWrapAlarmConsumer truncation tests now assert through SnapshotActiveAlarms(out ...) rather than an internal field, because the pairing is the contract. Also: GatewayAlarmMonitor's _snapshotTruncated comment now says "as of the last full reconcile" rather than implying it tracks the current _alarms contents, which live transitions keep moving via ApplyTransition between passes. Detection heuristic still untouched (fetchedRecordCount >= maxAlarmsPerFetch); no @COUNT parsing, per docs/AlarmProbeFindings.md. Still additive gateway metadata about our fetch mechanics, not MXAccess behavior — not a parity deviation, and no event is synthesized. Gateway: NonWindows.slnx builds clean (0 warnings); ~Alarm filter 107/107 pass. Worker + Worker.Tests are windev-gated; the signature change was reviewed by inspection across all 7 IMxAccessAlarmConsumer implementers, all 3 IAlarmCommandHandler implementers, and every call site. --- .../Alarms/GatewayAlarmMonitor.cs | 6 +- .../MxAccess/AlarmCommandExecutorTests.cs | 75 +++++++++++++++- .../MxAccess/AlarmCommandHandlerTests.cs | 74 +++++++++++++-- .../MxAccess/AlarmDispatcherTests.cs | 89 +++++++++++++++++-- .../MxAccess/FailoverAlarmConsumerTests.cs | 13 ++- .../MxAccess/MxAccessStaSessionTests.cs | 12 +-- .../MxAccess/SubtagAlarmConsumerTests.cs | 4 +- .../MxAccess/WnWrapAlarmConsumerXmlTests.cs | 47 +++++++--- .../Probes/AlarmSubtagLiveSmokeTests.cs | 2 +- .../Probes/AlarmsLiveSmokeTests.cs | 2 +- .../MxAccess/AlarmCommandHandler.cs | 27 ++---- .../MxAccess/AlarmDispatcher.cs | 29 +++--- .../MxAccess/FailoverAlarmConsumer.cs | 23 ++--- .../MxAccess/IAlarmCommandHandler.cs | 21 +++-- .../MxAccess/IMxAccessAlarmConsumer.cs | 49 ++++++---- .../MxAccess/MxAccessCommandExecutor.cs | 6 +- .../MxAccess/SubtagAlarmConsumer.cs | 20 ++--- .../MxAccess/WnWrapAlarmConsumer.cs | 31 +++---- 18 files changed, 376 insertions(+), 154 deletions(-) diff --git a/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs b/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs index 8ce372e..77aa58c 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs @@ -58,8 +58,10 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic 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. + // Written only by ApplyReconcile (and cleared with the cache), so it describes the last full + // reconcile — not necessarily the current _alarms contents, which live transitions keep moving + // via ApplyTransition between passes. Read it as "as of the last reconcile, the worker's fetch + // was capped", which is the right granularity for a completeness caveat. private bool _snapshotTruncated; private volatile GatewayAlarmMonitorState _state = GatewayAlarmMonitorState.Disabled; diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandExecutorTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandExecutorTests.cs index a2df0ef..c1556a7 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandExecutorTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandExecutorTests.cs @@ -262,6 +262,72 @@ public sealed class AlarmCommandExecutorTests Assert.Equal("Galaxy!A", handler.LastFilterPrefix); } + /// + /// The reply payload's SnapshotTruncated comes from the handler's + /// verdict. This is the last hop before the IPC frame; if the executor + /// dropped it, a filtered query returning no records would carry no + /// completeness caveat at all. + /// + /// The verdict the fake handler reports. + [Theory] + [InlineData(true)] + [InlineData(false)] + public void QueryActiveAlarms_StampsSnapshotTruncatedFromHandler(bool handlerReportsTruncated) + { + FakeAlarmHandler handler = new FakeAlarmHandler + { + SnapshotTruncated = handlerReportsTruncated, + QueryResult = new[] + { + new ActiveAlarmSnapshot { AlarmFullReference = "Galaxy!A.T1" }, + }, + }; + MxAccessCommandExecutor executor = NewExecutor(handler); + + StaCommand command = new StaCommand( + SessionId, CorrelationId, + new MxCommand + { + Kind = MxCommandKind.QueryActiveAlarms, + QueryActiveAlarmsCommand = new QueryActiveAlarmsCommand(), + }); + + MxCommandReply reply = executor.Execute(command); + + Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); + Assert.NotNull(reply.QueryActiveAlarms); + Assert.Equal(handlerReportsTruncated, reply.QueryActiveAlarms.SnapshotTruncated); + } + + /// + /// A truncated fetch whose records all filtered out still reports the + /// caveat on the payload — the case the per-record flag cannot cover. + /// + [Fact] + public void QueryActiveAlarms_WithTruncatedFetchAndNoRecords_StillReportsTruncation() + { + FakeAlarmHandler handler = new FakeAlarmHandler + { + SnapshotTruncated = true, + QueryResult = Array.Empty(), + }; + MxAccessCommandExecutor executor = NewExecutor(handler); + + StaCommand command = new StaCommand( + SessionId, CorrelationId, + new MxCommand + { + Kind = MxCommandKind.QueryActiveAlarms, + QueryActiveAlarmsCommand = new QueryActiveAlarmsCommand(), + }); + + MxCommandReply reply = executor.Execute(command); + + Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); + Assert.Empty(reply.QueryActiveAlarms.Snapshots); + Assert.True(reply.QueryActiveAlarms.SnapshotTruncated); + } + /// Verifies that unsubscribe routes to handler. [Fact] public void UnsubscribeAlarms_WithHandler_RoutesToHandler() @@ -371,8 +437,8 @@ public sealed class AlarmCommandExecutorTests /// Gets the last alarm filter prefix. public string? LastFilterPrefix { get; private set; } - /// Gets or sets the truncation verdict the executor stamps onto the reply payload. - public bool LastSnapshotTruncated { get; set; } + /// Gets or sets the truncation verdict this handler reports with its query result. + public bool SnapshotTruncated { get; set; } /// public void Subscribe(SubscribeAlarmsCommand command, string sessionId) @@ -416,9 +482,12 @@ public sealed class AlarmCommandExecutorTests public (string Name, string Provider, string Group)? LastAckByNameTuple { get; private set; } /// - public IReadOnlyList QueryActive(string? alarmFilterPrefix) + public IReadOnlyList QueryActive( + string? alarmFilterPrefix, + out bool snapshotTruncated) { LastFilterPrefix = alarmFilterPrefix; + snapshotTruncated = SnapshotTruncated; return QueryResult; } diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs index d44a07e..2b9e35e 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs @@ -151,7 +151,7 @@ public sealed class AlarmCommandHandlerTests () => consumer); handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1"); - IReadOnlyList snapshots = handler.QueryActive(null); + IReadOnlyList snapshots = handler.QueryActive(null, out _); Assert.Single(snapshots); Assert.Equal("Galaxy!TestArea.Tag1", snapshots[0].AlarmFullReference); @@ -175,12 +175,66 @@ public sealed class AlarmCommandHandlerTests () => consumer); handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1"); - IReadOnlyList filtered = handler.QueryActive("Galaxy!AreaA"); + IReadOnlyList filtered = handler.QueryActive("Galaxy!AreaA", out _); Assert.Single(filtered); Assert.Equal("Galaxy!AreaA.Tag1", filtered[0].AlarmFullReference); } + /// + /// The consumer's truncation verdict reaches the caller through the + /// handler and its dispatcher. This is the middle hop of the flag's + /// journey to the QueryActiveAlarms reply payload; without it the reply + /// builder would have nothing to stamp. + /// + /// The verdict the fake consumer reports. + [Theory] + [InlineData(true)] + [InlineData(false)] + public void QueryActive_ReportsConsumerTruncationVerdict(bool consumerReportsTruncated) + { + FakeConsumer consumer = new FakeConsumer + { + SnapshotTruncated = consumerReportsTruncated, + SnapshotResult = new[] { NewRecord("Galaxy", "AreaA", "Tag1") }, + }; + AlarmCommandHandler handler = new AlarmCommandHandler( + new MxAccessEventQueue(), + () => consumer); + handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1"); + + IReadOnlyList snapshots = handler.QueryActive(null, out bool snapshotTruncated); + + Assert.Equal(consumerReportsTruncated, snapshotTruncated); + Assert.Equal(consumerReportsTruncated, Assert.Single(snapshots).FromTruncatedSnapshot); + } + + /// + /// A prefix filter that removes every record must not remove the verdict + /// with them. This is exactly why the flag rides out separately as well as + /// on each record: a scoped query over a truncated fetch can legitimately + /// return nothing and still owe the caller the completeness caveat. + /// + [Fact] + public void QueryActive_WhenPrefixFiltersOutEveryRecord_StillReportsTruncation() + { + FakeConsumer consumer = new FakeConsumer + { + SnapshotTruncated = true, + SnapshotResult = new[] { NewRecord("Galaxy", "AreaB", "Tag2") }, + }; + AlarmCommandHandler handler = new AlarmCommandHandler( + new MxAccessEventQueue(), + () => consumer); + handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1"); + + IReadOnlyList filtered = + handler.QueryActive("Galaxy!AreaA", out bool snapshotTruncated); + + Assert.Empty(filtered); + Assert.True(snapshotTruncated); + } + /// Verifies that dispose unsubscribes and disposes consumer when subscribed. [Fact] public void Dispose_WhenSubscribed_UnsubscribesAndDisposesConsumer() @@ -227,7 +281,7 @@ public sealed class AlarmCommandHandlerTests handler.AcknowledgeByName("a", "p", "g", "c", "u", "n", "d", "F"); Assert.Equal(3, guardInvocations); - _ = handler.QueryActive(null); + _ = handler.QueryActive(null, out _); Assert.Equal(4, guardInvocations); handler.PollOnce(); @@ -268,7 +322,7 @@ public sealed class AlarmCommandHandlerTests () => handler.Acknowledge(Guid.Empty, "", "", "", "", "")); Assert.Throws( () => handler.AcknowledgeByName("", "", "", "", "", "", "", "")); - Assert.Throws(() => handler.QueryActive(null)); + Assert.Throws(() => handler.QueryActive(null, out _)); Assert.Throws(() => handler.PollOnce()); Assert.Throws(() => handler.Unsubscribe()); } @@ -470,11 +524,15 @@ public sealed class AlarmCommandHandlerTests /// Gets the last acknowledge-by-name parameters. public (string Name, string Provider, string Group)? LastAckByNameTuple { get; private set; } - /// - public IReadOnlyList SnapshotActiveAlarms() => SnapshotResult; + /// Gets or sets the truncation verdict this consumer reports with its snapshot. + public bool SnapshotTruncated { get; set; } - /// Gets or sets the truncation verdict reported for the last fetch. - public bool LastSnapshotTruncated { get; set; } + /// + public IReadOnlyList SnapshotActiveAlarms(out bool truncated) + { + truncated = SnapshotTruncated; + return SnapshotResult; + } /// Gets the number of times polled. public int PollCount { get; private set; } diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs index 7b0474a..d41b42f 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs @@ -267,7 +267,7 @@ public sealed class AlarmDispatcherTests new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), SessionId); - IReadOnlyList snapshots = dispatcher.SnapshotActiveAlarms(); + IReadOnlyList snapshots = dispatcher.SnapshotActiveAlarms(out _); Assert.Equal(2, snapshots.Count); Assert.Equal("Galaxy!TestArea.Tag1", snapshots[0].AlarmFullReference); @@ -323,7 +323,7 @@ public sealed class AlarmDispatcherTests new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), SessionId); - IReadOnlyList snapshots = dispatcher.SnapshotActiveAlarms(); + IReadOnlyList snapshots = dispatcher.SnapshotActiveAlarms(out _); Assert.Equal(2, snapshots.Count); Assert.True(snapshots[0].Degraded); @@ -333,6 +333,82 @@ public sealed class AlarmDispatcherTests Assert.Equal(AlarmProviderMode.Alarmmgr, snapshots[1].SourceProvider); } + /// + /// A truncated consumer snapshot stamps every mapped record with + /// FromTruncatedSnapshot and reports the verdict out of the same + /// call. Every record carries it because the public QueryActiveAlarms RPC + /// streams bare snapshots with no envelope to hold set-level status, so + /// a client that reads only one record must still learn the set may be + /// incomplete. + /// + [Fact] + public void SnapshotActiveAlarms_WhenConsumerReportsTruncated_StampsEveryRecord() + { + FakeAlarmConsumer consumer = new FakeAlarmConsumer + { + SnapshotTruncated = true, + SnapshotResult = new[] + { + NewSnapshotRecord("Tag1", degraded: false), + NewSnapshotRecord("Tag2", degraded: true), + }, + }; + using AlarmDispatcher dispatcher = new AlarmDispatcher( + consumer, + new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), + SessionId); + + IReadOnlyList snapshots = dispatcher.SnapshotActiveAlarms(out bool truncated); + + Assert.True(truncated); + Assert.Equal(2, snapshots.Count); + Assert.All(snapshots, snapshot => Assert.True(snapshot.FromTruncatedSnapshot)); + // Truncation is about the SET; the per-record provider fidelity flag is + // independent and must not be dragged along with it. + Assert.False(snapshots[0].Degraded); + Assert.True(snapshots[1].Degraded); + } + + /// + /// The control. A complete consumer snapshot must leave every record's + /// FromTruncatedSnapshot unset — without this, a field hard-wired + /// to true would satisfy the test above and every snapshot would read as + /// possibly-incomplete. + /// + [Fact] + public void SnapshotActiveAlarms_WhenConsumerReportsComplete_LeavesRecordsUnstamped() + { + FakeAlarmConsumer consumer = new FakeAlarmConsumer + { + SnapshotTruncated = false, + SnapshotResult = new[] { NewSnapshotRecord("Tag1", degraded: false) }, + }; + using AlarmDispatcher dispatcher = new AlarmDispatcher( + consumer, + new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), + SessionId); + + IReadOnlyList snapshots = dispatcher.SnapshotActiveAlarms(out bool truncated); + + Assert.False(truncated); + Assert.False(Assert.Single(snapshots).FromTruncatedSnapshot); + } + + private static MxAlarmSnapshotRecord NewSnapshotRecord(string tagName, bool degraded) + { + return new MxAlarmSnapshotRecord + { + AlarmGuid = Guid.NewGuid(), + ProviderName = "Galaxy", + Group = "TestArea", + TagName = tagName, + Type = "DSC", + Priority = 500, + State = MxAlarmStateKind.UnackAlm, + Degraded = degraded, + }; + } + /// Verifies that dispose unsubscribes the handler and disposes the consumer. [Fact] public void Dispose_WhenSubscribed_UnsubscribesHandlerAndDisposesConsumer() @@ -432,15 +508,16 @@ public sealed class AlarmDispatcherTests /// Gets the last acknowledge-by-name tuple (alarm name, provider, group). public (string Name, string Provider, string Group)? LastAckByNameTuple { get; private set; } + /// Gets or sets the truncation verdict this consumer reports with its snapshot. + public bool SnapshotTruncated { get; set; } + /// - public IReadOnlyList SnapshotActiveAlarms() + public IReadOnlyList SnapshotActiveAlarms(out bool truncated) { + truncated = SnapshotTruncated; return SnapshotResult; } - /// Gets or sets the truncation verdict the dispatcher stamps onto snapshots. - public bool LastSnapshotTruncated { get; set; } - /// Gets the count of poll operations. public int PollCount { get; private set; } diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/FailoverAlarmConsumerTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/FailoverAlarmConsumerTests.cs index 3625ab9..cabc520 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/FailoverAlarmConsumerTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/FailoverAlarmConsumerTests.cs @@ -78,10 +78,14 @@ public sealed class FailoverAlarmConsumerTests public int AcknowledgeByName(string n, string p, string gr, string c, string a, string b, string d, string e) => 11; /// - public IReadOnlyList SnapshotActiveAlarms() => Array.Empty(); + public IReadOnlyList SnapshotActiveAlarms(out bool truncated) + { + truncated = SnapshotTruncated; + return Array.Empty(); + } /// Gets or sets the truncation verdict this child reports, so delegation is observable. - public bool LastSnapshotTruncated { get; set; } + public bool SnapshotTruncated { get; set; } /// public void Dispose() { } @@ -124,7 +128,7 @@ public sealed class FailoverAlarmConsumerTests public int AcknowledgeByName(string n, string p, string gr, string c, string a, string b, string d, string e) => 22; /// - public IReadOnlyList SnapshotActiveAlarms() + public IReadOnlyList SnapshotActiveAlarms(out bool truncated) { SnapshotCalls++; if (ThrowOnSnapshot) @@ -132,11 +136,12 @@ public sealed class FailoverAlarmConsumerTests throw new InvalidOperationException("priming snapshot failed"); } + truncated = SnapshotTruncated; return Array.Empty(); } /// Gets or sets the truncation verdict this child reports, so delegation is observable. - public bool LastSnapshotTruncated { get; set; } + public bool SnapshotTruncated { get; set; } /// public void Dispose() { } diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs index 0e7fd07..062fb0f 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs @@ -643,9 +643,6 @@ public sealed class MxAccessStaSessionTests get { lock (gate) return lastPollThreadId; } } - /// Gets or sets the truncation verdict reported for the last fetch. - public bool LastSnapshotTruncated { get; set; } - /// public void Subscribe(SubscribeAlarmsCommand command, string sessionId) { @@ -671,8 +668,13 @@ public sealed class MxAccessStaSessionTests => 0; /// - public IReadOnlyList QueryActive(string? alarmFilterPrefix) - => Array.Empty(); + public IReadOnlyList QueryActive( + string? alarmFilterPrefix, + out bool snapshotTruncated) + { + snapshotTruncated = false; + return Array.Empty(); + } /// public void PollOnce() diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmConsumerTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmConsumerTests.cs index 596ae9a..093a3b9 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmConsumerTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmConsumerTests.cs @@ -130,7 +130,7 @@ public sealed class SubtagAlarmConsumerTests source.Raise(ActiveSubtag, true, new DateTime(2026, 6, 13, 10, 0, 0, DateTimeKind.Utc)); - IReadOnlyList snapshot = consumer.SnapshotActiveAlarms(); + IReadOnlyList snapshot = consumer.SnapshotActiveAlarms(out _); Assert.Single(snapshot); Assert.True(snapshot[0].Degraded); @@ -150,7 +150,7 @@ public sealed class SubtagAlarmConsumerTests source.Raise(ActiveSubtag, true, new DateTime(2026, 6, 13, 10, 0, 0, DateTimeKind.Utc)); - IReadOnlyList snapshot = consumer.SnapshotActiveAlarms(); + IReadOnlyList snapshot = consumer.SnapshotActiveAlarms(out _); Assert.NotNull(emitted); Assert.Single(snapshot); diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs index 28c4293..d1844f9 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs @@ -691,23 +691,29 @@ public sealed class WnWrapAlarmConsumerXmlTests // ------------------------------------------------------------------------- // Degraded-status signal. The truncation guard above keeps a capped fetch // from broadcasting phantom Clears, but it does so silently: the retained - // snapshot simply stops shrinking. LastSnapshotTruncated is what makes that - // suppression visible to the QueryActiveAlarms reply and, through it, the - // dashboard banner — so its set/reset behaviour is the contract, not detail. + // snapshot simply stops shrinking. The truncation verdict SnapshotActiveAlarms + // hands back alongside the records is what makes that suppression visible to + // the QueryActiveAlarms reply and, through it, the dashboard banner — so its + // set/reset behaviour is the contract, not detail. + // + // These assert through SnapshotActiveAlarms(out ...) rather than any internal + // field, because the pairing IS the contract: records and verdict must come + // out of one call, produced under one lock acquisition. // ------------------------------------------------------------------------- /// - /// A capped fetch sets the retained truncation verdict. Without this the - /// signal never leaves the consumer and the reply builder stamps a - /// complete-looking snapshot over a capped one. + /// A capped fetch sets the truncation verdict handed out with the + /// snapshot. Without this the signal never leaves the consumer and the + /// reply builder stamps a complete-looking snapshot over a capped one. /// [Fact] - public void FoldFetch_WhenFetchTruncated_SetsLastSnapshotTruncated() + public void SnapshotActiveAlarms_AfterTruncatedFetch_ReportsTruncated() { const int Cap = 8; using WnWrapAlarmConsumer consumer = new WnWrapAlarmConsumer(Cap); - Assert.False(consumer.LastSnapshotTruncated); + consumer.SnapshotActiveAlarms(out bool truncatedBeforeAnyFetch); + Assert.False(truncatedBeforeAnyFetch); Dictionary next = WnWrapAlarmConsumer.ParseSnapshotXml(BuildAlarmXml(Cap), out int fetchedRecordCount); @@ -715,8 +721,14 @@ public sealed class WnWrapAlarmConsumerXmlTests consumer.FoldFetch(next, truncated: true, out int retainedCount); - Assert.True(consumer.LastSnapshotTruncated); + IReadOnlyList snapshot = + consumer.SnapshotActiveAlarms(out bool truncated); + + Assert.True(truncated); Assert.Equal(Cap, retainedCount); + // The verdict describes THIS set — assert they arrive together, not just + // that the boolean flipped somewhere. + Assert.Equal(Cap, snapshot.Count); } /// @@ -727,7 +739,7 @@ public sealed class WnWrapAlarmConsumerXmlTests /// it — the opposite of what the signal is for. /// [Fact] - public void FoldFetch_AfterTruncatedFetch_SubCapFetchClearsLastSnapshotTruncated() + public void SnapshotActiveAlarms_AfterSubCapFetchFollowingTruncation_ReportsComplete() { const int Cap = 8; using WnWrapAlarmConsumer consumer = new WnWrapAlarmConsumer(Cap); @@ -735,7 +747,8 @@ public sealed class WnWrapAlarmConsumerXmlTests Dictionary capped = WnWrapAlarmConsumer.ParseSnapshotXml(BuildAlarmXml(Cap), out _); consumer.FoldFetch(capped, truncated: true, out _); - Assert.True(consumer.LastSnapshotTruncated); + consumer.SnapshotActiveAlarms(out bool truncatedAfterCappedFetch); + Assert.True(truncatedAfterCappedFetch); Dictionary complete = WnWrapAlarmConsumer.ParseSnapshotXml(BuildAlarmXml(Cap - 1), out int fetchedRecordCount); @@ -743,11 +756,15 @@ public sealed class WnWrapAlarmConsumerXmlTests consumer.FoldFetch(complete, truncated: false, out int retainedCount); - Assert.False(consumer.LastSnapshotTruncated); + IReadOnlyList snapshot = + consumer.SnapshotActiveAlarms(out bool truncated); + + Assert.False(truncated); // The complete fetch also replaced the snapshot wholesale, which is what // makes it authoritative about absence — pinned here so a future change // cannot clear the verdict while keeping the merge semantics. Assert.Equal(Cap - 1, retainedCount); + Assert.Equal(Cap - 1, snapshot.Count); } /// @@ -756,7 +773,7 @@ public sealed class WnWrapAlarmConsumerXmlTests /// still see the caveat. /// [Fact] - public void FoldFetch_WithConsecutiveTruncatedFetches_KeepsLastSnapshotTruncatedSet() + public void SnapshotActiveAlarms_WithConsecutiveTruncatedFetches_KeepsReportingTruncated() { const int Cap = 8; using WnWrapAlarmConsumer consumer = new WnWrapAlarmConsumer(Cap); @@ -766,7 +783,9 @@ public sealed class WnWrapAlarmConsumerXmlTests Dictionary capped = WnWrapAlarmConsumer.ParseSnapshotXml(BuildAlarmXml(Cap), out _); consumer.FoldFetch(capped, truncated: true, out _); - Assert.True(consumer.LastSnapshotTruncated); + + consumer.SnapshotActiveAlarms(out bool truncated); + Assert.True(truncated); } } diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmSubtagLiveSmokeTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmSubtagLiveSmokeTests.cs index aa3857c..9fb9254 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmSubtagLiveSmokeTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmSubtagLiveSmokeTests.cs @@ -368,7 +368,7 @@ public sealed class AlarmSubtagLiveSmokeTests raiseEvent.Record.AlarmGuid, raiseEvent.Record.Degraded, raiseEvent.Record.State)); // 2. Snapshot active alarms and confirm the raised alarm is present. - IReadOnlyList snapshot = consumer.SnapshotActiveAlarms(); + IReadOnlyList snapshot = consumer.SnapshotActiveAlarms(out _); Log(string.Format("SnapshotActiveAlarms count={0}", snapshot.Count)); foreach (MxAlarmSnapshotRecord s in snapshot) { diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmsLiveSmokeTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmsLiveSmokeTests.cs index c92b794..181add7 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmsLiveSmokeTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmsLiveSmokeTests.cs @@ -121,7 +121,7 @@ public sealed class AlarmsLiveSmokeTests Assert.Contains("Galaxy", raiseBody.AlarmFullReference); // 2. Snapshot the active set + verify the captured alarm is there. - var snapshot = dispatcher.SnapshotActiveAlarms(); + var snapshot = dispatcher.SnapshotActiveAlarms(out _); Log($"SnapshotActiveAlarms count={snapshot.Count}"); foreach (var s in snapshot) { diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs index 534b9fa..f2b5381 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs @@ -325,11 +325,15 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler } /// - public IReadOnlyList QueryActive(string? alarmFilterPrefix) + public IReadOnlyList QueryActive( + string? alarmFilterPrefix, + out bool snapshotTruncated) { threadAffinityCheck?.Invoke(); AlarmDispatcher? d = GetDispatcherOrThrow(); - IReadOnlyList all = d.SnapshotActiveAlarms(); + // The verdict rides out of the same call that produced the records, so + // filtering below cannot separate it from the set it describes. + IReadOnlyList all = d.SnapshotActiveAlarms(out snapshotTruncated); if (string.IsNullOrEmpty(alarmFilterPrefix)) return all; List filtered = new List(all.Count); foreach (ActiveAlarmSnapshot snap in all) @@ -342,25 +346,6 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler return filtered; } - /// - /// - /// Deliberately does not go through GetDispatcherOrThrow: an - /// unsubscribed handler has performed no fetch, and "no fetch" is not - /// truncated. Throwing here would turn a status read into a command - /// failure on a path the reply builder takes after the snapshot has - /// already been produced. - /// - public bool LastSnapshotTruncated - { - get - { - if (disposed) return false; - AlarmDispatcher? d; - lock (syncRoot) d = dispatcher; - return d is not null && d.LastSnapshotTruncated; - } - } - /// public void PollOnce() { diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs index c5906c9..e9f4012 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs @@ -154,15 +154,24 @@ public sealed class AlarmDispatcher : IDisposable /// protos for the /// QueryActiveAlarms RPC's ConditionRefresh stream. /// + /// + /// Receives whether the fetch behind the snapshot hit the per-fetch cap, + /// so the returned set may omit active alarms. Forwarded from the single + /// atomic consumer read, and stamped onto every returned record as + /// ActiveAlarmSnapshot.FromTruncatedSnapshot. Also returned + /// separately because a snapshot of zero records still has to report it. + /// /// The currently active alarm snapshots. - public IReadOnlyList SnapshotActiveAlarms() + public IReadOnlyList SnapshotActiveAlarms(out bool truncated) { if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher)); - // Read the truncation verdict before the snapshot, so a poll landing - // between the two can only widen the warning (a stale "truncated" over a - // complete snapshot), never narrow it into a false all-clear. - bool truncated = consumer.LastSnapshotTruncated; - IReadOnlyList records = consumer.SnapshotActiveAlarms(); + // One consumer call yields the records and the verdict together, under a + // single acquisition of the consumer's snapshot lock. Reading them + // separately would let a poll interleave and pair a stale not-truncated + // verdict with a capped snapshot — a set that reads complete while + // missing actives. The atomicity is structural here, not a consequence of + // the STA happening to serialize the two calls. + IReadOnlyList records = consumer.SnapshotActiveAlarms(out truncated); if (records.Count == 0) return Array.Empty(); List snapshots = new List(records.Count); foreach (MxAlarmSnapshotRecord record in records) @@ -172,14 +181,6 @@ public sealed class AlarmDispatcher : IDisposable return snapshots; } - /// - /// Whether the consumer's most recent fetch hit the per-fetch cap, so - /// the set returns may omit actives. - /// Stamped onto the QueryActiveAlarms reply payload, which is the only - /// carrier when the snapshot filters down to zero records. - /// - public bool LastSnapshotTruncated => !disposed && consumer.LastSnapshotTruncated; - private void OnTransition(object? sender, MxAlarmTransitionEvent transition) { if (disposed) return; diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/FailoverAlarmConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/FailoverAlarmConsumer.cs index 9c69645..967cac3 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/FailoverAlarmConsumer.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/FailoverAlarmConsumer.cs @@ -260,23 +260,18 @@ public sealed class FailoverAlarmConsumer : IMxAccessAlarmConsumer } /// - public IReadOnlyList SnapshotActiveAlarms() + /// + /// Both values come from ONE call to the active child, so the snapshot + /// and its verdict cannot end up describing different children across a + /// failover. A failover to the subtag standby therefore reports + /// not-truncated — correctly, since that child performs no capped fetch. + /// + public IReadOnlyList SnapshotActiveAlarms(out bool truncated) { if (disposed) throw new ObjectDisposedException(nameof(FailoverAlarmConsumer)); - return ActiveChild.SnapshotActiveAlarms(); + return ActiveChild.SnapshotActiveAlarms(out truncated); } - /// - /// - /// Delegated to the active child, matching - /// : the flag describes the snapshot - /// the same child produced, so reading it off the standby would pair a - /// verdict with a snapshot it does not belong to. A failover to the - /// subtag standby therefore reports not-truncated — correctly, since - /// that child performs no capped fetch. - /// - public bool LastSnapshotTruncated => !disposed && ActiveChild.LastSnapshotTruncated; - private IMxAccessAlarmConsumer ActiveChild => active == Active.Primary ? primary : standby; /// @@ -351,7 +346,7 @@ public sealed class FailoverAlarmConsumer : IMxAccessAlarmConsumer { try { - _ = standby.SnapshotActiveAlarms(); + _ = standby.SnapshotActiveAlarms(out _); } catch (Exception ex) when (ex is not OutOfMemoryException) { diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmCommandHandler.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmCommandHandler.cs index bd8b6a9..638f84f 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmCommandHandler.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmCommandHandler.cs @@ -71,18 +71,17 @@ public interface IAlarmCommandHandler : IDisposable /// prefix matched against AlarmFullReference. /// /// Optional prefix to filter alarms by. + /// + /// Receives whether the fetch behind the snapshot hit the per-fetch cap, + /// so the set may omit active alarms. Carried out alongside the records + /// rather than read from a separate property, both so the pair comes from + /// one atomic consumer read and because it is the only carrier left once + /// (or an empty galaxy) filters the + /// records down to none. when there is no active + /// subscription: no fetch has happened, so nothing is capped. + /// /// The currently active alarms matching the filter. - IReadOnlyList QueryActive(string? alarmFilterPrefix); - - /// - /// Whether the consumer's most recent fetch hit the per-fetch cap, so - /// the set draws from may omit active alarms. - /// Stamped on the QueryActiveAlarms reply payload — the only carrier - /// once a prefix filter (or an empty galaxy) leaves zero records to - /// carry the per-record flag. when there is no - /// active subscription: no fetch has happened, so nothing is capped. - /// - bool LastSnapshotTruncated { get; } + IReadOnlyList QueryActive(string? alarmFilterPrefix, out bool snapshotTruncated); /// /// Drives a single poll of the underlying alarm consumer on the diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs index 71f30e8..d5b2b14 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs @@ -36,20 +36,6 @@ public interface IMxAccessAlarmConsumer : IDisposable /// event EventHandler? AlarmTransitionEmitted; - /// - /// Whether the most recent fetch that reached the retained snapshot came - /// back holding the per-fetch cap. While this is - /// the snapshot returned by is - /// authoritative about presence only: the provider may hold actives it - /// had no room to report, and the consumer has suspended the - /// absence-implies-Clear inference. Not latched — the first sub-cap fetch - /// after a run of capped ones clears it, because that fetch is complete - /// and the snapshot it produced is again authoritative about absence. - /// Consumers with no per-fetch cap (the subtag fallback, which is - /// event-driven) always report . - /// - bool LastSnapshotTruncated { get; } - /// /// Initializes the AVEVA alarm-client connection, registers as a /// consumer, and subscribes to the supplied alarm-provider expression. @@ -111,12 +97,43 @@ public interface IMxAccessAlarmConsumer : IDisposable /// /// Returns the consumer's most recently parsed snapshot of currently - /// active alarms. Used by the gateway's QueryActiveAlarms (PR A.7) + /// active alarms, together with whether the fetch that produced it hit + /// the per-fetch cap. Used by the gateway's QueryActiveAlarms (PR A.7) /// ConditionRefresh path — operator clients call this after reconnect /// to seed local Part 9 state. /// + /// + /// + /// The verdict is an out parameter rather than a separate + /// property on purpose, and the reason is a correctness one. + /// Implementations must produce both values from a single acquisition + /// of whatever lock guards the retained snapshot, mirroring the write + /// side (WnWrapAlarmConsumer.FoldFetch updates snapshot and + /// verdict together). Two separate reads could straddle a poll that + /// flips not-truncated → truncated and pair a stale + /// with a capped snapshot — a snapshot that + /// reads as complete while missing actives, which is exactly the + /// false all-clear this signal exists to prevent. Making the pair + /// inseparable in the signature removes the possibility rather than + /// relying on callers, or on the STA serializing them. + /// + /// + /// While is the + /// returned snapshot is authoritative about presence only: the + /// provider may hold actives it had no room to report, and the + /// consumer has suspended the absence-implies-Clear inference. Not + /// latched — the first sub-cap fetch clears it, because that fetch is + /// complete and its snapshot is again authoritative about absence. + /// Consumers with no per-fetch cap (the subtag fallback, which is + /// advise-driven) always report . + /// + /// + /// + /// Receives whether the fetch behind the returned snapshot hit the + /// per-fetch cap. + /// /// The most recently parsed snapshot of currently active alarms. - IReadOnlyList SnapshotActiveAlarms(); + IReadOnlyList SnapshotActiveAlarms(out bool truncated); /// /// Drives a single synchronous poll of the underlying alarm source. diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs index f1ada4b..62b4e94 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs @@ -974,13 +974,15 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor try { IReadOnlyList snapshots = alarmCommandHandler.QueryActive( - command.Command.QueryActiveAlarmsCommand.AlarmFilterPrefix); + command.Command.QueryActiveAlarmsCommand.AlarmFilterPrefix, + out bool snapshotTruncated); QueryActiveAlarmsReplyPayload payload = new QueryActiveAlarmsReplyPayload(); payload.Snapshots.AddRange(snapshots); // Set-level degraded status: the snapshot may omit actives because // the provider fetch hit its cap. The records carry the same flag, // but a prefix filter can leave none, so the payload states it too. - payload.SnapshotTruncated = alarmCommandHandler.LastSnapshotTruncated; + // Same value the records were stamped with — one read, not a second. + payload.SnapshotTruncated = snapshotTruncated; MxCommandReply reply = CreateOkReply(command); reply.QueryActiveAlarms = payload; return reply; diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmConsumer.cs index 754a36d..0d0292c 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmConsumer.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmConsumer.cs @@ -45,16 +45,6 @@ public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer /// Fires once per synthesized alarm-state transition. public event EventHandler? AlarmTransitionEmitted; - /// - /// - /// Always . Subtag mode is advise-driven over a - /// fixed watch list — there is no bulk fetch and therefore no per-fetch - /// cap to hit. Subtag snapshots are lower-fidelity in other ways, which - /// MxAlarmSnapshotRecord.Degraded already reports; truncation is - /// not one of them. - /// - public bool LastSnapshotTruncated => false; - /// /// Initializes the consumer over a subtag source and a watch list of /// alarm targets. @@ -162,8 +152,16 @@ public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer } /// - public IReadOnlyList SnapshotActiveAlarms() + /// + /// is always : subtag + /// mode is advise-driven over a fixed watch list, so there is no bulk + /// fetch and no per-fetch cap to hit. Subtag snapshots are lower-fidelity + /// in other ways, which MxAlarmSnapshotRecord.Degraded already + /// reports; truncation is not one of them. + /// + public IReadOnlyList SnapshotActiveAlarms(out bool truncated) { + truncated = false; IReadOnlyList records = stateMachine.SnapshotActive(); foreach (MxAlarmSnapshotRecord record in records) { diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs index 0f07764..8280e13 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs @@ -128,8 +128,8 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer /// /// COM-free construction, for exercising the retained-snapshot state - /// machine ( / - /// / ) on a machine without AVEVA + /// machine ( / ) + /// on a machine without AVEVA /// installed. throws and /// no-ops on an instance built this way — both need the wnwrap coclass, /// which cannot be instantiated on the macOS/Linux test matrix. Internal @@ -372,8 +372,11 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer /// . Erring toward "still active" keeps the feed /// at-least-once: a stale entry is repaired by the next sub-cap poll, /// whereas a dropped one is broadcast as a Clear that never happened. + /// The snapshot and are produced under one + /// syncRoot acquisition, the same one + /// writes them both under, so no poll can interleave between them. /// - public IReadOnlyList SnapshotActiveAlarms() + public IReadOnlyList SnapshotActiveAlarms(out bool truncated) { if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer)); lock (syncRoot) @@ -387,22 +390,11 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer active.Add(record); } } + truncated = lastSnapshotTruncated; return active; } } - /// - /// - /// Read without the disposed guard - /// carries: this is degraded-status metadata a reply builder stamps - /// alongside a snapshot, and throwing from it would fail a query whose - /// snapshot half succeeded. - /// - public bool LastSnapshotTruncated - { - get { lock (syncRoot) { return lastSnapshotTruncated; } } - } - /// /// Sink for the rate-limited truncated-fetch warning. Defaults to /// , the stream @@ -463,10 +455,11 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer /// /// Folds one fetch into the retained state under a single lock: the /// transition diff, the snapshot merge/replace, and the truncation - /// verdict move together. Splitting them would let a concurrent - /// / - /// pair read a capped snapshot alongside the previous poll's "complete" - /// verdict — precisely the false all-clear the signal exists to prevent. + /// verdict move together. This is the write half of the pairing + /// reads; splitting either half would + /// let a reader see a capped snapshot alongside the previous poll's + /// "complete" verdict — precisely the false all-clear the signal exists + /// to prevent. /// The verdict is replaced, never latched: a sub-cap fetch is complete /// and restores absence authority, so leaving the flag set would strand /// the operator banner on after a single burst. From d9ea8a81f1d2570aad612c351f0d7e109c45e25c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 17 Aug 2026 04:48:20 -0400 Subject: [PATCH 11/17] chore(clients): regenerate for alarm truncation fields; READMEs note the degraded flag Task 8 added ActiveAlarmSnapshot.from_truncated_snapshot = 16 and QueryActiveAlarmsReplyPayload.snapshot_truncated = 2. Regenerate every downstream binding from the canonical Contracts protos: - client descriptor set (protoc 34.1 pin) - Go (protoc-gen-go v1.36.11 / protoc-gen-go-grpc 1.6.2) - Python (grpcio-tools 1.80.0 pin) - Java (gradle generateProto) - Rust vendored protos under clients/rust/protos, which build.rs falls back to for out-of-repo tarball builds and which must track Contracts .NET needed no regeneration - the client compiles against the Contracts Generated/ output already committed with the proto change. No client has a typed wrapper model around ActiveAlarmSnapshot; all five pass the generated type straight through, so codegen alone carries the field. Each client README's alarm section gains a paragraph on the flag: the snapshot set may omit actives and absence-implies-cleared inference was suspended, so callers must not reconcile deletions from a truncated set. Distinguished from the per-record 'degraded' subtag-fallback flag, which it is easily confused with. --- clients/dotnet/README.md | 7 + clients/go/README.md | 8 + .../internal/generated/mxaccess_gateway.pb.go | 53 +- clients/java/README.md | 8 + .../mxaccess_gateway/v1/MxaccessGateway.java | 796 ++++++++++++------ .../descriptors/mxaccessgw-client-v1.protoset | Bin 118154 -> 120969 bytes clients/python/README.md | 7 + .../generated/mxaccess_gateway_pb2.py | 160 ++-- clients/rust/README.md | 12 +- clients/rust/protos/mxaccess_gateway.proto | 17 + 10 files changed, 704 insertions(+), 364 deletions(-) diff --git a/clients/dotnet/README.md b/clients/dotnet/README.md index 3cbd0ff..bad5b6e 100644 --- a/clients/dotnet/README.md +++ b/clients/dotnet/README.md @@ -149,6 +149,13 @@ token and pass through the `MxGateway:Alarms` configuration on the server — when alarms are disabled, the gateway returns an empty list / empty stream rather than failing. +`ActiveAlarmSnapshot.FromTruncatedSnapshot` marks a record that came from a +provider fetch which hit the per-fetch cap: the snapshot set may omit active +alarms, and the gateway suspended its absence-implies-cleared inference for that +poll. Treat the set as possibly incomplete rather than reconciling deletions +from it. It is set-level degraded status, not a comment on the record's own +fidelity, and is distinct from `Degraded` (the subtag fallback provider). + `MxGatewaySession.CloseAsync` is explicit and idempotent. Repeated calls return the first `CloseSessionReply` instead of sending another close request. diff --git a/clients/go/README.md b/clients/go/README.md index 12bfd9d..86f9f62 100644 --- a/clients/go/README.md +++ b/clients/go/README.md @@ -145,6 +145,14 @@ call returns a `StreamAlarmsClient`; cancel its context to terminate the stream. All three pass straight through to the gateway's central alarm monitor. +`ActiveAlarmSnapshot.GetFromTruncatedSnapshot()` reports that the record came +from a provider fetch which hit the per-fetch cap: the snapshot set may omit +active alarms, and the gateway suspended its absence-implies-cleared inference +for that poll. Treat the set as possibly incomplete rather than reconciling +deletions from it. It is set-level degraded status, not a comment on the +record's own fidelity, and is distinct from `Degraded` (the subtag fallback +provider). + ## Write Semantics And Common Pitfalls These are MXAccess parity behaviors that surprise new callers. The gateway diff --git a/clients/go/internal/generated/mxaccess_gateway.pb.go b/clients/go/internal/generated/mxaccess_gateway.pb.go index d0f041c..f183bb9 100644 --- a/clients/go/internal/generated/mxaccess_gateway.pb.go +++ b/clients/go/internal/generated/mxaccess_gateway.pb.go @@ -6103,10 +6103,17 @@ func (x *AcknowledgeAlarmReplyPayload) GetNativeStatus() int32 { // an ActiveAlarmSnapshot proto for the gateway-side ConditionRefresh // stream. type QueryActiveAlarmsReplyPayload struct { - state protoimpl.MessageState `protogen:"open.v1"` - Snapshots []*ActiveAlarmSnapshot `protobuf:"bytes,1,rep,name=snapshots,proto3" json:"snapshots,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Snapshots []*ActiveAlarmSnapshot `protobuf:"bytes,1,rep,name=snapshots,proto3" json:"snapshots,omitempty"` + // 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. + SnapshotTruncated bool `protobuf:"varint,2,opt,name=snapshot_truncated,json=snapshotTruncated,proto3" json:"snapshot_truncated,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *QueryActiveAlarmsReplyPayload) Reset() { @@ -6146,6 +6153,13 @@ func (x *QueryActiveAlarmsReplyPayload) GetSnapshots() []*ActiveAlarmSnapshot { return nil } +func (x *QueryActiveAlarmsReplyPayload) GetSnapshotTruncated() bool { + if x != nil { + return x.SnapshotTruncated + } + return false +} + type MxEvent struct { state protoimpl.MessageState `protogen:"open.v1"` Family MxEventFamily `protobuf:"varint,1,opt,name=family,proto3,enum=mxaccess_gateway.v1.MxEventFamily" json:"family,omitempty"` @@ -6958,8 +6972,18 @@ type ActiveAlarmSnapshot struct { // OnAlarmTransitionEvent.source_provider; always ALARMMGR or SUBTAG on the // wire (never UNSPECIFIED). SourceProvider AlarmProviderMode `protobuf:"varint,15,opt,name=source_provider,json=sourceProvider,proto3,enum=mxaccess_gateway.v1.AlarmProviderMode" json:"source_provider,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // 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. + FromTruncatedSnapshot bool `protobuf:"varint,16,opt,name=from_truncated_snapshot,json=fromTruncatedSnapshot,proto3" json:"from_truncated_snapshot,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ActiveAlarmSnapshot) Reset() { @@ -7097,6 +7121,13 @@ func (x *ActiveAlarmSnapshot) GetSourceProvider() AlarmProviderMode { return AlarmProviderMode_ALARM_PROVIDER_MODE_UNSPECIFIED } +func (x *ActiveAlarmSnapshot) GetFromTruncatedSnapshot() bool { + if x != nil { + return x.FromTruncatedSnapshot + } + return false +} + type AcknowledgeAlarmRequest struct { state protoimpl.MessageState `protogen:"open.v1"` ClientCorrelationId string `protobuf:"bytes,2,opt,name=client_correlation_id,json=clientCorrelationId,proto3" json:"client_correlation_id,omitempty"` @@ -8979,9 +9010,10 @@ const file_mxaccess_gateway_proto_rawDesc = "" + "\x10DrainEventsReply\x124\n" + "\x06events\x18\x01 \x03(\v2\x1c.mxaccess_gateway.v1.MxEventR\x06events\"C\n" + "\x1cAcknowledgeAlarmReplyPayload\x12#\n" + - "\rnative_status\x18\x01 \x01(\x05R\fnativeStatus\"g\n" + + "\rnative_status\x18\x01 \x01(\x05R\fnativeStatus\"\x96\x01\n" + "\x1dQueryActiveAlarmsReplyPayload\x12F\n" + - "\tsnapshots\x18\x01 \x03(\v2(.mxaccess_gateway.v1.ActiveAlarmSnapshotR\tsnapshots\"\xb7\n" + + "\tsnapshots\x18\x01 \x03(\v2(.mxaccess_gateway.v1.ActiveAlarmSnapshotR\tsnapshots\x12-\n" + + "\x12snapshot_truncated\x18\x02 \x01(\bR\x11snapshotTruncated\"\xb7\n" + "\n" + "\aMxEvent\x12:\n" + "\x06family\x18\x01 \x01(\x0e2\".mxaccess_gateway.v1.MxEventFamilyR\x06family\x12\x1d\n" + @@ -9046,7 +9078,7 @@ const file_mxaccess_gateway_proto_rawDesc = "" + "\x04mode\x18\x01 \x01(\x0e2&.mxaccess_gateway.v1.AlarmProviderModeR\x04mode\x12\x16\n" + "\x06reason\x18\x02 \x01(\tR\x06reason\x12\x18\n" + "\ahresult\x18\x03 \x01(\x05R\ahresult\x12*\n" + - "\x02at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\x02at\"\xbd\x06\n" + + "\x02at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\x02at\"\xf5\x06\n" + "\x13ActiveAlarmSnapshot\x120\n" + "\x14alarm_full_reference\x18\x01 \x01(\tR\x12alarmFullReference\x126\n" + "\x17source_object_reference\x18\x02 \x01(\tR\x15sourceObjectReference\x12&\n" + @@ -9064,7 +9096,8 @@ const file_mxaccess_gateway_proto_rawDesc = "" + "\vlimit_value\x18\r \x01(\v2\x1c.mxaccess_gateway.v1.MxValueR\n" + "limitValue\x12\x1a\n" + "\bdegraded\x18\x0e \x01(\bR\bdegraded\x12O\n" + - "\x0fsource_provider\x18\x0f \x01(\x0e2&.mxaccess_gateway.v1.AlarmProviderModeR\x0esourceProvider\"\xd0\x01\n" + + "\x0fsource_provider\x18\x0f \x01(\x0e2&.mxaccess_gateway.v1.AlarmProviderModeR\x0esourceProvider\x126\n" + + "\x17from_truncated_snapshot\x18\x10 \x01(\bR\x15fromTruncatedSnapshot\"\xd0\x01\n" + "\x17AcknowledgeAlarmRequest\x122\n" + "\x15client_correlation_id\x18\x02 \x01(\tR\x13clientCorrelationId\x120\n" + "\x14alarm_full_reference\x18\x03 \x01(\tR\x12alarmFullReference\x12\x18\n" + diff --git a/clients/java/README.md b/clients/java/README.md index 1a2a761..6c89443 100644 --- a/clients/java/README.md +++ b/clients/java/README.md @@ -117,6 +117,14 @@ yields alarm-feed messages from the gateway's central monitor), and `acknowledgeAlarm` (ack by full alarm reference with an optional comment and ack target). Close the subscription to cancel the underlying gRPC stream. +`ActiveAlarmSnapshot.getFromTruncatedSnapshot()` reports that the record came +from a provider fetch which hit the per-fetch cap: the snapshot set may omit +active alarms, and the gateway suspended its absence-implies-cleared inference +for that poll. Treat the set as possibly incomplete rather than reconciling +deletions from it. It is set-level degraded status, not a comment on the +record's own fidelity, and is distinct from `getDegraded()` (the subtag +fallback provider). + ## Write Semantics And Common Pitfalls These are MXAccess parity behaviors that surprise new callers. The gateway diff --git a/clients/java/src/main/generated/main/java/mxaccess_gateway/v1/MxaccessGateway.java b/clients/java/src/main/generated/main/java/mxaccess_gateway/v1/MxaccessGateway.java index 85a91b0..defa7c5 100644 --- a/clients/java/src/main/generated/main/java/mxaccess_gateway/v1/MxaccessGateway.java +++ b/clients/java/src/main/generated/main/java/mxaccess_gateway/v1/MxaccessGateway.java @@ -71020,6 +71020,21 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { */ mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshotOrBuilder getSnapshotsOrBuilder( int index); + + /** + *
+     * 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; + * @return The snapshotTruncated. + */ + boolean getSnapshotTruncated(); } /** *
@@ -71107,6 +71122,26 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
       return snapshots_.get(index);
     }
 
+    public static final int SNAPSHOT_TRUNCATED_FIELD_NUMBER = 2;
+    private boolean snapshotTruncated_ = false;
+    /**
+     * 
+     * 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; + * @return The snapshotTruncated. + */ + @java.lang.Override + public boolean getSnapshotTruncated() { + return snapshotTruncated_; + } + private byte memoizedIsInitialized = -1; @java.lang.Override public final boolean isInitialized() { @@ -71124,6 +71159,9 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { for (int i = 0; i < snapshots_.size(); i++) { output.writeMessage(1, snapshots_.get(i)); } + if (snapshotTruncated_ != false) { + output.writeBool(2, snapshotTruncated_); + } getUnknownFields().writeTo(output); } @@ -71137,6 +71175,10 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { size += com.google.protobuf.CodedOutputStream .computeMessageSize(1, snapshots_.get(i)); } + if (snapshotTruncated_ != false) { + size += com.google.protobuf.CodedOutputStream + .computeBoolSize(2, snapshotTruncated_); + } size += getUnknownFields().getSerializedSize(); memoizedSize = size; return size; @@ -71154,6 +71196,8 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { if (!getSnapshotsList() .equals(other.getSnapshotsList())) return false; + if (getSnapshotTruncated() + != other.getSnapshotTruncated()) return false; if (!getUnknownFields().equals(other.getUnknownFields())) return false; return true; } @@ -71169,6 +71213,9 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { hash = (37 * hash) + SNAPSHOTS_FIELD_NUMBER; hash = (53 * hash) + getSnapshotsList().hashCode(); } + hash = (37 * hash) + SNAPSHOT_TRUNCATED_FIELD_NUMBER; + hash = (53 * hash) + com.google.protobuf.Internal.hashBoolean( + getSnapshotTruncated()); hash = (29 * hash) + getUnknownFields().hashCode(); memoizedHashCode = hash; return hash; @@ -71314,6 +71361,7 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { snapshotsBuilder_.clear(); } bitField0_ = (bitField0_ & ~0x00000001); + snapshotTruncated_ = false; return this; } @@ -71360,6 +71408,9 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { private void buildPartial0(mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsReplyPayload result) { int from_bitField0_ = bitField0_; + if (((from_bitField0_ & 0x00000002) != 0)) { + result.snapshotTruncated_ = snapshotTruncated_; + } } @java.lang.Override @@ -71400,6 +71451,9 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { } } } + if (other.getSnapshotTruncated() != false) { + setSnapshotTruncated(other.getSnapshotTruncated()); + } this.mergeUnknownFields(other.getUnknownFields()); onChanged(); return this; @@ -71439,6 +71493,11 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { } break; } // case 10 + case 16: { + snapshotTruncated_ = input.readBool(); + bitField0_ |= 0x00000002; + break; + } // case 16 default: { if (!super.parseUnknownField(input, extensionRegistry, tag)) { done = true; // was an endgroup tag @@ -71696,6 +71755,65 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { return snapshotsBuilder_; } + private boolean 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.
+       * 
+ * + * bool snapshot_truncated = 2; + * @return The snapshotTruncated. + */ + @java.lang.Override + public boolean getSnapshotTruncated() { + return 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.
+       * 
+ * + * bool snapshot_truncated = 2; + * @param value The snapshotTruncated to set. + * @return This builder for chaining. + */ + public Builder setSnapshotTruncated(boolean value) { + + snapshotTruncated_ = value; + bitField0_ |= 0x00000002; + onChanged(); + return this; + } + /** + *
+       * 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; + * @return This builder for chaining. + */ + public Builder clearSnapshotTruncated() { + bitField0_ = (bitField0_ & ~0x00000002); + snapshotTruncated_ = false; + onChanged(); + return this; + } + // @@protoc_insertion_point(builder_scope:mxaccess_gateway.v1.QueryActiveAlarmsReplyPayload) } @@ -83081,6 +83199,24 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { * @return The sourceProvider. */ mxaccess_gateway.v1.MxaccessGateway.AlarmProviderMode getSourceProvider(); + + /** + *
+     * 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; + * @return The fromTruncatedSnapshot. + */ + boolean getFromTruncatedSnapshot(); } /** *
@@ -83623,6 +83759,29 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
       return result == null ? mxaccess_gateway.v1.MxaccessGateway.AlarmProviderMode.UNRECOGNIZED : result;
     }
 
+    public static final int FROM_TRUNCATED_SNAPSHOT_FIELD_NUMBER = 16;
+    private boolean fromTruncatedSnapshot_ = false;
+    /**
+     * 
+     * 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; + * @return The fromTruncatedSnapshot. + */ + @java.lang.Override + public boolean getFromTruncatedSnapshot() { + return fromTruncatedSnapshot_; + } + private byte memoizedIsInitialized = -1; @java.lang.Override public final boolean isInitialized() { @@ -83682,6 +83841,9 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { if (sourceProvider_ != mxaccess_gateway.v1.MxaccessGateway.AlarmProviderMode.ALARM_PROVIDER_MODE_UNSPECIFIED.getNumber()) { output.writeEnum(15, sourceProvider_); } + if (fromTruncatedSnapshot_ != false) { + output.writeBool(16, fromTruncatedSnapshot_); + } getUnknownFields().writeTo(output); } @@ -83744,6 +83906,10 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { size += com.google.protobuf.CodedOutputStream .computeEnumSize(15, sourceProvider_); } + if (fromTruncatedSnapshot_ != false) { + size += com.google.protobuf.CodedOutputStream + .computeBoolSize(16, fromTruncatedSnapshot_); + } size += getUnknownFields().getSerializedSize(); memoizedSize = size; return size; @@ -83799,6 +83965,8 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { if (getDegraded() != other.getDegraded()) return false; if (sourceProvider_ != other.sourceProvider_) return false; + if (getFromTruncatedSnapshot() + != other.getFromTruncatedSnapshot()) return false; if (!getUnknownFields().equals(other.getUnknownFields())) return false; return true; } @@ -83849,6 +84017,9 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { getDegraded()); hash = (37 * hash) + SOURCE_PROVIDER_FIELD_NUMBER; hash = (53 * hash) + sourceProvider_; + hash = (37 * hash) + FROM_TRUNCATED_SNAPSHOT_FIELD_NUMBER; + hash = (53 * hash) + com.google.protobuf.Internal.hashBoolean( + getFromTruncatedSnapshot()); hash = (29 * hash) + getUnknownFields().hashCode(); memoizedHashCode = hash; return hash; @@ -84025,6 +84196,7 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { } degraded_ = false; sourceProvider_ = 0; + fromTruncatedSnapshot_ = false; return this; } @@ -84116,6 +84288,9 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { if (((from_bitField0_ & 0x00004000) != 0)) { result.sourceProvider_ = sourceProvider_; } + if (((from_bitField0_ & 0x00008000) != 0)) { + result.fromTruncatedSnapshot_ = fromTruncatedSnapshot_; + } result.bitField0_ |= to_bitField0_; } @@ -84190,6 +84365,9 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { if (other.sourceProvider_ != 0) { setSourceProviderValue(other.getSourceProviderValue()); } + if (other.getFromTruncatedSnapshot() != false) { + setFromTruncatedSnapshot(other.getFromTruncatedSnapshot()); + } this.mergeUnknownFields(other.getUnknownFields()); onChanged(); return this; @@ -84299,6 +84477,11 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { bitField0_ |= 0x00004000; break; } // case 120 + case 128: { + fromTruncatedSnapshot_ = input.readBool(); + bitField0_ |= 0x00008000; + break; + } // case 128 default: { if (!super.parseUnknownField(input, extensionRegistry, tag)) { done = true; // was an endgroup tag @@ -85616,6 +85799,74 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { return this; } + private boolean 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.
+       * 
+ * + * bool from_truncated_snapshot = 16; + * @return The fromTruncatedSnapshot. + */ + @java.lang.Override + public boolean getFromTruncatedSnapshot() { + return 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.
+       * 
+ * + * bool from_truncated_snapshot = 16; + * @param value The fromTruncatedSnapshot to set. + * @return This builder for chaining. + */ + public Builder setFromTruncatedSnapshot(boolean value) { + + fromTruncatedSnapshot_ = value; + bitField0_ |= 0x00008000; + onChanged(); + return this; + } + /** + *
+       * 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; + * @return This builder for chaining. + */ + public Builder clearFromTruncatedSnapshot() { + bitField0_ = (bitField0_ & ~0x00008000); + fromTruncatedSnapshot_ = false; + onChanged(); + return this; + } + // @@protoc_insertion_point(builder_scope:mxaccess_gateway.v1.ActiveAlarmSnapshot) } @@ -105307,278 +105558,279 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { "\n\016mxaccess_clsid\030\004 \001(\t\"@\n\020DrainEventsRep" + "ly\022,\n\006events\030\001 \003(\0132\034.mxaccess_gateway.v1" + ".MxEvent\"5\n\034AcknowledgeAlarmReplyPayload" + - "\022\025\n\rnative_status\030\001 \001(\005\"\\\n\035QueryActiveAl" + + "\022\025\n\rnative_status\030\001 \001(\005\"x\n\035QueryActiveAl" + "armsReplyPayload\022;\n\tsnapshots\030\001 \003(\0132(.mx" + - "access_gateway.v1.ActiveAlarmSnapshot\"\217\010" + - "\n\007MxEvent\0222\n\006family\030\001 \001(\0162\".mxaccess_gat" + - "eway.v1.MxEventFamily\022\022\n\nsession_id\030\002 \001(" + - "\t\022\025\n\rserver_handle\030\003 \001(\005\022\023\n\013item_handle\030" + - "\004 \001(\005\022+\n\005value\030\005 \001(\0132\034.mxaccess_gateway." + - "v1.MxValue\022\017\n\007quality\030\006 \001(\005\0224\n\020source_ti" + - "mestamp\030\007 \001(\0132\032.google.protobuf.Timestam" + - "p\0224\n\010statuses\030\010 \003(\0132\".mxaccess_gateway.v" + - "1.MxStatusProxy\022\027\n\017worker_sequence\030\t \001(\004" + - "\0224\n\020worker_timestamp\030\n \001(\0132\032.google.prot" + - "obuf.Timestamp\022=\n\031gateway_receive_timest" + - "amp\030\013 \001(\0132\032.google.protobuf.Timestamp\022\024\n" + - "\007hresult\030\014 \001(\005H\001\210\001\001\022\022\n\nraw_status\030\r \001(\t\022" + - "7\n\nreplay_gap\030\016 \001(\0132\036.mxaccess_gateway.v" + - "1.ReplayGapH\002\210\001\001\022@\n\016on_data_change\030\024 \001(\013" + - "2&.mxaccess_gateway.v1.OnDataChangeEvent" + - "H\000\022F\n\021on_write_complete\030\025 \001(\0132).mxaccess" + - "_gateway.v1.OnWriteCompleteEventH\000\022I\n\022op" + - "eration_complete\030\026 \001(\0132+.mxaccess_gatewa" + - "y.v1.OperationCompleteEventH\000\022Q\n\027on_buff" + - "ered_data_change\030\027 \001(\0132..mxaccess_gatewa" + - "y.v1.OnBufferedDataChangeEventH\000\022J\n\023on_a" + - "larm_transition\030\030 \001(\0132+.mxaccess_gateway" + - ".v1.OnAlarmTransitionEventH\000\022^\n\036on_alarm" + - "_provider_mode_changed\030\031 \001(\01324.mxaccess_" + - "gateway.v1.OnAlarmProviderModeChangedEve" + - "ntH\000B\006\n\004bodyB\n\n\010_hresultB\r\n\013_replay_gap\"" + - "P\n\tReplayGap\022 \n\030requested_after_sequence" + - "\030\001 \001(\004\022!\n\031oldest_available_sequence\030\002 \001(" + - "\004\"\023\n\021OnDataChangeEvent\"\026\n\024OnWriteComplet" + - "eEvent\"\030\n\026OperationCompleteEvent\"\324\001\n\031OnB" + - "ufferedDataChangeEvent\0222\n\tdata_type\030\001 \001(" + - "\0162\037.mxaccess_gateway.v1.MxDataType\0224\n\016qu" + - "ality_values\030\002 \001(\0132\034.mxaccess_gateway.v1" + - ".MxArray\0226\n\020timestamp_values\030\003 \001(\0132\034.mxa" + - "ccess_gateway.v1.MxArray\022\025\n\rraw_data_typ" + - "e\030\004 \001(\005\"\320\004\n\026OnAlarmTransitionEvent\022\034\n\024al" + - "arm_full_reference\030\001 \001(\t\022\037\n\027source_objec" + - "t_reference\030\002 \001(\t\022\027\n\017alarm_type_name\030\003 \001" + - "(\t\022A\n\017transition_kind\030\004 \001(\0162(.mxaccess_g" + - "ateway.v1.AlarmTransitionKind\022\020\n\010severit" + - "y\030\005 \001(\005\022<\n\030original_raise_timestamp\030\006 \001(" + - "\0132\032.google.protobuf.Timestamp\0228\n\024transit" + - "ion_timestamp\030\007 \001(\0132\032.google.protobuf.Ti" + - "mestamp\022\025\n\roperator_user\030\010 \001(\t\022\030\n\020operat" + - "or_comment\030\t \001(\t\022\020\n\010category\030\n \001(\t\022\023\n\013de" + - "scription\030\013 \001(\t\0223\n\rcurrent_value\030\014 \001(\0132\034" + - ".mxaccess_gateway.v1.MxValue\0221\n\013limit_va" + - "lue\030\r \001(\0132\034.mxaccess_gateway.v1.MxValue\022" + - "\020\n\010degraded\030\016 \001(\010\022?\n\017source_provider\030\017 \001" + - "(\0162&.mxaccess_gateway.v1.AlarmProviderMo" + - "de\"\240\001\n\037OnAlarmProviderModeChangedEvent\0224" + - "\n\004mode\030\001 \001(\0162&.mxaccess_gateway.v1.Alarm" + - "ProviderMode\022\016\n\006reason\030\002 \001(\t\022\017\n\007hresult\030" + - "\003 \001(\005\022&\n\002at\030\004 \001(\0132\032.google.protobuf.Time" + - "stamp\"\320\004\n\023ActiveAlarmSnapshot\022\034\n\024alarm_f" + - "ull_reference\030\001 \001(\t\022\037\n\027source_object_ref" + - "erence\030\002 \001(\t\022\027\n\017alarm_type_name\030\003 \001(\t\022\020\n" + - "\010severity\030\004 \001(\005\022<\n\030original_raise_timest" + - "amp\030\005 \001(\0132\032.google.protobuf.Timestamp\022?\n" + - "\rcurrent_state\030\006 \001(\0162(.mxaccess_gateway." + - "v1.AlarmConditionState\022\020\n\010category\030\007 \001(\t" + - "\022\023\n\013description\030\010 \001(\t\022=\n\031last_transition" + - "_timestamp\030\t \001(\0132\032.google.protobuf.Times" + - "tamp\022\025\n\roperator_user\030\n \001(\t\022\030\n\020operator_" + - "comment\030\013 \001(\t\0223\n\rcurrent_value\030\014 \001(\0132\034.m" + - "xaccess_gateway.v1.MxValue\0221\n\013limit_valu" + - "e\030\r \001(\0132\034.mxaccess_gateway.v1.MxValue\022\020\n" + - "\010degraded\030\016 \001(\010\022?\n\017source_provider\030\017 \001(\016" + + "access_gateway.v1.ActiveAlarmSnapshot\022\032\n" + + "\022snapshot_truncated\030\002 \001(\010\"\217\010\n\007MxEvent\0222\n" + + "\006family\030\001 \001(\0162\".mxaccess_gateway.v1.MxEv" + + "entFamily\022\022\n\nsession_id\030\002 \001(\t\022\025\n\rserver_" + + "handle\030\003 \001(\005\022\023\n\013item_handle\030\004 \001(\005\022+\n\005val" + + "ue\030\005 \001(\0132\034.mxaccess_gateway.v1.MxValue\022\017" + + "\n\007quality\030\006 \001(\005\0224\n\020source_timestamp\030\007 \001(" + + "\0132\032.google.protobuf.Timestamp\0224\n\010statuse" + + "s\030\010 \003(\0132\".mxaccess_gateway.v1.MxStatusPr" + + "oxy\022\027\n\017worker_sequence\030\t \001(\004\0224\n\020worker_t" + + "imestamp\030\n \001(\0132\032.google.protobuf.Timesta" + + "mp\022=\n\031gateway_receive_timestamp\030\013 \001(\0132\032." + + "google.protobuf.Timestamp\022\024\n\007hresult\030\014 \001" + + "(\005H\001\210\001\001\022\022\n\nraw_status\030\r \001(\t\0227\n\nreplay_ga" + + "p\030\016 \001(\0132\036.mxaccess_gateway.v1.ReplayGapH" + + "\002\210\001\001\022@\n\016on_data_change\030\024 \001(\0132&.mxaccess_" + + "gateway.v1.OnDataChangeEventH\000\022F\n\021on_wri" + + "te_complete\030\025 \001(\0132).mxaccess_gateway.v1." + + "OnWriteCompleteEventH\000\022I\n\022operation_comp" + + "lete\030\026 \001(\0132+.mxaccess_gateway.v1.Operati" + + "onCompleteEventH\000\022Q\n\027on_buffered_data_ch" + + "ange\030\027 \001(\0132..mxaccess_gateway.v1.OnBuffe" + + "redDataChangeEventH\000\022J\n\023on_alarm_transit" + + "ion\030\030 \001(\0132+.mxaccess_gateway.v1.OnAlarmT" + + "ransitionEventH\000\022^\n\036on_alarm_provider_mo" + + "de_changed\030\031 \001(\01324.mxaccess_gateway.v1.O" + + "nAlarmProviderModeChangedEventH\000B\006\n\004body" + + "B\n\n\010_hresultB\r\n\013_replay_gap\"P\n\tReplayGap" + + "\022 \n\030requested_after_sequence\030\001 \001(\004\022!\n\031ol" + + "dest_available_sequence\030\002 \001(\004\"\023\n\021OnDataC" + + "hangeEvent\"\026\n\024OnWriteCompleteEvent\"\030\n\026Op" + + "erationCompleteEvent\"\324\001\n\031OnBufferedDataC" + + "hangeEvent\0222\n\tdata_type\030\001 \001(\0162\037.mxaccess" + + "_gateway.v1.MxDataType\0224\n\016quality_values" + + "\030\002 \001(\0132\034.mxaccess_gateway.v1.MxArray\0226\n\020" + + "timestamp_values\030\003 \001(\0132\034.mxaccess_gatewa" + + "y.v1.MxArray\022\025\n\rraw_data_type\030\004 \001(\005\"\320\004\n\026" + + "OnAlarmTransitionEvent\022\034\n\024alarm_full_ref" + + "erence\030\001 \001(\t\022\037\n\027source_object_reference\030" + + "\002 \001(\t\022\027\n\017alarm_type_name\030\003 \001(\t\022A\n\017transi" + + "tion_kind\030\004 \001(\0162(.mxaccess_gateway.v1.Al" + + "armTransitionKind\022\020\n\010severity\030\005 \001(\005\022<\n\030o" + + "riginal_raise_timestamp\030\006 \001(\0132\032.google.p" + + "rotobuf.Timestamp\0228\n\024transition_timestam" + + "p\030\007 \001(\0132\032.google.protobuf.Timestamp\022\025\n\ro" + + "perator_user\030\010 \001(\t\022\030\n\020operator_comment\030\t" + + " \001(\t\022\020\n\010category\030\n \001(\t\022\023\n\013description\030\013 " + + "\001(\t\0223\n\rcurrent_value\030\014 \001(\0132\034.mxaccess_ga" + + "teway.v1.MxValue\0221\n\013limit_value\030\r \001(\0132\034." + + "mxaccess_gateway.v1.MxValue\022\020\n\010degraded\030" + + "\016 \001(\010\022?\n\017source_provider\030\017 \001(\0162&.mxacces" + + "s_gateway.v1.AlarmProviderMode\"\240\001\n\037OnAla" + + "rmProviderModeChangedEvent\0224\n\004mode\030\001 \001(\016" + "2&.mxaccess_gateway.v1.AlarmProviderMode" + - "\"\220\001\n\027AcknowledgeAlarmRequest\022\035\n\025client_c" + - "orrelation_id\030\002 \001(\t\022\034\n\024alarm_full_refere" + - "nce\030\003 \001(\t\022\017\n\007comment\030\004 \001(\t\022\025\n\roperator_u" + - "ser\030\005 \001(\tJ\004\010\001\020\002R\nsession_id\"\361\001\n\025Acknowle" + - "dgeAlarmReply\022\026\n\016correlation_id\030\002 \001(\t\022<\n" + - "\017protocol_status\030\003 \001(\0132#.mxaccess_gatewa" + - "y.v1.ProtocolStatus\022\024\n\007hresult\030\004 \001(\005H\000\210\001" + - "\001\0222\n\006status\030\005 \001(\0132\".mxaccess_gateway.v1." + - "MxStatusProxy\022\032\n\022diagnostic_message\030\006 \001(" + - "\tB\n\n\010_hresultJ\004\010\001\020\002R\nsession_id\"Q\n\023Strea" + - "mAlarmsRequest\022\035\n\025client_correlation_id\030" + - "\001 \001(\t\022\033\n\023alarm_filter_prefix\030\002 \001(\t\"\204\002\n\020A" + - "larmFeedMessage\022@\n\014active_alarm\030\001 \001(\0132(." + - "mxaccess_gateway.v1.ActiveAlarmSnapshotH" + - "\000\022\033\n\021snapshot_complete\030\002 \001(\010H\000\022A\n\ntransi" + - "tion\030\003 \001(\0132+.mxaccess_gateway.v1.OnAlarm" + - "TransitionEventH\000\022C\n\017provider_status\030\004 \001" + - "(\0132(.mxaccess_gateway.v1.AlarmProviderSt" + - "atusH\000B\t\n\007payload\"\230\001\n\023AlarmProviderStatu" + - "s\0224\n\004mode\030\001 \001(\0162&.mxaccess_gateway.v1.Al" + - "armProviderMode\022\020\n\010degraded\030\002 \001(\010\022\016\n\006rea" + - "son\030\003 \001(\t\022)\n\005since\030\004 \001(\0132\032.google.protob" + - "uf.Timestamp\"\353\001\n\rMxStatusProxy\022\017\n\007succes" + - "s\030\001 \001(\005\0227\n\010category\030\002 \001(\0162%.mxaccess_gat" + - "eway.v1.MxStatusCategory\0228\n\013detected_by\030" + - "\003 \001(\0162#.mxaccess_gateway.v1.MxStatusSour" + - "ce\022\016\n\006detail\030\004 \001(\005\022\024\n\014raw_category\030\005 \001(\005" + - "\022\027\n\017raw_detected_by\030\006 \001(\005\022\027\n\017diagnostic_" + - "text\030\007 \001(\t\"\351\003\n\007MxValue\0222\n\tdata_type\030\001 \001(" + - "\0162\037.mxaccess_gateway.v1.MxDataType\022\024\n\014va" + - "riant_type\030\002 \001(\t\022\017\n\007is_null\030\003 \001(\010\022\026\n\016raw" + - "_diagnostic\030\004 \001(\t\022\025\n\rraw_data_type\030\005 \001(\005" + - "\022\024\n\nbool_value\030\n \001(\010H\000\022\025\n\013int32_value\030\013 " + - "\001(\005H\000\022\025\n\013int64_value\030\014 \001(\003H\000\022\025\n\013float_va", - "lue\030\r \001(\002H\000\022\026\n\014double_value\030\016 \001(\001H\000\022\026\n\014s" + - "tring_value\030\017 \001(\tH\000\0225\n\017timestamp_value\030\020" + - " \001(\0132\032.google.protobuf.TimestampH\000\0223\n\013ar" + - "ray_value\030\021 \001(\0132\034.mxaccess_gateway.v1.Mx" + - "ArrayH\000\022\023\n\traw_value\030\022 \001(\014H\000\022@\n\022sparse_a" + - "rray_value\030\023 \001(\0132\".mxaccess_gateway.v1.M" + - "xSparseArrayH\000B\006\n\004kind\"\376\004\n\007MxArray\022:\n\021el" + - "ement_data_type\030\001 \001(\0162\037.mxaccess_gateway" + - ".v1.MxDataType\022\024\n\014variant_type\030\002 \001(\t\022\022\n\n" + - "dimensions\030\003 \003(\r\022\026\n\016raw_diagnostic\030\004 \001(\t" + - "\022\035\n\025raw_element_data_type\030\005 \001(\005\0225\n\013bool_" + - "values\030\n \001(\0132\036.mxaccess_gateway.v1.BoolA" + - "rrayH\000\0227\n\014int32_values\030\013 \001(\0132\037.mxaccess_" + - "gateway.v1.Int32ArrayH\000\0227\n\014int64_values\030" + - "\014 \001(\0132\037.mxaccess_gateway.v1.Int64ArrayH\000" + - "\0227\n\014float_values\030\r \001(\0132\037.mxaccess_gatewa" + - "y.v1.FloatArrayH\000\0229\n\rdouble_values\030\016 \001(\013" + - "2 .mxaccess_gateway.v1.DoubleArrayH\000\0229\n\r" + - "string_values\030\017 \001(\0132 .mxaccess_gateway.v" + - "1.StringArrayH\000\022?\n\020timestamp_values\030\020 \001(" + - "\0132#.mxaccess_gateway.v1.TimestampArrayH\000" + - "\0223\n\nraw_values\030\021 \001(\0132\035.mxaccess_gateway." + - "v1.RawArrayH\000B\010\n\006values\"\231\001\n\rMxSparseArra" + - "y\022:\n\021element_data_type\030\001 \001(\0162\037.mxaccess_" + - "gateway.v1.MxDataType\022\024\n\014total_length\030\002 " + - "\001(\r\0226\n\010elements\030\003 \003(\0132$.mxaccess_gateway" + - ".v1.MxSparseElement\"M\n\017MxSparseElement\022\r" + - "\n\005index\030\001 \001(\r\022+\n\005value\030\002 \001(\0132\034.mxaccess_" + - "gateway.v1.MxValue\"\033\n\tBoolArray\022\016\n\006value" + - "s\030\001 \003(\010\"\034\n\nInt32Array\022\016\n\006values\030\001 \003(\005\"\034\n" + - "\nInt64Array\022\016\n\006values\030\001 \003(\003\"\034\n\nFloatArra" + - "y\022\016\n\006values\030\001 \003(\002\"\035\n\013DoubleArray\022\016\n\006valu" + - "es\030\001 \003(\001\"\035\n\013StringArray\022\016\n\006values\030\001 \003(\t\"" + - "<\n\016TimestampArray\022*\n\006values\030\001 \003(\0132\032.goog" + - "le.protobuf.Timestamp\"\032\n\010RawArray\022\016\n\006val" + - "ues\030\001 \003(\014\"X\n\016ProtocolStatus\0225\n\004code\030\001 \001(" + - "\0162\'.mxaccess_gateway.v1.ProtocolStatusCo" + - "de\022\017\n\007message\030\002 \001(\t*\237\013\n\rMxCommandKind\022\037\n" + - "\033MX_COMMAND_KIND_UNSPECIFIED\020\000\022\034\n\030MX_COM" + - "MAND_KIND_REGISTER\020\001\022\036\n\032MX_COMMAND_KIND_" + - "UNREGISTER\020\002\022\034\n\030MX_COMMAND_KIND_ADD_ITEM" + - "\020\003\022\035\n\031MX_COMMAND_KIND_ADD_ITEM2\020\004\022\037\n\033MX_" + - "COMMAND_KIND_REMOVE_ITEM\020\005\022\032\n\026MX_COMMAND" + - "_KIND_ADVISE\020\006\022\035\n\031MX_COMMAND_KIND_UN_ADV" + - "ISE\020\007\022&\n\"MX_COMMAND_KIND_ADVISE_SUPERVIS" + - "ORY\020\010\022%\n!MX_COMMAND_KIND_ADD_BUFFERED_IT" + - "EM\020\t\0220\n,MX_COMMAND_KIND_SET_BUFFERED_UPD" + - "ATE_INTERVAL\020\n\022\033\n\027MX_COMMAND_KIND_SUSPEN" + - "D\020\013\022\034\n\030MX_COMMAND_KIND_ACTIVATE\020\014\022\031\n\025MX_" + - "COMMAND_KIND_WRITE\020\r\022\032\n\026MX_COMMAND_KIND_" + - "WRITE2\020\016\022!\n\035MX_COMMAND_KIND_WRITE_SECURE" + - "D\020\017\022\"\n\036MX_COMMAND_KIND_WRITE_SECURED2\020\020\022" + - "%\n!MX_COMMAND_KIND_AUTHENTICATE_USER\020\021\022(" + - "\n$MX_COMMAND_KIND_ARCHESTRA_USER_TO_ID\020\022" + - "\022!\n\035MX_COMMAND_KIND_ADD_ITEM_BULK\020\023\022$\n M" + - "X_COMMAND_KIND_ADVISE_ITEM_BULK\020\024\022$\n MX_" + - "COMMAND_KIND_REMOVE_ITEM_BULK\020\025\022\'\n#MX_CO" + - "MMAND_KIND_UN_ADVISE_ITEM_BULK\020\026\022\"\n\036MX_C" + - "OMMAND_KIND_SUBSCRIBE_BULK\020\027\022$\n MX_COMMA" + - "ND_KIND_UNSUBSCRIBE_BULK\020\030\022$\n MX_COMMAND" + - "_KIND_SUBSCRIBE_ALARMS\020\031\022&\n\"MX_COMMAND_K" + - "IND_UNSUBSCRIBE_ALARMS\020\032\022%\n!MX_COMMAND_K" + - "IND_ACKNOWLEDGE_ALARM\020\033\022\'\n#MX_COMMAND_KI" + - "ND_QUERY_ACTIVE_ALARMS\020\034\022-\n)MX_COMMAND_K" + - "IND_ACKNOWLEDGE_ALARM_BY_NAME\020\035\022\036\n\032MX_CO" + - "MMAND_KIND_WRITE_BULK\020\036\022\037\n\033MX_COMMAND_KI" + - "ND_WRITE2_BULK\020\037\022&\n\"MX_COMMAND_KIND_WRIT" + - "E_SECURED_BULK\020 \022\'\n#MX_COMMAND_KIND_WRIT" + - "E_SECURED2_BULK\020!\022\035\n\031MX_COMMAND_KIND_REA" + - "D_BULK\020\"\022\030\n\024MX_COMMAND_KIND_PING\020d\022%\n!MX" + - "_COMMAND_KIND_GET_SESSION_STATE\020e\022#\n\037MX_" + - "COMMAND_KIND_GET_WORKER_INFO\020f\022 \n\034MX_COM" + - "MAND_KIND_DRAIN_EVENTS\020g\022#\n\037MX_COMMAND_K" + - "IND_SHUTDOWN_WORKER\020h*z\n\021AlarmProviderMo" + - "de\022#\n\037ALARM_PROVIDER_MODE_UNSPECIFIED\020\000\022" + - " \n\034ALARM_PROVIDER_MODE_ALARMMGR\020\001\022\036\n\032ALA" + - "RM_PROVIDER_MODE_SUBTAG\020\002*\255\002\n\rMxEventFam" + - "ily\022\037\n\033MX_EVENT_FAMILY_UNSPECIFIED\020\000\022\"\n\036" + - "MX_EVENT_FAMILY_ON_DATA_CHANGE\020\001\022%\n!MX_E" + - "VENT_FAMILY_ON_WRITE_COMPLETE\020\002\022&\n\"MX_EV" + - "ENT_FAMILY_OPERATION_COMPLETE\020\003\022+\n\'MX_EV" + - "ENT_FAMILY_ON_BUFFERED_DATA_CHANGE\020\004\022\'\n#" + - "MX_EVENT_FAMILY_ON_ALARM_TRANSITION\020\005\0222\n" + - ".MX_EVENT_FAMILY_ON_ALARM_PROVIDER_MODE_" + - "CHANGED\020\006*\312\001\n\023AlarmTransitionKind\022%\n!ALA" + - "RM_TRANSITION_KIND_UNSPECIFIED\020\000\022\037\n\033ALAR" + - "M_TRANSITION_KIND_RAISE\020\001\022%\n!ALARM_TRANS" + - "ITION_KIND_ACKNOWLEDGE\020\002\022\037\n\033ALARM_TRANSI" + - "TION_KIND_CLEAR\020\003\022#\n\037ALARM_TRANSITION_KI" + - "ND_RETRIGGER\020\004*\252\001\n\023AlarmConditionState\022%" + - "\n!ALARM_CONDITION_STATE_UNSPECIFIED\020\000\022 \n" + - "\034ALARM_CONDITION_STATE_ACTIVE\020\001\022&\n\"ALARM" + - "_CONDITION_STATE_ACTIVE_ACKED\020\002\022\"\n\036ALARM" + - "_CONDITION_STATE_INACTIVE\020\003*\245\003\n\020MxStatus" + - "Category\022\"\n\036MX_STATUS_CATEGORY_UNSPECIFI" + - "ED\020\000\022\036\n\032MX_STATUS_CATEGORY_UNKNOWN\020\001\022\031\n\025" + - "MX_STATUS_CATEGORY_OK\020\002\022\036\n\032MX_STATUS_CAT" + - "EGORY_PENDING\020\003\022\036\n\032MX_STATUS_CATEGORY_WA" + - "RNING\020\004\022*\n&MX_STATUS_CATEGORY_COMMUNICAT" + - "ION_ERROR\020\005\022*\n&MX_STATUS_CATEGORY_CONFIG" + - "URATION_ERROR\020\006\022(\n$MX_STATUS_CATEGORY_OP" + - "ERATIONAL_ERROR\020\007\022%\n!MX_STATUS_CATEGORY_" + - "SECURITY_ERROR\020\010\022%\n!MX_STATUS_CATEGORY_S" + - "OFTWARE_ERROR\020\t\022\"\n\036MX_STATUS_CATEGORY_OT" + - "HER_ERROR\020\n*\312\002\n\016MxStatusSource\022 \n\034MX_STA" + - "TUS_SOURCE_UNSPECIFIED\020\000\022\034\n\030MX_STATUS_SO" + - "URCE_UNKNOWN\020\001\022#\n\037MX_STATUS_SOURCE_REQUE" + - "STING_LMX\020\002\022#\n\037MX_STATUS_SOURCE_RESPONDI" + - "NG_LMX\020\003\022#\n\037MX_STATUS_SOURCE_REQUESTING_" + - "NMX\020\004\022#\n\037MX_STATUS_SOURCE_RESPONDING_NMX" + - "\020\005\0221\n-MX_STATUS_SOURCE_REQUESTING_AUTOMA" + - "TION_OBJECT\020\006\0221\n-MX_STATUS_SOURCE_RESPON" + - "DING_AUTOMATION_OBJECT\020\007*\335\004\n\nMxDataType\022" + - "\034\n\030MX_DATA_TYPE_UNSPECIFIED\020\000\022\030\n\024MX_DATA" + - "_TYPE_UNKNOWN\020\001\022\030\n\024MX_DATA_TYPE_NO_DATA\020" + - "\002\022\030\n\024MX_DATA_TYPE_BOOLEAN\020\003\022\030\n\024MX_DATA_T" + - "YPE_INTEGER\020\004\022\026\n\022MX_DATA_TYPE_FLOAT\020\005\022\027\n" + - "\023MX_DATA_TYPE_DOUBLE\020\006\022\027\n\023MX_DATA_TYPE_S" + - "TRING\020\007\022\025\n\021MX_DATA_TYPE_TIME\020\010\022\035\n\031MX_DAT" + - "A_TYPE_ELAPSED_TIME\020\t\022\037\n\033MX_DATA_TYPE_RE" + - "FERENCE_TYPE\020\n\022\034\n\030MX_DATA_TYPE_STATUS_TY" + - "PE\020\013\022\025\n\021MX_DATA_TYPE_ENUM\020\014\022-\n)MX_DATA_T" + - "YPE_SECURITY_CLASSIFICATION_ENUM\020\r\022\"\n\036MX" + - "_DATA_TYPE_DATA_QUALITY_TYPE\020\016\022\037\n\033MX_DAT" + - "A_TYPE_QUALIFIED_ENUM\020\017\022!\n\035MX_DATA_TYPE_" + - "QUALIFIED_STRUCT\020\020\022)\n%MX_DATA_TYPE_INTER" + - "NATIONALIZED_STRING\020\021\022\033\n\027MX_DATA_TYPE_BI" + - "G_STRING\020\022\022\024\n\020MX_DATA_TYPE_END\020\023*\243\003\n\022Pro" + - "tocolStatusCode\022$\n PROTOCOL_STATUS_CODE_" + - "UNSPECIFIED\020\000\022\033\n\027PROTOCOL_STATUS_CODE_OK" + - "\020\001\022(\n$PROTOCOL_STATUS_CODE_INVALID_REQUE" + - "ST\020\002\022*\n&PROTOCOL_STATUS_CODE_SESSION_NOT" + - "_FOUND\020\003\022*\n&PROTOCOL_STATUS_CODE_SESSION" + - "_NOT_READY\020\004\022+\n\'PROTOCOL_STATUS_CODE_WOR" + - "KER_UNAVAILABLE\020\005\022 \n\034PROTOCOL_STATUS_COD" + - "E_TIMEOUT\020\006\022!\n\035PROTOCOL_STATUS_CODE_CANC" + - "ELED\020\007\022+\n\'PROTOCOL_STATUS_CODE_PROTOCOL_" + - "VIOLATION\020\010\022)\n%PROTOCOL_STATUS_CODE_MXAC" + - "CESS_FAILURE\020\t*\277\002\n\014SessionState\022\035\n\031SESSI" + - "ON_STATE_UNSPECIFIED\020\000\022\032\n\026SESSION_STATE_" + - "CREATING\020\001\022!\n\035SESSION_STATE_STARTING_WOR" + - "KER\020\002\022\"\n\036SESSION_STATE_WAITING_FOR_PIPE\020" + - "\003\022\035\n\031SESSION_STATE_HANDSHAKING\020\004\022%\n!SESS" + - "ION_STATE_INITIALIZING_WORKER\020\005\022\027\n\023SESSI" + - "ON_STATE_READY\020\006\022\031\n\025SESSION_STATE_CLOSIN" + - "G\020\007\022\030\n\024SESSION_STATE_CLOSED\020\010\022\031\n\025SESSION" + - "_STATE_FAULTED\020\t2\303\005\n\017MxAccessGateway\022]\n\013" + - "OpenSession\022\'.mxaccess_gateway.v1.OpenSe" + - "ssionRequest\032%.mxaccess_gateway.v1.OpenS" + - "essionReply\022`\n\014CloseSession\022(.mxaccess_g" + - "ateway.v1.CloseSessionRequest\032&.mxaccess" + - "_gateway.v1.CloseSessionReply\022T\n\006Invoke\022" + - "%.mxaccess_gateway.v1.MxCommandRequest\032#" + - ".mxaccess_gateway.v1.MxCommandReply\022X\n\014S" + - "treamEvents\022(.mxaccess_gateway.v1.Stream" + - "EventsRequest\032\034.mxaccess_gateway.v1.MxEv" + - "ent0\001\022l\n\020AcknowledgeAlarm\022,.mxaccess_gat" + - "eway.v1.AcknowledgeAlarmRequest\032*.mxacce" + - "ss_gateway.v1.AcknowledgeAlarmReply\022a\n\014S" + - "treamAlarms\022(.mxaccess_gateway.v1.Stream" + - "AlarmsRequest\032%.mxaccess_gateway.v1.Alar" + - "mFeedMessage0\001\022n\n\021QueryActiveAlarms\022-.mx" + - "access_gateway.v1.QueryActiveAlarmsReque" + - "st\032(.mxaccess_gateway.v1.ActiveAlarmSnap" + - "shot0\001B&\252\002#ZB.MOM.WW.MxGateway.Contracts" + - ".Protob\006proto3" + "\022\016\n\006reason\030\002 \001(\t\022\017\n\007hresult\030\003 \001(\005\022&\n\002at\030" + + "\004 \001(\0132\032.google.protobuf.Timestamp\"\361\004\n\023Ac" + + "tiveAlarmSnapshot\022\034\n\024alarm_full_referenc" + + "e\030\001 \001(\t\022\037\n\027source_object_reference\030\002 \001(\t" + + "\022\027\n\017alarm_type_name\030\003 \001(\t\022\020\n\010severity\030\004 " + + "\001(\005\022<\n\030original_raise_timestamp\030\005 \001(\0132\032." + + "google.protobuf.Timestamp\022?\n\rcurrent_sta" + + "te\030\006 \001(\0162(.mxaccess_gateway.v1.AlarmCond" + + "itionState\022\020\n\010category\030\007 \001(\t\022\023\n\013descript" + + "ion\030\010 \001(\t\022=\n\031last_transition_timestamp\030\t" + + " \001(\0132\032.google.protobuf.Timestamp\022\025\n\roper" + + "ator_user\030\n \001(\t\022\030\n\020operator_comment\030\013 \001(" + + "\t\0223\n\rcurrent_value\030\014 \001(\0132\034.mxaccess_gate" + + "way.v1.MxValue\0221\n\013limit_value\030\r \001(\0132\034.mx" + + "access_gateway.v1.MxValue\022\020\n\010degraded\030\016 " + + "\001(\010\022?\n\017source_provider\030\017 \001(\0162&.mxaccess_" + + "gateway.v1.AlarmProviderMode\022\037\n\027from_tru" + + "ncated_snapshot\030\020 \001(\010\"\220\001\n\027AcknowledgeAla" + + "rmRequest\022\035\n\025client_correlation_id\030\002 \001(\t" + + "\022\034\n\024alarm_full_reference\030\003 \001(\t\022\017\n\007commen" + + "t\030\004 \001(\t\022\025\n\roperator_user\030\005 \001(\tJ\004\010\001\020\002R\nse" + + "ssion_id\"\361\001\n\025AcknowledgeAlarmReply\022\026\n\016co" + + "rrelation_id\030\002 \001(\t\022<\n\017protocol_status\030\003 " + + "\001(\0132#.mxaccess_gateway.v1.ProtocolStatus" + + "\022\024\n\007hresult\030\004 \001(\005H\000\210\001\001\0222\n\006status\030\005 \001(\0132\"" + + ".mxaccess_gateway.v1.MxStatusProxy\022\032\n\022di" + + "agnostic_message\030\006 \001(\tB\n\n\010_hresultJ\004\010\001\020\002" + + "R\nsession_id\"Q\n\023StreamAlarmsRequest\022\035\n\025c" + + "lient_correlation_id\030\001 \001(\t\022\033\n\023alarm_filt" + + "er_prefix\030\002 \001(\t\"\204\002\n\020AlarmFeedMessage\022@\n\014" + + "active_alarm\030\001 \001(\0132(.mxaccess_gateway.v1" + + ".ActiveAlarmSnapshotH\000\022\033\n\021snapshot_compl" + + "ete\030\002 \001(\010H\000\022A\n\ntransition\030\003 \001(\0132+.mxacce" + + "ss_gateway.v1.OnAlarmTransitionEventH\000\022C" + + "\n\017provider_status\030\004 \001(\0132(.mxaccess_gatew" + + "ay.v1.AlarmProviderStatusH\000B\t\n\007payload\"\230" + + "\001\n\023AlarmProviderStatus\0224\n\004mode\030\001 \001(\0162&.m" + + "xaccess_gateway.v1.AlarmProviderMode\022\020\n\010" + + "degraded\030\002 \001(\010\022\016\n\006reason\030\003 \001(\t\022)\n\005since\030" + + "\004 \001(\0132\032.google.protobuf.Timestamp\"\353\001\n\rMx" + + "StatusProxy\022\017\n\007success\030\001 \001(\005\0227\n\010category" + + "\030\002 \001(\0162%.mxaccess_gateway.v1.MxStatusCat" + + "egory\0228\n\013detected_by\030\003 \001(\0162#.mxaccess_ga" + + "teway.v1.MxStatusSource\022\016\n\006detail\030\004 \001(\005\022" + + "\024\n\014raw_category\030\005 \001(\005\022\027\n\017raw_detected_by" + + "\030\006 \001(\005\022\027\n\017diagnostic_text\030\007 \001(\t\"\351\003\n\007MxVa" + + "lue\0222\n\tdata_type\030\001 \001(\0162\037.mxaccess_gatewa" + + "y.v1.MxDataType\022\024\n\014variant_type\030\002 \001(\t\022\017\n" + + "\007is_null\030\003 \001(\010\022\026\n\016raw_diagnostic\030\004 \001(\t\022\025" + + "\n\rraw_data_type\030\005 \001(\005\022\024\n\nbool_value\030\n \001(", + "\010H\000\022\025\n\013int32_value\030\013 \001(\005H\000\022\025\n\013int64_valu" + + "e\030\014 \001(\003H\000\022\025\n\013float_value\030\r \001(\002H\000\022\026\n\014doub" + + "le_value\030\016 \001(\001H\000\022\026\n\014string_value\030\017 \001(\tH\000" + + "\0225\n\017timestamp_value\030\020 \001(\0132\032.google.proto" + + "buf.TimestampH\000\0223\n\013array_value\030\021 \001(\0132\034.m" + + "xaccess_gateway.v1.MxArrayH\000\022\023\n\traw_valu" + + "e\030\022 \001(\014H\000\022@\n\022sparse_array_value\030\023 \001(\0132\"." + + "mxaccess_gateway.v1.MxSparseArrayH\000B\006\n\004k" + + "ind\"\376\004\n\007MxArray\022:\n\021element_data_type\030\001 \001" + + "(\0162\037.mxaccess_gateway.v1.MxDataType\022\024\n\014v" + + "ariant_type\030\002 \001(\t\022\022\n\ndimensions\030\003 \003(\r\022\026\n" + + "\016raw_diagnostic\030\004 \001(\t\022\035\n\025raw_element_dat" + + "a_type\030\005 \001(\005\0225\n\013bool_values\030\n \001(\0132\036.mxac" + + "cess_gateway.v1.BoolArrayH\000\0227\n\014int32_val" + + "ues\030\013 \001(\0132\037.mxaccess_gateway.v1.Int32Arr" + + "ayH\000\0227\n\014int64_values\030\014 \001(\0132\037.mxaccess_ga" + + "teway.v1.Int64ArrayH\000\0227\n\014float_values\030\r " + + "\001(\0132\037.mxaccess_gateway.v1.FloatArrayH\000\0229" + + "\n\rdouble_values\030\016 \001(\0132 .mxaccess_gateway" + + ".v1.DoubleArrayH\000\0229\n\rstring_values\030\017 \001(\013" + + "2 .mxaccess_gateway.v1.StringArrayH\000\022?\n\020" + + "timestamp_values\030\020 \001(\0132#.mxaccess_gatewa" + + "y.v1.TimestampArrayH\000\0223\n\nraw_values\030\021 \001(" + + "\0132\035.mxaccess_gateway.v1.RawArrayH\000B\010\n\006va" + + "lues\"\231\001\n\rMxSparseArray\022:\n\021element_data_t" + + "ype\030\001 \001(\0162\037.mxaccess_gateway.v1.MxDataTy" + + "pe\022\024\n\014total_length\030\002 \001(\r\0226\n\010elements\030\003 \003" + + "(\0132$.mxaccess_gateway.v1.MxSparseElement" + + "\"M\n\017MxSparseElement\022\r\n\005index\030\001 \001(\r\022+\n\005va" + + "lue\030\002 \001(\0132\034.mxaccess_gateway.v1.MxValue\"" + + "\033\n\tBoolArray\022\016\n\006values\030\001 \003(\010\"\034\n\nInt32Arr" + + "ay\022\016\n\006values\030\001 \003(\005\"\034\n\nInt64Array\022\016\n\006valu" + + "es\030\001 \003(\003\"\034\n\nFloatArray\022\016\n\006values\030\001 \003(\002\"\035" + + "\n\013DoubleArray\022\016\n\006values\030\001 \003(\001\"\035\n\013StringA" + + "rray\022\016\n\006values\030\001 \003(\t\"<\n\016TimestampArray\022*" + + "\n\006values\030\001 \003(\0132\032.google.protobuf.Timesta" + + "mp\"\032\n\010RawArray\022\016\n\006values\030\001 \003(\014\"X\n\016Protoc" + + "olStatus\0225\n\004code\030\001 \001(\0162\'.mxaccess_gatewa" + + "y.v1.ProtocolStatusCode\022\017\n\007message\030\002 \001(\t" + + "*\237\013\n\rMxCommandKind\022\037\n\033MX_COMMAND_KIND_UN" + + "SPECIFIED\020\000\022\034\n\030MX_COMMAND_KIND_REGISTER\020" + + "\001\022\036\n\032MX_COMMAND_KIND_UNREGISTER\020\002\022\034\n\030MX_" + + "COMMAND_KIND_ADD_ITEM\020\003\022\035\n\031MX_COMMAND_KI" + + "ND_ADD_ITEM2\020\004\022\037\n\033MX_COMMAND_KIND_REMOVE" + + "_ITEM\020\005\022\032\n\026MX_COMMAND_KIND_ADVISE\020\006\022\035\n\031M" + + "X_COMMAND_KIND_UN_ADVISE\020\007\022&\n\"MX_COMMAND" + + "_KIND_ADVISE_SUPERVISORY\020\010\022%\n!MX_COMMAND" + + "_KIND_ADD_BUFFERED_ITEM\020\t\0220\n,MX_COMMAND_" + + "KIND_SET_BUFFERED_UPDATE_INTERVAL\020\n\022\033\n\027M" + + "X_COMMAND_KIND_SUSPEND\020\013\022\034\n\030MX_COMMAND_K" + + "IND_ACTIVATE\020\014\022\031\n\025MX_COMMAND_KIND_WRITE\020" + + "\r\022\032\n\026MX_COMMAND_KIND_WRITE2\020\016\022!\n\035MX_COMM" + + "AND_KIND_WRITE_SECURED\020\017\022\"\n\036MX_COMMAND_K" + + "IND_WRITE_SECURED2\020\020\022%\n!MX_COMMAND_KIND_" + + "AUTHENTICATE_USER\020\021\022(\n$MX_COMMAND_KIND_A" + + "RCHESTRA_USER_TO_ID\020\022\022!\n\035MX_COMMAND_KIND" + + "_ADD_ITEM_BULK\020\023\022$\n MX_COMMAND_KIND_ADVI" + + "SE_ITEM_BULK\020\024\022$\n MX_COMMAND_KIND_REMOVE" + + "_ITEM_BULK\020\025\022\'\n#MX_COMMAND_KIND_UN_ADVIS" + + "E_ITEM_BULK\020\026\022\"\n\036MX_COMMAND_KIND_SUBSCRI" + + "BE_BULK\020\027\022$\n MX_COMMAND_KIND_UNSUBSCRIBE" + + "_BULK\020\030\022$\n MX_COMMAND_KIND_SUBSCRIBE_ALA" + + "RMS\020\031\022&\n\"MX_COMMAND_KIND_UNSUBSCRIBE_ALA" + + "RMS\020\032\022%\n!MX_COMMAND_KIND_ACKNOWLEDGE_ALA" + + "RM\020\033\022\'\n#MX_COMMAND_KIND_QUERY_ACTIVE_ALA" + + "RMS\020\034\022-\n)MX_COMMAND_KIND_ACKNOWLEDGE_ALA" + + "RM_BY_NAME\020\035\022\036\n\032MX_COMMAND_KIND_WRITE_BU" + + "LK\020\036\022\037\n\033MX_COMMAND_KIND_WRITE2_BULK\020\037\022&\n" + + "\"MX_COMMAND_KIND_WRITE_SECURED_BULK\020 \022\'\n" + + "#MX_COMMAND_KIND_WRITE_SECURED2_BULK\020!\022\035" + + "\n\031MX_COMMAND_KIND_READ_BULK\020\"\022\030\n\024MX_COMM" + + "AND_KIND_PING\020d\022%\n!MX_COMMAND_KIND_GET_S" + + "ESSION_STATE\020e\022#\n\037MX_COMMAND_KIND_GET_WO" + + "RKER_INFO\020f\022 \n\034MX_COMMAND_KIND_DRAIN_EVE" + + "NTS\020g\022#\n\037MX_COMMAND_KIND_SHUTDOWN_WORKER" + + "\020h*z\n\021AlarmProviderMode\022#\n\037ALARM_PROVIDE" + + "R_MODE_UNSPECIFIED\020\000\022 \n\034ALARM_PROVIDER_M" + + "ODE_ALARMMGR\020\001\022\036\n\032ALARM_PROVIDER_MODE_SU" + + "BTAG\020\002*\255\002\n\rMxEventFamily\022\037\n\033MX_EVENT_FAM" + + "ILY_UNSPECIFIED\020\000\022\"\n\036MX_EVENT_FAMILY_ON_" + + "DATA_CHANGE\020\001\022%\n!MX_EVENT_FAMILY_ON_WRIT" + + "E_COMPLETE\020\002\022&\n\"MX_EVENT_FAMILY_OPERATIO" + + "N_COMPLETE\020\003\022+\n\'MX_EVENT_FAMILY_ON_BUFFE" + + "RED_DATA_CHANGE\020\004\022\'\n#MX_EVENT_FAMILY_ON_" + + "ALARM_TRANSITION\020\005\0222\n.MX_EVENT_FAMILY_ON" + + "_ALARM_PROVIDER_MODE_CHANGED\020\006*\312\001\n\023Alarm" + + "TransitionKind\022%\n!ALARM_TRANSITION_KIND_" + + "UNSPECIFIED\020\000\022\037\n\033ALARM_TRANSITION_KIND_R" + + "AISE\020\001\022%\n!ALARM_TRANSITION_KIND_ACKNOWLE" + + "DGE\020\002\022\037\n\033ALARM_TRANSITION_KIND_CLEAR\020\003\022#" + + "\n\037ALARM_TRANSITION_KIND_RETRIGGER\020\004*\252\001\n\023" + + "AlarmConditionState\022%\n!ALARM_CONDITION_S" + + "TATE_UNSPECIFIED\020\000\022 \n\034ALARM_CONDITION_ST" + + "ATE_ACTIVE\020\001\022&\n\"ALARM_CONDITION_STATE_AC" + + "TIVE_ACKED\020\002\022\"\n\036ALARM_CONDITION_STATE_IN" + + "ACTIVE\020\003*\245\003\n\020MxStatusCategory\022\"\n\036MX_STAT" + + "US_CATEGORY_UNSPECIFIED\020\000\022\036\n\032MX_STATUS_C" + + "ATEGORY_UNKNOWN\020\001\022\031\n\025MX_STATUS_CATEGORY_" + + "OK\020\002\022\036\n\032MX_STATUS_CATEGORY_PENDING\020\003\022\036\n\032" + + "MX_STATUS_CATEGORY_WARNING\020\004\022*\n&MX_STATU" + + "S_CATEGORY_COMMUNICATION_ERROR\020\005\022*\n&MX_S" + + "TATUS_CATEGORY_CONFIGURATION_ERROR\020\006\022(\n$" + + "MX_STATUS_CATEGORY_OPERATIONAL_ERROR\020\007\022%" + + "\n!MX_STATUS_CATEGORY_SECURITY_ERROR\020\010\022%\n" + + "!MX_STATUS_CATEGORY_SOFTWARE_ERROR\020\t\022\"\n\036" + + "MX_STATUS_CATEGORY_OTHER_ERROR\020\n*\312\002\n\016MxS" + + "tatusSource\022 \n\034MX_STATUS_SOURCE_UNSPECIF" + + "IED\020\000\022\034\n\030MX_STATUS_SOURCE_UNKNOWN\020\001\022#\n\037M" + + "X_STATUS_SOURCE_REQUESTING_LMX\020\002\022#\n\037MX_S" + + "TATUS_SOURCE_RESPONDING_LMX\020\003\022#\n\037MX_STAT" + + "US_SOURCE_REQUESTING_NMX\020\004\022#\n\037MX_STATUS_" + + "SOURCE_RESPONDING_NMX\020\005\0221\n-MX_STATUS_SOU" + + "RCE_REQUESTING_AUTOMATION_OBJECT\020\006\0221\n-MX" + + "_STATUS_SOURCE_RESPONDING_AUTOMATION_OBJ" + + "ECT\020\007*\335\004\n\nMxDataType\022\034\n\030MX_DATA_TYPE_UNS" + + "PECIFIED\020\000\022\030\n\024MX_DATA_TYPE_UNKNOWN\020\001\022\030\n\024" + + "MX_DATA_TYPE_NO_DATA\020\002\022\030\n\024MX_DATA_TYPE_B" + + "OOLEAN\020\003\022\030\n\024MX_DATA_TYPE_INTEGER\020\004\022\026\n\022MX" + + "_DATA_TYPE_FLOAT\020\005\022\027\n\023MX_DATA_TYPE_DOUBL" + + "E\020\006\022\027\n\023MX_DATA_TYPE_STRING\020\007\022\025\n\021MX_DATA_" + + "TYPE_TIME\020\010\022\035\n\031MX_DATA_TYPE_ELAPSED_TIME" + + "\020\t\022\037\n\033MX_DATA_TYPE_REFERENCE_TYPE\020\n\022\034\n\030M" + + "X_DATA_TYPE_STATUS_TYPE\020\013\022\025\n\021MX_DATA_TYP" + + "E_ENUM\020\014\022-\n)MX_DATA_TYPE_SECURITY_CLASSI" + + "FICATION_ENUM\020\r\022\"\n\036MX_DATA_TYPE_DATA_QUA" + + "LITY_TYPE\020\016\022\037\n\033MX_DATA_TYPE_QUALIFIED_EN" + + "UM\020\017\022!\n\035MX_DATA_TYPE_QUALIFIED_STRUCT\020\020\022" + + ")\n%MX_DATA_TYPE_INTERNATIONALIZED_STRING" + + "\020\021\022\033\n\027MX_DATA_TYPE_BIG_STRING\020\022\022\024\n\020MX_DA" + + "TA_TYPE_END\020\023*\243\003\n\022ProtocolStatusCode\022$\n " + + "PROTOCOL_STATUS_CODE_UNSPECIFIED\020\000\022\033\n\027PR" + + "OTOCOL_STATUS_CODE_OK\020\001\022(\n$PROTOCOL_STAT" + + "US_CODE_INVALID_REQUEST\020\002\022*\n&PROTOCOL_ST" + + "ATUS_CODE_SESSION_NOT_FOUND\020\003\022*\n&PROTOCO" + + "L_STATUS_CODE_SESSION_NOT_READY\020\004\022+\n\'PRO" + + "TOCOL_STATUS_CODE_WORKER_UNAVAILABLE\020\005\022 " + + "\n\034PROTOCOL_STATUS_CODE_TIMEOUT\020\006\022!\n\035PROT" + + "OCOL_STATUS_CODE_CANCELED\020\007\022+\n\'PROTOCOL_" + + "STATUS_CODE_PROTOCOL_VIOLATION\020\010\022)\n%PROT" + + "OCOL_STATUS_CODE_MXACCESS_FAILURE\020\t*\277\002\n\014" + + "SessionState\022\035\n\031SESSION_STATE_UNSPECIFIE" + + "D\020\000\022\032\n\026SESSION_STATE_CREATING\020\001\022!\n\035SESSI" + + "ON_STATE_STARTING_WORKER\020\002\022\"\n\036SESSION_ST" + + "ATE_WAITING_FOR_PIPE\020\003\022\035\n\031SESSION_STATE_" + + "HANDSHAKING\020\004\022%\n!SESSION_STATE_INITIALIZ" + + "ING_WORKER\020\005\022\027\n\023SESSION_STATE_READY\020\006\022\031\n" + + "\025SESSION_STATE_CLOSING\020\007\022\030\n\024SESSION_STAT" + + "E_CLOSED\020\010\022\031\n\025SESSION_STATE_FAULTED\020\t2\303\005" + + "\n\017MxAccessGateway\022]\n\013OpenSession\022\'.mxacc" + + "ess_gateway.v1.OpenSessionRequest\032%.mxac" + + "cess_gateway.v1.OpenSessionReply\022`\n\014Clos" + + "eSession\022(.mxaccess_gateway.v1.CloseSess" + + "ionRequest\032&.mxaccess_gateway.v1.CloseSe" + + "ssionReply\022T\n\006Invoke\022%.mxaccess_gateway." + + "v1.MxCommandRequest\032#.mxaccess_gateway.v" + + "1.MxCommandReply\022X\n\014StreamEvents\022(.mxacc" + + "ess_gateway.v1.StreamEventsRequest\032\034.mxa" + + "ccess_gateway.v1.MxEvent0\001\022l\n\020Acknowledg" + + "eAlarm\022,.mxaccess_gateway.v1.Acknowledge" + + "AlarmRequest\032*.mxaccess_gateway.v1.Ackno" + + "wledgeAlarmReply\022a\n\014StreamAlarms\022(.mxacc" + + "ess_gateway.v1.StreamAlarmsRequest\032%.mxa" + + "ccess_gateway.v1.AlarmFeedMessage0\001\022n\n\021Q" + + "ueryActiveAlarms\022-.mxaccess_gateway.v1.Q" + + "ueryActiveAlarmsRequest\032(.mxaccess_gatew" + + "ay.v1.ActiveAlarmSnapshot0\001B&\252\002#ZB.MOM.W" + + "W.MxGateway.Contracts.Protob\006proto3" }; descriptor = com.google.protobuf.Descriptors.FileDescriptor .internalBuildGeneratedFileFrom(descriptorData, @@ -106023,7 +106275,7 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { internal_static_mxaccess_gateway_v1_QueryActiveAlarmsReplyPayload_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( internal_static_mxaccess_gateway_v1_QueryActiveAlarmsReplyPayload_descriptor, - new java.lang.String[] { "Snapshots", }); + new java.lang.String[] { "Snapshots", "SnapshotTruncated", }); internal_static_mxaccess_gateway_v1_MxEvent_descriptor = getDescriptor().getMessageType(73); internal_static_mxaccess_gateway_v1_MxEvent_fieldAccessorTable = new @@ -106077,7 +106329,7 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { internal_static_mxaccess_gateway_v1_ActiveAlarmSnapshot_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( internal_static_mxaccess_gateway_v1_ActiveAlarmSnapshot_descriptor, - new java.lang.String[] { "AlarmFullReference", "SourceObjectReference", "AlarmTypeName", "Severity", "OriginalRaiseTimestamp", "CurrentState", "Category", "Description", "LastTransitionTimestamp", "OperatorUser", "OperatorComment", "CurrentValue", "LimitValue", "Degraded", "SourceProvider", }); + new java.lang.String[] { "AlarmFullReference", "SourceObjectReference", "AlarmTypeName", "Severity", "OriginalRaiseTimestamp", "CurrentState", "Category", "Description", "LastTransitionTimestamp", "OperatorUser", "OperatorComment", "CurrentValue", "LimitValue", "Degraded", "SourceProvider", "FromTruncatedSnapshot", }); internal_static_mxaccess_gateway_v1_AcknowledgeAlarmRequest_descriptor = getDescriptor().getMessageType(82); internal_static_mxaccess_gateway_v1_AcknowledgeAlarmRequest_fieldAccessorTable = new diff --git a/clients/proto/descriptors/mxaccessgw-client-v1.protoset b/clients/proto/descriptors/mxaccessgw-client-v1.protoset index c3d6da883fdb94471b2b8c35106e68dcf372c4c4..fe740013a3b4bef4f16385b73edb061763e136d3 100644 GIT binary patch delta 26723 zcmb`QX_%GOm8j3zXMe*vXq3Y|A5jF9Dk6$eOpKx=l8!+@1!58`;2?EVPdyyPp3v|0)AYP-p3<-v8qsPoKkaX6g$?el_Dm9idaqjOhH@RlYgc0IOwRfbtP-s=i>9*OT-j|Kr77i-{ixACRu?$5UG;8ayn zf0I>hU7-pM<~^eNO}21^M^wMb7TY-0TrtrHQr#TYg1*`ux(Bkc*Uh1OKy(KYv3AAq zU^X*LWjpsPjjW-OebXF@NNxR;QcYvY$3z;@HCsFE9+ak_*%IAO+U+~vZV*m@)(H^d z?|i#~IPo3rWuqHvmW%?6sa&+$DV^Xl>|NIA&Qz|+ zDMojuRBJ{_`(k*F%G^Mk|Idw*PVVio2KwXfM&eR=H8qUL_K4*fs#A z2w$t*Fac?RYn2OkdkM-MA8rU$(5p(g=lF0_is~F6?)DPy>y+whf&!rmMuoBrRv6vu zRNTQAqkEm|>|KVgSE`#Ulu-iuY9-v)tJn_R_DV*{^{PwP0`slLGVz|Pn!o$-hJ^p7 zqs;Xk1w?bM?_(gEa}Nc(LouAEGI#yY1v~jK&x!w?V0Yk~=Bc==33dkwcEtrd(I8C+ z+ol5%uJ~YsG(g1%yMu(}79VT~RnV(Suy66frWDm%e6Tx6uy6IjhETm#O**72mT&dJ zrWoB@eXu(S-R6T0p$qzICD^z5U{h*@eXu)7u;_^MwjP2AukvjMVs@2xu!}gjTB)9mZ1w)vDEzzSIb}wlSp?ORIfMyB5Po6O~!-JnV|-qu#@=;^A7~gb-#y>mZ2iTJIo; z*|pxmuHxW2?;wPOpmh+$(mL;8*TlqlopK&hjHz|r!*0d!ag}+}IoJ)mkIUGRq0>zq ze8M{jVHUIwg2+DM9R#uZgmAg2E4Jp_%ka}dPTAH9R!i{VC< zdB!=|9m$P;YV9r#KII*RunJlSL1dp&abo*6jM=BWhuy`)r+p`ca1gW(f|z>RcS6tz zyG{sV>1pYNuj_)$OyMS#d6iY_IbAxhq5iVk3FQX1&UNMSO>6*HPJZD>D%qrh_R^|B zR-E3f)UncigfI;r<&kN7vpS}ilcjKrQb)T&aRW4aE>yF~Zc#_|bh6Oa)R251Xcqe* z%j#~5dz1=$6_ZVmV)(Yoe7N$*FEwO;_O^<tDyDqr%Y zw8A^;=wk|F=qx>?zIU6yefgs5ca^fcbp9rPRBi3x1<`m{+HH}{kv+tv?ai}Zxi{JF zTb{o$?{8PF>?Q@o!gkfIx2scT%} zpIo)uHwk~Ej@^Dj2dQJX^xSSngTl|7du{1a^?9lxP4c;K#G_1;d@hY>>!3xxX#VFd zCs%!uYDg1&q4Mob6C7n0jW1NE&V^G<9n`p|`PD6hs`jKB^5uK{q<)n7@;%b=&omnJ z^e>xF+j>^jm#K!V%wMW}7gvX@%wMYRJqo894Z6YJ=KHpucFx{ZLl)${DsQ)ZN0|kA zuWb2bDeK8mR%H&jG20W#eX{o*gt4B|j9)ibzxu020J zG$`EPTzU1Rs{N^kG~<31+kVm0G~<5h7t$y_rDJ{5y#LifCw-G@NHcyTJzdrVM5yB% z89bj$$d4|D({h=sbJ@=CWt-3Wx1*b{dF|OFr{$Ol$71kkae79sdF<;KCo^(xJn%;> z&dB9$X9ux3Bj?UmM@w}xbFMQ&v>|AR5@<^Eb`1ehotbN6579@9`?GS&1_MGBw5kb= z?yQ{a48|bT*j&WtGDnsJRRF#e~JLk6eM=Omg=vAeM%+BRU z+kjJy?(CcixKxYqRXJs+2FRkEWRIMeqgSnv z>86*Mysi1lHy^6H&0FGcSh~%}5X91L62tS228DMtk9+IFsyk8*3EdsO4SSi;-63r# zeYKZ_ZhrGeZ=ICPPcbO1AVm;um!2A1Z+X7$~3#s_mf^KL>2U^A*u^= z`NZ}UiqT!@`$;bqu}k*-1VR_|x)HjId_SQS)kV@zWKKD@7%t9bmgVdw|5zj!=iIV& ztVDK6&Yh zVzVvLeZ;}lIhRI)a1gW(g2=AUxik`p)z!WW_YnuzPoi+eYIk_CFfE>$fDXJsv~LczQu5TF7rh5()UIr>vOEq($0P5GamEp z3}Fwn_CV}C=Gz&>-ebO<`$}bx=TvXAKtQxJXxka&s~*p_vYAcL2zRkZgapRamYPNd%6ZueJbbDbs(xw` zm7ex7LfA+XqpM_09A6CooXfntva&tQCV!UoR2t!UX@nPjlpsuk)+C6@7k!jKEWYTY zbiBCwQchKwC_zLCv{3^2u9tGH>^cM*;iLK(;RMFiOS$9wnJD#R-=537;iA+J(M>sb zdh90-ZuU`va1gW(g2-<6Q3A2L*+;3LIJhO}vTP8lAgVGNU{ma}Y#_Q@axTl(PolKd zPpuHTps!XeZaUYlV$5C_}x{SFj_F7t^CO*NwL`N_Uo9DLu02EswmItU{Bz7GwE z)%Sg9s>Q+WJ~R-jpbHIbid|?xbhrD^R7+?+@S%aw1%0(*`2!ysN>TmbP-v^NqPW8c2oy;_Xscy`|JV;&2vyJyS`gKb{h$R={a6O=u7Ke7FNR;|GT*Wo zZ3{|+r`G+pG@@KHVdctC-ciZdelqGWZMokE8{(rt`%xej?DxS2sbIekc7OTeZ*ne) z1K}!YgAHQoo19DHfJWGzpA#5U-{d$5%52bIn)ZN?D17;sM6mr zhJVjxzH|NL`$+y>VlP&|FW+|1Z>%7!f>@Q=5=Qu--&ui}J!p5}N}o^+r|ZlNZTA`{ zAUR!|y@t{Qis3AsxzZI4plFshnO>z&EQVL<%r&m)M2fD`E?st_ICHhOt81b`yn*dE zf(T!&-AMw(nX9!qNhp0%F`T0_*E_Q(Avs68Y3d{~d!1JO%pij3AfR2NKxD7eU3&Xc ztX`*&J6^8alpa_N|DZE3I zURrLb;wE?Up&Q(W`pX-uOFyY^D2*C1^!y>E{`P+d`2QF?a>!X1j2Lousei$i_&*FV zcgX&-_;PczQJPd=H^JT>n+xXBMC{fi=?!r0lnM2h7fO95lqc3qt!pZsP(85J*spZ% z=%1c>{*X~;ju|^@*oc1`GGeUpW$anQ&OPtvL(U&M>=$E4ojLUUvFDELYc7#%r(7bI z3+A4Aa!u2C?wl{d$eH8Emm3@Z|J&+PGQOs+Zd}dy-(-0D|Lgy7AKp-|sY@&2`g=0h+5RHDPvcZq`G`VkVohz`)P^$7PO7ad zmy$~x>YM7v*Vm1;H@W>MHIy6W>l>SD>*`7q%8lb2YRAciySWc9)nMt;rkW|^<)VEG zcjKiYlP_($;*ewfu~bvKTyEs;J$`9&xn_#Lv^Q7uazi|!c0y@NJ$LhoxtuTjhs${r z2y+=PSN+yjZ6l8Z#@A1oSlckUJfWXamQN{9s4n>~B!+8gt#ReXrmFHpqR>>LPnG&K zmK!cBH;kP`6W7s>(#IV1O$HrmHeLf93Wj@YP@tYtQv)ARV-lOCTB;@tI5K`JFA%0Q zS@-xp~yTidfF!sy)AC`cuFzfV70^I+IJZt|LmiGP=si9h)d$mtIvmyH!L+5m z#}QoX6P(N6Ll^Wd^F4GQ(w&d@rRYAStH19&K7u1RVRVH@pbGkc9D(Y?x=Vjwit599 zz)40m8K{md>PI*wy3!-j1^v=-B)X624))>_uol@IOxok}*O-9w&eY|uvyaNe{>XM-O216N%K4%1qn<_bGd z9q7k`T=;L)J$=MGpt@0?GRUcRh)uj;Cr*#`YG&-UBv_8sist2mhMg2_LxSdf2y{cU1KBLRUgr!{ayR)W-QKrjH&p+mkM*6udr?2HwfCtzs-Vrx4^(n% z_`G)4P#~($>z>@PC8j&{DC!rq?&AW~16|PXGkc)>f^O$S-2>eh^fA4OtQq{mh$%Fbi6y z(C&CZQ|eyJfT(_+(94Wqe0tVASP!7E=(XMX9VuP;|yucnSsmmL#TpYRl+nga1#insLl-B zm1c5=glSe54b9D=XS`VjX#L$ zRe_5?D2vrqftyLrEQZ$xnd<_ZB03Y%YXg@)Ia8uDCvY7A!a>kF2;$(Jz;yr+)j5HS zKS(8W0^<`+{uehx4V?4`m*76?_)emH1K-B<-tT^P7bOEOqG!6M&*Aap@rtyo^mE>~$r~_Xd&OXHbgny+K>^x>^qw7Z(R^ z3Wf*(Xwx$ws*3}cA_q}j9GDqcql)a3R5c8kF<}3-as*rwxY-+FbeE*M908XG${ryG zs|Zt|Jt2XpE)BZcJr;=S(xAURLJU?M@s{~64)O7z^dMeS!I)a+`#6ZHWxkJtm|Euh z_+UBmEe~8m4x%kUAL=}+%Y7fG6xHQHCwcR$^jXF5fgtms>*8l2`9L5$7MZcnlFqln zdkEnnX!|&bgDV1eDG8#xB5;?IAhoQp4k~?iF?=}4taP=UjpW0DyUaUVDtW|rQwXaG z{5>WSRR+$ZfqPFw zDXNbKF2OiNXsxMrgmFqn4(xShy&Eh64C8v@>$}ZOXD_9ZM?KBc`nJ4){JW`lZ&jXC9_E8wCXI9Tn(k#DHBPU znjEDhd7CEHkO-_NC1!r$Q%{1jq3k3|lWQ9rP0p6zE|o6!zi4W_m`q|~vW#VE6Ksi# zI42)e8ZXtD)T8-L(gdHRyR_zty84<4@&lO5%hbpJO4Bry`MuHv@{;3g_&rmphUBpS ziB-uqn{jbsZCz8jp|M1+kepwADf!>!hI(8TWl0E2LUs}+DQcw28tYxw?i@2FiK$&0 zKojD~c3+SVu+5HVrH2;7_t{^Cb`>9r` zvOZMkqqJI?B|tmqC`9!mzchnJ*zN6x%KEy)FLV$e3)(djl%v`XzevL9?(hrUQ1S8O zR9Ci9ps!Ze*N^=gi7cugr>fNRiC-g!s?efcBSBO@acg8ktIGQNiC-j#N{aPUzXU;i z0cfWI5L2J}H4?YI3uJx$%&+p4qWW3T{{+{=$f*7`P&TPFR7pk^^s16k{cF(GCY9)al2ZL^ z;Ic|XC8fH{uVD}$5BiU9#TV@I`)Nuswaf3PK`iYGT$XgGBEh=bFR~D-p#OMDu%pL%O*@0wI2iOi z#$>yPO167isICfawi`xzS{NVZ8i_>g^w4EQhbl?Lg7!cMB0D{7F9|YsMlhzQhb|>L zRFR3D5xTrHgf3|F&LFxoLdiRuFFOL&8KFx%50ymh%ut``3MCN>+9WQ8sLl+#*jy`! z>dYkk{s8APnb=vOOBN0_nOM+f_&{`Lh24EzB@;U^c`*D3fbwwlAd&5427Y7RM6-6c1g zKa(kHLCZgP&MwF$3sNPS^%jJA`(g?qloo^>bl7SJsu2iu(>J`MtE_^y{|P4EeTa0X%PxV7c}i9 zXiATjM4uSO+ohrE>ttmr1x;R73XxqJ_U_|k8H>w8^9&{t!*Vu(y{<-hS!f=*h+&4? z@=%#hD1}d6SuPc z6QV0Zv#nP8=j^z{%p%xK^JQSn5F6?G+vwk78K6Durh%kV5%>vO~AG!<* zXoT6HE|52E!;0a?F!T48PfFQR_Q;LWK(EVgdYClBvz3lLvwx|2*7tkV`=P}!^VKg%xBaD2;j3S&Ja=^VS5+^iD)O~2 zg>H=+Wt3xZkS-H1Y_Pwe%eRmjU`t+M&S39SH=)4*J;FQAu zHLek~ZIwrk&HiiEwp2yB_O`IAy)YbR*8OeLwd;%u>;Cr2uX|^wRBulkKo0~!q&sgDiRKDq*omagh)sdBbN0_%;!(nD&-w|@MnQe7g*>_eR?~}c{YG zm3@7)mFhjd_YN~l_MWg{AIgB}>Zg)J)EX@bP$nI)E>NyY=+nV1|3{$MVf2&;GFFPptTj+k& z!yl>UZ(&=zuz;xiEj(5xKc&wrhTnymS&`jPpNHgkAwSZPS>iky9@8S10)&V>Xzyr1 zWT!=K?MMf)IxTV|{XBIBrPCvq!a2`us6o3)0ZnP%eguf_^oSJBU{fvQWkyt)UX?v7 znGv}mex8}`XGAV_1EMoSbgZk;%*d_GZ zvkef4CDZDXqkGddK zL9Z$;a%1EsNlH<@F|upBPKL`zH~Y>8p$mFlvD_TF*^*Lpn|)^+E_74mrb!4@&@K%i zsy9V$1_X_;`$fiZIYr$ZsZ(v15~Sn6w&Q^4-W<7^5JdOph?!9O+;DMmo(rN7s-PXr zAgc2scl!yVI?weLgpsX8$}X@_z(qRjzgA|`O5~<2iqWk^E;%|}PEof+E)@*n1?W{} zR=p*1Qx~PE-V%`trYBJ?lj`kJW$p>t301eJUn#Td?U6gUAcW5C(J7}E{+H2VQk`Ep zYCyKGdVX4sOsexEH+c^?lj{74G~{%dxrWQEx}dUt0R3t~sw1=Ng2+wY$WYA!8I-m$ znN%01;g=UDpxqh_HogUlZfNi~+6N|K#u z6PafzD$vDnZIpS!EsruEuJy~KmX%_iU&SB_LA#29Oz7+U>ZoM`UvCO|%^iT2O`U=W zuaDd>qqVFBk1@W?;SE9-w1+p)l)A&4mdXEdx8`6-W_!>cAVFjww~LI@BZ}dZQKrjF zZnHB2(I+E*;le)vk<%lX=boy(baM9ki_UYEcYct)r0Tg; zM;h!ozwnMQtLJmF@XC2%gfz+Xm47~k^TP9~inQDFk^3zoGF0?Y7gHT+zZd<)j*JLJ=IE#N$YS_Pl=;9l-$*21iFnvPnzk58>us)VI5qprWV3I* zk*4+j5%JQXwBATsZ;Nlek)G18wVpRJ=8EcQzm{ZjIcX%}BHH zeAVwkMw;U(XwN?&y01p=VL6EItI|DMZw|_?&Hm`E$R+3aEA_t>xyx=4 z8*fGKvKz$4Te27VNEVclg73F%`eF8xZ0-Az%Rcj0l-`eA_8Al@+peO+E@Zpwv_jMe z`tOxNw>@%iPbo!pd&G|{FJKRMelgq;Wj?FieR}r%pY8B_xbvj}Kla@Z!XRi3f*AbR zcRvt=A4~W97yLh8zHVpb^dD!F{yS3@X}6t``~5C5sO*gR{qAPjNM2A3zlbt_ul)7L z*-h17L|j(MtJn)9Pq-&ie~WTvFu@qvBRlwWrC5C2TiJO=c39QkNZWH0e?(?))X_em z1F2|l#904Vqe9`n%EU9X)2jAG+J4RjCZ_wMyiFT`sO*bK8=PZQ2b?i4^|r^W6R1yc32n9kG>M=ms*$mwzAfwQtz z)zf1)VP9yzczW!1Cm>4GV~#>ri7%cJSK_m?RmqIl1^q&Me2U$X3q)sz=*Z-Cq0r3O zz0iR05wyEt5Fcm8U2XOdG{WXdhSH-rKE;`e`|?pp&W=gLd?xQUM}_k3bK{nEXJ_kF zGB_V2V!xqcV?9Iz#C%s5*xx9&|YqWrnHUq4Mg>Z*!<*}3S|J_7~AuX z5UQXZ{0WTijd5%HD|8Uu8)JI0w81E$W*-=cF$3CD1c+*L9NU|0&%gwQy>mZ9>S56T($L<$yl%jfb?B=>r@_F-o z&w@||?XUn*ofo?!6DY4?=1JF*{B2MSZ;dne+OYAjn(&WF#esP#KmwGY-4?r)CPX1< zS5%NDzAbhsS(Ev_-8DFx5**O}8zvyKx4Q-o5>lT05I-%IXnmE~BrAXesWG@5b_1CMofBgK8(g1E1F3{5cyv4yE6Ie>w7=R_ff z7sf8Hz?~ikmQ+?wUZ9uFBfBV-Wl_B+tyNB3pbZ`EUqJYt_-MJ6m*Z-?g?oU!-y1)aeuyg^?3riPx zAeE&HtcaD#h2oMl6WHH`6-0JLJiuJ&P)ZkgFqM@q0D9SkJ^&kGrAHUTN8-#gZr&V? z=p!;8%1ks`2H;9R{X)0^+AS%F?8?|B=RsO&W$cpkqoqMtxz@*!WMDuuB_zV8*!|;S zAiAq!_hM_bwEk*80Yd14_K*Ogx;l>R1PG$KI(8G_XrVQ3$We{VouHkGKvdVniQBQk zM%v9{qh<8Aq%OHs^%2JOx|oYn8v`1BeXQ*=Guq6ppiL@(sIHG)P60%9eN0Zl zs?rS}OI0O71LkdtX|$Oc9!qs4L-Saw%f#?_sw)@Ups!ZuiN{k_Nz^=^sxnVJ5xZN< z(dOM4XfK#Sc{=?>%=yGNq+D!25xW%1Xi3=oG1Vu4p38_?dJf~Yu9;j-jE7Q<_2u#T$IGkhWIe=u_SIbq}7tQ z*?1_n>To;;eM8Cp^~ShhH(8@i`etL2#+LNWQ?a|D9&K)@L3=>}qWe@_v^!9cYM+YD z1+~&+is5r{<}Ys4jzRJ{KWfLwsQr_hGd0A=fOgb^(*A#n54UGN7_)zJb12eSdftx# zh_3-{M+Hr(8?_*+&-+n3Mn>%mZuY{Ej3&^A4#d(6vH4jy?GIz>g}7*UKp>`G@H?O} zLVxz77(y5HUdSl^bL<8&rAF8d;xSU~i?L09Q;iH_&<Mqs_y{}3L3B68?k8|#WHR6EbsgOkHC2_@DWF#^ z$kqC*sjiITS5sX^@oTX*85f#dMlopj+#ss2#cj-wXYJsDi%V z%OHL|KAb_^*@Rw3@#`{*XUom(FN@)}IJ3Q_&&X_$O}5EAFERh6jHP$|Sc0$#TALs? z-}OTY#N@kvDE(4;_n zeCB-n6_TIEJRdo%kZAoYac-yIdO)-QXlnuSb!Xhlb}10uow3VlfYh?n?nIRSburu( zXFhke{2Iw!acqxEzm`gN`*{YU60l$wNSIo7$1dFhqPsh8ZI=O%T6WuGg3`Ys!w_fo zx>|mN1%dd@UfV`+OxJmF)ACTwF}TZ{p0iu9k}t{U&z*G{(hJ%K?AVgQx|x zwSd%ez)tdk22ni_7wyF?NG%84xlh*=!@tLw?_4D{NdDbdQj46n>*u5z`ENAk)%*L*U_#!D@;^X^m)Q44541>{p^ z=Uop2QJtM{o!Bl0Q_JkUS=v+(6~k-tnfbPsK<52x@`2f?2a(irZQdPyAPPZy^Z}Vo zuFc!`E67qfCvS5qLKe*ppdhlzoV?w}!{BwPu;_wb*p?oW#oOysSy`q*FDu8S>r=b5 z!|zjBS(ia4o0$9I->1T~(jW3RtAR_h(t_rRpdg0-khfV3kg&~7KV4Q<(Cpf!5ZSqT zd(Hq^S#L;XrAfhd=E1C#B78$C%vyS5-v0WDLRmXOv#}FI_Qt$@5(~0+Hs{@O7eW>^ z%exdJ+nl$VB9O)Mro1~wL&zquy;X-1zA0~4R)kp{Z%z%%atQi3w?g*jy#2*KvMh)5 z(pHo85A+TnhU~n&J!BxuCbp8dKR5}Z#N0Z{x)Igp>CU>TBmdB5v z*?>qX!guD|$qK6U1b$hevo zY)+Q>utCUzeN5OuWS8ZSPi)*`+4d|?g=LZey|Cm`mgkT6vNY0tdHXMj;gZY|pxH(W zB70xny__VKKo-V#N(fodCY3A=$dGD$p`w^{ulUko3~XC8N9712lXf$WrY zke9Ho^urIL5VUg*h?$jsuE|SiR{7xvSv2c2h9s)1^5kXZpTCE})&9dFghBh^pec19 zo|mw$k)bQY4?-5SQv!(Wn!L-@<|V2vX`w_F^bSi@la|zD!uqI>dESH-v^hr*;Yaf> zKbe=XuJyAagkjK5{UEYy{cN6>sIGI%G7d{rL9^}#`QMnZuCoiTv;<*YpSmbv1?^%* fA%@rcwJI-D|6{3Pi52J!o2Wk4(s*&U!{q-5gS0B= delta 23938 zcmYkEdAt?HweR5Ej3eN95)U{;oKaC!R0a`=f}%303?hoY@2aY0-~afYTEA7*tEyHF-J9zl zNGHxs?>}mYS-G|4m?3YT68)y3XY(I^*0XuwZ_jF3@Y}X+s*knY|EJTUmLvYxfAnj3 zW=hnM=5eJmv*ngKd;1p$H5l6=Pj*!+`>NH3HVu*OP|fYGYSy9QpayF@m`ZMUS53Eu zgBxO@*kP)O?e1#UQ85zi(ltma+*37OLSYptsM4w{Ewc7njCN18Q|F*Hx!qee9YUc= z9M%-k?yWW)s)%%NHE$oJ(C4KzqFPX^jgWp`%~V=M&e~~>?U*O~tCg#a4@5^y_shdc zAUaAQzSKZKxCOmi5Zf;`5FlQ^)IfAJ6n>?FfRKVtTHshB@l`bh0z~_j2BM=O5C`;W zAOZpUK!8XOXdpnO2Q&~Jg}$zuUM@-yQc$H5h_9l6z7GocMD@Y)`Zan;&!YF zqp6cT>Npcd6GV$Zl}Zf9nGi!tk&ZKAFm;j`jyI-#W6S$D3~2 zWN3mhJwu_4CQz%DAWkruAL5;i45kUDdyj@$RfbF_LlCcT{?Dgp7uTB*cm9UtdK1Pj zh~#<`#$V?=nPe(6L+m?UjMAQ}SthJe^k*3f}?ovfkjYzj)JXzV(> z*nz5**iF&cQHpko#;&u(ZmPx(qFPX;61u4xI!cjF)zEbon&v`x0=WZ33#(RQH%((l zsk7GDb(YvQ6T9gJ|77GgYXm`r%^E=vVY82*r4}lgVZxw?kb-{DgGgtXFy=v|GmIPa z#&*e*x~a?$!S8})-DJHR$dJ}{k?7CX=tI;3`bPmVo~_XbaXVY1-$i0H#~44)Af#aC z1{CN@Yd+C|NavW&T^eqX5!8j~8#}N0|E!o?%qunGah|F9F^LVDd8SWa#)+|A^JIak z+!~&uE0zmP*2fK@uJRDK=tDrX1oRI9Vtb1|1c=vL^dY*sA+)g6%2R;;DZ09+SZHc~ z9RSfT^jc%P<;iWPa%X5!H)^@fWZhhgx=D*}SFaFWLEj<}+uK8n3Jc@*c5PBOiNYPm z9PR=MF<`(3MO@us+O$)2#(8f=Jl$dX9p*yTJx}g7l_jBp-LbseWIhjemj*791{SjZ zfw+Zz3qgz*Y0N;}F46{emj*62rk|@5qJf}qAc&{MrcGNdb;j*{pHhma#oDJm^5ova zR2~Q|?1AaM+QJ^v!uvFY5N<)=KoHyew1FUQ@6!hMkOtnb4TNYQ=o<*)>3(fskHYow z{U)@KVqD#?E$o>mEvE8NXkbtLw#eL(snb&$_;+m}gj>)z5XAQH+CUJmf7b@~lm`C8 zH?WBDBrSw}3qf4{Lt6+s>(D?DSO3rk_R5o`rt(N=U@t6}YG&;v4P2%TgzyUb27=fw zGg;xsHjLY4+QMGa!iRMbLNpNc4FqxZunt1d8HYg#;^|=-gm2mg$xO+!rt*TRc6-kD zIq%9p{@3p=zv9Y1f4buLS6uez-~I6qEnlxZ)fCT~xRXRRu1ceyGv){xK0>$#`zn&P zpEHN|54IGpFy^pOD9r%fz5rdv zD(hQbS#@Rg?AJ`z%k_9~Y1?XJ`n#bG5e3la3J{m8O|6fX5{1>~u)`b1Fj#s^eQz{R zdiBz#H;nOHL;fazG#&ip1yQ^qar>L($llVVwap!0yS-Sek>_vZ{k5izU-CgbtTjCc zggPZ-t~G-Ohvq`-LU?c|`y|l!H;8nd>Ew4&pfe^~SMdCO@?@i_yhEQ|+~TS|q5WQzu|uM5c*38Jrqh`iry-uO=A`=vrc@V=>aav|vJ7LE5! z*KQ3bx;m)wgXSyVIJxnIQXxGH$!WUr}w&3M>UZC>}* zVa?svKHGM$WWrzI@GxogKGXck+Fus?Gz9z+kNZr`4|Wib`*iztm{j+L4n~L=f_^H2 zuC(UY5D@7XroBJ89VX4+Z;bB@2r1~L1&nsTjt3C!ei;vvvkw#cvee4X4b*C7V0>9h zWlDTmN*Nem>4y0*BPRz?r7}XkGPTjZ!zo7lmG5w=7UKiP_|yP-RIQAa13J1WMS4I+ z*LxEB!}DZpR2koV$NG2A85@Ni&*2jBagk4p7Q!Lu9fCL<7u9@{0C6}jBDb|jZtKrp zJgVH(^7>oHR@+XB7*+R5y6G=2r!{wa`~Jph>WRPMX<8Ke5X958h~vivPC?=H=3Q@J z)HuCVNY_n|!gTEKx^8+z_+_m2m#&-9{9hZ7FJ_bq>984P9nwWJqz>t@{z5aOkhCBk z6!Z@YBApq9aRNHyFi!f*PJ31q#tDQJ^wI)GJF9G{bl5CusIL~|da0ET1GQS|uzD$# z4y%_^((LRg9IX1A1SzP}1nKOkR`_v3G1}RYOAPX~DZAt-j1vegsM-|moG4@=N|DZq z7$=fbj>waFQDs5oPxwb*IWG#!))CUP^P_M&h45Oyej@;5JU zDD3qhVhZ|w0Enxbqp;TlopIQxfOxt&BHb^OaW^1OZjUN=Mb&PLOrMrJHXU0nZjU%& zO1uY16YtO_Lih!J3qkzep)CaQdxy4gfVA+=$Zr^MBrODe3qgA0&Zvz)q=3%2o7+y< z0>;&yk=sugJ1|ccMU{I)36;wYTbLA);3Av{nT zxFiZ!J`gPeIk{a%riEQ`I5&f6mqg*tXP``&drGb35m2iY&-X;(&Ienh_lR^9;XX1? z9*8P`Z$9b0^NR-}R%wazk@6T1YMdebf!-g8zXvtWApRcII3FpMwM1rsTOc6f4Ei{O zJXK57#$Ts_&bYfjA`~#LTB80(xUo7YPacXYkAyf6!t|ktTMB95AZcK$#u=i4pl=|E zZL7u^#A~a@d5|=4sm2PTMIdo5h%@YpL!3dhOEu1eB+kpCaJq)jg8p<3B3%}R(=~{6 zSrl%R1_?b}Rx23_)D}yI>hrMn5ysA%KDt)s#Na%6BC0&yGH^>$EuM(N$zres;YsZ! z2$!ID3F7ie?IjS8Piij>mZm-xnMT)35WNKYUIKZpr=m7~9Ri(k-+|7!fN}LyGDj+Dk*Efh(eLX9Xbzk;-g< zU2(Xx0@1FB!kyI+>80m2wL)k?tyVlgFBwI4Fq9&FUNWn@vl=3}vU%zUvzu0G$MQEM zE2A(bK(UcA@mKn$F;8BNDz7(Rxb-i^tGe(u%9FpQT?5es&^H0Z-)q`6ApTy{u4$C^ zt&U8SYac|{fWB)$x@L9M#%Ctb8TUKVcMXiI)zOef*ELOf@@7=o7`mnj(>FEQH%SB6 zXxBhA5cCZMv0bBG1LAd!c1@EsaIJO?gcJ;21H0nTH6Yrx+BHqmHS4r%Ahe)XE1uVB z*HDUd-M3xSB)EaDnO^Wu!mvR*2t>F+I|vj@owQA|z`w1N7D5X8Ned!k+K|Jk@!X*yqjKlf4fN`}ma<@3v9wnjuM0*rM3;Im~ zi1riRJ%LC+@jYtn(Rs2fs_Y5loi(X@o%aa39<(p7+97PABa9wts zH0JB@h=m1d4eXy0#Q1A{Mv%sQt~(j<;biFjU-5uwgF%(OA$o7Q}Xf4O?aq zw-apG&JLB{y4Hs52O$MjD&yeV!q)taJ;i9RwITZrm2q&L4fm}OPXqcFJ|NobY+HZd z3L?GEa^EVQJyhs=8!j#&q@X`ufk>~n?fj}Rlou+_nnU+8IT#F;X*Rj}=uaOhCfhKA z`5P^tY{SkE#KmOW&gWhb50h=!)ee;rJH>{LI7IEBpQj+wDYn6%-$A5PY}khnm4}>a z!{)94X$$P<5$KBBXtlDro@%@L97-v!rdoel#AC3xo@T?|8lq94hFBU(pUV8Me(f_rngvb+hg1 zFA;~D8mApDd%Ry$Lkeoo)R4}w?fmX_Xklt-XV@P8I%=qC;Pk`QOVBo;1-04+v@>n) zkIC2~ooRdd8}p&24W}KlK#;aU3ThnNAf08q4$@Mjvuxv0GBad69K>5bZGIRxxI;&hr&aVg4!X6BE89WKT1oH-eiwGK1kcfHwVnN4J*8;p+5txfZ97h3*808JP}2@6sM2;w=h@Ca{ZNW@o*g86FNtx^ zA*__^?RmvuJL<_1`-Z|e=wzwnGTcpK%t$W-~oV8k> z`{I9_Vo^Cf<6_%Sd%QE!#rDLL zgR~1rEN*3jvtz#gH4zZZu`Yqd?ikb?d)aHx?N6%X2Q^9>?>(Dvh6zi^T2&1uovfuVzX zqXl(o>y5U>cGRx!jkd)eK7b$_`<*;#wUuRI+5Qfetu}n<@EuvUm+JBlu>gR6HwR+7 z)avUH7`IEUy9P1#ggkk~Rvrs(Pr&pM8$KjCLEJv7y8#Hdpmz&m`=|{!10Y@>^}7LM zPt23YZRLsJ^+YTmw?$~}iQ;y-x`l8Hdbc3P%hfH2+vV!^q&#`bR-OrNPr~vk8(y}b zBwn9ZuMl2A?-i_)W1m*HFm9h#wH+*sic` zeJTL)y27vU#-5TVFWSmW!Rsm2BDXJUkDVfJSE^eGx1e_m;&!FF1#!Dl-F`PuR@us{ z!R>dkT&26h?~2z~Y`Bkshymzh@LgUV!03jlh8!jY`$evZL#b%-zr;d>HiJdH2_At$%eZtkXklb zuCDe(B$(6l<0&v|*P{DXwQ zPNc(xCYLRiZU_AUq7doiILubi8GqYiqBtcsoeDR#hq>7bt5*7XN}T$g2Bm1H#2x%s zhr^_aQ{#|?A$kDxXF?F^)HvMBfk>yuE(cpXOwNSUN@)_4GhlsMIRs9NL;l7X?X*(M zA@Ii7_(Q}nli~{WXCx5mjd2gZ%L0+!7$4;i5yK3Jyy-M^O-0)8lXy9VUmq<~ZB}K*R#nu9ibza~!f9rAV9OuJYNQv1jDTthh2e_O~5p zU^y$6J&WY*Gi319wS^EZ1pOcfX<$7LZ$dz{^*Fo<0jZ_#8))ng^5mwtGB4Ef11xWf z!yAMjNF{T1m_m3h;6vOPfH9sMAL{pNAa3XSacbQvdn>aT@Z&0_%q!nT(@XA&vY{f^x*{2-V%p96%g$$akx`CQzp*B zIOITxYC)CC#90`Jm-v(-T^NU3jWdOAb+w-F8YMFaR;|pOTgz%?=G^M5HTH+R3X3a? zLm+;LB?(pW~^S>kbd9NsoSj5yGbIFL3kkK6du5J&~fZCxTS4JRa;{HVkLa z8dp9F5gCrh6^0=KZ=pureF9f#c|h});*aDOyhdhi)dj1Z3u`aS|( zX}GEbkv^k|akvbPXEj<7QjloL1u2a7Sq&G6_F2v4!-bwJtCgVv`bblV^f}GPpfe8n zc(^R3D>S=7JS^xFBZ#zEp_vgzyF#_@+N~z}sO^n0c z#Tw`nBZ%|`O^gMM_65z1!{uh}Ma?@9PXPK+0OI*YO^hI}Uev@0;^{?AjKd|zuGHxW z@c^K9m*muyvA#Q}80|`(kHaOYz7(5d-SC2_7Stz@r23LR0i{S^ijO)bj4&?JUXG2w zOBinCA`MijT%^4m_waWKl**>#440dw`&-7V*Ex#fH}HXp|Rk`u=I-8efa1nH36zQ>Ey z5MQ}}1N}aJxVwLQFYYAwA}oh6uHTEpP1tb51>9y{D+H}vz=8g{kwWU(9Ea;h5b5SP zTuBW#T)=IK?QuaW_iv!TyQ2{4mbkmWVgr$GDdMA#4Nc|(?)^C25e;`2aG<}~0nxr6 z_tJiq3%K{oZsh{*gV-J)s+9{kP^-<6eh_!}ukwZ)xqtg0KIS-I>oCIw+}7Bh6|`~z z2l{K0;YKdtw#J7K(^9ls<1>HM@VXo?&d!t1;>x(zYM<)rYTM7^tX;#Ebnn@c1NX%B z&3&tXYTBcNg1_QxPh9g?At1i?$YffOIuWH~TgUgSE{uw?Nf=3IyR0`hsrh&FAfmB} ze>ESQJeiPGrX+5E5lfDnzy~byT(OZhT$>oD6QU4w(G(k|LMK}bH- zhusaiFviy z^-%N^ie@G5qQTg6^JGp^xj7V_OVOMplW*j$Jy*KurX*x8h<1Vp`85m1_@?Awe-Q%` z%bSw0RGuqgpBwrUPx3w*^a~H@N*nx?1(D88!dB>9>7sdw@o5D@3aV5bUpg;o@RNsP zwDXdl{w2n_Li3YwVuI)f(63n_+WAR1F@esQP02#}dSXPL+?G_{Y5i&-)1~cg5}-F_ z3pGN*a94fEpz7MjyL8<1M||CtgkKkc__|95?EiHV3YXN68C?BK_)F4ijUTpHC8t@ zKB~iEgd5Y3COv#k1JOL1oN!{p|8q^F)0WqtZL0pYad|0`p}jom;S=`=x9%^Oq5XR& zVcmba{+~xx|IqYw830+}pH9N@WrSPcpO!&3!?%nTentJ@qpP!;R+Ji9*;gbrznvZ7 z7WNeh+t~?T!^*z0{_4@y>l#;<8d=d-CN+P&9N|{L9?ZaHP%$Lncedf^W8O3HD$>+Irzc~r#br8*F(fFo9Tf$mE!{x;_=yzry)on?5 z9V*8{&>6cA{$+3{Ta(HTAAndaw+>9er5P=VM#fjV_gMP&UY3PSZSaBkm&>!it z2YK|%9*FHny6i=g2tO|UiWF48zEu2P#!?o^lWr>=OA|qLEKAw8(lJS3dlG&X5xLn9 zx^*8%vXX62Sog=tS#2bHh@>*Le$olmvBhUe_>JO7>GfTqIt!5^K;Ls9weQm82-3D) zp>~WZ+#MD}A*7&6B_g{uNm7b*cUaqPF;X6Sj}A5nEvQ=YyhpPorD*r)U>hm4SJNbf z6!c32h;*-JK+qY7Z+}P1DeCjYoaC>jKn4!%2M&n#bIpVx+Rr5u%9tA|P23kcQ3xsM zCo_n2UlLw=fk^j-v4SzSUnIsau#j9$!TPk4O~25TMKRhhl5it7Qch9(lW_kB(F#zd zl2!L>>Y^0s{)GEKMiSMMR1YNPidNSA1@O1Y1^TC&+l z$+TnZt4?ODjV(2jX~(7^p<_ceW7F{6!bmwXjZ4F&3q;UBzg+;4j!Q$r1(A+RNw~gm z$hYIuZ=XiKJpjG5|41W=cYGQWFNk)0O5(l7xTcbLC${cArE06kPfSDR9qBUf#581H z5Yfbx%zK-EK2qs0*v?GdHr?3s^JI2fx!pZW$H<Fks* zR{23E#K@UjU-|v&K!suq#_wN+fOz)9~8@Y^Y{oN`9%!usC1vw)&r&3ev>IX}As@<(Bs)VLaGT?hpX_T_%Wj zNg6&B1JN##;n5lql2H=ImezG=R+m*zZArrw2!EyimNZ;}fcR)h!xadKkCv4E@fMk) zqXd_=uKH2+H`U)QOT!%>e?_z`4R?H?Soye$hFt$}7_>sv2kP_6q2b zyDnsA}@$mjV7^To0~J-47Lwy(mxCq?HX}W?Y2j8tsdV zonhkh}NZS zIK7A`UZ2*}5!J?Gz4qWm?vdB0VPgxTSuYw%UKa_ym4-J%5N!ngiVM=lx6&T|js|qb z?oE)f7w5^ww6Zfi`NddnOgZd+EFVQ(oXE3pOVcUM$IFyXHE`qiSWZwml7BtW%0~yXLx!52+b2>KNjB*dTj71d%(;jR#HA&>qy$so47 zLV)9fizW8e;Zfx_3uDmDd_h<0^EgOr_mqw${Gd9PWqpr2jwSqiOIwLIsJ3#n*o&?E z_7lfM>GRaT@Daio94I4E5aZ9&!R{MIkO1#1Z6%_h+R822zS5S>?-!|YA0DEV-7a`U z@XPou;3M6ah7_~i-Cuf=EiLGGP88yJzwVrPbHZ;pOIz8?f@)jC_RG?iMfIz)R@uXX zzUzp81IAyahsmoiITR${q?PM3zq6BP`zDQDK8W4Cy($ZpKoo+0vj*bksw`~Q66waP zGkZrcuf|<0$~jLQy}O{U1kwmhQQd;R)zqmwlW09X5ZQ} z1jd!NG6X=il_4;$D2*8c<4a>10-zep5Ex(DG6W`M#@#s4BncCElmR?%JGE zhQPI@tqcKBZ41T#?2L^aohK8s%6uPvI~vo8S$JJIS|;EPSxCPSO#uCt6vXz1EL?$t zMCpbsT!D_3fKCe0w-8SXx|C1|yW;Slfq-ZyW#MCp(Gva1Sy-7Mw4nbM0z^7FOMLfPhv+%xWv^>G|EPP-+x^TG_)@Dh+PtU@;Q;cyvJ>zwhtOlcH z>NjWBFEbFXKz~dFkv3=Hlm;Sg&N!ucDZ^k!DV1X|Sn~(@(JnL0D7A79o>6K^3^Plu zygmfATFDc&dNBS$dU_F?W`;uN=M5P zxL#@-(1NO!G*U06aulwYQjWs2v+#Cnv^xre{tXp~bavKD-fr%sC_Lh)CvBuLvodXb01Nwmqy3#OfL8J?H){c=`duzyEIFi`}`mO`< zbZh2*rbYZ=T-}=Geg_2N>eejmfW`>@O=mHL7SvYAEdE;-CNZVXI85R(QtfS-f3rq4 zGKoPyi9w{d6&Ytm8GJC>+cFNXGKQrBuGF0t?m3EM8P<Hn* z8b_JLi?i@y{}`FXOG>GH0|2VDL0(`kDYY_-my}v&@jaP!H!g%+F0??u=LV79leKqy z4k<-?Pv$O&q<@&i_v$2ukb*kyWfI?;9n2)|=DJ>H@x3yOC&>G+OY-EQtnzT{fS*_6 zYVnZddFk^@WG=PpT!QckdY>RZTXiadxNOy_bcu}cr8<=$Mi1x*G3ZLeR05GM)u~im zB6De3<~MjWLIySH=MqROmSy29TM$>vvhF@8Ag-2W?z+p^OY`K>tnzrs6PIH7XcoQ` zyHuX-F5 zdGbt>u?_@@iID9cuX{ zmak^vcdfsaT3*Y-4y9K0{jiFoOG*AAORrdN?VoCD<%3}8sUz|!M z@9H@xEu0_NA9N@scf9KkI-GM-nW*oDsU_smy;Tv!_PwxY2cgZStw=$&^`(A)k1eg; zBDV6M-U(t1tFau4wv>*E!~3OUS%pEhl|#|{r7c1FAoEuk_?2Z9bXOLF*nW`tD+`cr zE4G%8F6$`hwxv>v@z#)3LDta^OIwL4sJ3z@`mnTR;ruA`AAzHkg%WhTI6-Vb%KTS` zF{de3%8#>fxP>qV-C~ZhoQprs{Jjv!(zs2}vkIEFa65e2WmIB z!FGGrv$wxZNM-TcQHD)cJ5XDE5XL*gwiCx}S$Ags%WWZSL4RQYV!Jc*zxSk+E$SzF zM1`;g{Xrg-+k{UtcaWFC#JDGKpuU>Ky1GW&BYeFsul(dB>4~a z*GsS~4!290B#EnQq2C}Jg9BZ^f!JPM8|=F+lReHgrLE)$P;DhgTvOT-q_H*s-+vUe zNsu2ALXyOUvJvEnYis_$Pe3X;0`wyo#P-^nzrv4ec`~t9nHhRj zEGO1tw^52~(yKSr!t{eEEMT8&VEo)r3%RBy-8!ijrXS?dt$3%+$@<_B zwxEA-(3OSR7sUM(dUTR_*-n{~zOQY`Fjc diff --git a/clients/python/README.md b/clients/python/README.md index 9230eaa..ae73a3f 100644 --- a/clients/python/README.md +++ b/clients/python/README.md @@ -115,6 +115,13 @@ messages from the gateway's central monitor), and and ack target). Cancel the surrounding task or `aclose()` the iterator to terminate the stream. +`ActiveAlarmSnapshot.from_truncated_snapshot` reports that the record came from +a provider fetch which hit the per-fetch cap: the snapshot set may omit active +alarms, and the gateway suspended its absence-implies-cleared inference for that +poll. Treat the set as possibly incomplete rather than reconciling deletions +from it. It is set-level degraded status, not a comment on the record's own +fidelity, and is distinct from `degraded` (the subtag fallback provider). + Canceling a Python task cancels the client-side gRPC call or stream wait. It does not abort an in-flight MXAccess COM call inside the worker process. diff --git a/clients/python/src/zb_mom_ww_mxgateway/generated/mxaccess_gateway_pb2.py b/clients/python/src/zb_mom_ww_mxgateway/generated/mxaccess_gateway_pb2.py index a945409..ca60d48 100644 --- a/clients/python/src/zb_mom_ww_mxgateway/generated/mxaccess_gateway_pb2.py +++ b/clients/python/src/zb_mom_ww_mxgateway/generated/mxaccess_gateway_pb2.py @@ -26,7 +26,7 @@ from google.protobuf import duration_pb2 as google_dot_protobuf_dot_duration__pb from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x16mxaccess_gateway.proto\x12\x13mxaccess_gateway.v1\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"j\n\x18QueryActiveAlarmsRequest\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x1d\n\x15\x63lient_correlation_id\x18\x02 \x01(\t\x12\x1b\n\x13\x61larm_filter_prefix\x18\x03 \x01(\t\"\x9f\x01\n\x12OpenSessionRequest\x12\x19\n\x11requested_backend\x18\x01 \x01(\t\x12\x1b\n\x13\x63lient_session_name\x18\x02 \x01(\t\x12\x1d\n\x15\x63lient_correlation_id\x18\x03 \x01(\t\x12\x32\n\x0f\x63ommand_timeout\x18\x04 \x01(\x0b\x32\x19.google.protobuf.Duration\"\xaa\x02\n\x10OpenSessionReply\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x14\n\x0c\x62\x61\x63kend_name\x18\x02 \x01(\t\x12\x19\n\x11worker_process_id\x18\x03 \x01(\x05\x12\x1f\n\x17worker_protocol_version\x18\x04 \x01(\r\x12\x14\n\x0c\x63\x61pabilities\x18\x05 \x03(\t\x12:\n\x17\x64\x65\x66\x61ult_command_timeout\x18\x06 \x01(\x0b\x32\x19.google.protobuf.Duration\x12<\n\x0fprotocol_status\x18\x07 \x01(\x0b\x32#.mxaccess_gateway.v1.ProtocolStatus\x12 \n\x18gateway_protocol_version\x18\x08 \x01(\r\"H\n\x13\x43loseSessionRequest\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x1d\n\x15\x63lient_correlation_id\x18\x02 \x01(\t\"\x9d\x01\n\x11\x43loseSessionReply\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x36\n\x0b\x66inal_state\x18\x02 \x01(\x0e\x32!.mxaccess_gateway.v1.SessionState\x12<\n\x0fprotocol_status\x18\x03 \x01(\x0b\x32#.mxaccess_gateway.v1.ProtocolStatus\"H\n\x13StreamEventsRequest\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x1d\n\x15\x61\x66ter_worker_sequence\x18\x02 \x01(\x04\"v\n\x10MxCommandRequest\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x1d\n\x15\x63lient_correlation_id\x18\x02 \x01(\t\x12/\n\x07\x63ommand\x18\x03 \x01(\x0b\x32\x1e.mxaccess_gateway.v1.MxCommand\"\xc0\x15\n\tMxCommand\x12\x30\n\x04kind\x18\x01 \x01(\x0e\x32\".mxaccess_gateway.v1.MxCommandKind\x12\x38\n\x08register\x18\n \x01(\x0b\x32$.mxaccess_gateway.v1.RegisterCommandH\x00\x12<\n\nunregister\x18\x0b \x01(\x0b\x32&.mxaccess_gateway.v1.UnregisterCommandH\x00\x12\x37\n\x08\x61\x64\x64_item\x18\x0c \x01(\x0b\x32#.mxaccess_gateway.v1.AddItemCommandH\x00\x12\x39\n\tadd_item2\x18\r \x01(\x0b\x32$.mxaccess_gateway.v1.AddItem2CommandH\x00\x12=\n\x0bremove_item\x18\x0e \x01(\x0b\x32&.mxaccess_gateway.v1.RemoveItemCommandH\x00\x12\x34\n\x06\x61\x64vise\x18\x0f \x01(\x0b\x32\".mxaccess_gateway.v1.AdviseCommandH\x00\x12\x39\n\tun_advise\x18\x10 \x01(\x0b\x32$.mxaccess_gateway.v1.UnAdviseCommandH\x00\x12K\n\x12\x61\x64vise_supervisory\x18\x11 \x01(\x0b\x32-.mxaccess_gateway.v1.AdviseSupervisoryCommandH\x00\x12H\n\x11\x61\x64\x64_buffered_item\x18\x12 \x01(\x0b\x32+.mxaccess_gateway.v1.AddBufferedItemCommandH\x00\x12]\n\x1cset_buffered_update_interval\x18\x13 \x01(\x0b\x32\x35.mxaccess_gateway.v1.SetBufferedUpdateIntervalCommandH\x00\x12\x36\n\x07suspend\x18\x14 \x01(\x0b\x32#.mxaccess_gateway.v1.SuspendCommandH\x00\x12\x38\n\x08\x61\x63tivate\x18\x15 \x01(\x0b\x32$.mxaccess_gateway.v1.ActivateCommandH\x00\x12\x32\n\x05write\x18\x16 \x01(\x0b\x32!.mxaccess_gateway.v1.WriteCommandH\x00\x12\x34\n\x06write2\x18\x17 \x01(\x0b\x32\".mxaccess_gateway.v1.Write2CommandH\x00\x12\x41\n\rwrite_secured\x18\x18 \x01(\x0b\x32(.mxaccess_gateway.v1.WriteSecuredCommandH\x00\x12\x43\n\x0ewrite_secured2\x18\x19 \x01(\x0b\x32).mxaccess_gateway.v1.WriteSecured2CommandH\x00\x12I\n\x11\x61uthenticate_user\x18\x1a \x01(\x0b\x32,.mxaccess_gateway.v1.AuthenticateUserCommandH\x00\x12M\n\x14\x61rchestra_user_to_id\x18\x1b \x01(\x0b\x32-.mxaccess_gateway.v1.ArchestrAUserToIdCommandH\x00\x12@\n\radd_item_bulk\x18\x1c \x01(\x0b\x32\'.mxaccess_gateway.v1.AddItemBulkCommandH\x00\x12\x46\n\x10\x61\x64vise_item_bulk\x18\x1d \x01(\x0b\x32*.mxaccess_gateway.v1.AdviseItemBulkCommandH\x00\x12\x46\n\x10remove_item_bulk\x18\x1e \x01(\x0b\x32*.mxaccess_gateway.v1.RemoveItemBulkCommandH\x00\x12K\n\x13un_advise_item_bulk\x18\x1f \x01(\x0b\x32,.mxaccess_gateway.v1.UnAdviseItemBulkCommandH\x00\x12\x43\n\x0esubscribe_bulk\x18 \x01(\x0b\x32).mxaccess_gateway.v1.SubscribeBulkCommandH\x00\x12G\n\x10unsubscribe_bulk\x18! \x01(\x0b\x32+.mxaccess_gateway.v1.UnsubscribeBulkCommandH\x00\x12G\n\x10subscribe_alarms\x18\" \x01(\x0b\x32+.mxaccess_gateway.v1.SubscribeAlarmsCommandH\x00\x12K\n\x12unsubscribe_alarms\x18# \x01(\x0b\x32-.mxaccess_gateway.v1.UnsubscribeAlarmsCommandH\x00\x12Q\n\x19\x61\x63knowledge_alarm_command\x18$ \x01(\x0b\x32,.mxaccess_gateway.v1.AcknowledgeAlarmCommandH\x00\x12T\n\x1bquery_active_alarms_command\x18% \x01(\x0b\x32-.mxaccess_gateway.v1.QueryActiveAlarmsCommandH\x00\x12_\n!acknowledge_alarm_by_name_command\x18& \x01(\x0b\x32\x32.mxaccess_gateway.v1.AcknowledgeAlarmByNameCommandH\x00\x12;\n\nwrite_bulk\x18\' \x01(\x0b\x32%.mxaccess_gateway.v1.WriteBulkCommandH\x00\x12=\n\x0bwrite2_bulk\x18( \x01(\x0b\x32&.mxaccess_gateway.v1.Write2BulkCommandH\x00\x12J\n\x12write_secured_bulk\x18) \x01(\x0b\x32,.mxaccess_gateway.v1.WriteSecuredBulkCommandH\x00\x12L\n\x13write_secured2_bulk\x18* \x01(\x0b\x32-.mxaccess_gateway.v1.WriteSecured2BulkCommandH\x00\x12\x39\n\tread_bulk\x18+ \x01(\x0b\x32$.mxaccess_gateway.v1.ReadBulkCommandH\x00\x12\x30\n\x04ping\x18\x64 \x01(\x0b\x32 .mxaccess_gateway.v1.PingCommandH\x00\x12H\n\x11get_session_state\x18\x65 \x01(\x0b\x32+.mxaccess_gateway.v1.GetSessionStateCommandH\x00\x12\x44\n\x0fget_worker_info\x18\x66 \x01(\x0b\x32).mxaccess_gateway.v1.GetWorkerInfoCommandH\x00\x12?\n\x0c\x64rain_events\x18g \x01(\x0b\x32\'.mxaccess_gateway.v1.DrainEventsCommandH\x00\x12\x45\n\x0fshutdown_worker\x18h \x01(\x0b\x32*.mxaccess_gateway.v1.ShutdownWorkerCommandH\x00\x42\t\n\x07payload\"&\n\x0fRegisterCommand\x12\x13\n\x0b\x63lient_name\x18\x01 \x01(\t\"*\n\x11UnregisterCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\"@\n\x0e\x41\x64\x64ItemCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x17\n\x0fitem_definition\x18\x02 \x01(\t\"W\n\x0f\x41\x64\x64Item2Command\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x17\n\x0fitem_definition\x18\x02 \x01(\t\x12\x14\n\x0citem_context\x18\x03 \x01(\t\"?\n\x11RemoveItemCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x02 \x01(\x05\";\n\rAdviseCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x02 \x01(\x05\"=\n\x0fUnAdviseCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x02 \x01(\x05\"F\n\x18\x41\x64viseSupervisoryCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x02 \x01(\x05\"^\n\x16\x41\x64\x64\x42ufferedItemCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x17\n\x0fitem_definition\x18\x02 \x01(\t\x12\x14\n\x0citem_context\x18\x03 \x01(\t\"_\n SetBufferedUpdateIntervalCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12$\n\x1cupdate_interval_milliseconds\x18\x02 \x01(\x05\"<\n\x0eSuspendCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x02 \x01(\x05\"=\n\x0f\x41\x63tivateCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x02 \x01(\x05\"x\n\x0cWriteCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x02 \x01(\x05\x12+\n\x05value\x18\x03 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x0f\n\x07user_id\x18\x04 \x01(\x05\"\xb0\x01\n\rWrite2Command\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x02 \x01(\x05\x12+\n\x05value\x18\x03 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x35\n\x0ftimestamp_value\x18\x04 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x0f\n\x07user_id\x18\x05 \x01(\x05\"\xa1\x01\n\x13WriteSecuredCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x02 \x01(\x05\x12\x17\n\x0f\x63urrent_user_id\x18\x03 \x01(\x05\x12\x18\n\x10verifier_user_id\x18\x04 \x01(\x05\x12+\n\x05value\x18\x05 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\"\xd9\x01\n\x14WriteSecured2Command\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x02 \x01(\x05\x12\x17\n\x0f\x63urrent_user_id\x18\x03 \x01(\x05\x12\x18\n\x10verifier_user_id\x18\x04 \x01(\x05\x12+\n\x05value\x18\x05 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x35\n\x0ftimestamp_value\x18\x06 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\"c\n\x17\x41uthenticateUserCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bverify_user\x18\x02 \x01(\t\x12\x1c\n\x14verify_user_password\x18\x03 \x01(\t\"G\n\x18\x41rchestrAUserToIdCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x14\n\x0cuser_id_guid\x18\x02 \x01(\t\"B\n\x12\x41\x64\x64ItemBulkCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x15\n\rtag_addresses\x18\x02 \x03(\t\"D\n\x15\x41\x64viseItemBulkCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x14\n\x0citem_handles\x18\x02 \x03(\x05\"D\n\x15RemoveItemBulkCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x14\n\x0citem_handles\x18\x02 \x03(\x05\"F\n\x17UnAdviseItemBulkCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x14\n\x0citem_handles\x18\x02 \x03(\x05\"D\n\x14SubscribeBulkCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x15\n\rtag_addresses\x18\x02 \x03(\t\"\xee\x01\n\x16SubscribeAlarmsCommand\x12\x1f\n\x17subscription_expression\x18\x01 \x01(\t\x12;\n\x0b\x66orced_mode\x18\x02 \x01(\x0e\x32&.mxaccess_gateway.v1.AlarmProviderMode\x12:\n\nwatch_list\x18\x03 \x03(\x0b\x32&.mxaccess_gateway.v1.AlarmSubtagTarget\x12:\n\x08\x66\x61ilover\x18\x04 \x01(\x0b\x32(.mxaccess_gateway.v1.AlarmFailoverConfig\"\x1a\n\x18UnsubscribeAlarmsCommand\"\xb4\x01\n\x11\x41larmSubtagTarget\x12\x1c\n\x14\x61larm_full_reference\x18\x01 \x01(\t\x12\x1f\n\x17source_object_reference\x18\x02 \x01(\t\x12\x15\n\ractive_subtag\x18\x03 \x01(\t\x12\x14\n\x0c\x61\x63ked_subtag\x18\x04 \x01(\t\x12\x1a\n\x12\x61\x63k_comment_subtag\x18\x05 \x01(\t\x12\x17\n\x0fpriority_subtag\x18\x06 \x01(\t\"\x85\x01\n\x13\x41larmFailoverConfig\x12%\n\x1d\x63onsecutive_failure_threshold\x18\x01 \x01(\x05\x12\'\n\x1f\x66\x61ilback_probe_interval_seconds\x18\x02 \x01(\x05\x12\x1e\n\x16\x66\x61ilback_stable_probes\x18\x03 \x01(\x05\"\xa1\x01\n\x17\x41\x63knowledgeAlarmCommand\x12\x12\n\nalarm_guid\x18\x01 \x01(\t\x12\x0f\n\x07\x63omment\x18\x02 \x01(\t\x12\x15\n\roperator_user\x18\x03 \x01(\t\x12\x15\n\roperator_node\x18\x04 \x01(\t\x12\x17\n\x0foperator_domain\x18\x05 \x01(\t\x12\x1a\n\x12operator_full_name\x18\x06 \x01(\t\"7\n\x18QueryActiveAlarmsCommand\x12\x1b\n\x13\x61larm_filter_prefix\x18\x01 \x01(\t\"\xd2\x01\n\x1d\x41\x63knowledgeAlarmByNameCommand\x12\x12\n\nalarm_name\x18\x01 \x01(\t\x12\x15\n\rprovider_name\x18\x02 \x01(\t\x12\x12\n\ngroup_name\x18\x03 \x01(\t\x12\x0f\n\x07\x63omment\x18\x04 \x01(\t\x12\x15\n\roperator_user\x18\x05 \x01(\t\x12\x15\n\roperator_node\x18\x06 \x01(\t\x12\x17\n\x0foperator_domain\x18\x07 \x01(\t\x12\x1a\n\x12operator_full_name\x18\x08 \x01(\t\"E\n\x16UnsubscribeBulkCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x14\n\x0citem_handles\x18\x02 \x03(\x05\"_\n\x10WriteBulkCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x34\n\x07\x65ntries\x18\x02 \x03(\x0b\x32#.mxaccess_gateway.v1.WriteBulkEntry\"c\n\x0eWriteBulkEntry\x12\x13\n\x0bitem_handle\x18\x01 \x01(\x05\x12+\n\x05value\x18\x02 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x0f\n\x07user_id\x18\x03 \x01(\x05\"a\n\x11Write2BulkCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x35\n\x07\x65ntries\x18\x02 \x03(\x0b\x32$.mxaccess_gateway.v1.Write2BulkEntry\"\x9b\x01\n\x0fWrite2BulkEntry\x12\x13\n\x0bitem_handle\x18\x01 \x01(\x05\x12+\n\x05value\x18\x02 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x35\n\x0ftimestamp_value\x18\x03 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x0f\n\x07user_id\x18\x04 \x01(\x05\"m\n\x17WriteSecuredBulkCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12;\n\x07\x65ntries\x18\x02 \x03(\x0b\x32*.mxaccess_gateway.v1.WriteSecuredBulkEntry\"\x8c\x01\n\x15WriteSecuredBulkEntry\x12\x13\n\x0bitem_handle\x18\x01 \x01(\x05\x12\x17\n\x0f\x63urrent_user_id\x18\x02 \x01(\x05\x12\x18\n\x10verifier_user_id\x18\x03 \x01(\x05\x12+\n\x05value\x18\x04 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\"o\n\x18WriteSecured2BulkCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12<\n\x07\x65ntries\x18\x02 \x03(\x0b\x32+.mxaccess_gateway.v1.WriteSecured2BulkEntry\"\xc4\x01\n\x16WriteSecured2BulkEntry\x12\x13\n\x0bitem_handle\x18\x01 \x01(\x05\x12\x17\n\x0f\x63urrent_user_id\x18\x02 \x01(\x05\x12\x18\n\x10verifier_user_id\x18\x03 \x01(\x05\x12+\n\x05value\x18\x04 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x35\n\x0ftimestamp_value\x18\x05 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\"S\n\x0fReadBulkCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x15\n\rtag_addresses\x18\x02 \x03(\t\x12\x12\n\ntimeout_ms\x18\x03 \x01(\r\"\x1e\n\x0bPingCommand\x12\x0f\n\x07message\x18\x01 \x01(\t\"\x18\n\x16GetSessionStateCommand\"\x16\n\x14GetWorkerInfoCommand\"(\n\x12\x44rainEventsCommand\x12\x12\n\nmax_events\x18\x01 \x01(\r\"H\n\x15ShutdownWorkerCommand\x12/\n\x0cgrace_period\x18\x01 \x01(\x0b\x32\x19.google.protobuf.Duration\"\x86\x0f\n\x0eMxCommandReply\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x16\n\x0e\x63orrelation_id\x18\x02 \x01(\t\x12\x30\n\x04kind\x18\x03 \x01(\x0e\x32\".mxaccess_gateway.v1.MxCommandKind\x12<\n\x0fprotocol_status\x18\x04 \x01(\x0b\x32#.mxaccess_gateway.v1.ProtocolStatus\x12\x14\n\x07hresult\x18\x05 \x01(\x05H\x01\x88\x01\x01\x12\x32\n\x0creturn_value\x18\x06 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x34\n\x08statuses\x18\x07 \x03(\x0b\x32\".mxaccess_gateway.v1.MxStatusProxy\x12\x1a\n\x12\x64iagnostic_message\x18\x08 \x01(\t\x12\x36\n\x08register\x18\x14 \x01(\x0b\x32\".mxaccess_gateway.v1.RegisterReplyH\x00\x12\x35\n\x08\x61\x64\x64_item\x18\x15 \x01(\x0b\x32!.mxaccess_gateway.v1.AddItemReplyH\x00\x12\x37\n\tadd_item2\x18\x16 \x01(\x0b\x32\".mxaccess_gateway.v1.AddItem2ReplyH\x00\x12\x46\n\x11\x61\x64\x64_buffered_item\x18\x17 \x01(\x0b\x32).mxaccess_gateway.v1.AddBufferedItemReplyH\x00\x12\x34\n\x07suspend\x18\x18 \x01(\x0b\x32!.mxaccess_gateway.v1.SuspendReplyH\x00\x12\x36\n\x08\x61\x63tivate\x18\x19 \x01(\x0b\x32\".mxaccess_gateway.v1.ActivateReplyH\x00\x12G\n\x11\x61uthenticate_user\x18\x1a \x01(\x0b\x32*.mxaccess_gateway.v1.AuthenticateUserReplyH\x00\x12K\n\x14\x61rchestra_user_to_id\x18\x1b \x01(\x0b\x32+.mxaccess_gateway.v1.ArchestrAUserToIdReplyH\x00\x12@\n\radd_item_bulk\x18\x1c \x01(\x0b\x32\'.mxaccess_gateway.v1.BulkSubscribeReplyH\x00\x12\x43\n\x10\x61\x64vise_item_bulk\x18\x1d \x01(\x0b\x32\'.mxaccess_gateway.v1.BulkSubscribeReplyH\x00\x12\x43\n\x10remove_item_bulk\x18\x1e \x01(\x0b\x32\'.mxaccess_gateway.v1.BulkSubscribeReplyH\x00\x12\x46\n\x13un_advise_item_bulk\x18\x1f \x01(\x0b\x32\'.mxaccess_gateway.v1.BulkSubscribeReplyH\x00\x12\x41\n\x0esubscribe_bulk\x18 \x01(\x0b\x32\'.mxaccess_gateway.v1.BulkSubscribeReplyH\x00\x12\x43\n\x10unsubscribe_bulk\x18! \x01(\x0b\x32\'.mxaccess_gateway.v1.BulkSubscribeReplyH\x00\x12N\n\x11\x61\x63knowledge_alarm\x18\" \x01(\x0b\x32\x31.mxaccess_gateway.v1.AcknowledgeAlarmReplyPayloadH\x00\x12Q\n\x13query_active_alarms\x18# \x01(\x0b\x32\x32.mxaccess_gateway.v1.QueryActiveAlarmsReplyPayloadH\x00\x12\x39\n\nwrite_bulk\x18$ \x01(\x0b\x32#.mxaccess_gateway.v1.BulkWriteReplyH\x00\x12:\n\x0bwrite2_bulk\x18% \x01(\x0b\x32#.mxaccess_gateway.v1.BulkWriteReplyH\x00\x12\x41\n\x12write_secured_bulk\x18& \x01(\x0b\x32#.mxaccess_gateway.v1.BulkWriteReplyH\x00\x12\x42\n\x13write_secured2_bulk\x18\' \x01(\x0b\x32#.mxaccess_gateway.v1.BulkWriteReplyH\x00\x12\x37\n\tread_bulk\x18( \x01(\x0b\x32\".mxaccess_gateway.v1.BulkReadReplyH\x00\x12?\n\rsession_state\x18\x64 \x01(\x0b\x32&.mxaccess_gateway.v1.SessionStateReplyH\x00\x12;\n\x0bworker_info\x18\x65 \x01(\x0b\x32$.mxaccess_gateway.v1.WorkerInfoReplyH\x00\x12=\n\x0c\x64rain_events\x18\x66 \x01(\x0b\x32%.mxaccess_gateway.v1.DrainEventsReplyH\x00\x42\t\n\x07payloadB\n\n\x08_hresult\"&\n\rRegisterReply\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\"#\n\x0c\x41\x64\x64ItemReply\x12\x13\n\x0bitem_handle\x18\x01 \x01(\x05\"$\n\rAddItem2Reply\x12\x13\n\x0bitem_handle\x18\x01 \x01(\x05\"+\n\x14\x41\x64\x64\x42ufferedItemReply\x12\x13\n\x0bitem_handle\x18\x01 \x01(\x05\"B\n\x0cSuspendReply\x12\x32\n\x06status\x18\x01 \x01(\x0b\x32\".mxaccess_gateway.v1.MxStatusProxy\"C\n\rActivateReply\x12\x32\n\x06status\x18\x01 \x01(\x0b\x32\".mxaccess_gateway.v1.MxStatusProxy\"(\n\x15\x41uthenticateUserReply\x12\x0f\n\x07user_id\x18\x01 \x01(\x05\")\n\x16\x41rchestrAUserToIdReply\x12\x0f\n\x07user_id\x18\x01 \x01(\x05\"\x81\x01\n\x0fSubscribeResult\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0btag_address\x18\x02 \x01(\t\x12\x13\n\x0bitem_handle\x18\x03 \x01(\x05\x12\x16\n\x0ewas_successful\x18\x04 \x01(\x08\x12\x15\n\rerror_message\x18\x05 \x01(\t\"K\n\x12\x42ulkSubscribeReply\x12\x35\n\x07results\x18\x01 \x03(\x0b\x32$.mxaccess_gateway.v1.SubscribeResult\"\xc4\x01\n\x0f\x42ulkWriteResult\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x02 \x01(\x05\x12\x16\n\x0ewas_successful\x18\x03 \x01(\x08\x12\x14\n\x07hresult\x18\x04 \x01(\x05H\x00\x88\x01\x01\x12\x34\n\x08statuses\x18\x05 \x03(\x0b\x32\".mxaccess_gateway.v1.MxStatusProxy\x12\x15\n\rerror_message\x18\x06 \x01(\tB\n\n\x08_hresult\"G\n\x0e\x42ulkWriteReply\x12\x35\n\x07results\x18\x01 \x03(\x0b\x32$.mxaccess_gateway.v1.BulkWriteResult\"\xbe\x02\n\x0e\x42ulkReadResult\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0btag_address\x18\x02 \x01(\t\x12\x13\n\x0bitem_handle\x18\x03 \x01(\x05\x12\x16\n\x0ewas_successful\x18\x04 \x01(\x08\x12\x12\n\nwas_cached\x18\x05 \x01(\x08\x12+\n\x05value\x18\x06 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x0f\n\x07quality\x18\x07 \x01(\x05\x12\x34\n\x10source_timestamp\x18\x08 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x34\n\x08statuses\x18\t \x03(\x0b\x32\".mxaccess_gateway.v1.MxStatusProxy\x12\x15\n\rerror_message\x18\n \x01(\t\"E\n\rBulkReadReply\x12\x34\n\x07results\x18\x01 \x03(\x0b\x32#.mxaccess_gateway.v1.BulkReadResult\"E\n\x11SessionStateReply\x12\x30\n\x05state\x18\x01 \x01(\x0e\x32!.mxaccess_gateway.v1.SessionState\"u\n\x0fWorkerInfoReply\x12\x19\n\x11worker_process_id\x18\x01 \x01(\x05\x12\x16\n\x0eworker_version\x18\x02 \x01(\t\x12\x17\n\x0fmxaccess_progid\x18\x03 \x01(\t\x12\x16\n\x0emxaccess_clsid\x18\x04 \x01(\t\"@\n\x10\x44rainEventsReply\x12,\n\x06\x65vents\x18\x01 \x03(\x0b\x32\x1c.mxaccess_gateway.v1.MxEvent\"5\n\x1c\x41\x63knowledgeAlarmReplyPayload\x12\x15\n\rnative_status\x18\x01 \x01(\x05\"\\\n\x1dQueryActiveAlarmsReplyPayload\x12;\n\tsnapshots\x18\x01 \x03(\x0b\x32(.mxaccess_gateway.v1.ActiveAlarmSnapshot\"\x8f\x08\n\x07MxEvent\x12\x32\n\x06\x66\x61mily\x18\x01 \x01(\x0e\x32\".mxaccess_gateway.v1.MxEventFamily\x12\x12\n\nsession_id\x18\x02 \x01(\t\x12\x15\n\rserver_handle\x18\x03 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x04 \x01(\x05\x12+\n\x05value\x18\x05 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x0f\n\x07quality\x18\x06 \x01(\x05\x12\x34\n\x10source_timestamp\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x34\n\x08statuses\x18\x08 \x03(\x0b\x32\".mxaccess_gateway.v1.MxStatusProxy\x12\x17\n\x0fworker_sequence\x18\t \x01(\x04\x12\x34\n\x10worker_timestamp\x18\n \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12=\n\x19gateway_receive_timestamp\x18\x0b \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x14\n\x07hresult\x18\x0c \x01(\x05H\x01\x88\x01\x01\x12\x12\n\nraw_status\x18\r \x01(\t\x12\x37\n\nreplay_gap\x18\x0e \x01(\x0b\x32\x1e.mxaccess_gateway.v1.ReplayGapH\x02\x88\x01\x01\x12@\n\x0eon_data_change\x18\x14 \x01(\x0b\x32&.mxaccess_gateway.v1.OnDataChangeEventH\x00\x12\x46\n\x11on_write_complete\x18\x15 \x01(\x0b\x32).mxaccess_gateway.v1.OnWriteCompleteEventH\x00\x12I\n\x12operation_complete\x18\x16 \x01(\x0b\x32+.mxaccess_gateway.v1.OperationCompleteEventH\x00\x12Q\n\x17on_buffered_data_change\x18\x17 \x01(\x0b\x32..mxaccess_gateway.v1.OnBufferedDataChangeEventH\x00\x12J\n\x13on_alarm_transition\x18\x18 \x01(\x0b\x32+.mxaccess_gateway.v1.OnAlarmTransitionEventH\x00\x12^\n\x1eon_alarm_provider_mode_changed\x18\x19 \x01(\x0b\x32\x34.mxaccess_gateway.v1.OnAlarmProviderModeChangedEventH\x00\x42\x06\n\x04\x62odyB\n\n\x08_hresultB\r\n\x0b_replay_gap\"P\n\tReplayGap\x12 \n\x18requested_after_sequence\x18\x01 \x01(\x04\x12!\n\x19oldest_available_sequence\x18\x02 \x01(\x04\"\x13\n\x11OnDataChangeEvent\"\x16\n\x14OnWriteCompleteEvent\"\x18\n\x16OperationCompleteEvent\"\xd4\x01\n\x19OnBufferedDataChangeEvent\x12\x32\n\tdata_type\x18\x01 \x01(\x0e\x32\x1f.mxaccess_gateway.v1.MxDataType\x12\x34\n\x0equality_values\x18\x02 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxArray\x12\x36\n\x10timestamp_values\x18\x03 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxArray\x12\x15\n\rraw_data_type\x18\x04 \x01(\x05\"\xd0\x04\n\x16OnAlarmTransitionEvent\x12\x1c\n\x14\x61larm_full_reference\x18\x01 \x01(\t\x12\x1f\n\x17source_object_reference\x18\x02 \x01(\t\x12\x17\n\x0f\x61larm_type_name\x18\x03 \x01(\t\x12\x41\n\x0ftransition_kind\x18\x04 \x01(\x0e\x32(.mxaccess_gateway.v1.AlarmTransitionKind\x12\x10\n\x08severity\x18\x05 \x01(\x05\x12<\n\x18original_raise_timestamp\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x38\n\x14transition_timestamp\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x15\n\roperator_user\x18\x08 \x01(\t\x12\x18\n\x10operator_comment\x18\t \x01(\t\x12\x10\n\x08\x63\x61tegory\x18\n \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x0b \x01(\t\x12\x33\n\rcurrent_value\x18\x0c \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x31\n\x0blimit_value\x18\r \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x10\n\x08\x64\x65graded\x18\x0e \x01(\x08\x12?\n\x0fsource_provider\x18\x0f \x01(\x0e\x32&.mxaccess_gateway.v1.AlarmProviderMode\"\xa0\x01\n\x1fOnAlarmProviderModeChangedEvent\x12\x34\n\x04mode\x18\x01 \x01(\x0e\x32&.mxaccess_gateway.v1.AlarmProviderMode\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x0f\n\x07hresult\x18\x03 \x01(\x05\x12&\n\x02\x61t\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xd0\x04\n\x13\x41\x63tiveAlarmSnapshot\x12\x1c\n\x14\x61larm_full_reference\x18\x01 \x01(\t\x12\x1f\n\x17source_object_reference\x18\x02 \x01(\t\x12\x17\n\x0f\x61larm_type_name\x18\x03 \x01(\t\x12\x10\n\x08severity\x18\x04 \x01(\x05\x12<\n\x18original_raise_timestamp\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12?\n\rcurrent_state\x18\x06 \x01(\x0e\x32(.mxaccess_gateway.v1.AlarmConditionState\x12\x10\n\x08\x63\x61tegory\x18\x07 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x08 \x01(\t\x12=\n\x19last_transition_timestamp\x18\t \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x15\n\roperator_user\x18\n \x01(\t\x12\x18\n\x10operator_comment\x18\x0b \x01(\t\x12\x33\n\rcurrent_value\x18\x0c \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x31\n\x0blimit_value\x18\r \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x10\n\x08\x64\x65graded\x18\x0e \x01(\x08\x12?\n\x0fsource_provider\x18\x0f \x01(\x0e\x32&.mxaccess_gateway.v1.AlarmProviderMode\"\x90\x01\n\x17\x41\x63knowledgeAlarmRequest\x12\x1d\n\x15\x63lient_correlation_id\x18\x02 \x01(\t\x12\x1c\n\x14\x61larm_full_reference\x18\x03 \x01(\t\x12\x0f\n\x07\x63omment\x18\x04 \x01(\t\x12\x15\n\roperator_user\x18\x05 \x01(\tJ\x04\x08\x01\x10\x02R\nsession_id\"\xf1\x01\n\x15\x41\x63knowledgeAlarmReply\x12\x16\n\x0e\x63orrelation_id\x18\x02 \x01(\t\x12<\n\x0fprotocol_status\x18\x03 \x01(\x0b\x32#.mxaccess_gateway.v1.ProtocolStatus\x12\x14\n\x07hresult\x18\x04 \x01(\x05H\x00\x88\x01\x01\x12\x32\n\x06status\x18\x05 \x01(\x0b\x32\".mxaccess_gateway.v1.MxStatusProxy\x12\x1a\n\x12\x64iagnostic_message\x18\x06 \x01(\tB\n\n\x08_hresultJ\x04\x08\x01\x10\x02R\nsession_id\"Q\n\x13StreamAlarmsRequest\x12\x1d\n\x15\x63lient_correlation_id\x18\x01 \x01(\t\x12\x1b\n\x13\x61larm_filter_prefix\x18\x02 \x01(\t\"\x84\x02\n\x10\x41larmFeedMessage\x12@\n\x0c\x61\x63tive_alarm\x18\x01 \x01(\x0b\x32(.mxaccess_gateway.v1.ActiveAlarmSnapshotH\x00\x12\x1b\n\x11snapshot_complete\x18\x02 \x01(\x08H\x00\x12\x41\n\ntransition\x18\x03 \x01(\x0b\x32+.mxaccess_gateway.v1.OnAlarmTransitionEventH\x00\x12\x43\n\x0fprovider_status\x18\x04 \x01(\x0b\x32(.mxaccess_gateway.v1.AlarmProviderStatusH\x00\x42\t\n\x07payload\"\x98\x01\n\x13\x41larmProviderStatus\x12\x34\n\x04mode\x18\x01 \x01(\x0e\x32&.mxaccess_gateway.v1.AlarmProviderMode\x12\x10\n\x08\x64\x65graded\x18\x02 \x01(\x08\x12\x0e\n\x06reason\x18\x03 \x01(\t\x12)\n\x05since\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xeb\x01\n\rMxStatusProxy\x12\x0f\n\x07success\x18\x01 \x01(\x05\x12\x37\n\x08\x63\x61tegory\x18\x02 \x01(\x0e\x32%.mxaccess_gateway.v1.MxStatusCategory\x12\x38\n\x0b\x64\x65tected_by\x18\x03 \x01(\x0e\x32#.mxaccess_gateway.v1.MxStatusSource\x12\x0e\n\x06\x64\x65tail\x18\x04 \x01(\x05\x12\x14\n\x0craw_category\x18\x05 \x01(\x05\x12\x17\n\x0fraw_detected_by\x18\x06 \x01(\x05\x12\x17\n\x0f\x64iagnostic_text\x18\x07 \x01(\t\"\xe9\x03\n\x07MxValue\x12\x32\n\tdata_type\x18\x01 \x01(\x0e\x32\x1f.mxaccess_gateway.v1.MxDataType\x12\x14\n\x0cvariant_type\x18\x02 \x01(\t\x12\x0f\n\x07is_null\x18\x03 \x01(\x08\x12\x16\n\x0eraw_diagnostic\x18\x04 \x01(\t\x12\x15\n\rraw_data_type\x18\x05 \x01(\x05\x12\x14\n\nbool_value\x18\n \x01(\x08H\x00\x12\x15\n\x0bint32_value\x18\x0b \x01(\x05H\x00\x12\x15\n\x0bint64_value\x18\x0c \x01(\x03H\x00\x12\x15\n\x0b\x66loat_value\x18\r \x01(\x02H\x00\x12\x16\n\x0c\x64ouble_value\x18\x0e \x01(\x01H\x00\x12\x16\n\x0cstring_value\x18\x0f \x01(\tH\x00\x12\x35\n\x0ftimestamp_value\x18\x10 \x01(\x0b\x32\x1a.google.protobuf.TimestampH\x00\x12\x33\n\x0b\x61rray_value\x18\x11 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxArrayH\x00\x12\x13\n\traw_value\x18\x12 \x01(\x0cH\x00\x12@\n\x12sparse_array_value\x18\x13 \x01(\x0b\x32\".mxaccess_gateway.v1.MxSparseArrayH\x00\x42\x06\n\x04kind\"\xfe\x04\n\x07MxArray\x12:\n\x11\x65lement_data_type\x18\x01 \x01(\x0e\x32\x1f.mxaccess_gateway.v1.MxDataType\x12\x14\n\x0cvariant_type\x18\x02 \x01(\t\x12\x12\n\ndimensions\x18\x03 \x03(\r\x12\x16\n\x0eraw_diagnostic\x18\x04 \x01(\t\x12\x1d\n\x15raw_element_data_type\x18\x05 \x01(\x05\x12\x35\n\x0b\x62ool_values\x18\n \x01(\x0b\x32\x1e.mxaccess_gateway.v1.BoolArrayH\x00\x12\x37\n\x0cint32_values\x18\x0b \x01(\x0b\x32\x1f.mxaccess_gateway.v1.Int32ArrayH\x00\x12\x37\n\x0cint64_values\x18\x0c \x01(\x0b\x32\x1f.mxaccess_gateway.v1.Int64ArrayH\x00\x12\x37\n\x0c\x66loat_values\x18\r \x01(\x0b\x32\x1f.mxaccess_gateway.v1.FloatArrayH\x00\x12\x39\n\rdouble_values\x18\x0e \x01(\x0b\x32 .mxaccess_gateway.v1.DoubleArrayH\x00\x12\x39\n\rstring_values\x18\x0f \x01(\x0b\x32 .mxaccess_gateway.v1.StringArrayH\x00\x12?\n\x10timestamp_values\x18\x10 \x01(\x0b\x32#.mxaccess_gateway.v1.TimestampArrayH\x00\x12\x33\n\nraw_values\x18\x11 \x01(\x0b\x32\x1d.mxaccess_gateway.v1.RawArrayH\x00\x42\x08\n\x06values\"\x99\x01\n\rMxSparseArray\x12:\n\x11\x65lement_data_type\x18\x01 \x01(\x0e\x32\x1f.mxaccess_gateway.v1.MxDataType\x12\x14\n\x0ctotal_length\x18\x02 \x01(\r\x12\x36\n\x08\x65lements\x18\x03 \x03(\x0b\x32$.mxaccess_gateway.v1.MxSparseElement\"M\n\x0fMxSparseElement\x12\r\n\x05index\x18\x01 \x01(\r\x12+\n\x05value\x18\x02 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\"\x1b\n\tBoolArray\x12\x0e\n\x06values\x18\x01 \x03(\x08\"\x1c\n\nInt32Array\x12\x0e\n\x06values\x18\x01 \x03(\x05\"\x1c\n\nInt64Array\x12\x0e\n\x06values\x18\x01 \x03(\x03\"\x1c\n\nFloatArray\x12\x0e\n\x06values\x18\x01 \x03(\x02\"\x1d\n\x0b\x44oubleArray\x12\x0e\n\x06values\x18\x01 \x03(\x01\"\x1d\n\x0bStringArray\x12\x0e\n\x06values\x18\x01 \x03(\t\"<\n\x0eTimestampArray\x12*\n\x06values\x18\x01 \x03(\x0b\x32\x1a.google.protobuf.Timestamp\"\x1a\n\x08RawArray\x12\x0e\n\x06values\x18\x01 \x03(\x0c\"X\n\x0eProtocolStatus\x12\x35\n\x04\x63ode\x18\x01 \x01(\x0e\x32\'.mxaccess_gateway.v1.ProtocolStatusCode\x12\x0f\n\x07message\x18\x02 \x01(\t*\x9f\x0b\n\rMxCommandKind\x12\x1f\n\x1bMX_COMMAND_KIND_UNSPECIFIED\x10\x00\x12\x1c\n\x18MX_COMMAND_KIND_REGISTER\x10\x01\x12\x1e\n\x1aMX_COMMAND_KIND_UNREGISTER\x10\x02\x12\x1c\n\x18MX_COMMAND_KIND_ADD_ITEM\x10\x03\x12\x1d\n\x19MX_COMMAND_KIND_ADD_ITEM2\x10\x04\x12\x1f\n\x1bMX_COMMAND_KIND_REMOVE_ITEM\x10\x05\x12\x1a\n\x16MX_COMMAND_KIND_ADVISE\x10\x06\x12\x1d\n\x19MX_COMMAND_KIND_UN_ADVISE\x10\x07\x12&\n\"MX_COMMAND_KIND_ADVISE_SUPERVISORY\x10\x08\x12%\n!MX_COMMAND_KIND_ADD_BUFFERED_ITEM\x10\t\x12\x30\n,MX_COMMAND_KIND_SET_BUFFERED_UPDATE_INTERVAL\x10\n\x12\x1b\n\x17MX_COMMAND_KIND_SUSPEND\x10\x0b\x12\x1c\n\x18MX_COMMAND_KIND_ACTIVATE\x10\x0c\x12\x19\n\x15MX_COMMAND_KIND_WRITE\x10\r\x12\x1a\n\x16MX_COMMAND_KIND_WRITE2\x10\x0e\x12!\n\x1dMX_COMMAND_KIND_WRITE_SECURED\x10\x0f\x12\"\n\x1eMX_COMMAND_KIND_WRITE_SECURED2\x10\x10\x12%\n!MX_COMMAND_KIND_AUTHENTICATE_USER\x10\x11\x12(\n$MX_COMMAND_KIND_ARCHESTRA_USER_TO_ID\x10\x12\x12!\n\x1dMX_COMMAND_KIND_ADD_ITEM_BULK\x10\x13\x12$\n MX_COMMAND_KIND_ADVISE_ITEM_BULK\x10\x14\x12$\n MX_COMMAND_KIND_REMOVE_ITEM_BULK\x10\x15\x12\'\n#MX_COMMAND_KIND_UN_ADVISE_ITEM_BULK\x10\x16\x12\"\n\x1eMX_COMMAND_KIND_SUBSCRIBE_BULK\x10\x17\x12$\n MX_COMMAND_KIND_UNSUBSCRIBE_BULK\x10\x18\x12$\n MX_COMMAND_KIND_SUBSCRIBE_ALARMS\x10\x19\x12&\n\"MX_COMMAND_KIND_UNSUBSCRIBE_ALARMS\x10\x1a\x12%\n!MX_COMMAND_KIND_ACKNOWLEDGE_ALARM\x10\x1b\x12\'\n#MX_COMMAND_KIND_QUERY_ACTIVE_ALARMS\x10\x1c\x12-\n)MX_COMMAND_KIND_ACKNOWLEDGE_ALARM_BY_NAME\x10\x1d\x12\x1e\n\x1aMX_COMMAND_KIND_WRITE_BULK\x10\x1e\x12\x1f\n\x1bMX_COMMAND_KIND_WRITE2_BULK\x10\x1f\x12&\n\"MX_COMMAND_KIND_WRITE_SECURED_BULK\x10 \x12\'\n#MX_COMMAND_KIND_WRITE_SECURED2_BULK\x10!\x12\x1d\n\x19MX_COMMAND_KIND_READ_BULK\x10\"\x12\x18\n\x14MX_COMMAND_KIND_PING\x10\x64\x12%\n!MX_COMMAND_KIND_GET_SESSION_STATE\x10\x65\x12#\n\x1fMX_COMMAND_KIND_GET_WORKER_INFO\x10\x66\x12 \n\x1cMX_COMMAND_KIND_DRAIN_EVENTS\x10g\x12#\n\x1fMX_COMMAND_KIND_SHUTDOWN_WORKER\x10h*z\n\x11\x41larmProviderMode\x12#\n\x1f\x41LARM_PROVIDER_MODE_UNSPECIFIED\x10\x00\x12 \n\x1c\x41LARM_PROVIDER_MODE_ALARMMGR\x10\x01\x12\x1e\n\x1a\x41LARM_PROVIDER_MODE_SUBTAG\x10\x02*\xad\x02\n\rMxEventFamily\x12\x1f\n\x1bMX_EVENT_FAMILY_UNSPECIFIED\x10\x00\x12\"\n\x1eMX_EVENT_FAMILY_ON_DATA_CHANGE\x10\x01\x12%\n!MX_EVENT_FAMILY_ON_WRITE_COMPLETE\x10\x02\x12&\n\"MX_EVENT_FAMILY_OPERATION_COMPLETE\x10\x03\x12+\n\'MX_EVENT_FAMILY_ON_BUFFERED_DATA_CHANGE\x10\x04\x12\'\n#MX_EVENT_FAMILY_ON_ALARM_TRANSITION\x10\x05\x12\x32\n.MX_EVENT_FAMILY_ON_ALARM_PROVIDER_MODE_CHANGED\x10\x06*\xca\x01\n\x13\x41larmTransitionKind\x12%\n!ALARM_TRANSITION_KIND_UNSPECIFIED\x10\x00\x12\x1f\n\x1b\x41LARM_TRANSITION_KIND_RAISE\x10\x01\x12%\n!ALARM_TRANSITION_KIND_ACKNOWLEDGE\x10\x02\x12\x1f\n\x1b\x41LARM_TRANSITION_KIND_CLEAR\x10\x03\x12#\n\x1f\x41LARM_TRANSITION_KIND_RETRIGGER\x10\x04*\xaa\x01\n\x13\x41larmConditionState\x12%\n!ALARM_CONDITION_STATE_UNSPECIFIED\x10\x00\x12 \n\x1c\x41LARM_CONDITION_STATE_ACTIVE\x10\x01\x12&\n\"ALARM_CONDITION_STATE_ACTIVE_ACKED\x10\x02\x12\"\n\x1e\x41LARM_CONDITION_STATE_INACTIVE\x10\x03*\xa5\x03\n\x10MxStatusCategory\x12\"\n\x1eMX_STATUS_CATEGORY_UNSPECIFIED\x10\x00\x12\x1e\n\x1aMX_STATUS_CATEGORY_UNKNOWN\x10\x01\x12\x19\n\x15MX_STATUS_CATEGORY_OK\x10\x02\x12\x1e\n\x1aMX_STATUS_CATEGORY_PENDING\x10\x03\x12\x1e\n\x1aMX_STATUS_CATEGORY_WARNING\x10\x04\x12*\n&MX_STATUS_CATEGORY_COMMUNICATION_ERROR\x10\x05\x12*\n&MX_STATUS_CATEGORY_CONFIGURATION_ERROR\x10\x06\x12(\n$MX_STATUS_CATEGORY_OPERATIONAL_ERROR\x10\x07\x12%\n!MX_STATUS_CATEGORY_SECURITY_ERROR\x10\x08\x12%\n!MX_STATUS_CATEGORY_SOFTWARE_ERROR\x10\t\x12\"\n\x1eMX_STATUS_CATEGORY_OTHER_ERROR\x10\n*\xca\x02\n\x0eMxStatusSource\x12 \n\x1cMX_STATUS_SOURCE_UNSPECIFIED\x10\x00\x12\x1c\n\x18MX_STATUS_SOURCE_UNKNOWN\x10\x01\x12#\n\x1fMX_STATUS_SOURCE_REQUESTING_LMX\x10\x02\x12#\n\x1fMX_STATUS_SOURCE_RESPONDING_LMX\x10\x03\x12#\n\x1fMX_STATUS_SOURCE_REQUESTING_NMX\x10\x04\x12#\n\x1fMX_STATUS_SOURCE_RESPONDING_NMX\x10\x05\x12\x31\n-MX_STATUS_SOURCE_REQUESTING_AUTOMATION_OBJECT\x10\x06\x12\x31\n-MX_STATUS_SOURCE_RESPONDING_AUTOMATION_OBJECT\x10\x07*\xdd\x04\n\nMxDataType\x12\x1c\n\x18MX_DATA_TYPE_UNSPECIFIED\x10\x00\x12\x18\n\x14MX_DATA_TYPE_UNKNOWN\x10\x01\x12\x18\n\x14MX_DATA_TYPE_NO_DATA\x10\x02\x12\x18\n\x14MX_DATA_TYPE_BOOLEAN\x10\x03\x12\x18\n\x14MX_DATA_TYPE_INTEGER\x10\x04\x12\x16\n\x12MX_DATA_TYPE_FLOAT\x10\x05\x12\x17\n\x13MX_DATA_TYPE_DOUBLE\x10\x06\x12\x17\n\x13MX_DATA_TYPE_STRING\x10\x07\x12\x15\n\x11MX_DATA_TYPE_TIME\x10\x08\x12\x1d\n\x19MX_DATA_TYPE_ELAPSED_TIME\x10\t\x12\x1f\n\x1bMX_DATA_TYPE_REFERENCE_TYPE\x10\n\x12\x1c\n\x18MX_DATA_TYPE_STATUS_TYPE\x10\x0b\x12\x15\n\x11MX_DATA_TYPE_ENUM\x10\x0c\x12-\n)MX_DATA_TYPE_SECURITY_CLASSIFICATION_ENUM\x10\r\x12\"\n\x1eMX_DATA_TYPE_DATA_QUALITY_TYPE\x10\x0e\x12\x1f\n\x1bMX_DATA_TYPE_QUALIFIED_ENUM\x10\x0f\x12!\n\x1dMX_DATA_TYPE_QUALIFIED_STRUCT\x10\x10\x12)\n%MX_DATA_TYPE_INTERNATIONALIZED_STRING\x10\x11\x12\x1b\n\x17MX_DATA_TYPE_BIG_STRING\x10\x12\x12\x14\n\x10MX_DATA_TYPE_END\x10\x13*\xa3\x03\n\x12ProtocolStatusCode\x12$\n PROTOCOL_STATUS_CODE_UNSPECIFIED\x10\x00\x12\x1b\n\x17PROTOCOL_STATUS_CODE_OK\x10\x01\x12(\n$PROTOCOL_STATUS_CODE_INVALID_REQUEST\x10\x02\x12*\n&PROTOCOL_STATUS_CODE_SESSION_NOT_FOUND\x10\x03\x12*\n&PROTOCOL_STATUS_CODE_SESSION_NOT_READY\x10\x04\x12+\n\'PROTOCOL_STATUS_CODE_WORKER_UNAVAILABLE\x10\x05\x12 \n\x1cPROTOCOL_STATUS_CODE_TIMEOUT\x10\x06\x12!\n\x1dPROTOCOL_STATUS_CODE_CANCELED\x10\x07\x12+\n\'PROTOCOL_STATUS_CODE_PROTOCOL_VIOLATION\x10\x08\x12)\n%PROTOCOL_STATUS_CODE_MXACCESS_FAILURE\x10\t*\xbf\x02\n\x0cSessionState\x12\x1d\n\x19SESSION_STATE_UNSPECIFIED\x10\x00\x12\x1a\n\x16SESSION_STATE_CREATING\x10\x01\x12!\n\x1dSESSION_STATE_STARTING_WORKER\x10\x02\x12\"\n\x1eSESSION_STATE_WAITING_FOR_PIPE\x10\x03\x12\x1d\n\x19SESSION_STATE_HANDSHAKING\x10\x04\x12%\n!SESSION_STATE_INITIALIZING_WORKER\x10\x05\x12\x17\n\x13SESSION_STATE_READY\x10\x06\x12\x19\n\x15SESSION_STATE_CLOSING\x10\x07\x12\x18\n\x14SESSION_STATE_CLOSED\x10\x08\x12\x19\n\x15SESSION_STATE_FAULTED\x10\t2\xc3\x05\n\x0fMxAccessGateway\x12]\n\x0bOpenSession\x12\'.mxaccess_gateway.v1.OpenSessionRequest\x1a%.mxaccess_gateway.v1.OpenSessionReply\x12`\n\x0c\x43loseSession\x12(.mxaccess_gateway.v1.CloseSessionRequest\x1a&.mxaccess_gateway.v1.CloseSessionReply\x12T\n\x06Invoke\x12%.mxaccess_gateway.v1.MxCommandRequest\x1a#.mxaccess_gateway.v1.MxCommandReply\x12X\n\x0cStreamEvents\x12(.mxaccess_gateway.v1.StreamEventsRequest\x1a\x1c.mxaccess_gateway.v1.MxEvent0\x01\x12l\n\x10\x41\x63knowledgeAlarm\x12,.mxaccess_gateway.v1.AcknowledgeAlarmRequest\x1a*.mxaccess_gateway.v1.AcknowledgeAlarmReply\x12\x61\n\x0cStreamAlarms\x12(.mxaccess_gateway.v1.StreamAlarmsRequest\x1a%.mxaccess_gateway.v1.AlarmFeedMessage0\x01\x12n\n\x11QueryActiveAlarms\x12-.mxaccess_gateway.v1.QueryActiveAlarmsRequest\x1a(.mxaccess_gateway.v1.ActiveAlarmSnapshot0\x01\x42&\xaa\x02#ZB.MOM.WW.MxGateway.Contracts.Protob\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x16mxaccess_gateway.proto\x12\x13mxaccess_gateway.v1\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"j\n\x18QueryActiveAlarmsRequest\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x1d\n\x15\x63lient_correlation_id\x18\x02 \x01(\t\x12\x1b\n\x13\x61larm_filter_prefix\x18\x03 \x01(\t\"\x9f\x01\n\x12OpenSessionRequest\x12\x19\n\x11requested_backend\x18\x01 \x01(\t\x12\x1b\n\x13\x63lient_session_name\x18\x02 \x01(\t\x12\x1d\n\x15\x63lient_correlation_id\x18\x03 \x01(\t\x12\x32\n\x0f\x63ommand_timeout\x18\x04 \x01(\x0b\x32\x19.google.protobuf.Duration\"\xaa\x02\n\x10OpenSessionReply\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x14\n\x0c\x62\x61\x63kend_name\x18\x02 \x01(\t\x12\x19\n\x11worker_process_id\x18\x03 \x01(\x05\x12\x1f\n\x17worker_protocol_version\x18\x04 \x01(\r\x12\x14\n\x0c\x63\x61pabilities\x18\x05 \x03(\t\x12:\n\x17\x64\x65\x66\x61ult_command_timeout\x18\x06 \x01(\x0b\x32\x19.google.protobuf.Duration\x12<\n\x0fprotocol_status\x18\x07 \x01(\x0b\x32#.mxaccess_gateway.v1.ProtocolStatus\x12 \n\x18gateway_protocol_version\x18\x08 \x01(\r\"H\n\x13\x43loseSessionRequest\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x1d\n\x15\x63lient_correlation_id\x18\x02 \x01(\t\"\x9d\x01\n\x11\x43loseSessionReply\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x36\n\x0b\x66inal_state\x18\x02 \x01(\x0e\x32!.mxaccess_gateway.v1.SessionState\x12<\n\x0fprotocol_status\x18\x03 \x01(\x0b\x32#.mxaccess_gateway.v1.ProtocolStatus\"H\n\x13StreamEventsRequest\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x1d\n\x15\x61\x66ter_worker_sequence\x18\x02 \x01(\x04\"v\n\x10MxCommandRequest\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x1d\n\x15\x63lient_correlation_id\x18\x02 \x01(\t\x12/\n\x07\x63ommand\x18\x03 \x01(\x0b\x32\x1e.mxaccess_gateway.v1.MxCommand\"\xc0\x15\n\tMxCommand\x12\x30\n\x04kind\x18\x01 \x01(\x0e\x32\".mxaccess_gateway.v1.MxCommandKind\x12\x38\n\x08register\x18\n \x01(\x0b\x32$.mxaccess_gateway.v1.RegisterCommandH\x00\x12<\n\nunregister\x18\x0b \x01(\x0b\x32&.mxaccess_gateway.v1.UnregisterCommandH\x00\x12\x37\n\x08\x61\x64\x64_item\x18\x0c \x01(\x0b\x32#.mxaccess_gateway.v1.AddItemCommandH\x00\x12\x39\n\tadd_item2\x18\r \x01(\x0b\x32$.mxaccess_gateway.v1.AddItem2CommandH\x00\x12=\n\x0bremove_item\x18\x0e \x01(\x0b\x32&.mxaccess_gateway.v1.RemoveItemCommandH\x00\x12\x34\n\x06\x61\x64vise\x18\x0f \x01(\x0b\x32\".mxaccess_gateway.v1.AdviseCommandH\x00\x12\x39\n\tun_advise\x18\x10 \x01(\x0b\x32$.mxaccess_gateway.v1.UnAdviseCommandH\x00\x12K\n\x12\x61\x64vise_supervisory\x18\x11 \x01(\x0b\x32-.mxaccess_gateway.v1.AdviseSupervisoryCommandH\x00\x12H\n\x11\x61\x64\x64_buffered_item\x18\x12 \x01(\x0b\x32+.mxaccess_gateway.v1.AddBufferedItemCommandH\x00\x12]\n\x1cset_buffered_update_interval\x18\x13 \x01(\x0b\x32\x35.mxaccess_gateway.v1.SetBufferedUpdateIntervalCommandH\x00\x12\x36\n\x07suspend\x18\x14 \x01(\x0b\x32#.mxaccess_gateway.v1.SuspendCommandH\x00\x12\x38\n\x08\x61\x63tivate\x18\x15 \x01(\x0b\x32$.mxaccess_gateway.v1.ActivateCommandH\x00\x12\x32\n\x05write\x18\x16 \x01(\x0b\x32!.mxaccess_gateway.v1.WriteCommandH\x00\x12\x34\n\x06write2\x18\x17 \x01(\x0b\x32\".mxaccess_gateway.v1.Write2CommandH\x00\x12\x41\n\rwrite_secured\x18\x18 \x01(\x0b\x32(.mxaccess_gateway.v1.WriteSecuredCommandH\x00\x12\x43\n\x0ewrite_secured2\x18\x19 \x01(\x0b\x32).mxaccess_gateway.v1.WriteSecured2CommandH\x00\x12I\n\x11\x61uthenticate_user\x18\x1a \x01(\x0b\x32,.mxaccess_gateway.v1.AuthenticateUserCommandH\x00\x12M\n\x14\x61rchestra_user_to_id\x18\x1b \x01(\x0b\x32-.mxaccess_gateway.v1.ArchestrAUserToIdCommandH\x00\x12@\n\radd_item_bulk\x18\x1c \x01(\x0b\x32\'.mxaccess_gateway.v1.AddItemBulkCommandH\x00\x12\x46\n\x10\x61\x64vise_item_bulk\x18\x1d \x01(\x0b\x32*.mxaccess_gateway.v1.AdviseItemBulkCommandH\x00\x12\x46\n\x10remove_item_bulk\x18\x1e \x01(\x0b\x32*.mxaccess_gateway.v1.RemoveItemBulkCommandH\x00\x12K\n\x13un_advise_item_bulk\x18\x1f \x01(\x0b\x32,.mxaccess_gateway.v1.UnAdviseItemBulkCommandH\x00\x12\x43\n\x0esubscribe_bulk\x18 \x01(\x0b\x32).mxaccess_gateway.v1.SubscribeBulkCommandH\x00\x12G\n\x10unsubscribe_bulk\x18! \x01(\x0b\x32+.mxaccess_gateway.v1.UnsubscribeBulkCommandH\x00\x12G\n\x10subscribe_alarms\x18\" \x01(\x0b\x32+.mxaccess_gateway.v1.SubscribeAlarmsCommandH\x00\x12K\n\x12unsubscribe_alarms\x18# \x01(\x0b\x32-.mxaccess_gateway.v1.UnsubscribeAlarmsCommandH\x00\x12Q\n\x19\x61\x63knowledge_alarm_command\x18$ \x01(\x0b\x32,.mxaccess_gateway.v1.AcknowledgeAlarmCommandH\x00\x12T\n\x1bquery_active_alarms_command\x18% \x01(\x0b\x32-.mxaccess_gateway.v1.QueryActiveAlarmsCommandH\x00\x12_\n!acknowledge_alarm_by_name_command\x18& \x01(\x0b\x32\x32.mxaccess_gateway.v1.AcknowledgeAlarmByNameCommandH\x00\x12;\n\nwrite_bulk\x18\' \x01(\x0b\x32%.mxaccess_gateway.v1.WriteBulkCommandH\x00\x12=\n\x0bwrite2_bulk\x18( \x01(\x0b\x32&.mxaccess_gateway.v1.Write2BulkCommandH\x00\x12J\n\x12write_secured_bulk\x18) \x01(\x0b\x32,.mxaccess_gateway.v1.WriteSecuredBulkCommandH\x00\x12L\n\x13write_secured2_bulk\x18* \x01(\x0b\x32-.mxaccess_gateway.v1.WriteSecured2BulkCommandH\x00\x12\x39\n\tread_bulk\x18+ \x01(\x0b\x32$.mxaccess_gateway.v1.ReadBulkCommandH\x00\x12\x30\n\x04ping\x18\x64 \x01(\x0b\x32 .mxaccess_gateway.v1.PingCommandH\x00\x12H\n\x11get_session_state\x18\x65 \x01(\x0b\x32+.mxaccess_gateway.v1.GetSessionStateCommandH\x00\x12\x44\n\x0fget_worker_info\x18\x66 \x01(\x0b\x32).mxaccess_gateway.v1.GetWorkerInfoCommandH\x00\x12?\n\x0c\x64rain_events\x18g \x01(\x0b\x32\'.mxaccess_gateway.v1.DrainEventsCommandH\x00\x12\x45\n\x0fshutdown_worker\x18h \x01(\x0b\x32*.mxaccess_gateway.v1.ShutdownWorkerCommandH\x00\x42\t\n\x07payload\"&\n\x0fRegisterCommand\x12\x13\n\x0b\x63lient_name\x18\x01 \x01(\t\"*\n\x11UnregisterCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\"@\n\x0e\x41\x64\x64ItemCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x17\n\x0fitem_definition\x18\x02 \x01(\t\"W\n\x0f\x41\x64\x64Item2Command\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x17\n\x0fitem_definition\x18\x02 \x01(\t\x12\x14\n\x0citem_context\x18\x03 \x01(\t\"?\n\x11RemoveItemCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x02 \x01(\x05\";\n\rAdviseCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x02 \x01(\x05\"=\n\x0fUnAdviseCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x02 \x01(\x05\"F\n\x18\x41\x64viseSupervisoryCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x02 \x01(\x05\"^\n\x16\x41\x64\x64\x42ufferedItemCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x17\n\x0fitem_definition\x18\x02 \x01(\t\x12\x14\n\x0citem_context\x18\x03 \x01(\t\"_\n SetBufferedUpdateIntervalCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12$\n\x1cupdate_interval_milliseconds\x18\x02 \x01(\x05\"<\n\x0eSuspendCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x02 \x01(\x05\"=\n\x0f\x41\x63tivateCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x02 \x01(\x05\"x\n\x0cWriteCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x02 \x01(\x05\x12+\n\x05value\x18\x03 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x0f\n\x07user_id\x18\x04 \x01(\x05\"\xb0\x01\n\rWrite2Command\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x02 \x01(\x05\x12+\n\x05value\x18\x03 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x35\n\x0ftimestamp_value\x18\x04 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x0f\n\x07user_id\x18\x05 \x01(\x05\"\xa1\x01\n\x13WriteSecuredCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x02 \x01(\x05\x12\x17\n\x0f\x63urrent_user_id\x18\x03 \x01(\x05\x12\x18\n\x10verifier_user_id\x18\x04 \x01(\x05\x12+\n\x05value\x18\x05 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\"\xd9\x01\n\x14WriteSecured2Command\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x02 \x01(\x05\x12\x17\n\x0f\x63urrent_user_id\x18\x03 \x01(\x05\x12\x18\n\x10verifier_user_id\x18\x04 \x01(\x05\x12+\n\x05value\x18\x05 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x35\n\x0ftimestamp_value\x18\x06 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\"c\n\x17\x41uthenticateUserCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bverify_user\x18\x02 \x01(\t\x12\x1c\n\x14verify_user_password\x18\x03 \x01(\t\"G\n\x18\x41rchestrAUserToIdCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x14\n\x0cuser_id_guid\x18\x02 \x01(\t\"B\n\x12\x41\x64\x64ItemBulkCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x15\n\rtag_addresses\x18\x02 \x03(\t\"D\n\x15\x41\x64viseItemBulkCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x14\n\x0citem_handles\x18\x02 \x03(\x05\"D\n\x15RemoveItemBulkCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x14\n\x0citem_handles\x18\x02 \x03(\x05\"F\n\x17UnAdviseItemBulkCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x14\n\x0citem_handles\x18\x02 \x03(\x05\"D\n\x14SubscribeBulkCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x15\n\rtag_addresses\x18\x02 \x03(\t\"\xee\x01\n\x16SubscribeAlarmsCommand\x12\x1f\n\x17subscription_expression\x18\x01 \x01(\t\x12;\n\x0b\x66orced_mode\x18\x02 \x01(\x0e\x32&.mxaccess_gateway.v1.AlarmProviderMode\x12:\n\nwatch_list\x18\x03 \x03(\x0b\x32&.mxaccess_gateway.v1.AlarmSubtagTarget\x12:\n\x08\x66\x61ilover\x18\x04 \x01(\x0b\x32(.mxaccess_gateway.v1.AlarmFailoverConfig\"\x1a\n\x18UnsubscribeAlarmsCommand\"\xb4\x01\n\x11\x41larmSubtagTarget\x12\x1c\n\x14\x61larm_full_reference\x18\x01 \x01(\t\x12\x1f\n\x17source_object_reference\x18\x02 \x01(\t\x12\x15\n\ractive_subtag\x18\x03 \x01(\t\x12\x14\n\x0c\x61\x63ked_subtag\x18\x04 \x01(\t\x12\x1a\n\x12\x61\x63k_comment_subtag\x18\x05 \x01(\t\x12\x17\n\x0fpriority_subtag\x18\x06 \x01(\t\"\x85\x01\n\x13\x41larmFailoverConfig\x12%\n\x1d\x63onsecutive_failure_threshold\x18\x01 \x01(\x05\x12\'\n\x1f\x66\x61ilback_probe_interval_seconds\x18\x02 \x01(\x05\x12\x1e\n\x16\x66\x61ilback_stable_probes\x18\x03 \x01(\x05\"\xa1\x01\n\x17\x41\x63knowledgeAlarmCommand\x12\x12\n\nalarm_guid\x18\x01 \x01(\t\x12\x0f\n\x07\x63omment\x18\x02 \x01(\t\x12\x15\n\roperator_user\x18\x03 \x01(\t\x12\x15\n\roperator_node\x18\x04 \x01(\t\x12\x17\n\x0foperator_domain\x18\x05 \x01(\t\x12\x1a\n\x12operator_full_name\x18\x06 \x01(\t\"7\n\x18QueryActiveAlarmsCommand\x12\x1b\n\x13\x61larm_filter_prefix\x18\x01 \x01(\t\"\xd2\x01\n\x1d\x41\x63knowledgeAlarmByNameCommand\x12\x12\n\nalarm_name\x18\x01 \x01(\t\x12\x15\n\rprovider_name\x18\x02 \x01(\t\x12\x12\n\ngroup_name\x18\x03 \x01(\t\x12\x0f\n\x07\x63omment\x18\x04 \x01(\t\x12\x15\n\roperator_user\x18\x05 \x01(\t\x12\x15\n\roperator_node\x18\x06 \x01(\t\x12\x17\n\x0foperator_domain\x18\x07 \x01(\t\x12\x1a\n\x12operator_full_name\x18\x08 \x01(\t\"E\n\x16UnsubscribeBulkCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x14\n\x0citem_handles\x18\x02 \x03(\x05\"_\n\x10WriteBulkCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x34\n\x07\x65ntries\x18\x02 \x03(\x0b\x32#.mxaccess_gateway.v1.WriteBulkEntry\"c\n\x0eWriteBulkEntry\x12\x13\n\x0bitem_handle\x18\x01 \x01(\x05\x12+\n\x05value\x18\x02 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x0f\n\x07user_id\x18\x03 \x01(\x05\"a\n\x11Write2BulkCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x35\n\x07\x65ntries\x18\x02 \x03(\x0b\x32$.mxaccess_gateway.v1.Write2BulkEntry\"\x9b\x01\n\x0fWrite2BulkEntry\x12\x13\n\x0bitem_handle\x18\x01 \x01(\x05\x12+\n\x05value\x18\x02 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x35\n\x0ftimestamp_value\x18\x03 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x0f\n\x07user_id\x18\x04 \x01(\x05\"m\n\x17WriteSecuredBulkCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12;\n\x07\x65ntries\x18\x02 \x03(\x0b\x32*.mxaccess_gateway.v1.WriteSecuredBulkEntry\"\x8c\x01\n\x15WriteSecuredBulkEntry\x12\x13\n\x0bitem_handle\x18\x01 \x01(\x05\x12\x17\n\x0f\x63urrent_user_id\x18\x02 \x01(\x05\x12\x18\n\x10verifier_user_id\x18\x03 \x01(\x05\x12+\n\x05value\x18\x04 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\"o\n\x18WriteSecured2BulkCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12<\n\x07\x65ntries\x18\x02 \x03(\x0b\x32+.mxaccess_gateway.v1.WriteSecured2BulkEntry\"\xc4\x01\n\x16WriteSecured2BulkEntry\x12\x13\n\x0bitem_handle\x18\x01 \x01(\x05\x12\x17\n\x0f\x63urrent_user_id\x18\x02 \x01(\x05\x12\x18\n\x10verifier_user_id\x18\x03 \x01(\x05\x12+\n\x05value\x18\x04 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x35\n\x0ftimestamp_value\x18\x05 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\"S\n\x0fReadBulkCommand\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x15\n\rtag_addresses\x18\x02 \x03(\t\x12\x12\n\ntimeout_ms\x18\x03 \x01(\r\"\x1e\n\x0bPingCommand\x12\x0f\n\x07message\x18\x01 \x01(\t\"\x18\n\x16GetSessionStateCommand\"\x16\n\x14GetWorkerInfoCommand\"(\n\x12\x44rainEventsCommand\x12\x12\n\nmax_events\x18\x01 \x01(\r\"H\n\x15ShutdownWorkerCommand\x12/\n\x0cgrace_period\x18\x01 \x01(\x0b\x32\x19.google.protobuf.Duration\"\x86\x0f\n\x0eMxCommandReply\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x16\n\x0e\x63orrelation_id\x18\x02 \x01(\t\x12\x30\n\x04kind\x18\x03 \x01(\x0e\x32\".mxaccess_gateway.v1.MxCommandKind\x12<\n\x0fprotocol_status\x18\x04 \x01(\x0b\x32#.mxaccess_gateway.v1.ProtocolStatus\x12\x14\n\x07hresult\x18\x05 \x01(\x05H\x01\x88\x01\x01\x12\x32\n\x0creturn_value\x18\x06 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x34\n\x08statuses\x18\x07 \x03(\x0b\x32\".mxaccess_gateway.v1.MxStatusProxy\x12\x1a\n\x12\x64iagnostic_message\x18\x08 \x01(\t\x12\x36\n\x08register\x18\x14 \x01(\x0b\x32\".mxaccess_gateway.v1.RegisterReplyH\x00\x12\x35\n\x08\x61\x64\x64_item\x18\x15 \x01(\x0b\x32!.mxaccess_gateway.v1.AddItemReplyH\x00\x12\x37\n\tadd_item2\x18\x16 \x01(\x0b\x32\".mxaccess_gateway.v1.AddItem2ReplyH\x00\x12\x46\n\x11\x61\x64\x64_buffered_item\x18\x17 \x01(\x0b\x32).mxaccess_gateway.v1.AddBufferedItemReplyH\x00\x12\x34\n\x07suspend\x18\x18 \x01(\x0b\x32!.mxaccess_gateway.v1.SuspendReplyH\x00\x12\x36\n\x08\x61\x63tivate\x18\x19 \x01(\x0b\x32\".mxaccess_gateway.v1.ActivateReplyH\x00\x12G\n\x11\x61uthenticate_user\x18\x1a \x01(\x0b\x32*.mxaccess_gateway.v1.AuthenticateUserReplyH\x00\x12K\n\x14\x61rchestra_user_to_id\x18\x1b \x01(\x0b\x32+.mxaccess_gateway.v1.ArchestrAUserToIdReplyH\x00\x12@\n\radd_item_bulk\x18\x1c \x01(\x0b\x32\'.mxaccess_gateway.v1.BulkSubscribeReplyH\x00\x12\x43\n\x10\x61\x64vise_item_bulk\x18\x1d \x01(\x0b\x32\'.mxaccess_gateway.v1.BulkSubscribeReplyH\x00\x12\x43\n\x10remove_item_bulk\x18\x1e \x01(\x0b\x32\'.mxaccess_gateway.v1.BulkSubscribeReplyH\x00\x12\x46\n\x13un_advise_item_bulk\x18\x1f \x01(\x0b\x32\'.mxaccess_gateway.v1.BulkSubscribeReplyH\x00\x12\x41\n\x0esubscribe_bulk\x18 \x01(\x0b\x32\'.mxaccess_gateway.v1.BulkSubscribeReplyH\x00\x12\x43\n\x10unsubscribe_bulk\x18! \x01(\x0b\x32\'.mxaccess_gateway.v1.BulkSubscribeReplyH\x00\x12N\n\x11\x61\x63knowledge_alarm\x18\" \x01(\x0b\x32\x31.mxaccess_gateway.v1.AcknowledgeAlarmReplyPayloadH\x00\x12Q\n\x13query_active_alarms\x18# \x01(\x0b\x32\x32.mxaccess_gateway.v1.QueryActiveAlarmsReplyPayloadH\x00\x12\x39\n\nwrite_bulk\x18$ \x01(\x0b\x32#.mxaccess_gateway.v1.BulkWriteReplyH\x00\x12:\n\x0bwrite2_bulk\x18% \x01(\x0b\x32#.mxaccess_gateway.v1.BulkWriteReplyH\x00\x12\x41\n\x12write_secured_bulk\x18& \x01(\x0b\x32#.mxaccess_gateway.v1.BulkWriteReplyH\x00\x12\x42\n\x13write_secured2_bulk\x18\' \x01(\x0b\x32#.mxaccess_gateway.v1.BulkWriteReplyH\x00\x12\x37\n\tread_bulk\x18( \x01(\x0b\x32\".mxaccess_gateway.v1.BulkReadReplyH\x00\x12?\n\rsession_state\x18\x64 \x01(\x0b\x32&.mxaccess_gateway.v1.SessionStateReplyH\x00\x12;\n\x0bworker_info\x18\x65 \x01(\x0b\x32$.mxaccess_gateway.v1.WorkerInfoReplyH\x00\x12=\n\x0c\x64rain_events\x18\x66 \x01(\x0b\x32%.mxaccess_gateway.v1.DrainEventsReplyH\x00\x42\t\n\x07payloadB\n\n\x08_hresult\"&\n\rRegisterReply\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\"#\n\x0c\x41\x64\x64ItemReply\x12\x13\n\x0bitem_handle\x18\x01 \x01(\x05\"$\n\rAddItem2Reply\x12\x13\n\x0bitem_handle\x18\x01 \x01(\x05\"+\n\x14\x41\x64\x64\x42ufferedItemReply\x12\x13\n\x0bitem_handle\x18\x01 \x01(\x05\"B\n\x0cSuspendReply\x12\x32\n\x06status\x18\x01 \x01(\x0b\x32\".mxaccess_gateway.v1.MxStatusProxy\"C\n\rActivateReply\x12\x32\n\x06status\x18\x01 \x01(\x0b\x32\".mxaccess_gateway.v1.MxStatusProxy\"(\n\x15\x41uthenticateUserReply\x12\x0f\n\x07user_id\x18\x01 \x01(\x05\")\n\x16\x41rchestrAUserToIdReply\x12\x0f\n\x07user_id\x18\x01 \x01(\x05\"\x81\x01\n\x0fSubscribeResult\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0btag_address\x18\x02 \x01(\t\x12\x13\n\x0bitem_handle\x18\x03 \x01(\x05\x12\x16\n\x0ewas_successful\x18\x04 \x01(\x08\x12\x15\n\rerror_message\x18\x05 \x01(\t\"K\n\x12\x42ulkSubscribeReply\x12\x35\n\x07results\x18\x01 \x03(\x0b\x32$.mxaccess_gateway.v1.SubscribeResult\"\xc4\x01\n\x0f\x42ulkWriteResult\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x02 \x01(\x05\x12\x16\n\x0ewas_successful\x18\x03 \x01(\x08\x12\x14\n\x07hresult\x18\x04 \x01(\x05H\x00\x88\x01\x01\x12\x34\n\x08statuses\x18\x05 \x03(\x0b\x32\".mxaccess_gateway.v1.MxStatusProxy\x12\x15\n\rerror_message\x18\x06 \x01(\tB\n\n\x08_hresult\"G\n\x0e\x42ulkWriteReply\x12\x35\n\x07results\x18\x01 \x03(\x0b\x32$.mxaccess_gateway.v1.BulkWriteResult\"\xbe\x02\n\x0e\x42ulkReadResult\x12\x15\n\rserver_handle\x18\x01 \x01(\x05\x12\x13\n\x0btag_address\x18\x02 \x01(\t\x12\x13\n\x0bitem_handle\x18\x03 \x01(\x05\x12\x16\n\x0ewas_successful\x18\x04 \x01(\x08\x12\x12\n\nwas_cached\x18\x05 \x01(\x08\x12+\n\x05value\x18\x06 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x0f\n\x07quality\x18\x07 \x01(\x05\x12\x34\n\x10source_timestamp\x18\x08 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x34\n\x08statuses\x18\t \x03(\x0b\x32\".mxaccess_gateway.v1.MxStatusProxy\x12\x15\n\rerror_message\x18\n \x01(\t\"E\n\rBulkReadReply\x12\x34\n\x07results\x18\x01 \x03(\x0b\x32#.mxaccess_gateway.v1.BulkReadResult\"E\n\x11SessionStateReply\x12\x30\n\x05state\x18\x01 \x01(\x0e\x32!.mxaccess_gateway.v1.SessionState\"u\n\x0fWorkerInfoReply\x12\x19\n\x11worker_process_id\x18\x01 \x01(\x05\x12\x16\n\x0eworker_version\x18\x02 \x01(\t\x12\x17\n\x0fmxaccess_progid\x18\x03 \x01(\t\x12\x16\n\x0emxaccess_clsid\x18\x04 \x01(\t\"@\n\x10\x44rainEventsReply\x12,\n\x06\x65vents\x18\x01 \x03(\x0b\x32\x1c.mxaccess_gateway.v1.MxEvent\"5\n\x1c\x41\x63knowledgeAlarmReplyPayload\x12\x15\n\rnative_status\x18\x01 \x01(\x05\"x\n\x1dQueryActiveAlarmsReplyPayload\x12;\n\tsnapshots\x18\x01 \x03(\x0b\x32(.mxaccess_gateway.v1.ActiveAlarmSnapshot\x12\x1a\n\x12snapshot_truncated\x18\x02 \x01(\x08\"\x8f\x08\n\x07MxEvent\x12\x32\n\x06\x66\x61mily\x18\x01 \x01(\x0e\x32\".mxaccess_gateway.v1.MxEventFamily\x12\x12\n\nsession_id\x18\x02 \x01(\t\x12\x15\n\rserver_handle\x18\x03 \x01(\x05\x12\x13\n\x0bitem_handle\x18\x04 \x01(\x05\x12+\n\x05value\x18\x05 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x0f\n\x07quality\x18\x06 \x01(\x05\x12\x34\n\x10source_timestamp\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x34\n\x08statuses\x18\x08 \x03(\x0b\x32\".mxaccess_gateway.v1.MxStatusProxy\x12\x17\n\x0fworker_sequence\x18\t \x01(\x04\x12\x34\n\x10worker_timestamp\x18\n \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12=\n\x19gateway_receive_timestamp\x18\x0b \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x14\n\x07hresult\x18\x0c \x01(\x05H\x01\x88\x01\x01\x12\x12\n\nraw_status\x18\r \x01(\t\x12\x37\n\nreplay_gap\x18\x0e \x01(\x0b\x32\x1e.mxaccess_gateway.v1.ReplayGapH\x02\x88\x01\x01\x12@\n\x0eon_data_change\x18\x14 \x01(\x0b\x32&.mxaccess_gateway.v1.OnDataChangeEventH\x00\x12\x46\n\x11on_write_complete\x18\x15 \x01(\x0b\x32).mxaccess_gateway.v1.OnWriteCompleteEventH\x00\x12I\n\x12operation_complete\x18\x16 \x01(\x0b\x32+.mxaccess_gateway.v1.OperationCompleteEventH\x00\x12Q\n\x17on_buffered_data_change\x18\x17 \x01(\x0b\x32..mxaccess_gateway.v1.OnBufferedDataChangeEventH\x00\x12J\n\x13on_alarm_transition\x18\x18 \x01(\x0b\x32+.mxaccess_gateway.v1.OnAlarmTransitionEventH\x00\x12^\n\x1eon_alarm_provider_mode_changed\x18\x19 \x01(\x0b\x32\x34.mxaccess_gateway.v1.OnAlarmProviderModeChangedEventH\x00\x42\x06\n\x04\x62odyB\n\n\x08_hresultB\r\n\x0b_replay_gap\"P\n\tReplayGap\x12 \n\x18requested_after_sequence\x18\x01 \x01(\x04\x12!\n\x19oldest_available_sequence\x18\x02 \x01(\x04\"\x13\n\x11OnDataChangeEvent\"\x16\n\x14OnWriteCompleteEvent\"\x18\n\x16OperationCompleteEvent\"\xd4\x01\n\x19OnBufferedDataChangeEvent\x12\x32\n\tdata_type\x18\x01 \x01(\x0e\x32\x1f.mxaccess_gateway.v1.MxDataType\x12\x34\n\x0equality_values\x18\x02 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxArray\x12\x36\n\x10timestamp_values\x18\x03 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxArray\x12\x15\n\rraw_data_type\x18\x04 \x01(\x05\"\xd0\x04\n\x16OnAlarmTransitionEvent\x12\x1c\n\x14\x61larm_full_reference\x18\x01 \x01(\t\x12\x1f\n\x17source_object_reference\x18\x02 \x01(\t\x12\x17\n\x0f\x61larm_type_name\x18\x03 \x01(\t\x12\x41\n\x0ftransition_kind\x18\x04 \x01(\x0e\x32(.mxaccess_gateway.v1.AlarmTransitionKind\x12\x10\n\x08severity\x18\x05 \x01(\x05\x12<\n\x18original_raise_timestamp\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x38\n\x14transition_timestamp\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x15\n\roperator_user\x18\x08 \x01(\t\x12\x18\n\x10operator_comment\x18\t \x01(\t\x12\x10\n\x08\x63\x61tegory\x18\n \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x0b \x01(\t\x12\x33\n\rcurrent_value\x18\x0c \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x31\n\x0blimit_value\x18\r \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x10\n\x08\x64\x65graded\x18\x0e \x01(\x08\x12?\n\x0fsource_provider\x18\x0f \x01(\x0e\x32&.mxaccess_gateway.v1.AlarmProviderMode\"\xa0\x01\n\x1fOnAlarmProviderModeChangedEvent\x12\x34\n\x04mode\x18\x01 \x01(\x0e\x32&.mxaccess_gateway.v1.AlarmProviderMode\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x0f\n\x07hresult\x18\x03 \x01(\x05\x12&\n\x02\x61t\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xf1\x04\n\x13\x41\x63tiveAlarmSnapshot\x12\x1c\n\x14\x61larm_full_reference\x18\x01 \x01(\t\x12\x1f\n\x17source_object_reference\x18\x02 \x01(\t\x12\x17\n\x0f\x61larm_type_name\x18\x03 \x01(\t\x12\x10\n\x08severity\x18\x04 \x01(\x05\x12<\n\x18original_raise_timestamp\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12?\n\rcurrent_state\x18\x06 \x01(\x0e\x32(.mxaccess_gateway.v1.AlarmConditionState\x12\x10\n\x08\x63\x61tegory\x18\x07 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x08 \x01(\t\x12=\n\x19last_transition_timestamp\x18\t \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x15\n\roperator_user\x18\n \x01(\t\x12\x18\n\x10operator_comment\x18\x0b \x01(\t\x12\x33\n\rcurrent_value\x18\x0c \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x31\n\x0blimit_value\x18\r \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\x12\x10\n\x08\x64\x65graded\x18\x0e \x01(\x08\x12?\n\x0fsource_provider\x18\x0f \x01(\x0e\x32&.mxaccess_gateway.v1.AlarmProviderMode\x12\x1f\n\x17\x66rom_truncated_snapshot\x18\x10 \x01(\x08\"\x90\x01\n\x17\x41\x63knowledgeAlarmRequest\x12\x1d\n\x15\x63lient_correlation_id\x18\x02 \x01(\t\x12\x1c\n\x14\x61larm_full_reference\x18\x03 \x01(\t\x12\x0f\n\x07\x63omment\x18\x04 \x01(\t\x12\x15\n\roperator_user\x18\x05 \x01(\tJ\x04\x08\x01\x10\x02R\nsession_id\"\xf1\x01\n\x15\x41\x63knowledgeAlarmReply\x12\x16\n\x0e\x63orrelation_id\x18\x02 \x01(\t\x12<\n\x0fprotocol_status\x18\x03 \x01(\x0b\x32#.mxaccess_gateway.v1.ProtocolStatus\x12\x14\n\x07hresult\x18\x04 \x01(\x05H\x00\x88\x01\x01\x12\x32\n\x06status\x18\x05 \x01(\x0b\x32\".mxaccess_gateway.v1.MxStatusProxy\x12\x1a\n\x12\x64iagnostic_message\x18\x06 \x01(\tB\n\n\x08_hresultJ\x04\x08\x01\x10\x02R\nsession_id\"Q\n\x13StreamAlarmsRequest\x12\x1d\n\x15\x63lient_correlation_id\x18\x01 \x01(\t\x12\x1b\n\x13\x61larm_filter_prefix\x18\x02 \x01(\t\"\x84\x02\n\x10\x41larmFeedMessage\x12@\n\x0c\x61\x63tive_alarm\x18\x01 \x01(\x0b\x32(.mxaccess_gateway.v1.ActiveAlarmSnapshotH\x00\x12\x1b\n\x11snapshot_complete\x18\x02 \x01(\x08H\x00\x12\x41\n\ntransition\x18\x03 \x01(\x0b\x32+.mxaccess_gateway.v1.OnAlarmTransitionEventH\x00\x12\x43\n\x0fprovider_status\x18\x04 \x01(\x0b\x32(.mxaccess_gateway.v1.AlarmProviderStatusH\x00\x42\t\n\x07payload\"\x98\x01\n\x13\x41larmProviderStatus\x12\x34\n\x04mode\x18\x01 \x01(\x0e\x32&.mxaccess_gateway.v1.AlarmProviderMode\x12\x10\n\x08\x64\x65graded\x18\x02 \x01(\x08\x12\x0e\n\x06reason\x18\x03 \x01(\t\x12)\n\x05since\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xeb\x01\n\rMxStatusProxy\x12\x0f\n\x07success\x18\x01 \x01(\x05\x12\x37\n\x08\x63\x61tegory\x18\x02 \x01(\x0e\x32%.mxaccess_gateway.v1.MxStatusCategory\x12\x38\n\x0b\x64\x65tected_by\x18\x03 \x01(\x0e\x32#.mxaccess_gateway.v1.MxStatusSource\x12\x0e\n\x06\x64\x65tail\x18\x04 \x01(\x05\x12\x14\n\x0craw_category\x18\x05 \x01(\x05\x12\x17\n\x0fraw_detected_by\x18\x06 \x01(\x05\x12\x17\n\x0f\x64iagnostic_text\x18\x07 \x01(\t\"\xe9\x03\n\x07MxValue\x12\x32\n\tdata_type\x18\x01 \x01(\x0e\x32\x1f.mxaccess_gateway.v1.MxDataType\x12\x14\n\x0cvariant_type\x18\x02 \x01(\t\x12\x0f\n\x07is_null\x18\x03 \x01(\x08\x12\x16\n\x0eraw_diagnostic\x18\x04 \x01(\t\x12\x15\n\rraw_data_type\x18\x05 \x01(\x05\x12\x14\n\nbool_value\x18\n \x01(\x08H\x00\x12\x15\n\x0bint32_value\x18\x0b \x01(\x05H\x00\x12\x15\n\x0bint64_value\x18\x0c \x01(\x03H\x00\x12\x15\n\x0b\x66loat_value\x18\r \x01(\x02H\x00\x12\x16\n\x0c\x64ouble_value\x18\x0e \x01(\x01H\x00\x12\x16\n\x0cstring_value\x18\x0f \x01(\tH\x00\x12\x35\n\x0ftimestamp_value\x18\x10 \x01(\x0b\x32\x1a.google.protobuf.TimestampH\x00\x12\x33\n\x0b\x61rray_value\x18\x11 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxArrayH\x00\x12\x13\n\traw_value\x18\x12 \x01(\x0cH\x00\x12@\n\x12sparse_array_value\x18\x13 \x01(\x0b\x32\".mxaccess_gateway.v1.MxSparseArrayH\x00\x42\x06\n\x04kind\"\xfe\x04\n\x07MxArray\x12:\n\x11\x65lement_data_type\x18\x01 \x01(\x0e\x32\x1f.mxaccess_gateway.v1.MxDataType\x12\x14\n\x0cvariant_type\x18\x02 \x01(\t\x12\x12\n\ndimensions\x18\x03 \x03(\r\x12\x16\n\x0eraw_diagnostic\x18\x04 \x01(\t\x12\x1d\n\x15raw_element_data_type\x18\x05 \x01(\x05\x12\x35\n\x0b\x62ool_values\x18\n \x01(\x0b\x32\x1e.mxaccess_gateway.v1.BoolArrayH\x00\x12\x37\n\x0cint32_values\x18\x0b \x01(\x0b\x32\x1f.mxaccess_gateway.v1.Int32ArrayH\x00\x12\x37\n\x0cint64_values\x18\x0c \x01(\x0b\x32\x1f.mxaccess_gateway.v1.Int64ArrayH\x00\x12\x37\n\x0c\x66loat_values\x18\r \x01(\x0b\x32\x1f.mxaccess_gateway.v1.FloatArrayH\x00\x12\x39\n\rdouble_values\x18\x0e \x01(\x0b\x32 .mxaccess_gateway.v1.DoubleArrayH\x00\x12\x39\n\rstring_values\x18\x0f \x01(\x0b\x32 .mxaccess_gateway.v1.StringArrayH\x00\x12?\n\x10timestamp_values\x18\x10 \x01(\x0b\x32#.mxaccess_gateway.v1.TimestampArrayH\x00\x12\x33\n\nraw_values\x18\x11 \x01(\x0b\x32\x1d.mxaccess_gateway.v1.RawArrayH\x00\x42\x08\n\x06values\"\x99\x01\n\rMxSparseArray\x12:\n\x11\x65lement_data_type\x18\x01 \x01(\x0e\x32\x1f.mxaccess_gateway.v1.MxDataType\x12\x14\n\x0ctotal_length\x18\x02 \x01(\r\x12\x36\n\x08\x65lements\x18\x03 \x03(\x0b\x32$.mxaccess_gateway.v1.MxSparseElement\"M\n\x0fMxSparseElement\x12\r\n\x05index\x18\x01 \x01(\r\x12+\n\x05value\x18\x02 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxValue\"\x1b\n\tBoolArray\x12\x0e\n\x06values\x18\x01 \x03(\x08\"\x1c\n\nInt32Array\x12\x0e\n\x06values\x18\x01 \x03(\x05\"\x1c\n\nInt64Array\x12\x0e\n\x06values\x18\x01 \x03(\x03\"\x1c\n\nFloatArray\x12\x0e\n\x06values\x18\x01 \x03(\x02\"\x1d\n\x0b\x44oubleArray\x12\x0e\n\x06values\x18\x01 \x03(\x01\"\x1d\n\x0bStringArray\x12\x0e\n\x06values\x18\x01 \x03(\t\"<\n\x0eTimestampArray\x12*\n\x06values\x18\x01 \x03(\x0b\x32\x1a.google.protobuf.Timestamp\"\x1a\n\x08RawArray\x12\x0e\n\x06values\x18\x01 \x03(\x0c\"X\n\x0eProtocolStatus\x12\x35\n\x04\x63ode\x18\x01 \x01(\x0e\x32\'.mxaccess_gateway.v1.ProtocolStatusCode\x12\x0f\n\x07message\x18\x02 \x01(\t*\x9f\x0b\n\rMxCommandKind\x12\x1f\n\x1bMX_COMMAND_KIND_UNSPECIFIED\x10\x00\x12\x1c\n\x18MX_COMMAND_KIND_REGISTER\x10\x01\x12\x1e\n\x1aMX_COMMAND_KIND_UNREGISTER\x10\x02\x12\x1c\n\x18MX_COMMAND_KIND_ADD_ITEM\x10\x03\x12\x1d\n\x19MX_COMMAND_KIND_ADD_ITEM2\x10\x04\x12\x1f\n\x1bMX_COMMAND_KIND_REMOVE_ITEM\x10\x05\x12\x1a\n\x16MX_COMMAND_KIND_ADVISE\x10\x06\x12\x1d\n\x19MX_COMMAND_KIND_UN_ADVISE\x10\x07\x12&\n\"MX_COMMAND_KIND_ADVISE_SUPERVISORY\x10\x08\x12%\n!MX_COMMAND_KIND_ADD_BUFFERED_ITEM\x10\t\x12\x30\n,MX_COMMAND_KIND_SET_BUFFERED_UPDATE_INTERVAL\x10\n\x12\x1b\n\x17MX_COMMAND_KIND_SUSPEND\x10\x0b\x12\x1c\n\x18MX_COMMAND_KIND_ACTIVATE\x10\x0c\x12\x19\n\x15MX_COMMAND_KIND_WRITE\x10\r\x12\x1a\n\x16MX_COMMAND_KIND_WRITE2\x10\x0e\x12!\n\x1dMX_COMMAND_KIND_WRITE_SECURED\x10\x0f\x12\"\n\x1eMX_COMMAND_KIND_WRITE_SECURED2\x10\x10\x12%\n!MX_COMMAND_KIND_AUTHENTICATE_USER\x10\x11\x12(\n$MX_COMMAND_KIND_ARCHESTRA_USER_TO_ID\x10\x12\x12!\n\x1dMX_COMMAND_KIND_ADD_ITEM_BULK\x10\x13\x12$\n MX_COMMAND_KIND_ADVISE_ITEM_BULK\x10\x14\x12$\n MX_COMMAND_KIND_REMOVE_ITEM_BULK\x10\x15\x12\'\n#MX_COMMAND_KIND_UN_ADVISE_ITEM_BULK\x10\x16\x12\"\n\x1eMX_COMMAND_KIND_SUBSCRIBE_BULK\x10\x17\x12$\n MX_COMMAND_KIND_UNSUBSCRIBE_BULK\x10\x18\x12$\n MX_COMMAND_KIND_SUBSCRIBE_ALARMS\x10\x19\x12&\n\"MX_COMMAND_KIND_UNSUBSCRIBE_ALARMS\x10\x1a\x12%\n!MX_COMMAND_KIND_ACKNOWLEDGE_ALARM\x10\x1b\x12\'\n#MX_COMMAND_KIND_QUERY_ACTIVE_ALARMS\x10\x1c\x12-\n)MX_COMMAND_KIND_ACKNOWLEDGE_ALARM_BY_NAME\x10\x1d\x12\x1e\n\x1aMX_COMMAND_KIND_WRITE_BULK\x10\x1e\x12\x1f\n\x1bMX_COMMAND_KIND_WRITE2_BULK\x10\x1f\x12&\n\"MX_COMMAND_KIND_WRITE_SECURED_BULK\x10 \x12\'\n#MX_COMMAND_KIND_WRITE_SECURED2_BULK\x10!\x12\x1d\n\x19MX_COMMAND_KIND_READ_BULK\x10\"\x12\x18\n\x14MX_COMMAND_KIND_PING\x10\x64\x12%\n!MX_COMMAND_KIND_GET_SESSION_STATE\x10\x65\x12#\n\x1fMX_COMMAND_KIND_GET_WORKER_INFO\x10\x66\x12 \n\x1cMX_COMMAND_KIND_DRAIN_EVENTS\x10g\x12#\n\x1fMX_COMMAND_KIND_SHUTDOWN_WORKER\x10h*z\n\x11\x41larmProviderMode\x12#\n\x1f\x41LARM_PROVIDER_MODE_UNSPECIFIED\x10\x00\x12 \n\x1c\x41LARM_PROVIDER_MODE_ALARMMGR\x10\x01\x12\x1e\n\x1a\x41LARM_PROVIDER_MODE_SUBTAG\x10\x02*\xad\x02\n\rMxEventFamily\x12\x1f\n\x1bMX_EVENT_FAMILY_UNSPECIFIED\x10\x00\x12\"\n\x1eMX_EVENT_FAMILY_ON_DATA_CHANGE\x10\x01\x12%\n!MX_EVENT_FAMILY_ON_WRITE_COMPLETE\x10\x02\x12&\n\"MX_EVENT_FAMILY_OPERATION_COMPLETE\x10\x03\x12+\n\'MX_EVENT_FAMILY_ON_BUFFERED_DATA_CHANGE\x10\x04\x12\'\n#MX_EVENT_FAMILY_ON_ALARM_TRANSITION\x10\x05\x12\x32\n.MX_EVENT_FAMILY_ON_ALARM_PROVIDER_MODE_CHANGED\x10\x06*\xca\x01\n\x13\x41larmTransitionKind\x12%\n!ALARM_TRANSITION_KIND_UNSPECIFIED\x10\x00\x12\x1f\n\x1b\x41LARM_TRANSITION_KIND_RAISE\x10\x01\x12%\n!ALARM_TRANSITION_KIND_ACKNOWLEDGE\x10\x02\x12\x1f\n\x1b\x41LARM_TRANSITION_KIND_CLEAR\x10\x03\x12#\n\x1f\x41LARM_TRANSITION_KIND_RETRIGGER\x10\x04*\xaa\x01\n\x13\x41larmConditionState\x12%\n!ALARM_CONDITION_STATE_UNSPECIFIED\x10\x00\x12 \n\x1c\x41LARM_CONDITION_STATE_ACTIVE\x10\x01\x12&\n\"ALARM_CONDITION_STATE_ACTIVE_ACKED\x10\x02\x12\"\n\x1e\x41LARM_CONDITION_STATE_INACTIVE\x10\x03*\xa5\x03\n\x10MxStatusCategory\x12\"\n\x1eMX_STATUS_CATEGORY_UNSPECIFIED\x10\x00\x12\x1e\n\x1aMX_STATUS_CATEGORY_UNKNOWN\x10\x01\x12\x19\n\x15MX_STATUS_CATEGORY_OK\x10\x02\x12\x1e\n\x1aMX_STATUS_CATEGORY_PENDING\x10\x03\x12\x1e\n\x1aMX_STATUS_CATEGORY_WARNING\x10\x04\x12*\n&MX_STATUS_CATEGORY_COMMUNICATION_ERROR\x10\x05\x12*\n&MX_STATUS_CATEGORY_CONFIGURATION_ERROR\x10\x06\x12(\n$MX_STATUS_CATEGORY_OPERATIONAL_ERROR\x10\x07\x12%\n!MX_STATUS_CATEGORY_SECURITY_ERROR\x10\x08\x12%\n!MX_STATUS_CATEGORY_SOFTWARE_ERROR\x10\t\x12\"\n\x1eMX_STATUS_CATEGORY_OTHER_ERROR\x10\n*\xca\x02\n\x0eMxStatusSource\x12 \n\x1cMX_STATUS_SOURCE_UNSPECIFIED\x10\x00\x12\x1c\n\x18MX_STATUS_SOURCE_UNKNOWN\x10\x01\x12#\n\x1fMX_STATUS_SOURCE_REQUESTING_LMX\x10\x02\x12#\n\x1fMX_STATUS_SOURCE_RESPONDING_LMX\x10\x03\x12#\n\x1fMX_STATUS_SOURCE_REQUESTING_NMX\x10\x04\x12#\n\x1fMX_STATUS_SOURCE_RESPONDING_NMX\x10\x05\x12\x31\n-MX_STATUS_SOURCE_REQUESTING_AUTOMATION_OBJECT\x10\x06\x12\x31\n-MX_STATUS_SOURCE_RESPONDING_AUTOMATION_OBJECT\x10\x07*\xdd\x04\n\nMxDataType\x12\x1c\n\x18MX_DATA_TYPE_UNSPECIFIED\x10\x00\x12\x18\n\x14MX_DATA_TYPE_UNKNOWN\x10\x01\x12\x18\n\x14MX_DATA_TYPE_NO_DATA\x10\x02\x12\x18\n\x14MX_DATA_TYPE_BOOLEAN\x10\x03\x12\x18\n\x14MX_DATA_TYPE_INTEGER\x10\x04\x12\x16\n\x12MX_DATA_TYPE_FLOAT\x10\x05\x12\x17\n\x13MX_DATA_TYPE_DOUBLE\x10\x06\x12\x17\n\x13MX_DATA_TYPE_STRING\x10\x07\x12\x15\n\x11MX_DATA_TYPE_TIME\x10\x08\x12\x1d\n\x19MX_DATA_TYPE_ELAPSED_TIME\x10\t\x12\x1f\n\x1bMX_DATA_TYPE_REFERENCE_TYPE\x10\n\x12\x1c\n\x18MX_DATA_TYPE_STATUS_TYPE\x10\x0b\x12\x15\n\x11MX_DATA_TYPE_ENUM\x10\x0c\x12-\n)MX_DATA_TYPE_SECURITY_CLASSIFICATION_ENUM\x10\r\x12\"\n\x1eMX_DATA_TYPE_DATA_QUALITY_TYPE\x10\x0e\x12\x1f\n\x1bMX_DATA_TYPE_QUALIFIED_ENUM\x10\x0f\x12!\n\x1dMX_DATA_TYPE_QUALIFIED_STRUCT\x10\x10\x12)\n%MX_DATA_TYPE_INTERNATIONALIZED_STRING\x10\x11\x12\x1b\n\x17MX_DATA_TYPE_BIG_STRING\x10\x12\x12\x14\n\x10MX_DATA_TYPE_END\x10\x13*\xa3\x03\n\x12ProtocolStatusCode\x12$\n PROTOCOL_STATUS_CODE_UNSPECIFIED\x10\x00\x12\x1b\n\x17PROTOCOL_STATUS_CODE_OK\x10\x01\x12(\n$PROTOCOL_STATUS_CODE_INVALID_REQUEST\x10\x02\x12*\n&PROTOCOL_STATUS_CODE_SESSION_NOT_FOUND\x10\x03\x12*\n&PROTOCOL_STATUS_CODE_SESSION_NOT_READY\x10\x04\x12+\n\'PROTOCOL_STATUS_CODE_WORKER_UNAVAILABLE\x10\x05\x12 \n\x1cPROTOCOL_STATUS_CODE_TIMEOUT\x10\x06\x12!\n\x1dPROTOCOL_STATUS_CODE_CANCELED\x10\x07\x12+\n\'PROTOCOL_STATUS_CODE_PROTOCOL_VIOLATION\x10\x08\x12)\n%PROTOCOL_STATUS_CODE_MXACCESS_FAILURE\x10\t*\xbf\x02\n\x0cSessionState\x12\x1d\n\x19SESSION_STATE_UNSPECIFIED\x10\x00\x12\x1a\n\x16SESSION_STATE_CREATING\x10\x01\x12!\n\x1dSESSION_STATE_STARTING_WORKER\x10\x02\x12\"\n\x1eSESSION_STATE_WAITING_FOR_PIPE\x10\x03\x12\x1d\n\x19SESSION_STATE_HANDSHAKING\x10\x04\x12%\n!SESSION_STATE_INITIALIZING_WORKER\x10\x05\x12\x17\n\x13SESSION_STATE_READY\x10\x06\x12\x19\n\x15SESSION_STATE_CLOSING\x10\x07\x12\x18\n\x14SESSION_STATE_CLOSED\x10\x08\x12\x19\n\x15SESSION_STATE_FAULTED\x10\t2\xc3\x05\n\x0fMxAccessGateway\x12]\n\x0bOpenSession\x12\'.mxaccess_gateway.v1.OpenSessionRequest\x1a%.mxaccess_gateway.v1.OpenSessionReply\x12`\n\x0c\x43loseSession\x12(.mxaccess_gateway.v1.CloseSessionRequest\x1a&.mxaccess_gateway.v1.CloseSessionReply\x12T\n\x06Invoke\x12%.mxaccess_gateway.v1.MxCommandRequest\x1a#.mxaccess_gateway.v1.MxCommandReply\x12X\n\x0cStreamEvents\x12(.mxaccess_gateway.v1.StreamEventsRequest\x1a\x1c.mxaccess_gateway.v1.MxEvent0\x01\x12l\n\x10\x41\x63knowledgeAlarm\x12,.mxaccess_gateway.v1.AcknowledgeAlarmRequest\x1a*.mxaccess_gateway.v1.AcknowledgeAlarmReply\x12\x61\n\x0cStreamAlarms\x12(.mxaccess_gateway.v1.StreamAlarmsRequest\x1a%.mxaccess_gateway.v1.AlarmFeedMessage0\x01\x12n\n\x11QueryActiveAlarms\x12-.mxaccess_gateway.v1.QueryActiveAlarmsRequest\x1a(.mxaccess_gateway.v1.ActiveAlarmSnapshot0\x01\x42&\xaa\x02#ZB.MOM.WW.MxGateway.Contracts.Protob\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -34,26 +34,26 @@ _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'mxaccess_gateway_pb2', _glo if not _descriptor._USE_C_DESCRIPTORS: _globals['DESCRIPTOR']._loaded_options = None _globals['DESCRIPTOR']._serialized_options = b'\252\002#ZB.MOM.WW.MxGateway.Contracts.Proto' - _globals['_MXCOMMANDKIND']._serialized_start=17502 - _globals['_MXCOMMANDKIND']._serialized_end=18941 - _globals['_ALARMPROVIDERMODE']._serialized_start=18943 - _globals['_ALARMPROVIDERMODE']._serialized_end=19065 - _globals['_MXEVENTFAMILY']._serialized_start=19068 - _globals['_MXEVENTFAMILY']._serialized_end=19369 - _globals['_ALARMTRANSITIONKIND']._serialized_start=19372 - _globals['_ALARMTRANSITIONKIND']._serialized_end=19574 - _globals['_ALARMCONDITIONSTATE']._serialized_start=19577 - _globals['_ALARMCONDITIONSTATE']._serialized_end=19747 - _globals['_MXSTATUSCATEGORY']._serialized_start=19750 - _globals['_MXSTATUSCATEGORY']._serialized_end=20171 - _globals['_MXSTATUSSOURCE']._serialized_start=20174 - _globals['_MXSTATUSSOURCE']._serialized_end=20504 - _globals['_MXDATATYPE']._serialized_start=20507 - _globals['_MXDATATYPE']._serialized_end=21112 - _globals['_PROTOCOLSTATUSCODE']._serialized_start=21115 - _globals['_PROTOCOLSTATUSCODE']._serialized_end=21534 - _globals['_SESSIONSTATE']._serialized_start=21537 - _globals['_SESSIONSTATE']._serialized_end=21856 + _globals['_MXCOMMANDKIND']._serialized_start=17563 + _globals['_MXCOMMANDKIND']._serialized_end=19002 + _globals['_ALARMPROVIDERMODE']._serialized_start=19004 + _globals['_ALARMPROVIDERMODE']._serialized_end=19126 + _globals['_MXEVENTFAMILY']._serialized_start=19129 + _globals['_MXEVENTFAMILY']._serialized_end=19430 + _globals['_ALARMTRANSITIONKIND']._serialized_start=19433 + _globals['_ALARMTRANSITIONKIND']._serialized_end=19635 + _globals['_ALARMCONDITIONSTATE']._serialized_start=19638 + _globals['_ALARMCONDITIONSTATE']._serialized_end=19808 + _globals['_MXSTATUSCATEGORY']._serialized_start=19811 + _globals['_MXSTATUSCATEGORY']._serialized_end=20232 + _globals['_MXSTATUSSOURCE']._serialized_start=20235 + _globals['_MXSTATUSSOURCE']._serialized_end=20565 + _globals['_MXDATATYPE']._serialized_start=20568 + _globals['_MXDATATYPE']._serialized_end=21173 + _globals['_PROTOCOLSTATUSCODE']._serialized_start=21176 + _globals['_PROTOCOLSTATUSCODE']._serialized_end=21595 + _globals['_SESSIONSTATE']._serialized_start=21598 + _globals['_SESSIONSTATE']._serialized_end=21917 _globals['_QUERYACTIVEALARMSREQUEST']._serialized_start=112 _globals['_QUERYACTIVEALARMSREQUEST']._serialized_end=218 _globals['_OPENSESSIONREQUEST']._serialized_start=221 @@ -199,63 +199,63 @@ if not _descriptor._USE_C_DESCRIPTORS: _globals['_ACKNOWLEDGEALARMREPLYPAYLOAD']._serialized_start=11730 _globals['_ACKNOWLEDGEALARMREPLYPAYLOAD']._serialized_end=11783 _globals['_QUERYACTIVEALARMSREPLYPAYLOAD']._serialized_start=11785 - _globals['_QUERYACTIVEALARMSREPLYPAYLOAD']._serialized_end=11877 - _globals['_MXEVENT']._serialized_start=11880 - _globals['_MXEVENT']._serialized_end=12919 - _globals['_REPLAYGAP']._serialized_start=12921 - _globals['_REPLAYGAP']._serialized_end=13001 - _globals['_ONDATACHANGEEVENT']._serialized_start=13003 - _globals['_ONDATACHANGEEVENT']._serialized_end=13022 - _globals['_ONWRITECOMPLETEEVENT']._serialized_start=13024 - _globals['_ONWRITECOMPLETEEVENT']._serialized_end=13046 - _globals['_OPERATIONCOMPLETEEVENT']._serialized_start=13048 - _globals['_OPERATIONCOMPLETEEVENT']._serialized_end=13072 - _globals['_ONBUFFEREDDATACHANGEEVENT']._serialized_start=13075 - _globals['_ONBUFFEREDDATACHANGEEVENT']._serialized_end=13287 - _globals['_ONALARMTRANSITIONEVENT']._serialized_start=13290 - _globals['_ONALARMTRANSITIONEVENT']._serialized_end=13882 - _globals['_ONALARMPROVIDERMODECHANGEDEVENT']._serialized_start=13885 - _globals['_ONALARMPROVIDERMODECHANGEDEVENT']._serialized_end=14045 - _globals['_ACTIVEALARMSNAPSHOT']._serialized_start=14048 - _globals['_ACTIVEALARMSNAPSHOT']._serialized_end=14640 - _globals['_ACKNOWLEDGEALARMREQUEST']._serialized_start=14643 - _globals['_ACKNOWLEDGEALARMREQUEST']._serialized_end=14787 - _globals['_ACKNOWLEDGEALARMREPLY']._serialized_start=14790 - _globals['_ACKNOWLEDGEALARMREPLY']._serialized_end=15031 - _globals['_STREAMALARMSREQUEST']._serialized_start=15033 - _globals['_STREAMALARMSREQUEST']._serialized_end=15114 - _globals['_ALARMFEEDMESSAGE']._serialized_start=15117 - _globals['_ALARMFEEDMESSAGE']._serialized_end=15377 - _globals['_ALARMPROVIDERSTATUS']._serialized_start=15380 - _globals['_ALARMPROVIDERSTATUS']._serialized_end=15532 - _globals['_MXSTATUSPROXY']._serialized_start=15535 - _globals['_MXSTATUSPROXY']._serialized_end=15770 - _globals['_MXVALUE']._serialized_start=15773 - _globals['_MXVALUE']._serialized_end=16262 - _globals['_MXARRAY']._serialized_start=16265 - _globals['_MXARRAY']._serialized_end=16903 - _globals['_MXSPARSEARRAY']._serialized_start=16906 - _globals['_MXSPARSEARRAY']._serialized_end=17059 - _globals['_MXSPARSEELEMENT']._serialized_start=17061 - _globals['_MXSPARSEELEMENT']._serialized_end=17138 - _globals['_BOOLARRAY']._serialized_start=17140 - _globals['_BOOLARRAY']._serialized_end=17167 - _globals['_INT32ARRAY']._serialized_start=17169 - _globals['_INT32ARRAY']._serialized_end=17197 - _globals['_INT64ARRAY']._serialized_start=17199 - _globals['_INT64ARRAY']._serialized_end=17227 - _globals['_FLOATARRAY']._serialized_start=17229 - _globals['_FLOATARRAY']._serialized_end=17257 - _globals['_DOUBLEARRAY']._serialized_start=17259 - _globals['_DOUBLEARRAY']._serialized_end=17288 - _globals['_STRINGARRAY']._serialized_start=17290 - _globals['_STRINGARRAY']._serialized_end=17319 - _globals['_TIMESTAMPARRAY']._serialized_start=17321 - _globals['_TIMESTAMPARRAY']._serialized_end=17381 - _globals['_RAWARRAY']._serialized_start=17383 - _globals['_RAWARRAY']._serialized_end=17409 - _globals['_PROTOCOLSTATUS']._serialized_start=17411 - _globals['_PROTOCOLSTATUS']._serialized_end=17499 - _globals['_MXACCESSGATEWAY']._serialized_start=21859 - _globals['_MXACCESSGATEWAY']._serialized_end=22566 + _globals['_QUERYACTIVEALARMSREPLYPAYLOAD']._serialized_end=11905 + _globals['_MXEVENT']._serialized_start=11908 + _globals['_MXEVENT']._serialized_end=12947 + _globals['_REPLAYGAP']._serialized_start=12949 + _globals['_REPLAYGAP']._serialized_end=13029 + _globals['_ONDATACHANGEEVENT']._serialized_start=13031 + _globals['_ONDATACHANGEEVENT']._serialized_end=13050 + _globals['_ONWRITECOMPLETEEVENT']._serialized_start=13052 + _globals['_ONWRITECOMPLETEEVENT']._serialized_end=13074 + _globals['_OPERATIONCOMPLETEEVENT']._serialized_start=13076 + _globals['_OPERATIONCOMPLETEEVENT']._serialized_end=13100 + _globals['_ONBUFFEREDDATACHANGEEVENT']._serialized_start=13103 + _globals['_ONBUFFEREDDATACHANGEEVENT']._serialized_end=13315 + _globals['_ONALARMTRANSITIONEVENT']._serialized_start=13318 + _globals['_ONALARMTRANSITIONEVENT']._serialized_end=13910 + _globals['_ONALARMPROVIDERMODECHANGEDEVENT']._serialized_start=13913 + _globals['_ONALARMPROVIDERMODECHANGEDEVENT']._serialized_end=14073 + _globals['_ACTIVEALARMSNAPSHOT']._serialized_start=14076 + _globals['_ACTIVEALARMSNAPSHOT']._serialized_end=14701 + _globals['_ACKNOWLEDGEALARMREQUEST']._serialized_start=14704 + _globals['_ACKNOWLEDGEALARMREQUEST']._serialized_end=14848 + _globals['_ACKNOWLEDGEALARMREPLY']._serialized_start=14851 + _globals['_ACKNOWLEDGEALARMREPLY']._serialized_end=15092 + _globals['_STREAMALARMSREQUEST']._serialized_start=15094 + _globals['_STREAMALARMSREQUEST']._serialized_end=15175 + _globals['_ALARMFEEDMESSAGE']._serialized_start=15178 + _globals['_ALARMFEEDMESSAGE']._serialized_end=15438 + _globals['_ALARMPROVIDERSTATUS']._serialized_start=15441 + _globals['_ALARMPROVIDERSTATUS']._serialized_end=15593 + _globals['_MXSTATUSPROXY']._serialized_start=15596 + _globals['_MXSTATUSPROXY']._serialized_end=15831 + _globals['_MXVALUE']._serialized_start=15834 + _globals['_MXVALUE']._serialized_end=16323 + _globals['_MXARRAY']._serialized_start=16326 + _globals['_MXARRAY']._serialized_end=16964 + _globals['_MXSPARSEARRAY']._serialized_start=16967 + _globals['_MXSPARSEARRAY']._serialized_end=17120 + _globals['_MXSPARSEELEMENT']._serialized_start=17122 + _globals['_MXSPARSEELEMENT']._serialized_end=17199 + _globals['_BOOLARRAY']._serialized_start=17201 + _globals['_BOOLARRAY']._serialized_end=17228 + _globals['_INT32ARRAY']._serialized_start=17230 + _globals['_INT32ARRAY']._serialized_end=17258 + _globals['_INT64ARRAY']._serialized_start=17260 + _globals['_INT64ARRAY']._serialized_end=17288 + _globals['_FLOATARRAY']._serialized_start=17290 + _globals['_FLOATARRAY']._serialized_end=17318 + _globals['_DOUBLEARRAY']._serialized_start=17320 + _globals['_DOUBLEARRAY']._serialized_end=17349 + _globals['_STRINGARRAY']._serialized_start=17351 + _globals['_STRINGARRAY']._serialized_end=17380 + _globals['_TIMESTAMPARRAY']._serialized_start=17382 + _globals['_TIMESTAMPARRAY']._serialized_end=17442 + _globals['_RAWARRAY']._serialized_start=17444 + _globals['_RAWARRAY']._serialized_end=17470 + _globals['_PROTOCOLSTATUS']._serialized_start=17472 + _globals['_PROTOCOLSTATUS']._serialized_end=17560 + _globals['_MXACCESSGATEWAY']._serialized_start=21920 + _globals['_MXACCESSGATEWAY']._serialized_end=22627 # @@protoc_insertion_point(module_scope) diff --git a/clients/rust/README.md b/clients/rust/README.md index c60c8bd..43b9684 100644 --- a/clients/rust/README.md +++ b/clients/rust/README.md @@ -121,8 +121,16 @@ creates an authenticated `tonic` client and attaches `authorization: Bearer `close_session_raw`, `invoke_raw`, `stream_events`, `query_active_alarms`, `stream_alarms`, `acknowledge_alarm`, and `raw_client`. `stream_alarms` returns an `AlarmFeedStream` async stream of alarm-feed messages and -shares the gateway's central alarm monitor with every other client. The -session helpers keep MXAccess handles visible: +shares the gateway's central alarm monitor with every other client. + +`ActiveAlarmSnapshot::from_truncated_snapshot` reports that the record came from +a provider fetch which hit the per-fetch cap: the snapshot set may omit active +alarms, and the gateway suspended its absence-implies-cleared inference for that +poll. Treat the set as possibly incomplete rather than reconciling deletions +from it. It is set-level degraded status, not a comment on the record's own +fidelity, and is distinct from `degraded` (the subtag fallback provider). + +The session helpers keep MXAccess handles visible: ```rust let session = client.open_session(request).await?; diff --git a/clients/rust/protos/mxaccess_gateway.proto b/clients/rust/protos/mxaccess_gateway.proto index db5fb4f..0ee3fb2 100644 --- a/clients/rust/protos/mxaccess_gateway.proto +++ b/clients/rust/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 { From 1d8a4a64424a753b3436ac5bc2d88c443e94152d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 17 Aug 2026 04:48:34 -0400 Subject: [PATCH 12/17] test(dashboard)+docs: SEC-25 live-LDAP ACL coverage; design marked implemented The per-session dashboard event ACL shipped in 693a78d + 7ec0b35 with unit coverage over a fabricated principal. What a fabricated principal cannot show is that the group names the shared directory actually returns -- short RDN values, not DNs -- are the ones Dashboard:GroupToTag keys match. Two [LiveLdapFact]s close that: gw-viewer binds for real, its GwReader membership grants team-a, and IDashboardSessionAcl then admits a team-a-tagged session and refuses a team-b-tagged one; multi-role takes the Administrator bypass. The mapping is config-side only -- no GLAuth entry, group, or membership was added, and glauth.md records that explicitly so a future reader does not go looking for a directory change that never happened. multi-role is a member of GwReader as well as GwAdmin, so it holds team-a too. Its bypass is therefore asserted on team-b and on the untagged session -- the two it would lose if the Administrator branch were ever dropped -- rather than on team-a, which would pass either way. One cheap hardening from a prior review: a GatewayOptionsTests case binds Dashboard:GroupToTag through a real ConfigurationBuilder and looks the group up mis-cased. The property initializer seeds an OrdinalIgnoreCase dictionary, but only the binder decides whether that instance survives; if it did not, a mis-cased group name from the directory would grant no tags and the ACL would deny with no diagnostic. Docs follow the shipped shape: docs/Sessions.md gains the session-tag model (owner-key sourced, immutable, visibility-not-access), gateway.md and CLAUDE.md gain the ACL in their dashboard-auth paragraphs, and three GatewayDashboardDesign.md passages that still described the ACL as outstanding now describe both gated seams and the decision order. GatewayConfiguration.md's ShowTagValues row no longer claims the redaction is the only thing between a Viewer and another session's values -- it is now the second of two independent layers. gateway.md's hub-token lifetime corrected 30 minutes -> 5, matching HubTokenService. Authentication.md disambiguates --dashboard-tags as the only constraint flag that splits on commas. The plan doc header is Implemented; its as-built section 12 already existed and is not duplicated. Verified: NonWindows.slnx builds clean; GatewayOptions/DashboardSessionAcl/ EventsHub filters 37/37; the live-LDAP suite skips cleanly without the env var and runs 7/7 green against the shared GLAuth with it. --- CLAUDE.md | 2 +- archreview/remediation/00-tracking.md | 3 +- .../remediation/40-security-dashboard.md | 2 + .../remediation/60-testing-docs-gaps.md | 6 +- docs/Authentication.md | 6 +- docs/GatewayConfiguration.md | 2 +- docs/GatewayDashboardDesign.md | 34 ++- docs/Sessions.md | 8 + .../2026-07-10-dashboard-session-acl-tst15.md | 4 +- gateway.md | 16 +- glauth.md | 23 ++ .../DashboardLdapLiveTests.cs | 220 +++++++++++++++++- .../Configuration/GatewayOptionsTests.cs | 27 +++ 13 files changed, 326 insertions(+), 27 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 88d2218..853d0aa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -159,7 +159,7 @@ Gateway gRPC clients authenticate with an API key in metadata: `authorization: B Session event streaming is **owner-scoped**: the API key that opened a session is recorded on the session, and every `StreamEvents` attach/reattach is rejected with `PermissionDenied` unless the caller's key id matches the owner. Possessing the `event` scope and knowing a session id is not sufficient — this closes the reconnect/fan-out trust boundary (detach-grace and replay retention are on by default) so an `event`-scoped key cannot attach to another key's retained session. -Dashboard auth is LDAP-backed (separate from the gRPC API-key model). `/login` binds against `MxGateway:Ldap` and maps the user's LDAP groups to `Admin` or `Viewer` via `MxGateway:Dashboard:GroupToRole`, then issues an HTTP-only secure cookie named `__Host-MxGatewayDashboard` when `Dashboard:RequireHttpsCookie` is true (default) and no `Dashboard:CookieName` override is set, else the plain `MxGatewayDashboard` (the `__Host-` prefix requires a Secure cookie). SignalR hubs at `/hubs/{snapshot,alarms,events}` accept either the cookie or a 5-minute bearer minted at `/hubs/token`. `Dashboard:AllowAnonymousLocalhost` (default `true`) grants **read-only** loopback access — it satisfies the Viewer requirement but never the Admin-only requirement, so anonymous localhost can view the dashboard but not reach API-key CRUD or session Close/Kill (`Authentication:Mode=Disabled` is scoped the same way). `Dashboard:DisableLogin` (default `false`) auto-authenticates every dashboard request — including remote browsers — as `Dashboard:AutoLoginUser` (default `multi-role`) with both Admin and Viewer roles; dev/test only, never enable in production. +Dashboard auth is LDAP-backed (separate from the gRPC API-key model). `/login` binds against `MxGateway:Ldap` and maps the user's LDAP groups to `Admin` or `Viewer` via `MxGateway:Dashboard:GroupToRole`, then issues an HTTP-only secure cookie named `__Host-MxGatewayDashboard` when `Dashboard:RequireHttpsCookie` is true (default) and no `Dashboard:CookieName` override is set, else the plain `MxGatewayDashboard` (the `__Host-` prefix requires a Secure cookie). SignalR hubs at `/hubs/{snapshot,alarms,events}` accept either the cookie or a 5-minute bearer minted at `/hubs/token`. Dashboard event visibility is **tag-scoped per session** (`IDashboardSessionAcl`, gating both the events hub and the session-details page's in-process subscribe): an Administrator sees every session, while any other caller sees a session only when its tags — inherited from the owning API key's `apikey --dashboard-tags`, never from the client's request — intersect the tags their LDAP groups grant via `Dashboard:GroupToTag`; untagged sessions follow `Dashboard:UntaggedSessionVisibility` (default `AdminOnly`), and a principal with no tag claims (anonymous localhost included) is an empty-grant Viewer. `Dashboard:AllowAnonymousLocalhost` (default `true`) grants **read-only** loopback access — it satisfies the Viewer requirement but never the Admin-only requirement, so anonymous localhost can view the dashboard but not reach API-key CRUD or session Close/Kill (`Authentication:Mode=Disabled` is scoped the same way). `Dashboard:DisableLogin` (default `false`) auto-authenticates every dashboard request — including remote browsers — as `Dashboard:AutoLoginUser` (default `multi-role`) with both Admin and Viewer roles; dev/test only, never enable in production. ## Process / Platform Notes diff --git a/archreview/remediation/00-tracking.md b/archreview/remediation/00-tracking.md index 3e1042a..f13be4a 100644 --- a/archreview/remediation/00-tracking.md +++ b/archreview/remediation/00-tracking.md @@ -227,7 +227,7 @@ Full design + implementation for each row lives in the linked domain doc under i | TST-12 | Medium | P0 | S | — | Done | CLAUDE.md misstates default retention behaviour | | TST-13 | Medium | P2 | S | — | Done | gateway.md carries stale design-era sketches | | TST-14 | Medium | P2 | S | — | Not started | Repo-root working artifacts need triage | -| TST-15 | Medium | P2 | M | TST-04 | Not started | Dashboard EventsHub has no per-session ACL | +| TST-15 | Medium | P2 | M | TST-04 | Done | Dashboard EventsHub has no per-session ACL | | TST-16 | Medium | — | S | — | Not started | `Dashboard:ShowTagValues` is a dead flag | | TST-17 | Medium | — | S | — | Not started | Vendor-gated alarm parity residuals silently lossy | | TST-18 | Low | — | S | — | Not started | Hosted-service wrappers untested | @@ -253,6 +253,7 @@ Findings the review flagged as one coordinated design pass — sequence them tog | Date | Change | |---|---| +| 2026-08-17 | **TST-15 → `Done` (discharges the ACL half of SEC-25): per-session dashboard event ACL shipped** (branch `feat/deferred-closeout`, commits `693a78d` + `7ec0b35`). Implements `docs/plans/2026-07-10-dashboard-session-acl-tst15.md`, whose header is now `Implemented` with as-built notes in its §12. `IDashboardSessionAcl.CanViewSession` is the single decision **both** subscribe seams consult — `EventsHub.SubscribeSession` (denial is a `HubException`; the caller is neither joined to the group nor registered in `EventsHubViewerRegistry`, so the mirror stays off) and `SessionDetailsPage`'s in-process subscribe (inline denial, no subscription) — so neither path is the weaker one and the `TODO(per-session-acl)` is gone. Decision order, fail-closed on every branch: authenticated Administrator → allow (evaluated **before** the registry lookup, so Admin × unknown-session allows — pinned by a test because reordering the two checks is a plausible refactor); session not found → deny; untagged session → `Dashboard:UntaggedSessionVisibility` (`AdminOnly` default); else allow iff `session.Tags ∩ zb:dashboardtag` claims, ordinal-ignore-case. Session tags are inherited from the owning API key's `dashboard_tags` constraint (`apikey --dashboard-tags`, already in the `ApiKeyConstraints` JSON blob — no SQLite migration) and never from the client's wire request. Viewer grants come from `Dashboard:GroupToTag` over the user's LDAP groups, stamped at cookie login (`DashboardAuthenticator.CreatePrincipal`) and **re-resolved, not copied**, at hub-token mint (`HubTokenService.Issue`), so the 5-minute token lifetime bounds a stale grant. Anonymous localhost is an empty-grant Viewer; `Dashboard:DisableLogin` auto-login carries both roles and so takes the admin bypass unchanged. Tests: `DashboardSessionAclTests` (decision table, every branch asserted in its denying direction too), `EventsHubTests`, `DashboardAuthenticatorTests`, `HubTokenServiceTests`, a `GatewayOptionsTests` case proving `Dashboard:GroupToTag` keeps its ordinal-ignore-case lookup through configuration binding, and two `[LiveLdapFact]`s in `DashboardLdapLiveTests` that drive a real bind against the shared GLAuth (`gw-viewer` → `team-a` grant admits the `team-a` session and refuses the `team-b` one; `multi-role` bypasses on the sessions its own grant does not cover). The live pair needed **no GLAuth change** — the tag layer is config-side, keyed on the existing `GwAdmin`/`GwReader` groups (recorded in `glauth.md`). Docs: `docs/Sessions.md` (session-tag model), `gateway.md` + CLAUDE.md dashboard-auth paragraphs, `docs/GatewayDashboardDesign.md` (three passages that described the ACL as outstanding), `docs/GatewayConfiguration.md` (`ShowTagValues` row: redaction is now the second of two layers, not the only one), `docs/Authentication.md` (`--dashboard-tags` is the only *constraint* flag that splits on commas). | | 2026-08-10 | **TST-25 acceptance Check 6 (forced-failure nightly issue) → Done.** The 2026-07-13 record wrote this check off as "abandoned to shared-runner congestion"; that was wrong on both counts. The 2026-07-13 probe *did* land (issue #125, `[CHECK6 PROBE]`, run 375), and since 2026-07-17 the `nightly-windev` `if: failure()` step has filed an issue on **every** red nightly — #126–#139, all authored by the `gitea-actions` bot. Traced run 672 (schedule, main, red) line by line: main step fails → `exitcode '1': failure` → the `if: failure()` step runs → `POST /api/v1/repos/dohertj2/mxaccessgw/issues` with the built-in token masked to `***` → issue #139 created at the matching timestamp. Re-confirmed by a fresh forced-failure probe on the throwaway branch `test/tst25-check6-nightly-issue` (temporary `tst25-check6-probe.yml` reproducing the job shape with `exit 1` for the live step; run 677 → issue #140). Branch deleted, issues #125 and #140 closed with explanatory comments. **One real defect found and fixed** (`fix/tst25-nightly-issue-path`, not merged): `${{ github.server_url }}` is the runner-internal `http://gitea:3000`, so every filed issue's run link was unreachable from a browser. The API call must keep using it (the job container resolves `gitea` only on the docker network and has no LAN egress to the public origin), so the fix adds a `PUBLIC_SERVER_URL: https://gitea.dohertylan.com` job env used **only** for the browser-facing link in the issue body; the probe validated the fixed template (#140 carries a `https://gitea.dohertylan.com/...` link that returns 200). **Separately observed, not fixed:** the nightly has been red continuously since at least 2026-07-17 (run 672: `x86 Worker.Tests failed with exit code 1`, 1 failed / 398 passed / 11 skipped — the known `EventBurst_DrainLoopCoalescesFlushes` class of flake), and the step de-duplicates nothing, so 14 issues are open, seven of them (#132–#138) for the identical SHA `47c0b64`. Worth a follow-up: fix the red nightly, and consider having the step reuse an open issue with the same title instead of filing a new one. | | 2026-08-10 | **TST-24 → `Done`: per-client wire tests land for the two clients that lacked them** (branch `feat/tst-24-client-wire-tests`). Audit first corrected the finding's premise: **Go, Rust, and Java already had real-server wire tests** — `newBufconnClient`/`fakeGatewayServer` over `grpc/test/bufconn`, `spawn_fake_gateway` over a loopback `TcpListener` with tonic's `Server`, and `InProcessGateway`/`TestGatewayService` over `InProcessServerBuilder` — each already asserting the round trip, the server-observed `authorization` bearer header, and the `ReplayGap` sentinel. The real gaps were **.NET** (every test substituted `FakeGatewayTransport` for `IMxGatewayClientTransport`, and the test project had no server package) and **Python** (stub monkeypatching everywhere except one opt-in TLS test serving only `OpenSession`). Added `WireFakeGatewayServer` + `MxGatewayClientWireTests` (Kestrel h2c on `127.0.0.1:0` serving `MxAccessGatewayBase`; new `Grpc.AspNetCore.Server` 2.76.0 + `Microsoft.AspNetCore.App` refs on the test project) and `clients/python/tests/test_wire_fake_gateway.py` (`grpc.aio` server on `127.0.0.1:0`, no new deps). Four shapes each: full round trip with every reply field asserted, the bearer header **as received by the server** on the streaming RPC too, the `ReplayGap` sentinel surfaced as the client's typed signal, and a genuine `PERMISSION_DENIED` mapping to the typed authorization error. CI: the `portable` job only *built* the .NET client, so a `dotnet test` step was added. **The new tests immediately caught a shipped bug** — Python `GatewayClient.connect()`/`GalaxyRepositoryClient.connect()` constructed the `grpc.aio` channel inside `asyncio.to_thread`, which raises `RuntimeError: There is no current event loop in thread 'asyncio_0'` because a `grpc.aio` channel binds to the loop current on the constructing thread; every non-stub connection failed, and the one test guarding the off-loop behaviour (Client.Python-028) monkeypatched `create_channel` and so asserted the bug. Fixed by splitting `resolve_channel_security` (blocking TOFU probe, off-loop) from `create_channel` (on-loop), with the `-028` tests retargeted to assert both halves. Verified: .NET 133 passed/1 skipped (pre-existing live-gateway skip), Python 168 passed/1 skipped plus 6/6 opt-in TLS. Docs: `docs/GatewayTesting.md` § Client Wire Tests, `clients/dotnet/README.md`, `clients/python/README.md`. | | 2026-08-10 | **TST-05 revisited under the restored Windows tier → `Partially done`** (branch `feat/tst-24-client-wire-tests`, doc/tracker-only). The finding's **scheduling** half is closed: cycle-2 TST-25's `nightly-windev` job (cron `0 6 * * *`) runs `scripts/ci/run-windev-ci.sh live` → `windev-worker-ci.ps1 -Mode live`, which sets `MXGATEWAY_RUN_LIVE_MXACCESS_TESTS=1`, runs `WorkerLiveMxAccessSmokeTests` on windev after the x86 build/Worker.Tests/full-slnx steps, and files a Gitea issue when red. The **coverage-audit** half is *not* closed, and the audit the design asked for now has a negative answer: the suite's eight `[LiveMxAccessFact]`s cover all six late-added COM commands (`Suspend`, `Activate`, `AuthenticateUser`, `ArchestrAUserToId`, `AddBufferedItem`, `SetBufferedUpdateInterval`) but zero of the five control commands — `MxCommandKind.{Ping,GetSessionState,GetWorkerInfo,DrainEvents,ShutdownWorker}` appear nowhere in `WorkerLiveMxAccessSmokeTests.cs`, so the exact paths the Finding calls masked are still only proven against `FakeWorkerHarness` canned replies while the real implementations live in `Worker/Ipc/WorkerPipeSession.cs`. Residual work (two `[LiveMxAccessFact]`s, windev-only to author and verify) is specified in [60-testing-docs-gaps.md](60-testing-docs-gaps.md#tst-05--real-worker-controlcom-paths-verified-opt-in-only---medium--p1). | diff --git a/archreview/remediation/40-security-dashboard.md b/archreview/remediation/40-security-dashboard.md index 90af3a1..7a29c36 100644 --- a/archreview/remediation/40-security-dashboard.md +++ b/archreview/remediation/40-security-dashboard.md @@ -457,6 +457,8 @@ This document turns every finding in the Security/Dashboard/Observability review - Tests: broadcaster test asserting values redacted when `ShowTagValues=false`. - Docs: `docs/GatewayDashboardDesign.md` — clarify the current v1 posture. +**Update 2026-08-17 — the deferred half landed.** The scoping mechanism this finding waited on shipped as TST-15 (`693a78d` + `7ec0b35`): `IDashboardSessionAcl` gates `SubscribeSession` *and* the session-details page's in-process subscribe, so the `TODO(per-session-acl)` is gone and the redaction is no longer the only thing between a low-trust Viewer and another session's events. See the TST-15 section in [60-testing-docs-gaps.md](60-testing-docs-gaps.md#tst-15--dashboard-eventshub-has-no-per-session-acl) and the 2026-08-17 change-log row in [00-tracking.md](00-tracking.md#change-log). Redaction stays — the two layers are independent: the ACL decides who may subscribe, `ShowTagValues` decides what a permitted subscriber sees. + **Verification.** `dotnet build src/ZB.MOM.WW.MxGateway.Server` and the events-hub/broadcaster test filter. --- diff --git a/archreview/remediation/60-testing-docs-gaps.md b/archreview/remediation/60-testing-docs-gaps.md index 8a6671a..f376539 100644 --- a/archreview/remediation/60-testing-docs-gaps.md +++ b/archreview/remediation/60-testing-docs-gaps.md @@ -335,11 +335,11 @@ If TST-02's interim mitigation (flip retention off) is chosen instead of impleme **Impact.** Acceptable for a single-tenant dashboard; wrong the moment `GroupToRole` admits low-trust viewers. It is the dashboard-side twin of the gRPC owner-revalidation gap (TST-02). -**Design.** Fully fleshed out in `docs/plans/2026-07-10-dashboard-session-acl-tst15.md` (epic Phase 4, Tasks 16–19, TST-04). In brief: the dashboard authenticates LDAP users (Admin/Viewer) while sessions are API-key-owned (`OwnerKeyId`) — two disjoint identity domains — so the ACL needs a bridge: a **session tag** sourced from the owning API key (riding in the existing `ApiKeyConstraints` JSON blob, no SQLite migration). Admin sees all; a Viewer may `SubscribeSession` iff `session.Tags ∩ viewer.GrantedTags ≠ ∅` (granted via a new `Dashboard:GroupToTag` map, carried into the hub token as tag claims); untagged sessions are Admin-only by default. The Viewer-default decision (admin-sees-all vs strict) is settled there. Until Phase 4 lands, keep the TODO (it correctly documents the accepted single-tenant assumption); do not silently remove it. +**Design.** Fully fleshed out in `docs/plans/2026-07-10-dashboard-session-acl-tst15.md` (epic Phase 4, Tasks 16–19, TST-04). In brief: the dashboard authenticates LDAP users (Admin/Viewer) while sessions are API-key-owned (`OwnerKeyId`) — two disjoint identity domains — so the ACL needs a bridge: a **session tag** sourced from the owning API key (riding in the existing `ApiKeyConstraints` JSON blob, no SQLite migration). Admin sees all; a Viewer may `SubscribeSession` iff `session.Tags ∩ viewer.GrantedTags ≠ ∅` (granted via a new `Dashboard:GroupToTag` map, carried into the hub token as tag claims); untagged sessions are Admin-only by default. The Viewer-default decision (admin-sees-all vs strict) is settled there. -**Implementation.** `Dashboard/Hubs/EventsHub.cs` (ACL check on group join), hub-token minting to carry the session tag, `Configuration/DashboardOptions.cs` for any group-to-tag config (Task 17). Tests: `...Tests/Gateway/Dashboard/` hub ACL cases incl. live-LDAP users (Task 19). Docs: `docs/Sessions.md`/`gateway.md` dashboard section document the ACL model; CLAUDE.md dashboard-auth paragraph. +**Implementation.** Shipped 2026-08-17 on `feat/deferred-closeout` (`693a78d` + `7ec0b35`); the `TODO(per-session-acl)` is gone. `Dashboard/IDashboardSessionAcl.cs` + `Dashboard/DashboardSessionAcl.cs` hold the single decision, consulted by `Dashboard/Hubs/EventsHub.cs` (`SubscribeSession` → `HubException` on denial, no group join and no viewer registration) and by `Dashboard/Components/Pages/SessionDetailsPage.razor`'s in-process subscribe — the design's one correction, since the page path was not a hub client and would otherwise have been the unguarded seam. Tags ride from the owning key via `ISessionManager.OpenSessionAsync`'s tagged overload into the immutable `GatewaySession.Tags`; grants are stamped by `DashboardAuthenticator.CreatePrincipal` and re-resolved at `HubTokenService.Issue`. Config: `Dashboard:GroupToTag` and `Dashboard:UntaggedSessionVisibility` on `Configuration/DashboardOptions.cs`. Tests: `Tests/Gateway/Dashboard/DashboardSessionAclTests.cs`, `EventsHubTests.cs`, a `Configuration/GatewayOptionsTests.cs` binding case for the `GroupToTag` comparer, and two `[LiveLdapFact]`s in `IntegrationTests/DashboardLdapLiveTests.cs`. Docs: `docs/Sessions.md`, `gateway.md`, CLAUDE.md, `docs/GatewayDashboardDesign.md`, `docs/GatewayConfiguration.md`, `glauth.md`. -**Verification.** `dotnet test ... --filter FullyQualifiedName~EventsHub`; `dotnet build src/ZB.MOM.WW.MxGateway.Server`. +**Verification.** `dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx`; `dotnet test ... --filter FullyQualifiedName~DashboardSessionAclTests`, `~EventsHubTests`, `~GatewayOptionsTests`; live-LDAP pair run green against the shared GLAuth with `MXGATEWAY_RUN_LIVE_LDAP_TESTS=1` (and skipping cleanly without it). --- diff --git a/docs/Authentication.md b/docs/Authentication.md index 6b05974..bc59c3e 100644 --- a/docs/Authentication.md +++ b/docs/Authentication.md @@ -277,7 +277,11 @@ Constraint flags are optional. `--read-subtree`, `--write-subtree`, comma-separated list (`--dashboard-tags team-a,team-b`) and is repeatable; its segments are trimmed and de-duplicated ordinal-ignore-case, and an empty segment is rejected rather than dropped so a stray comma cannot silently persist a grant -the operator did not write. Existing rows with null constraints remain fully +the operator did not write. It is the **only constraint flag** that splits its +value on commas (`--scopes`, which is not a constraint, is the other flag that +does): the repeatable subtree and glob flags each take exactly one value per +occurrence, so `--read-subtree "Area1/*,Area2/*"` is a single literal pattern +containing a comma, not two patterns. Repeat the flag instead. Existing rows with null constraints remain fully unconstrained after migration; rows written before `--dashboard-tags` existed deserialize as untagged, unchanged in every other respect. diff --git a/docs/GatewayConfiguration.md b/docs/GatewayConfiguration.md index 378f955..e99ee78 100644 --- a/docs/GatewayConfiguration.md +++ b/docs/GatewayConfiguration.md @@ -192,7 +192,7 @@ events (a "gap") and must re-snapshot; whatever is still retained is replayed. | `MxGateway:Dashboard:SnapshotIntervalMilliseconds` | `1000` | Dashboard snapshot refresh interval used by the snapshot SignalR hub and the pages that subscribe to it. | | `MxGateway:Dashboard:RecentFaultLimit` | `100` | Maximum number of fault summaries projected into each dashboard snapshot. | | `MxGateway:Dashboard:RecentSessionLimit` | `200` | Maximum number of session summaries projected into each dashboard snapshot. | -| `MxGateway:Dashboard:ShowTagValues` | `false` | Controls whether tag values reach the dashboard's SignalR events hub mirror. `false` (default): `DashboardEventBroadcaster` blanks tag values from a deep-cloned copy of each `MxEvent` before it reaches any hub subscriber — event metadata (tag reference, quality, status, timestamps) still renders; see `docs/GatewayDashboardDesign.md`'s `EventsHub` row for the mechanism. Security-relevant because the per-session hub ACL that would scope a Viewer to specific sessions does not exist yet: with no per-session scoping, this redaction is currently the only thing standing between a low-trust Viewer and other sessions' tag values, so setting this `true` exposes every session's tag values to every authenticated dashboard viewer. The flag gates only the SignalR hub mirror — it does **not** cover the `/browse` live-value display, which remains a separate, still-open residual. | +| `MxGateway:Dashboard:ShowTagValues` | `false` | Controls whether tag values reach the dashboard's SignalR events hub mirror. `false` (default): `DashboardEventBroadcaster` blanks tag values from a deep-cloned copy of each `MxEvent` before it reaches any hub subscriber — event metadata (tag reference, quality, status, timestamps) still renders; see `docs/GatewayDashboardDesign.md`'s `EventsHub` row for the mechanism. This is now the second of two independent layers, not the only one: `IDashboardSessionAcl` decides *which* sessions a caller may subscribe to at all (see `GroupToTag` / `UntaggedSessionVisibility` below), while this flag decides what a permitted subscriber sees. Setting it `true` therefore exposes tag values to everyone the ACL admits — every Administrator, plus each Viewer holding a matching tag. The flag gates only the SignalR hub mirror — it does **not** cover the `/browse` live-value display, which remains a separate, still-open residual. | | `MxGateway:Dashboard:GroupToRole` | _(empty)_ | LDAP group → dashboard role mapping. Keys are LDAP group names (short CN or full DN — leading-RDN match). Values must be `Admin` (read/write, API-key CRUD) or `Viewer` (read-only). A user whose LDAP groups don't intersect this map cannot sign in; with no mapping at all, only the loopback bypass admits anyone. | | `MxGateway:Dashboard:GroupToTag` | _(empty)_ | LDAP group → dashboard visibility tags. Keys follow the same convention as `GroupToRole` (short CN or full DN — leading-RDN match, case-insensitive); values are tag lists. A dashboard user's granted tag set is the union over the groups they belong to; an unmapped group contributes nothing. **Visibility only:** tags scope which sessions' event streams a Viewer may observe on the dashboard — they never grant or deny data access, which stays with the API key's scopes and constraints. Independent of `GroupToRole`: a group may appear in either map, both, or neither. Empty (the default) means Viewers hold no tags, so under the default `UntaggedSessionVisibility` they observe no session's events. | | `MxGateway:Dashboard:UntaggedSessionVisibility` | `AdminOnly` | Who may observe a session that carries no tags (its owning API key declared none). `AdminOnly` (default, fail-closed) restricts untagged sessions to dashboard Administrators. `AllViewers` shows them to every Viewer — opt-in for a single-tenant deployment that wants the pre-tag behaviour. Administrators always see every session regardless of tags. | diff --git a/docs/GatewayDashboardDesign.md b/docs/GatewayDashboardDesign.md index 798e018..6ddce40 100644 --- a/docs/GatewayDashboardDesign.md +++ b/docs/GatewayDashboardDesign.md @@ -274,7 +274,7 @@ bearer). Each hub class is `[Authorize(Policy = HubClientsPolicy)]`. |---|---|---|---|---| | `DashboardSnapshotHub` | `/hubs/snapshot` | `DashboardSnapshotPublisher` (BackgroundService consuming `IDashboardSnapshotService.WatchSnapshotsAsync`) | `DashboardSnapshot` | Sent to all connected clients on every snapshot tick, but only while at least one client is connected (see "Idle gating" below); new connections receive the current snapshot synchronously in `OnConnectedAsync`. | | `AlarmsHub` | `/hubs/alarms` | `AlarmsHubPublisher` (BackgroundService consuming `IGatewayAlarmService.StreamAsync(filter: null)`) | `AlarmFeedMessage` (`active_alarm` / `snapshot_complete` / `transition`) | Connected clients auto-join `__alarms__`; all clients receive every message. Publisher auto-reconnects every 5s on stream faults. | -| `EventsHub` | `/hubs/events` | `DashboardEventBroadcaster` invoked by each session's internal dashboard-mirror subscriber on its `SessionEventDistributor` (registered when the session becomes Ready) | `MxEvent` | Clients call `SubscribeSession(sessionId)` to join `session:{id}`, which also registers them in `EventsHubViewerRegistry` — the mirror is gated on that registry, which counts hub and in-process viewers alike (see "Mirror gating" below). The dashboard is a first-class distributor subscriber, so it receives the session's events whether or not a gRPC client is streaming. It sees RAW session events — not the per-gRPC-subscriber `AfterWorkerSequence` filtering that `EventStreamService` applies at its own boundary — because the dashboard is a separate LDAP-authenticated monitoring view meant to show the session's full event activity. Tag values are stripped from the mirrored `MxEvent` copy by `DashboardEventBroadcaster` when `Dashboard:ShowTagValues` is false (the default) — event metadata (tag reference, quality, status, timestamps) still renders, but the value fields are blanked, so no value leaks through this seam. The per-session ACL that would scope a Viewer to specific sessions is still outstanding for this seam and the in-process one alike (SEC-25 / remediation roadmap item 12); the value redaction is the near-term hardening that closes the value-leak seam independently of that ACL. | +| `EventsHub` | `/hubs/events` | `DashboardEventBroadcaster` invoked by each session's internal dashboard-mirror subscriber on its `SessionEventDistributor` (registered when the session becomes Ready) | `MxEvent` | Clients call `SubscribeSession(sessionId)` to join `session:{id}`, which also registers them in `EventsHubViewerRegistry` — the mirror is gated on that registry, which counts hub and in-process viewers alike (see "Mirror gating" below). The dashboard is a first-class distributor subscriber, so it receives the session's events whether or not a gRPC client is streaming. It sees RAW session events — not the per-gRPC-subscriber `AfterWorkerSequence` filtering that `EventStreamService` applies at its own boundary — because the dashboard is a separate LDAP-authenticated monitoring view meant to show the session's full event activity. Tag values are stripped from the mirrored `MxEvent` copy by `DashboardEventBroadcaster` when `Dashboard:ShowTagValues` is false (the default) — event metadata (tag reference, quality, status, timestamps) still renders, but the value fields are blanked, so no value leaks through this seam. `SubscribeSession` is gated by `IDashboardSessionAcl` (SEC-25 / TST-15): a denied caller gets a `HubException`, is not joined to the group, and is not registered as a viewer, so the mirror stays off for a session nobody is legitimately watching. The same ACL gates the in-process seam the session-details page uses, so neither path is the weaker one. Value redaction remains an independent layer — it bounds what a *permitted* subscriber sees. | ### Default cadences @@ -696,9 +696,26 @@ The in-process page feeds carry no authentication of their own, and need none: `MapRazorComponents()` applies `RequireAuthorization(ViewerPolicy)` to the component endpoints, so a page can only run inside a circuit whose principal is already an authorized Viewer. The hub-token flow below therefore covers only the -remote hub surface. Neither seam scopes a Viewer to particular sessions — SEC-25 -(the per-session ACL) is outstanding for both, and the mirror's value redaction -remains the near-term mitigation, unchanged by the move in-process. +remote hub surface. + +Neither policy scopes a Viewer to particular sessions — that is +`IDashboardSessionAcl`'s job (SEC-25 / TST-15), consulted by both subscribe seams: +`EventsHub.SubscribeSession` for remote hub clients and the session-details page's +in-process subscribe, which renders an inline denial instead of subscribing. The +decision is: authenticated Administrator → allow (checked before the session is +looked up, so an Administrator naming a session that just closed is still allowed); +unknown session id → deny; untagged session → `Dashboard:UntaggedSessionVisibility` +(`AdminOnly` by default); otherwise allow iff the session's tags intersect the +caller's granted tags, ordinal-ignore-case. A session's tags are inherited from its +owning API key's `--dashboard-tags` constraint and are immutable for the session's +life, so a subscribe-time decision cannot go stale while the subscription lives and +no per-event re-check is needed. A Viewer's grant comes from +`Dashboard:GroupToTag` applied to their LDAP groups, stamped as +`zb:dashboardtag` claims at cookie login and re-resolved (not copied) at hub-token +mint, so the token's 5-minute lifetime bounds how long a revoked grant survives. A +principal carrying no tag claims — anonymous localhost included — is an +empty-grant Viewer. The mirror's value redaction is an independent layer: it bounds +what a permitted subscriber sees, not who may subscribe. Two environmental bypasses still apply, both scoped to **read-only** access: `MxGateway:Authentication:Mode = Disabled` and `MxGateway:Dashboard:AllowAnonymousLocalhost` @@ -781,9 +798,12 @@ carry no server-side revocation state (no jti denylist). A token captured before logout remains valid until it expires, and a role change or key revocation does not take effect on an already-issued token until then. The 5-minute lifetime is the deliberate mitigation: it bounds that exposure window without the cost of a -revocation store. Server-side revocation is deferred until per-session hub ACLs -land (see the per-session-ACL note), at which point tokens gain session/role -binding and a denylist becomes worthwhile. +revocation store. It now bounds a stale *tag* grant the same way: the token carries +the tags resolved from the caller's LDAP groups at mint time, so removing a +`GroupToTag` entry takes effect for token-authenticated hub connections within one +lifetime. That is where the per-session ACL's revocation need landed — a jti +denylist stays deferred, since the short lifetime already bounds every grant the +token carries. ## Configuration diff --git a/docs/Sessions.md b/docs/Sessions.md index b77dce5..c582a9b 100644 --- a/docs/Sessions.md +++ b/docs/Sessions.md @@ -47,6 +47,14 @@ public void TransitionTo(SessionState nextState) `Closed` is terminal, `Faulted` only allows a transition to `Closed`, and `Closing` only allows a transition to `Closed` or `Faulted`. This guards against late callbacks (worker exit, heartbeat timeout) re-animating a session that is already tearing down or torn down — once `CloseAsync` has set `Closing` under `_syncRoot`, no `TransitionTo(Ready)` from another thread can walk the session back to `Ready`. Both close-related writes (`Closing` and `Closed`) go through `_syncRoot` exactly like every other state write; `_closeLock` only serializes concurrent close attempts. +#### Session tags and dashboard event visibility + +`GatewaySession.Tags` is an immutable, ordinal-ignore-case set of dashboard visibility tags, stamped once at construction from the `ownerDashboardTags` argument and never mutated for the session's life. The values come from the owning API key's `ApiKeyConstraints.DashboardTags` (set with `apikey --dashboard-tags`), which `MxAccessGatewayService` reads off the authenticated caller and passes to the tagged `OpenSessionAsync` overload. They are never read from the client's wire request, so a client cannot label its own session with another tenant's tag. A session whose owner key declared no tags — and every session opened through the tagless `OpenSessionAsync` overload, which unit-test fakes inherit by default — is untagged. + +Tags gate **visibility only**: which sessions' event metadata a dashboard user may observe. They are not a data-access constraint, so they neither widen nor narrow what the owning key can read or write, and they play no part in the gRPC event stream, whose attach check is owner-key identity (see [Reconnect and replay](#reconnect-and-replay)). + +`IDashboardSessionAcl.CanViewSession` is the single decision both dashboard subscribe seams consult — `EventsHub.SubscribeSession` for remote hub clients and the session-details page's in-process subscribe. An authenticated Administrator is allowed first, before the session is even looked up; otherwise an unknown session id is denied, an untagged session follows `MxGateway:Dashboard:UntaggedSessionVisibility` (`AdminOnly` by default), and a tagged session is allowed only when its tags intersect the caller's granted tags. A Viewer's grant comes from `MxGateway:Dashboard:GroupToTag` applied to their LDAP groups; a principal carrying no tag claims — anonymous localhost included — is an empty-grant Viewer and sees no tagged session. Because `Tags` is immutable, the decision taken at subscribe time cannot go stale while the subscription lives, so there is no per-event re-check. See `docs/GatewayDashboardDesign.md`. + ### SessionManager (ISessionManager) `SessionManager` is the orchestrator. It exposes `OpenSessionAsync`, `TryGetSession`, `InvokeAsync`, `CloseSessionAsync`, `KillWorkerAsync`, `CloseExpiredLeasesAsync`, and `ShutdownAsync`. It composes `ISessionRegistry`, `ISessionWorkerClientFactory`, `GatewayMetrics`, and `GatewayOptions`. 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 ceff06c..2c3ece6 100644 --- a/docs/plans/2026-07-10-dashboard-session-acl-tst15.md +++ b/docs/plans/2026-07-10-dashboard-session-acl-tst15.md @@ -1,6 +1,8 @@ # Dashboard EventsHub per-session ACL (TST-15 / SEC-25 · session-resilience epic Phase 4) -Status: **Design** — approved-to-implement pending the schema-touch call in §9. +Status: **Implemented** — branch `feat/deferred-closeout`, 2026-08-17, commits +`693a78d` + `7ec0b35`. As-built notes in §12; the sections above are the design as +approved, kept for the rationale they record. Findings: TST-15 (`Medium`, P2), SEC-25 (`Low`, P2). Epic tasks: 16–19 of `docs/plans/2026-06-15-session-resilience.md`. Depends on: TST-02 (owner-scoped gRPC attach, shipped P0), SEC-25 near-term diff --git a/gateway.md b/gateway.md index 8fc8278..1013b24 100644 --- a/gateway.md +++ b/gateway.md @@ -277,12 +277,26 @@ and no `MxGateway:Dashboard:CookieName` override is set; otherwise it is named it is dropped for HTTP-dev or custom-name deployments). `/logout` clears it. Login and logout posts validate antiforgery tokens. SignalR hub connections accept either the -cookie or a 30-minute data-protected bearer minted at `/hubs/token`. +cookie or a 5-minute data-protected bearer minted at `/hubs/token`. `MxGateway:Dashboard:AllowAnonymousLocalhost` permits loopback to bypass the cookie requirement; remote requests always require an authenticated principal with at least the Viewer role. Setting `MxGateway:Dashboard:Enabled` to `false` leaves the dashboard and hub routes unmapped. +A dashboard role alone does not decide *which* sessions a user may watch: +`IDashboardSessionAcl` gates both event-subscribe seams — `EventsHub.SubscribeSession` +for remote hub clients and the session-details page's in-process subscribe — so +neither is the weaker path. An authenticated Administrator is allowed +unconditionally; every other caller may observe a session only when the session's +tags intersect the tags their LDAP groups grant through +`MxGateway:Dashboard:GroupToTag`. A session's tags are inherited from its owning +API key's `--dashboard-tags` constraint, never from the client's request, so a +client cannot label its own session with another tenant's tag. Untagged sessions +follow `MxGateway:Dashboard:UntaggedSessionVisibility`, which defaults to +`AdminOnly`; a principal with no tag claims — anonymous localhost included — is an +empty-grant Viewer and sees no tagged session. Tags gate visibility only and are +never a data-access grant. + ### Worker Process Runtime: diff --git a/glauth.md b/glauth.md index ff415f0..27db022 100644 --- a/glauth.md +++ b/glauth.md @@ -92,6 +92,29 @@ See [Provisioning the GwAdmin group](#provisioning-the-gwadmin-group) below for > `MxGateway:Dashboard:GroupToRole` — same operations are authorized. (This > dashboard role is distinct from the lowercase gRPC `admin` *API-key scope*.) +### Dashboard visibility tags in the live tests + +`DashboardLdapLiveTests` covers the per-session dashboard event ACL (SEC-25) against this +directory. **No GLAuth change was needed, and none was made** — the tag layer is entirely +config-side, so the fixture simply names groups that already exist: + +| Fixture `MxGateway:Dashboard` setting | Value | +| --- | --- | +| `GroupToRole` | `GwAdmin` → `Administrator`, `GwReader` → `Viewer` | +| `GroupToTag` | `GwReader` → `team-a` | +| `UntaggedSessionVisibility` | `AdminOnly` (the shipped default, stated explicitly because the assertions read it) | + +`team-a` and `team-b` are operator-chosen labels that exist only in the test's configuration +and on its in-memory sessions; nothing in the directory carries them. `gw-viewer` therefore +logs in as a Viewer granted `team-a` and is admitted to a `team-a`-tagged session but refused a +`team-b`-tagged one. `multi-role` is a member of **both** `GwAdmin` and `GwReader`, so this map +grants it `team-a` as well — its `team-a` allow would hold even without the Administrator +bypass, which is why the bypass is asserted on the `team-b` and untagged sessions instead. + +What only a live bind proves here is that the group names `ILdapAuthService` returns from this +directory (short RDN values, not DNs) are the ones `GroupToTag` keys match; a fabricated +principal cannot show that. + ## Two bind patterns ### 1. Direct bind (simplest) diff --git a/src/ZB.MOM.WW.MxGateway.IntegrationTests/DashboardLdapLiveTests.cs b/src/ZB.MOM.WW.MxGateway.IntegrationTests/DashboardLdapLiveTests.cs index f470396..fef196f 100644 --- a/src/ZB.MOM.WW.MxGateway.IntegrationTests/DashboardLdapLiveTests.cs +++ b/src/ZB.MOM.WW.MxGateway.IntegrationTests/DashboardLdapLiveTests.cs @@ -1,11 +1,14 @@ +using System.Diagnostics.CodeAnalysis; using System.Security.Claims; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ZB.MOM.WW.Auth.Abstractions.Ldap; using ZB.MOM.WW.Auth.Ldap; +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; using LibraryLdapOptions = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapOptions; namespace ZB.MOM.WW.MxGateway.IntegrationTests; @@ -23,6 +26,18 @@ public sealed class DashboardLdapLiveTests ///
private const string SharedDirectoryPassword = "password"; + /// + /// Dashboard visibility tags (SEC-25) used by the ACL scenarios below. They are operator-chosen + /// labels that exist only in this fixture's configuration and in the fake sessions' owner-tag + /// list — nothing in the shared directory carries them. + /// + private const string TeamATag = "team-a"; + private const string TeamBTag = "team-b"; + + private const string TeamASessionId = "session-team-a"; + private const string TeamBSessionId = "session-team-b"; + private const string UntaggedSessionId = "session-untagged"; + /// /// Verifies that admin — a shared-directory user whose othergroups include /// GwAdmin (gid 5610) — authenticates successfully and is granted the Admin dashboard role. @@ -152,20 +167,93 @@ public sealed class DashboardLdapLiveTests Assert.Null(result.Principal); } + /// + /// Verifies the SEC-25 tag grant end-to-end from a real LDAP bind: gw-viewer's only + /// group (GwReader) is mapped to the team-a visibility tag by Dashboard:GroupToTag, + /// and the principal that bind produces is admitted by for a + /// team-a-tagged session but refused for a team-b-tagged one. + /// + /// + /// The mapping under test is entirely config-side: no GLAuth entry, group, or membership was + /// added for it — the shared directory's existing GwReader group is simply named as a key in + /// this fixture's GroupToTag map. What only a live bind can prove is that the group + /// names ILdapAuthService actually returns from the shared directory (short RDN values, + /// not DNs) are the ones GroupToTag keys match, which a fabricated principal cannot show. + /// The denial half is the load-bearing assertion: before the ACL, every Viewer saw every session. + /// + /// A task that represents the asynchronous operation. + [LiveLdapFact] + public async Task AuthenticateAsync_ViewerWithGroupToTagGrant_SeesOnlyItsOwnTaggedSession() + { + DashboardAuthenticator authenticator = CreateAuthenticator(LibraryOptions(), TaggedDashboardOptions()); + + DashboardAuthenticationResult result = await authenticator.AuthenticateAsync( + "gw-viewer", + SharedDirectoryPassword, + CancellationToken.None); + + Assert.True(result.Succeeded); + Assert.NotNull(result.Principal); + Assert.True(result.Principal.IsInRole(DashboardRoles.Viewer)); + Assert.False(result.Principal.IsInRole(DashboardRoles.Admin)); + Assert.Contains(result.Principal.Claims, claim => + claim.Type == DashboardAuthenticationDefaults.DashboardTagClaimType + && claim.Value == TeamATag); + + IDashboardSessionAcl acl = CreateAcl(); + + Assert.True(acl.CanViewSession(result.Principal, TeamASessionId)); + Assert.False(acl.CanViewSession(result.Principal, TeamBSessionId)); + + // Untagged sessions stay Admin-only under the shipped default, so the Viewer's grant does + // not silently widen to sessions whose owning key declared no tags. + Assert.False(acl.CanViewSession(result.Principal, UntaggedSessionId)); + } + + /// + /// Verifies that multi-role — an Administrator in the shared directory — reaches every + /// session regardless of tags. + /// + /// + /// The bypass is proved by the two sessions the account's own grant does not cover. + /// multi-role is a member of GwReader as well as GwAdmin, so this fixture's + /// GroupToTag map grants it team-a — the team-a allow would therefore hold + /// even with the bypass removed and proves nothing on its own. team-b (a tag it does not + /// hold) and the untagged session (Admin-only under the shipped default) are the assertions + /// that fail if the Administrator branch is ever dropped. + /// + /// A task that represents the asynchronous operation. + [LiveLdapFact] + public async Task AuthenticateAsync_Administrator_BypassesTagCheckForEverySession() + { + DashboardAuthenticator authenticator = CreateAuthenticator(LibraryOptions(), TaggedDashboardOptions()); + + DashboardAuthenticationResult result = await authenticator.AuthenticateAsync( + "multi-role", + SharedDirectoryPassword, + CancellationToken.None); + + Assert.True(result.Succeeded); + Assert.NotNull(result.Principal); + Assert.True(result.Principal.IsInRole(DashboardRoles.Admin)); + + IDashboardSessionAcl acl = CreateAcl(); + + Assert.True(acl.CanViewSession(result.Principal, TeamASessionId)); + Assert.True(acl.CanViewSession(result.Principal, TeamBSessionId)); + Assert.True(acl.CanViewSession(result.Principal, UntaggedSessionId)); + } + private static DashboardAuthenticator CreateAuthenticator() => CreateAuthenticator(LibraryOptions()); - private static DashboardAuthenticator CreateAuthenticator(LibraryLdapOptions ldapOptions) + private static DashboardAuthenticator CreateAuthenticator(LibraryLdapOptions ldapOptions) => + CreateAuthenticator(ldapOptions, AdminOnlyDashboardOptions()); + + private static DashboardAuthenticator CreateAuthenticator( + LibraryLdapOptions ldapOptions, + DashboardOptions dashboardOptions) { - GatewayOptions gatewayOptions = new() - { - Dashboard = new DashboardOptions - { - GroupToRole = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["GwAdmin"] = DashboardRoles.Admin, - }, - }, - }; + GatewayOptions gatewayOptions = new() { Dashboard = dashboardOptions }; return new DashboardAuthenticator( new LdapAuthService(ldapOptions), @@ -174,6 +262,67 @@ public sealed class DashboardLdapLiveTests NullLogger.Instance); } + /// + /// The historical fixture map: GwAdmin is the only mapped group, so GwReader members are denied + /// login outright. Kept for the tests that assert that denial. + /// + private static DashboardOptions AdminOnlyDashboardOptions() => new() + { + GroupToRole = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["GwAdmin"] = DashboardRoles.Admin, + }, + }; + + /// + /// The SEC-25 fixture map: GwReader is admitted as a Viewer and granted team-a. Both keys + /// name groups that already exist in the shared directory — the tag layer is config-only. + /// + private static DashboardOptions TaggedDashboardOptions() => new() + { + GroupToRole = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["GwAdmin"] = DashboardRoles.Admin, + ["GwReader"] = DashboardRoles.Viewer, + }, + GroupToTag = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["GwReader"] = [TeamATag], + }, + }; + + private static DashboardSessionAcl CreateAcl() => new( + new FixedSessionManager( + [ + CreateSession(TeamASessionId, [TeamATag]), + CreateSession(TeamBSessionId, [TeamBTag]), + CreateSession(UntaggedSessionId, tags: null), + ]), + Options.Create(new GatewayOptions + { + // Explicit rather than defaulted: the untagged assertions above read this value. + Dashboard = new DashboardOptions + { + UntaggedSessionVisibility = UntaggedSessionVisibility.AdminOnly, + }, + })); + + private static GatewaySession CreateSession(string sessionId, string[]? tags) => new( + 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); + /// /// Builds the shared library by binding the real /// MxGateway:Ldap configuration section the same way production does in @@ -228,4 +377,53 @@ public sealed class DashboardLdapLiveTests return options; } + + /// + /// Registry double serving a fixed set of sessions. The ACL only ever calls + /// ; the remaining members exist to satisfy the interface and are + /// never reached by these tests. + /// + /// The sessions this registry resolves. + private sealed class FixedSessionManager(IReadOnlyList sessions) : ISessionManager + { + /// + public Task OpenSessionAsync( + SessionOpenRequest request, + string? clientIdentity, + string? ownerKeyId, + CancellationToken cancellationToken) => throw new NotSupportedException(); + + /// + public bool TryGetSession(string sessionId, [MaybeNullWhen(false)] out GatewaySession session) + { + session = sessions.FirstOrDefault(candidate => candidate.SessionId == sessionId); + + return session is not null; + } + + /// + public Task InvokeAsync( + string sessionId, + WorkerCommand command, + CancellationToken cancellationToken) => throw new NotSupportedException(); + + /// + public Task CloseSessionAsync( + string sessionId, + CancellationToken cancellationToken) => throw new NotSupportedException(); + + /// + public Task KillWorkerAsync( + string sessionId, + string reason, + CancellationToken cancellationToken) => throw new NotSupportedException(); + + /// + 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/Configuration/GatewayOptionsTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsTests.cs index 1506edc..73594ec 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsTests.cs @@ -161,6 +161,33 @@ public sealed class GatewayOptionsTests Assert.Null(new DashboardOptions().AutoLoginUser); } + /// + /// Verifies that Dashboard:GroupToTag keeps its ordinal-ignore-case group lookup after + /// configuration binding, and that UntaggedSessionVisibility binds from its string form. + /// + /// + /// The property initializer seeds the dictionary with , + /// but only the binder decides whether that instance is populated in place or replaced by a + /// default-comparer one. Asserting the comparer on a hand-constructed + /// would prove nothing about the configured path; a mis-cased LDAP group name from the directory + /// would then silently grant no tags, and the SEC-25 ACL would deny with no diagnostic. + /// + [Fact] + public void DashboardOptions_GroupToTag_BindsCaseInsensitively() + { + GatewayOptions options = BindOptions(new Dictionary + { + ["MxGateway:Dashboard:GroupToTag:GwReader:0"] = "team-a", + ["MxGateway:Dashboard:GroupToTag:GwReader:1"] = "team-b", + ["MxGateway:Dashboard:UntaggedSessionVisibility"] = "AllViewers", + ["MxGateway:Ldap:ServiceAccountPassword"] = "test-bind-password", + }); + + Assert.True(options.Dashboard.GroupToTag.TryGetValue("gwREADER", out string[]? tags)); + Assert.Equal(["team-a", "team-b"], tags); + Assert.Equal(UntaggedSessionVisibility.AllViewers, options.Dashboard.UntaggedSessionVisibility); + } + private static GatewayOptions BindOptions(IReadOnlyDictionary configurationValues) { using ServiceProvider services = BuildServices(configurationValues); From 64da630258577c390a1d70735cb774fc8ff3280e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 17 Aug 2026 04:52:20 -0400 Subject: [PATCH 13/17] docs(rust-client): field-access notation fix in the truncation note (review nit) --- clients/rust/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clients/rust/README.md b/clients/rust/README.md index 43b9684..cf2f0e1 100644 --- a/clients/rust/README.md +++ b/clients/rust/README.md @@ -123,7 +123,7 @@ creates an authenticated `tonic` client and attaches `authorization: Bearer returns an `AlarmFeedStream` async stream of alarm-feed messages and shares the gateway's central alarm monitor with every other client. -`ActiveAlarmSnapshot::from_truncated_snapshot` reports that the record came from +The `from_truncated_snapshot` field on `ActiveAlarmSnapshot` reports that the record came from a provider fetch which hit the per-fetch cap: the snapshot set may omit active alarms, and the gateway suspended its absence-implies-cleared inference for that poll. Treat the set as possibly incomplete rather than reconciling deletions From d05f38b661a8ecb537709ab5125a9be472583751 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 17 Aug 2026 04:54:36 -0400 Subject: [PATCH 14/17] docs(plans): closeout tasks 1-10 complete --- ...2026-08-17-deferred-closeout.md.tasks.json | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/plans/2026-08-17-deferred-closeout.md.tasks.json b/docs/plans/2026-08-17-deferred-closeout.md.tasks.json index 84d07f6..0e49fb5 100644 --- a/docs/plans/2026-08-17-deferred-closeout.md.tasks.json +++ b/docs/plans/2026-08-17-deferred-closeout.md.tasks.json @@ -1,16 +1,16 @@ { "planPath": "docs/plans/2026-08-17-deferred-closeout.md", "tasks": [ - { "id": 1, "subject": "Task 1: Remove dead ISessionManager.ReadEventsAsync chain", "status": "pending" }, - { "id": 2, "subject": "Task 2: Frame-writer lock-parking — unpark awaited control-frame completion", "status": "pending" }, - { "id": 3, "subject": "Task 3: windev live-alarm probes — GUID identity, ALARM_RECORDS/@COUNT", "status": "pending" }, - { "id": 4, "subject": "Task 4: SEC-25 groundwork — DashboardTags on key, Tags on session", "status": "pending", "blockedBy": [1] }, - { "id": 5, "subject": "Task 5: SEC-25 config — GroupToTag, UntaggedSessionVisibility, validator, mapper", "status": "pending" }, - { "id": 6, "subject": "Task 6: SEC-25 enforcement — ACL, token/cookie tag claims, both seams gated", "status": "pending", "blockedBy": [4, 5] }, - { "id": 7, "subject": "Task 7: SEC-25 closure — live-LDAP tests, docs sweep, design-doc status", "status": "pending", "blockedBy": [6] }, - { "id": 8, "subject": "Task 8: Alarm-truncation degraded-status signal — proto + worker + gateway + dashboard", "status": "pending", "blockedBy": [3] }, - { "id": 9, "subject": "Task 9: Client regeneration + rebuild for new alarm fields", "status": "pending", "blockedBy": [8] }, - { "id": 10, "subject": "Task 10: Phase gate — full gateway suite on macOS", "status": "pending", "blockedBy": [1, 4, 5, 6, 7, 8, 9] }, + { "id": 1, "subject": "Task 1: Remove dead ISessionManager.ReadEventsAsync chain", "status": "completed" }, + { "id": 2, "subject": "Task 2: Frame-writer lock-parking — unpark awaited control-frame completion", "status": "completed" }, + { "id": 3, "subject": "Task 3: windev live-alarm probes — GUID identity, ALARM_RECORDS/@COUNT", "status": "completed" }, + { "id": 4, "subject": "Task 4: SEC-25 groundwork — DashboardTags on key, Tags on session", "status": "completed", "blockedBy": [1] }, + { "id": 5, "subject": "Task 5: SEC-25 config — GroupToTag, UntaggedSessionVisibility, validator, mapper", "status": "completed" }, + { "id": 6, "subject": "Task 6: SEC-25 enforcement — ACL, token/cookie tag claims, both seams gated", "status": "completed", "blockedBy": [4, 5] }, + { "id": 7, "subject": "Task 7: SEC-25 closure — live-LDAP tests, docs sweep, design-doc status", "status": "completed", "blockedBy": [6] }, + { "id": 8, "subject": "Task 8: Alarm-truncation degraded-status signal — proto + worker + gateway + dashboard", "status": "completed", "blockedBy": [3] }, + { "id": 9, "subject": "Task 9: Client regeneration + rebuild for new alarm fields", "status": "completed", "blockedBy": [8] }, + { "id": 10, "subject": "Task 10: Phase gate — full gateway suite on macOS", "status": "completed", "blockedBy": [1, 4, 5, 6, 7, 8, 9] }, { "id": 11, "subject": "Task 11: windev gate — full Windows verification", "status": "pending", "blockedBy": [2, 10] }, { "id": 12, "subject": "Task 12: Wrap-up — closure notes, umbrella check, final review", "status": "pending", "blockedBy": [11] } ], From f5a58d884b1e22892e31fbc2c3157256118f02f8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 17 Aug 2026 05:03:02 -0400 Subject: [PATCH 15/17] docs(plans): closeout as-built record; prior plan's out-of-scope table closed --- docs/plans/2026-08-15-deferred-remediation.md | 9 ++++ docs/plans/2026-08-17-deferred-closeout.md | 43 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/docs/plans/2026-08-15-deferred-remediation.md b/docs/plans/2026-08-15-deferred-remediation.md index 4da7f6c..a01c5a2 100644 --- a/docs/plans/2026-08-15-deferred-remediation.md +++ b/docs/plans/2026-08-15-deferred-remediation.md @@ -309,6 +309,15 @@ Push the branch to origin, then on windev (`ssh windev`, clone `C:\build\mxacces | SEC-25 per-session dashboard event ACL | Security roadmap item; Task 6 deliberately preserves the current posture. | | `MxAccessWriteCompletionCache` clone | Different lifecycle than the value cache; consciously kept (Task 12.5). | +Closure (2026-08-17, [`docs/plans/2026-08-17-deferred-closeout.md`](2026-08-17-deferred-closeout.md), +branch `feat/deferred-closeout`): the probes were **attempted** — the rig's alarm-condition +writes are refused (`SecurityError` from the responding object), so GUID identity is confirmed +for the active→returned leg only and `@COUNT` stays unverified; evidence and unblock paths in +`docs/AlarmProbeFindings.md`. The truncation degraded-status signal **shipped** (additive proto +fields, worker→gateway→dashboard, all five clients regenerated). SEC-25 **shipped** (per-session +event ACL on both dashboard subscribe seams; design doc marked Implemented). The +`MxAccessWriteCompletionCache` clone row remains consciously kept. + --- ## As-built notes (execution record) diff --git a/docs/plans/2026-08-17-deferred-closeout.md b/docs/plans/2026-08-17-deferred-closeout.md index 817295d..11e03eb 100644 --- a/docs/plans/2026-08-17-deferred-closeout.md +++ b/docs/plans/2026-08-17-deferred-closeout.md @@ -386,6 +386,49 @@ skipped. --- +## As-built notes (execution record, 2026-08-17) + +All 12 tasks completed on `feat/deferred-closeout`; every implement went through its +classification's review chain to Approved. Verification: macOS build 0W/0E + gateway +1123/1123; windev full slnx 0W/0E, worker x86 523/523 (11 standing opt-in skips), +gateway 1123/1123, live MXAccess smoke 8/8 — green first try, no stale-obj quirk. + +- **Task 3 ended blocked, with evidence.** The rig refuses the alarm-condition writes + (`SecurityError` from the responding automation object — the test UDAs need + `AuthenticateUser`+`WriteSecured` or the in-engine flip script that drove the + 2026-05-01 capture). `docs/AlarmProbeFindings.md` records what was tried, the + partial GUID answer (active→returned leg confirmed), and the unblock paths. +- **Commit `693a78d` contains two tasks' work.** A concurrent implementer's `git commit` + without pathspecs swept Task 6's staged ACL files into Task 8's alarm commit. Nothing + was lost; both halves were reviewed separately and their fix rounds (`7ec0b35`, + `b9fb0dd`) are clean single-task commits. Process rule tightened mid-run: pathspecs + on the commit itself, not just the add. +- **Task 8's atomicity fix went structural.** Review found the flag/snapshot pairing + relied on STA serialization while claiming a read-order guarantee; the fix made + `SnapshotActiveAlarms(out bool truncated)` the *only* accessor, so the unpaired read + is unexpressible. +- **Task 6 review caught a real regression** (async attach re-entrancy under rapid + navigation) — closed with a generation guard and a mutation-verified interleaving test. +- **Live-LDAP ACL tests ran green against the shared GLAuth** (7/7, plus 7/7 clean-skip + without the env gate). `multi-role` sits in both GLAuth groups, so the Admin-bypass + test asserts the sessions a dropped bypass would actually lose (team-b + untagged). +- **Task 9 found and fixed pre-existing drift**: `clients/rust/protos/mxaccess_gateway.proto` + had diverged from Contracts (masked by the in-repo build path); refreshed byte-identical, + and the client protoset descriptors were regenerated. + +Follow-ups recorded, not started: +- `IGatewayAlarmService.StreamAsync` / `AlarmFeedMessage` does not carry the truncation + signal — live central-feed consumers (lmxopcua, ScadaBridge) cannot see snapshot + degradation; add if those consumers need completeness reasoning. +- No guard keeps `clients/rust/protos/` in sync with Contracts (a `diff` check in + `scripts/check-codegen.ps1` would close it). +- `EffectiveDashboardConfiguration` (dashboard settings page) doesn't display + `GroupToTag` / `UntaggedSessionVisibility`, though it shows `GroupToRole`. +- The alarm probes' remaining questions (ack-leg GUID stability, `@COUNT` semantics) + unblock via the paths in `docs/AlarmProbeFindings.md`. + +--- + ## Execution notes for the orchestrator - Branch `feat/deferred-closeout` off `main` before Task 1. From b621d692d0766d773035ef41c6929448dde04f97 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 17 Aug 2026 05:23:23 -0400 Subject: [PATCH 16/17] =?UTF-8?q?docs+test(closeout):=20final-review=20res?= =?UTF-8?q?ervations=20=E2=80=94=20stale=20ACL=20prose,=20worker=20test=20?= =?UTF-8?q?gaps,=20config=20sample=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- archreview/remediation/00-tracking.md | 1 + docs/Authorization.md | 17 +++++-- docs/DesignDecisions.md | 20 +++++--- docs/GatewayConfiguration.md | 4 +- docs/GatewayDashboardDesign.md | 6 ++- docs/GatewayProcessDesign.md | 2 +- docs/Sessions.md | 2 +- .../2026-07-10-dashboard-session-acl-tst15.md | 3 +- docs/plans/2026-08-17-deferred-closeout.md | 2 +- ...2026-08-17-deferred-closeout.md.tasks.json | 2 +- .../Alarms/IGatewayAlarmService.cs | 16 ++++-- .../Hubs/DashboardEventBroadcaster.cs | 9 ++-- .../Dashboard/IDashboardSessionAcl.cs | 9 ++-- .../Ipc/WorkerFrameProtocolTests.cs | 50 +++++++++++++++---- .../MxAccess/FailoverAlarmConsumerTests.cs | 38 ++++++++++++++ .../Ipc/WorkerPipeSession.cs | 21 +++++--- .../MxAccess/IAlarmCommandHandler.cs | 9 +++- stillpending.md | 2 +- 18 files changed, 167 insertions(+), 46 deletions(-) diff --git a/archreview/remediation/00-tracking.md b/archreview/remediation/00-tracking.md index f13be4a..1bbeec2 100644 --- a/archreview/remediation/00-tracking.md +++ b/archreview/remediation/00-tracking.md @@ -253,6 +253,7 @@ Findings the review flagged as one coordinated design pass — sequence them tog | Date | Change | |---|---| +| 2026-08-17 | **Alarm-snapshot truncation now has a structural degraded-status signal** (branch `feat/deferred-closeout`, commits `693a78d` + `b9fb0dd`). No review ID — this is branch work outside the 153-finding register, recorded here so the tracker is not silent on a shipped change to the alarm surface. Before it, a capped `GetXmlCurrentAlarms2` fetch suppressed absence-implies-Clear inference and said so only in a rate-limited worker stderr warning, so no client and no operator could tell a complete active set from a capped one. Two additive proto3 booleans carry the verdict out — `QueryActiveAlarmsReplyPayload.snapshot_truncated = 2` and `ActiveAlarmSnapshot.from_truncated_snapshot = 16` (per record, because `QueryActiveAlarms` is a bare `stream ActiveAlarmSnapshot` with no envelope; the reply payload states it too, since a prefix filter can leave zero records and a capped fetch with nothing to report still has to say so). Flow: `WnWrapAlarmConsumer` → `AlarmDispatcher` / `IAlarmCommandHandler` → `MxAccessCommandExecutor` reply → `GatewayAlarmMonitor` → `IGatewayAlarmService.SnapshotTruncated` → `AlarmsPage` banner. `b9fb0dd` then made the pairing structural after review: `IMxAccessAlarmConsumer` and `IAlarmCommandHandler` expose one accessor (`SnapshotActiveAlarms(out bool truncated)` / `QueryActive(..., out bool snapshotTruncated)`) satisfied from a single lock acquisition, so the snapshot and its verdict can no longer be read across a poll; the separate `LastSnapshotTruncated` property is gone from every layer. Detection is deliberately unchanged (`fetchedRecordCount >= maxAlarmsPerFetch`); switching to `ALARM_RECORDS/@COUNT` stays blocked on probe evidence (`docs/AlarmProbeFindings.md`). Not latched, and dropped with the cache generation by `ClearCache`. Additive gateway metadata about our fetch mechanics, not MXAccess behaviour — no synthesized event, so not a parity deviation. Docs: `gateway.md` alarm surface, `docs/DesignDecisions.md`. | | 2026-08-17 | **TST-15 → `Done` (discharges the ACL half of SEC-25): per-session dashboard event ACL shipped** (branch `feat/deferred-closeout`, commits `693a78d` + `7ec0b35`). Implements `docs/plans/2026-07-10-dashboard-session-acl-tst15.md`, whose header is now `Implemented` with as-built notes in its §12. `IDashboardSessionAcl.CanViewSession` is the single decision **both** subscribe seams consult — `EventsHub.SubscribeSession` (denial is a `HubException`; the caller is neither joined to the group nor registered in `EventsHubViewerRegistry`, so the mirror stays off) and `SessionDetailsPage`'s in-process subscribe (inline denial, no subscription) — so neither path is the weaker one and the `TODO(per-session-acl)` is gone. Decision order, fail-closed on every branch: authenticated Administrator → allow (evaluated **before** the registry lookup, so Admin × unknown-session allows — pinned by a test because reordering the two checks is a plausible refactor); session not found → deny; untagged session → `Dashboard:UntaggedSessionVisibility` (`AdminOnly` default); else allow iff `session.Tags ∩ zb:dashboardtag` claims, ordinal-ignore-case. Session tags are inherited from the owning API key's `dashboard_tags` constraint (`apikey --dashboard-tags`, already in the `ApiKeyConstraints` JSON blob — no SQLite migration) and never from the client's wire request. Viewer grants come from `Dashboard:GroupToTag` over the user's LDAP groups, stamped at cookie login (`DashboardAuthenticator.CreatePrincipal`) and **re-resolved, not copied**, at hub-token mint (`HubTokenService.Issue`), so the 5-minute token lifetime bounds a stale grant. Anonymous localhost is an empty-grant Viewer; `Dashboard:DisableLogin` auto-login carries both roles and so takes the admin bypass unchanged. Tests: `DashboardSessionAclTests` (decision table, every branch asserted in its denying direction too), `EventsHubTests`, `DashboardAuthenticatorTests`, `HubTokenServiceTests`, a `GatewayOptionsTests` case proving `Dashboard:GroupToTag` keeps its ordinal-ignore-case lookup through configuration binding, and two `[LiveLdapFact]`s in `DashboardLdapLiveTests` that drive a real bind against the shared GLAuth (`gw-viewer` → `team-a` grant admits the `team-a` session and refuses the `team-b` one; `multi-role` bypasses on the sessions its own grant does not cover). The live pair needed **no GLAuth change** — the tag layer is config-side, keyed on the existing `GwAdmin`/`GwReader` groups (recorded in `glauth.md`). Docs: `docs/Sessions.md` (session-tag model), `gateway.md` + CLAUDE.md dashboard-auth paragraphs, `docs/GatewayDashboardDesign.md` (three passages that described the ACL as outstanding), `docs/GatewayConfiguration.md` (`ShowTagValues` row: redaction is now the second of two layers, not the only one), `docs/Authentication.md` (`--dashboard-tags` is the only *constraint* flag that splits on commas). | | 2026-08-10 | **TST-25 acceptance Check 6 (forced-failure nightly issue) → Done.** The 2026-07-13 record wrote this check off as "abandoned to shared-runner congestion"; that was wrong on both counts. The 2026-07-13 probe *did* land (issue #125, `[CHECK6 PROBE]`, run 375), and since 2026-07-17 the `nightly-windev` `if: failure()` step has filed an issue on **every** red nightly — #126–#139, all authored by the `gitea-actions` bot. Traced run 672 (schedule, main, red) line by line: main step fails → `exitcode '1': failure` → the `if: failure()` step runs → `POST /api/v1/repos/dohertj2/mxaccessgw/issues` with the built-in token masked to `***` → issue #139 created at the matching timestamp. Re-confirmed by a fresh forced-failure probe on the throwaway branch `test/tst25-check6-nightly-issue` (temporary `tst25-check6-probe.yml` reproducing the job shape with `exit 1` for the live step; run 677 → issue #140). Branch deleted, issues #125 and #140 closed with explanatory comments. **One real defect found and fixed** (`fix/tst25-nightly-issue-path`, not merged): `${{ github.server_url }}` is the runner-internal `http://gitea:3000`, so every filed issue's run link was unreachable from a browser. The API call must keep using it (the job container resolves `gitea` only on the docker network and has no LAN egress to the public origin), so the fix adds a `PUBLIC_SERVER_URL: https://gitea.dohertylan.com` job env used **only** for the browser-facing link in the issue body; the probe validated the fixed template (#140 carries a `https://gitea.dohertylan.com/...` link that returns 200). **Separately observed, not fixed:** the nightly has been red continuously since at least 2026-07-17 (run 672: `x86 Worker.Tests failed with exit code 1`, 1 failed / 398 passed / 11 skipped — the known `EventBurst_DrainLoopCoalescesFlushes` class of flake), and the step de-duplicates nothing, so 14 issues are open, seven of them (#132–#138) for the identical SHA `47c0b64`. Worth a follow-up: fix the red nightly, and consider having the step reuse an open issue with the same title instead of filing a new one. | | 2026-08-10 | **TST-24 → `Done`: per-client wire tests land for the two clients that lacked them** (branch `feat/tst-24-client-wire-tests`). Audit first corrected the finding's premise: **Go, Rust, and Java already had real-server wire tests** — `newBufconnClient`/`fakeGatewayServer` over `grpc/test/bufconn`, `spawn_fake_gateway` over a loopback `TcpListener` with tonic's `Server`, and `InProcessGateway`/`TestGatewayService` over `InProcessServerBuilder` — each already asserting the round trip, the server-observed `authorization` bearer header, and the `ReplayGap` sentinel. The real gaps were **.NET** (every test substituted `FakeGatewayTransport` for `IMxGatewayClientTransport`, and the test project had no server package) and **Python** (stub monkeypatching everywhere except one opt-in TLS test serving only `OpenSession`). Added `WireFakeGatewayServer` + `MxGatewayClientWireTests` (Kestrel h2c on `127.0.0.1:0` serving `MxAccessGatewayBase`; new `Grpc.AspNetCore.Server` 2.76.0 + `Microsoft.AspNetCore.App` refs on the test project) and `clients/python/tests/test_wire_fake_gateway.py` (`grpc.aio` server on `127.0.0.1:0`, no new deps). Four shapes each: full round trip with every reply field asserted, the bearer header **as received by the server** on the streaming RPC too, the `ReplayGap` sentinel surfaced as the client's typed signal, and a genuine `PERMISSION_DENIED` mapping to the typed authorization error. CI: the `portable` job only *built* the .NET client, so a `dotnet test` step was added. **The new tests immediately caught a shipped bug** — Python `GatewayClient.connect()`/`GalaxyRepositoryClient.connect()` constructed the `grpc.aio` channel inside `asyncio.to_thread`, which raises `RuntimeError: There is no current event loop in thread 'asyncio_0'` because a `grpc.aio` channel binds to the loop current on the constructing thread; every non-stub connection failed, and the one test guarding the off-loop behaviour (Client.Python-028) monkeypatched `create_channel` and so asserted the bug. Fixed by splitting `resolve_channel_security` (blocking TOFU probe, off-loop) from `create_channel` (on-loop), with the `-028` tests retargeted to assert both halves. Verified: .NET 133 passed/1 skipped (pre-existing live-gateway skip), Python 168 passed/1 skipped plus 6/6 opt-in TLS. Docs: `docs/GatewayTesting.md` § Client Wire Tests, `clients/dotnet/README.md`, `clients/python/README.md`. | diff --git a/docs/Authorization.md b/docs/Authorization.md index 2762fa5..bc4e7c9 100644 --- a/docs/Authorization.md +++ b/docs/Authorization.md @@ -198,10 +198,19 @@ Tags are set at key creation with trimmed and de-duplicated ordinal-ignore-case). Keys created from the dashboard API Keys page are currently always untagged. -The tag is carried end to end today; the dashboard ACL that consumes it — scoping -a Viewer's `EventsHub` subscriptions to the sessions their LDAP groups are -granted — is a separate change. Until it lands, the tag affects nothing at -runtime. +The dashboard ACL that consumes the tag shipped on 2026-08-17 (SEC-25 / TST-15). +`IDashboardSessionAcl.CanViewSession` is consulted at both dashboard subscribe +seams — the SignalR `EventsHub.SubscribeSession` join and the in-process +`IDashboardSessionEventSubscriber.Subscribe` behind the session-details page — so +per-session event visibility is enforced at runtime: a Viewer observes a session +only when the session's tags intersect the tags their LDAP groups are granted +through `MxGateway:Dashboard:GroupToTag`. Administrators bypass the intersection, +and a session with no tags is visible to Administrators only unless +`MxGateway:Dashboard:UntaggedSessionVisibility` is set to `AllViewers`. + +That enforcement is still *visibility*, not data access. The ACL decides which +sessions' mirrored events a dashboard principal may observe; it does not widen or +narrow what any API key may read, write, browse, or subscribe to over gRPC. Glob matching is anchored, case-insensitive, and supports `*` and `?`. Subtree and tag glob lists are alternatives: matching either list allows that diff --git a/docs/DesignDecisions.md b/docs/DesignDecisions.md index 6b60257..f3fd10b 100644 --- a/docs/DesignDecisions.md +++ b/docs/DesignDecisions.md @@ -256,11 +256,19 @@ decisions rather than one open backlog. shipped as **TST-01** (`GatewayEndToEndReconnectReplayTests`). Task 14 (client `ReplayGap` handling) shipped as **CLI-15** for four of five clients (.NET/Go/Rust/Python); the Java client is the only remainder. -- **Phase 4 (per-session dashboard ACL)** — scoped, not yet built. Tracked as archreview - **TST-15**. The Viewer-default decision is settled: admin-sees-all, Viewer strictly - scoped to sessions it owns or is granted — matching the gRPC owner-binding decision in - [Session Reconnect](#session-reconnect) above, for consistency between the gRPC and - dashboard surfaces. +- **Phase 4 (per-session dashboard ACL)** — shipped 2026-08-17 (branch + `feat/deferred-closeout`), tracked as archreview **TST-15**. The Viewer default is + admin-sees-all, Viewer strictly scoped — but the scope is the session **tag**, not + session ownership. The dashboard authenticates LDAP users while sessions are owned by + API keys, two disjoint identity domains, so there is no "sessions it owns" branch to + write: `GatewaySession.Tags` is inherited from the owning key's `DashboardTags`, a + dashboard group grants tags via `MxGateway:Dashboard:GroupToTag`, and + `IDashboardSessionAcl.CanViewSession` allows a Viewer iff the two sets intersect. + Administrators bypass the intersection; an untagged session is Admin-only under the + default `MxGateway:Dashboard:UntaggedSessionVisibility=AdminOnly`. The decision is + taken at both subscribe seams (`EventsHub.SubscribeSession` and the in-process + `IDashboardSessionEventSubscriber.Subscribe`), never per event. See + `docs/plans/2026-07-10-dashboard-session-acl-tst15.md` and `docs/Authorization.md`. - **Phase 5 (orphan-worker reattach)** — deferred, not planned. It would reverse the "Gateway restart does not reattach orphan workers" invariant (see CLAUDE.md), adding a stable gateway-instance id, an adoption-manifest SQLite store, a worker phone-home @@ -270,7 +278,7 @@ decisions rather than one open backlog. if it does** until that task actually lands. `docs/plans/2026-06-15-session-resilience.md.tasks.json` remains the sole resume state -for the still-pending Phase 4 tasks (16-19) and the deferred Phase 5 tasks (20-28) — one +for the Phase 4 tasks (16-19, now shipped) and the deferred Phase 5 tasks (20-28) — one authority, no mirror. ## Authentication diff --git a/docs/GatewayConfiguration.md b/docs/GatewayConfiguration.md index e99ee78..0eb0d41 100644 --- a/docs/GatewayConfiguration.md +++ b/docs/GatewayConfiguration.md @@ -58,7 +58,7 @@ paths, timeouts, queue sizes, enum values, or protocol values are invalid. "RecentSessionLimit": 200, "ShowTagValues": false, "GroupToRole": { - "GwAdmin": "Admin", + "GwAdmin": "Administrator", "GwReader": "Viewer" }, "GroupToTag": { @@ -193,7 +193,7 @@ events (a "gap") and must re-snapshot; whatever is still retained is replayed. | `MxGateway:Dashboard:RecentFaultLimit` | `100` | Maximum number of fault summaries projected into each dashboard snapshot. | | `MxGateway:Dashboard:RecentSessionLimit` | `200` | Maximum number of session summaries projected into each dashboard snapshot. | | `MxGateway:Dashboard:ShowTagValues` | `false` | Controls whether tag values reach the dashboard's SignalR events hub mirror. `false` (default): `DashboardEventBroadcaster` blanks tag values from a deep-cloned copy of each `MxEvent` before it reaches any hub subscriber — event metadata (tag reference, quality, status, timestamps) still renders; see `docs/GatewayDashboardDesign.md`'s `EventsHub` row for the mechanism. This is now the second of two independent layers, not the only one: `IDashboardSessionAcl` decides *which* sessions a caller may subscribe to at all (see `GroupToTag` / `UntaggedSessionVisibility` below), while this flag decides what a permitted subscriber sees. Setting it `true` therefore exposes tag values to everyone the ACL admits — every Administrator, plus each Viewer holding a matching tag. The flag gates only the SignalR hub mirror — it does **not** cover the `/browse` live-value display, which remains a separate, still-open residual. | -| `MxGateway:Dashboard:GroupToRole` | _(empty)_ | LDAP group → dashboard role mapping. Keys are LDAP group names (short CN or full DN — leading-RDN match). Values must be `Admin` (read/write, API-key CRUD) or `Viewer` (read-only). A user whose LDAP groups don't intersect this map cannot sign in; with no mapping at all, only the loopback bypass admits anyone. | +| `MxGateway:Dashboard:GroupToRole` | _(empty)_ | LDAP group → dashboard role mapping. Keys are LDAP group names (short CN or full DN — leading-RDN match). Values must be `Administrator` (read/write, API-key CRUD) or `Viewer` (read-only) — matched ordinally by the startup validator, so the spelling is exact and `Admin` is rejected. A user whose LDAP groups don't intersect this map cannot sign in; with no mapping at all, only the loopback bypass admits anyone. | | `MxGateway:Dashboard:GroupToTag` | _(empty)_ | LDAP group → dashboard visibility tags. Keys follow the same convention as `GroupToRole` (short CN or full DN — leading-RDN match, case-insensitive); values are tag lists. A dashboard user's granted tag set is the union over the groups they belong to; an unmapped group contributes nothing. **Visibility only:** tags scope which sessions' event streams a Viewer may observe on the dashboard — they never grant or deny data access, which stays with the API key's scopes and constraints. Independent of `GroupToRole`: a group may appear in either map, both, or neither. Empty (the default) means Viewers hold no tags, so under the default `UntaggedSessionVisibility` they observe no session's events. | | `MxGateway:Dashboard:UntaggedSessionVisibility` | `AdminOnly` | Who may observe a session that carries no tags (its owning API key declared none). `AdminOnly` (default, fail-closed) restricts untagged sessions to dashboard Administrators. `AllViewers` shows them to every Viewer — opt-in for a single-tenant deployment that wants the pre-tag behaviour. Administrators always see every session regardless of tags. | | `MxGateway:Dashboard:DisableLogin` | `false` | Dev/test only. When `true`, replaces the cookie authentication handler with `DashboardAutoLoginAuthenticationHandler`, which auto-authenticates every dashboard request — including requests from remote browsers, not just loopback — as `AutoLoginUser` holding both `Administrator` and `Viewer` roles. No login form, LDAP bind, or cookie is involved. A loud one-time startup warning is logged. Differs from `AllowAnonymousLocalhost`: `DisableLogin` mints a real authenticated principal (so role-gated write affordances appear), whereas `AllowAnonymousLocalhost` satisfies the authorization requirement on loopback only without minting a principal (write affordances stay hidden). Never enable in production. **Production hard-stop (SEC-04):** when the host runs in the `Production` environment and `DisableLogin` is `true`, startup validation fails and the process aborts — the flag is only accepted outside Production, where the one-time startup warning still fires. | diff --git a/docs/GatewayDashboardDesign.md b/docs/GatewayDashboardDesign.md index 6ddce40..572d70a 100644 --- a/docs/GatewayDashboardDesign.md +++ b/docs/GatewayDashboardDesign.md @@ -801,7 +801,11 @@ the deliberate mitigation: it bounds that exposure window without the cost of a revocation store. It now bounds a stale *tag* grant the same way: the token carries the tags resolved from the caller's LDAP groups at mint time, so removing a `GroupToTag` entry takes effect for token-authenticated hub connections within one -lifetime. That is where the per-session ACL's revocation need landed — a jti +lifetime. That 5-minute staleness bound covers token-authenticated connections only: +a cookie principal carries the `zb:dashboardtag` claims stamped at login for the +cookie's whole life, so for cookie-authenticated (in-process page) subscriptions a +revoked `GroupToTag` grant takes effect at the user's next login, not within five +minutes. That is where the per-session ACL's revocation need landed — a jti denylist stays deferred, since the short lifetime already bounds every grant the token carries. diff --git a/docs/GatewayProcessDesign.md b/docs/GatewayProcessDesign.md index 194800b..0fe7cd9 100644 --- a/docs/GatewayProcessDesign.md +++ b/docs/GatewayProcessDesign.md @@ -751,7 +751,7 @@ secure, and strict SameSite. It is named `__Host-MxGatewayDashboard` when `MxGateway:Dashboard:CookieName` override is set; otherwise it falls back to the plain `MxGatewayDashboard` name (the `__Host-` prefix requires a Secure cookie). Logout clears it. Login and logout posts validate antiforgery tokens. SignalR -connections additionally accept a 30-minute data-protected bearer minted at +connections additionally accept a 5-minute data-protected bearer minted at `/hubs/token`. `Dashboard:AllowAnonymousLocalhost` permits loopback requests to bypass the cookie requirement and defaults to `true`. diff --git a/docs/Sessions.md b/docs/Sessions.md index c582a9b..77f8621 100644 --- a/docs/Sessions.md +++ b/docs/Sessions.md @@ -6,7 +6,7 @@ The sessions subsystem owns the in-memory representation of an active gateway-to A session is the gateway-side handle that callers use to invoke worker commands, stream worker events, and tear the worker down. The subsystem is split between the per-session state machine (`GatewaySession`), an in-memory directory (`SessionRegistry`), the orchestrator that opens and closes sessions (`SessionManager`), the worker construction step (`SessionWorkerClientFactory`), and a hosted service that drains sessions during host shutdown (`SessionShutdownHostedService`). -All four interfaces (`ISessionManager`, `ISessionRegistry`, `ISessionWorkerClientFactory`) plus `SessionShutdownHostedService` are wired as singletons by `SessionServiceCollectionExtensions.AddGatewaySessions`. +All three interfaces (`ISessionManager`, `ISessionRegistry`, `ISessionWorkerClientFactory`) plus `SessionShutdownHostedService` are wired as singletons by `SessionServiceCollectionExtensions.AddGatewaySessions`. ## Key Types 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 2c3ece6..558410d 100644 --- a/docs/plans/2026-07-10-dashboard-session-acl-tst15.md +++ b/docs/plans/2026-07-10-dashboard-session-acl-tst15.md @@ -87,7 +87,8 @@ public sealed record ApiKeyConstraints( At `OpenSession`, the resolved `ApiKeyIdentity.EffectiveConstraints.DashboardTags` is copied onto the new `GatewaySession.Tags`. The `apikey` admin CLI gains -`--dashboard-tags team-a,team-b` on `create`/`update`. +`--dashboard-tags team-a,team-b` on `create-key` (there is no `update` +subcommand — see `docs/Authentication.md`). Semantic note: `ApiKeyConstraints` today scopes *data-access* authorization (read/write subtrees, globs, classification). A dashboard *visibility* tag is a diff --git a/docs/plans/2026-08-17-deferred-closeout.md b/docs/plans/2026-08-17-deferred-closeout.md index 11e03eb..a5d8830 100644 --- a/docs/plans/2026-08-17-deferred-closeout.md +++ b/docs/plans/2026-08-17-deferred-closeout.md @@ -171,7 +171,7 @@ Task 10/11 gates it → commit **Files:** - Modify: `src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyConstraints.cs` (add `IReadOnlyList DashboardTags`, default empty) - Modify: `src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyConstraintSerializer.cs` (round-trip the new field; absent-in-JSON → empty — old rows keep deserializing) -- Modify: `src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAdminCommandLineParser.cs` + `ApiKeyAdminCliRunner.cs` + `ApiKeyAdminCommand.cs` + `ApiKeyAdminListedKey.cs` (CLI `--dashboard-tags team-a,team-b` on create/update; shown in list output) +- Modify: `src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAdminCommandLineParser.cs` + `ApiKeyAdminCliRunner.cs` + `ApiKeyAdminCommand.cs` + `ApiKeyAdminListedKey.cs` (CLI `--dashboard-tags team-a,team-b` on `create-key` — there is no `update` subcommand; shown in list output) - Modify: `src/ZB.MOM.WW.MxGateway.Server/Sessions/GatewaySession.cs` (new `public IReadOnlySet Tags { get; }`, set at construction from the owner key's effective constraints; empty = untagged) - Modify: `src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionManager.cs` (`OpenSession` path copies `ApiKeyIdentity.EffectiveConstraints.DashboardTags` onto the session) - Test: serializer round-trip incl. legacy-JSON-without-field; CLI parse; session tag inheritance via the fake-worker harness diff --git a/docs/plans/2026-08-17-deferred-closeout.md.tasks.json b/docs/plans/2026-08-17-deferred-closeout.md.tasks.json index 0e49fb5..1c22e58 100644 --- a/docs/plans/2026-08-17-deferred-closeout.md.tasks.json +++ b/docs/plans/2026-08-17-deferred-closeout.md.tasks.json @@ -11,7 +11,7 @@ { "id": 8, "subject": "Task 8: Alarm-truncation degraded-status signal — proto + worker + gateway + dashboard", "status": "completed", "blockedBy": [3] }, { "id": 9, "subject": "Task 9: Client regeneration + rebuild for new alarm fields", "status": "completed", "blockedBy": [8] }, { "id": 10, "subject": "Task 10: Phase gate — full gateway suite on macOS", "status": "completed", "blockedBy": [1, 4, 5, 6, 7, 8, 9] }, - { "id": 11, "subject": "Task 11: windev gate — full Windows verification", "status": "pending", "blockedBy": [2, 10] }, + { "id": 11, "subject": "Task 11: windev gate — full Windows verification", "status": "completed", "blockedBy": [2, 10] }, { "id": 12, "subject": "Task 12: Wrap-up — closure notes, umbrella check, final review", "status": "pending", "blockedBy": [11] } ], "lastUpdated": "2026-08-17T00:00:00Z" diff --git a/src/ZB.MOM.WW.MxGateway.Server/Alarms/IGatewayAlarmService.cs b/src/ZB.MOM.WW.MxGateway.Server/Alarms/IGatewayAlarmService.cs index fd596bd..413fbcb 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Alarms/IGatewayAlarmService.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Alarms/IGatewayAlarmService.cs @@ -40,11 +40,21 @@ public interface IGatewayAlarmService /// /// 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 + /// per-fetch cap, so the active-alarm set may be missing 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. + /// + /// Read it as "as of the last full reconcile, the fetch was capped", not as + /// a property of a particular array: the two are + /// separate reads, and live transitions keep moving the cached set between + /// reconciles. A consumer that reads both — the dashboard poll does — can + /// therefore straddle a reconcile, in which case its caveat describes the + /// adjacent generation and the banner is at worst one poll stale. That is + /// the intended granularity for a completeness hint; pairing them exactly + /// would need a combined accessor this seam deliberately does not have. + /// /// bool SnapshotTruncated { get; } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardEventBroadcaster.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardEventBroadcaster.cs index cf94e1d..909e7cc 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardEventBroadcaster.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardEventBroadcaster.cs @@ -20,9 +20,12 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs; /// values are stripped from a redacted copy of the event before it reaches any /// dashboard client. The source is shared with the gRPC /// event path and the reconnect replay ring, so it is never mutated in place — -/// the redaction is applied to a deep clone. This closes the value-leak seam at -/// the mirror independently of the still-outstanding per-session hub ACL -/// (see ). +/// the redaction is applied to a deep clone. This is the second of two +/// independent layers: decides at the +/// subscribe seam which sessions a caller may observe at all (see +/// ), while the redaction decides what a permitted +/// subscriber sees — so the value-leak seam stays closed whatever the ACL +/// admits. /// /// Hub context used to send to the session's group. /// diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardSessionAcl.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardSessionAcl.cs index 5ae4f87..4d62553 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardSessionAcl.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardSessionAcl.cs @@ -23,9 +23,12 @@ public interface IDashboardSessionAcl /// 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. + /// The dashboard caller. is denied outright — there is no + /// caller to grant tags to, so it never reaches the untagged-session branch and is + /// refused even under UntaggedSessionVisibility=AllViewers. An + /// unauthenticated or claim-less principal (the anonymous-localhost path included) + /// is a Viewer holding an empty tag grant, which denies every tagged session but + /// still follows that branch. /// /// Session id the caller wants to observe. /// when the caller may observe the session; otherwise . diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Ipc/WorkerFrameProtocolTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Ipc/WorkerFrameProtocolTests.cs index 9426325..8ab6d0b 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Ipc/WorkerFrameProtocolTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Ipc/WorkerFrameProtocolTests.cs @@ -1000,6 +1000,16 @@ public sealed class WorkerFrameProtocolTests /// two passes over the same stream, and a double-release would either do that or throw /// out of a later drain. Contiguous 1..N sequences with no /// duplicates and no trailing bytes is the observable form of both. + /// + /// The contention has to be real, so the stream is gated rather than a plain + /// : against a synchronously-completing stream each call finishes its own + /// drain before the next one starts, and neither a lost lock race nor a detached acquisition ever + /// happens. Gating write 1 parks the drainer while all 2 * perClass frames are queued, so + /// every one of those callers provably loses the race; gating the pass's first event write — + /// write 1 plus the perClass control frames — stops the pass with the control run flushed + /// and completed while the lock is still held, which is what makes the detached path deterministic + /// rather than merely likely: those callers can only have returned on their own completion. + /// /// /// A task that represents the asynchronous operation. [Fact] @@ -1007,24 +1017,46 @@ public sealed class WorkerFrameProtocolTests { const int perClass = 40; WorkerFrameProtocolOptions options = CreateOptions(); - using MemoryStream stream = new(); + using GatedWriteStream stream = new(secondGateWriteIndex: perClass + 2); WorkerFrameWriter writer = new(stream, options); - Task[] writes = new Task[perClass * 2]; + // The drainer: it takes the lock, then parks inside its own write with the lock held. + Task drainer = writer.WriteAsync(CreateGatewayHelloEnvelope(), WorkerFrameWritePriority.Control); + await AwaitWithTimeoutAsync(stream.FirstWriteStarted); + + // Queued against a held lock, so all 2 * perClass callers contend and all of them lose: each + // one's frame is written by the drainer's pass, never by its own. + Task[] controlWrites = new Task[perClass]; + Task[] eventWrites = new Task[perClass]; for (int index = 0; index < perClass; index++) { - writes[index * 2] = writer.WriteAsync(CreateGatewayHelloEnvelope(), WorkerFrameWritePriority.Control); - writes[(index * 2) + 1] = writer.WriteAsync(CreateEventEnvelope(), WorkerFrameWritePriority.Event); + controlWrites[index] = writer.WriteAsync(CreateGatewayHelloEnvelope(), WorkerFrameWritePriority.Control); + eventWrites[index] = writer.WriteAsync(CreateEventEnvelope(), WorkerFrameWritePriority.Event); } - await AwaitWithTimeoutAsync(Task.WhenAll(writes)); + Assert.All(controlWrites, write => Assert.False(write.IsCompleted)); + Assert.All(eventWrites, write => Assert.False(write.IsCompleted)); - // A detached acquisition drains whatever it finds and releases; this write goes through the - // same lock afterwards, so it can only succeed if the lock was left in a usable state. + stream.ReleaseFirstWrite(); + await AwaitWithTimeoutAsync(stream.SecondGateWriteStarted); + + // The boundary flush delivered every control frame, and the drainer is now parked on the first + // event write — so the lock cannot be free. Each of these callers therefore returned on its own + // completion with a live acquisition behind it: the detached path, taken perClass times. + await AwaitWithTimeoutAsync(Task.WhenAll(controlWrites)); + Assert.False(drainer.IsCompleted); + Assert.All(eventWrites, write => Assert.False(write.IsCompleted)); + + stream.ReleaseSecondGateWrite(); + await AwaitWithTimeoutAsync(Task.WhenAll(eventWrites)); + await AwaitWithTimeoutAsync(drainer); + + // Every detached acquisition drains what it finds and releases; this write goes through the + // same lock afterwards, so it can only succeed if none of them stranded or double-released it. await AwaitWithTimeoutAsync( writer.WriteAsync(CreateShutdownAckEnvelope(), WorkerFrameWritePriority.Control)); - const int total = (perClass * 2) + 1; + const int total = (perClass * 2) + 2; int controlCount = 0; int eventCount = 0; stream.Position = 0; @@ -1043,7 +1075,7 @@ public sealed class WorkerFrameProtocolTests } } - Assert.Equal(perClass + 1, controlCount); + Assert.Equal(perClass + 2, controlCount); Assert.Equal(perClass, eventCount); // No frame was written twice and none was left queued. Assert.Equal(stream.Length, stream.Position); diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/FailoverAlarmConsumerTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/FailoverAlarmConsumerTests.cs index cabc520..f95beca 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/FailoverAlarmConsumerTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/FailoverAlarmConsumerTests.cs @@ -330,6 +330,44 @@ public sealed class FailoverAlarmConsumerTests Assert.Equal(22, sut.AcknowledgeByName("a", "p", "g", "c", "n", "node", "dom", "full")); } + /// + /// Proves that the snapshot truncation verdict is read from whichever child + /// is currently active, not cached from the primary: a capped primary reports + /// , and after failover the standby's own verdict + /// replaces it. The verdict drives the dashboard's completeness caveat, so a + /// stale one would either keep a banner on screen for a feed that is now + /// complete or, worse, clear it for one that is not. + /// + [Fact] + public void SnapshotActiveAlarms_TruncationVerdictComesFromActiveChild() + { + FlakyPrimary primary = new FlakyPrimary { ThrowOnPoll = false, SnapshotTruncated = true }; + StubStandby standby = new StubStandby { SnapshotTruncated = false }; + FailoverSettings settings = new FailoverSettings(threshold: 1, probeIntervalSeconds: 0, stableProbes: 1); + using FailoverAlarmConsumer sut = new FailoverAlarmConsumer(primary, standby, settings); + + sut.Subscribe(@"\\HOST\Galaxy!Area"); + Assert.Equal(AlarmProviderMode.Alarmmgr, sut.Mode); + + // Active = Primary → the primary's capped fetch surfaces. + _ = sut.SnapshotActiveAlarms(out bool truncatedOnPrimary); + Assert.True(truncatedOnPrimary); + + // Force a failover by failing the primary past threshold. + primary.ThrowOnPoll = true; + sut.PollOnce(); // threshold=1 → switch to Subtag + Assert.Equal(AlarmProviderMode.Subtag, sut.Mode); + + // Active = Standby → its own verdict, not the primary's leftover true. + _ = sut.SnapshotActiveAlarms(out bool truncatedOnStandby); + Assert.False(truncatedOnStandby); + + // And the standby really is the source: flip its verdict and the answer follows. + standby.SnapshotTruncated = true; + _ = sut.SnapshotActiveAlarms(out bool truncatedAfterStandbyCaps); + Assert.True(truncatedAfterStandbyCaps); + } + /// /// Proves that an intermittent failure during failback probing resets the /// clean-probe counter to zero, requiring a fresh unbroken run of diff --git a/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSession.cs b/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSession.cs index f849813..060f841 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSession.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSession.cs @@ -168,13 +168,20 @@ public sealed class WorkerPipeSession // Closing the transport is what actually ends a pipe read parked in the kernel: on net48 // NamedPipeClientStream.ReadAsync ignores its CancellationToken, so the message loop's // cancellation can never reach one (WRK-31). It is deliberately the LAST teardown step, - // because in the ordinary case every frame this session will ever write has completed by - // the time control reaches here: WorkerFrameWriter.WriteAsync signals only after the - // frame is written AND flushed, and each exit path awaits its final write before - // unwinding — the shutdown ack and shutdown-timeout fault inside the loop's dispatch, the - // event-drain and oversized-event faults inside the drain task the loop awaits, the - // watchdog fault inside the heartbeat task the loop awaits, and the handshake fault - // inside CompleteStartupHandshakeAsync's catch. + // because in the ordinary case every frame this session will ever write has been written + // AND flushed by the time control reaches here: WorkerFrameWriter.WriteAsync signals only + // after both, and each exit path awaits its final write before unwinding — the shutdown + // ack and shutdown-timeout fault inside the loop's dispatch, the event-drain and + // oversized-event faults inside the drain task the loop awaits, the watchdog fault inside + // the heartbeat task the loop awaits, and the handshake fault inside + // CompleteStartupHandshakeAsync's catch. + // + // That is a statement about frames, not about the writer being idle. A caller that + // returned on its own completion while another drainer held the write lock leaves a + // detached lock acquisition behind (WorkerFrameWriter.DetachLockWait), so a drain pass can + // still be scheduled after every caller has unwound. It is harmless here — the queues are + // empty by then, and a pass with nothing to dequeue writes and flushes nothing — but the + // invariant to rely on is "no frame is left undelivered", not "no writer work remains". // // "Ordinary" is the honest word, not "always": the loop's wait on the heartbeat and // drain tasks is budgeted (BackgroundTaskStopTimeout), and a stream write is genuinely diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmCommandHandler.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmCommandHandler.cs index 638f84f..eb98010 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmCommandHandler.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmCommandHandler.cs @@ -77,10 +77,15 @@ public interface IAlarmCommandHandler : IDisposable /// rather than read from a separate property, both so the pair comes from /// one atomic consumer read and because it is the only carrier left once /// (or an empty galaxy) filters the - /// records down to none. when there is no active - /// subscription: no fetch has happened, so nothing is capped. + /// records down to none. Never assigned when there is no active + /// subscription — that case throws rather than reporting an empty, + /// never-capped set, so a query issued before SubscribeAlarms is a + /// caller error and not a silent all-clear. /// /// The currently active alarms matching the filter. + /// + /// Thrown when there is no active subscription. + /// IReadOnlyList QueryActive(string? alarmFilterPrefix, out bool snapshotTruncated); /// diff --git a/stillpending.md b/stillpending.md index 929ccf8..edfd2d2 100644 --- a/stillpending.md +++ b/stillpending.md @@ -62,7 +62,7 @@ These are documented, deliberate, and mostly enforced. Listed so the deferred su - 🔵 **Lazy browse is wire-only** — no lazy SQL / cache loading. `docs/DesignDecisions.md:365-376`, `docs/plans/2026-05-28-lazy-browse-design.md:30`. - 🔵 **No server-side / streaming browse search** — `docs/plans/2026-05-28-lazy-browse-design.md:208`. - 🔵 **Alarm command surface is ack + query only** — no Clear/Disable/Enable/Silence/Shelve/Inhibit; matches the MXAccess alarm-client set. `Worker/MxAccess/AlarmCommandHandler.cs`, shelve/suppress out of scope per `docs/AlarmClientDiscovery.md:60-66`. -- 🟡 **Dashboard EventsHub has no per-session ACL — still true on `main`, planned (epic Phase 4, not started).** Any authenticated dashboard user may still subscribe to any session group (`Dashboard/Hubs/EventsHub.cs` `TODO(per-session-acl)`). The enabling foundation (session `OwnerKeyId`) already merged in epic Phase 1; epic Phase 4 (Tasks 16–19) adds the gRPC session-owner gate, a session tag + group-to-tag config, and EventsHub per-session ACL with a hub-token tag claim. `docs/plans/2026-06-15-session-resilience.md` Phase 4. (See also §8.) +- ✅ **Dashboard EventsHub per-session ACL — RESOLVED (2026-08-17, branch `feat/deferred-closeout`, archreview TST-15).** The `TODO(per-session-acl)` no longer exists in `src/`. `IDashboardSessionAcl.CanViewSession` is consulted at both subscribe seams (`Dashboard/Hubs/EventsHub.cs` `SubscribeSession` and the in-process `IDashboardSessionEventSubscriber.Subscribe` behind the session-details page), so an authenticated dashboard user reaches a session's events only as an Administrator or as a Viewer whose `Dashboard:GroupToTag` grant intersects the session's tags (inherited from the owning API key); untagged sessions are Admin-only under the default `Dashboard:UntaggedSessionVisibility=AdminOnly`. Epic Phase 4 (Tasks 16–19) is complete. `docs/plans/2026-07-10-dashboard-session-acl-tst15.md`, `docs/Authorization.md`. (See also §8.) - ✅ **Adopt the shared `ZB.MOM.WW.GalaxyRepository` library (cross-repo normalization) — RESOLVED (2026-06-25, branch `feat/galaxyrepository-adoption`).** The inline Galaxy-browse code (`src/ZB.MOM.WW.MxGateway.Server/Galaxy/**` + `Grpc/GalaxyRepositoryGrpcService.cs`/`GalaxyProtoMapper.cs`, 27 files / −2959 LOC) is deleted; mxaccessgw now consumes `ZB.MOM.WW.GalaxyRepository` **0.2.0** (published to Gitea), same `galaxy_repository.v1` wire (no client change). The authz-parity gate was resolved by pushing the per-key browse-subtree filter **upstream** as an injectable `IGalaxyBrowseScopeProvider` (default no-op; HistorianGateway @ 0.1.0 unaffected); mxaccessgw supplies `GatewayBrowseScopeProvider` and switched `GatewayGrpcScopeResolver` to the lib proto types. Alarm-attribute discovery (`GetAlarmAttributesAsync`) was upstreamed too; the dashboard summary stays host-side (`DashboardGalaxySummaryProjector`). Lib 64 tests green; gateway 327 targeted green. Full record + caveats (NSSM config, pre-existing NU1903 + IntegrationTests `EventStreamService` breaks): **`A2-galaxyrepository-adoption-handoff.md`**. Remaining: cross-repo propagation (HistorianGateway `pending.md` §A2, scadaproj index) + opt-in live Galaxy-SQL validation. Full handoff: **`A2-galaxyrepository-adoption-handoff.md`** (repo root). Tracked cross-repo in HistorianGateway `pending.md` §A2 + scadaproj component normalization. --- From 870b744e9b7d171e9a239be2fee22b52c617b874 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 17 Aug 2026 05:27:31 -0400 Subject: [PATCH 17/17] =?UTF-8?q?docs(closeout):=20reviewer's=20two=20resi?= =?UTF-8?q?dual-record=20asks=20=E2=80=94=20alarms-hub=20redaction=20gap?= =?UTF-8?q?=20named,=20ConstraintText=20artifact=20recorded;=20all=2012=20?= =?UTF-8?q?tasks=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/GatewayConfiguration.md | 2 +- docs/plans/2026-08-17-deferred-closeout.md | 6 ++++++ docs/plans/2026-08-17-deferred-closeout.md.tasks.json | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/GatewayConfiguration.md b/docs/GatewayConfiguration.md index 0eb0d41..fc9a94f 100644 --- a/docs/GatewayConfiguration.md +++ b/docs/GatewayConfiguration.md @@ -192,7 +192,7 @@ events (a "gap") and must re-snapshot; whatever is still retained is replayed. | `MxGateway:Dashboard:SnapshotIntervalMilliseconds` | `1000` | Dashboard snapshot refresh interval used by the snapshot SignalR hub and the pages that subscribe to it. | | `MxGateway:Dashboard:RecentFaultLimit` | `100` | Maximum number of fault summaries projected into each dashboard snapshot. | | `MxGateway:Dashboard:RecentSessionLimit` | `200` | Maximum number of session summaries projected into each dashboard snapshot. | -| `MxGateway:Dashboard:ShowTagValues` | `false` | Controls whether tag values reach the dashboard's SignalR events hub mirror. `false` (default): `DashboardEventBroadcaster` blanks tag values from a deep-cloned copy of each `MxEvent` before it reaches any hub subscriber — event metadata (tag reference, quality, status, timestamps) still renders; see `docs/GatewayDashboardDesign.md`'s `EventsHub` row for the mechanism. This is now the second of two independent layers, not the only one: `IDashboardSessionAcl` decides *which* sessions a caller may subscribe to at all (see `GroupToTag` / `UntaggedSessionVisibility` below), while this flag decides what a permitted subscriber sees. Setting it `true` therefore exposes tag values to everyone the ACL admits — every Administrator, plus each Viewer holding a matching tag. The flag gates only the SignalR hub mirror — it does **not** cover the `/browse` live-value display, which remains a separate, still-open residual. | +| `MxGateway:Dashboard:ShowTagValues` | `false` | Controls whether tag values reach the dashboard's SignalR events hub mirror. `false` (default): `DashboardEventBroadcaster` blanks tag values from a deep-cloned copy of each `MxEvent` before it reaches any hub subscriber — event metadata (tag reference, quality, status, timestamps) still renders; see `docs/GatewayDashboardDesign.md`'s `EventsHub` row for the mechanism. This is now the second of two independent layers, not the only one: `IDashboardSessionAcl` decides *which* sessions a caller may subscribe to at all (see `GroupToTag` / `UntaggedSessionVisibility` below), while this flag decides what a permitted subscriber sees. Setting it `true` therefore exposes tag values to everyone the ACL admits — every Administrator, plus each Viewer holding a matching tag. The flag gates only the SignalR events hub mirror — it does **not** cover the `/browse` live-value display, nor the alarms hub (`AlarmsHubPublisher` broadcasts alarm transitions with their `current_value`/`limit_value` fields unredacted); both remain separate, still-open residuals. | | `MxGateway:Dashboard:GroupToRole` | _(empty)_ | LDAP group → dashboard role mapping. Keys are LDAP group names (short CN or full DN — leading-RDN match). Values must be `Administrator` (read/write, API-key CRUD) or `Viewer` (read-only) — matched ordinally by the startup validator, so the spelling is exact and `Admin` is rejected. A user whose LDAP groups don't intersect this map cannot sign in; with no mapping at all, only the loopback bypass admits anyone. | | `MxGateway:Dashboard:GroupToTag` | _(empty)_ | LDAP group → dashboard visibility tags. Keys follow the same convention as `GroupToRole` (short CN or full DN — leading-RDN match, case-insensitive); values are tag lists. A dashboard user's granted tag set is the union over the groups they belong to; an unmapped group contributes nothing. **Visibility only:** tags scope which sessions' event streams a Viewer may observe on the dashboard — they never grant or deny data access, which stays with the API key's scopes and constraints. Independent of `GroupToRole`: a group may appear in either map, both, or neither. Empty (the default) means Viewers hold no tags, so under the default `UntaggedSessionVisibility` they observe no session's events. | | `MxGateway:Dashboard:UntaggedSessionVisibility` | `AdminOnly` | Who may observe a session that carries no tags (its owning API key declared none). `AdminOnly` (default, fail-closed) restricts untagged sessions to dashboard Administrators. `AllViewers` shows them to every Viewer — opt-in for a single-tenant deployment that wants the pre-tag behaviour. Administrators always see every session regardless of tags. | diff --git a/docs/plans/2026-08-17-deferred-closeout.md b/docs/plans/2026-08-17-deferred-closeout.md index a5d8830..97cb50e 100644 --- a/docs/plans/2026-08-17-deferred-closeout.md +++ b/docs/plans/2026-08-17-deferred-closeout.md @@ -424,6 +424,12 @@ Follow-ups recorded, not started: `scripts/check-codegen.ps1` would close it). - `EffectiveDashboardConfiguration` (dashboard settings page) doesn't display `GroupToTag` / `UntaggedSessionVisibility`, though it shows `GroupToRole`. +- ApiKeysPage's `ConstraintText` neither offers tag input nor lists `DashboardTags`, + and since `IsEmpty` now counts tags, a tags-only key renders `-` where a truly + unconstrained key renders `unconstrained` — two spellings of one meaning. +- `AlarmsHubPublisher` broadcasts alarm transitions with `current_value`/`limit_value` + unredacted — the `ShowTagValues` redaction covers only the events hub mirror + (pre-existing; now noted in `docs/GatewayConfiguration.md`). - The alarm probes' remaining questions (ack-leg GUID stability, `@COUNT` semantics) unblock via the paths in `docs/AlarmProbeFindings.md`. diff --git a/docs/plans/2026-08-17-deferred-closeout.md.tasks.json b/docs/plans/2026-08-17-deferred-closeout.md.tasks.json index 1c22e58..23528be 100644 --- a/docs/plans/2026-08-17-deferred-closeout.md.tasks.json +++ b/docs/plans/2026-08-17-deferred-closeout.md.tasks.json @@ -12,7 +12,7 @@ { "id": 9, "subject": "Task 9: Client regeneration + rebuild for new alarm fields", "status": "completed", "blockedBy": [8] }, { "id": 10, "subject": "Task 10: Phase gate — full gateway suite on macOS", "status": "completed", "blockedBy": [1, 4, 5, 6, 7, 8, 9] }, { "id": 11, "subject": "Task 11: windev gate — full Windows verification", "status": "completed", "blockedBy": [2, 10] }, - { "id": 12, "subject": "Task 12: Wrap-up — closure notes, umbrella check, final review", "status": "pending", "blockedBy": [11] } + { "id": 12, "subject": "Task 12: Wrap-up — closure notes, umbrella check, final review", "status": "completed", "blockedBy": [11] } ], "lastUpdated": "2026-08-17T00:00:00Z" }