From 338bc20a0a28d31688e813cf7304af559faaf63c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 17 Aug 2026 07:01:48 -0400 Subject: [PATCH 01/26] =?UTF-8?q?docs(plan):=20follow-up=20closeout=20?= =?UTF-8?q?=E2=80=94=20feed-level=20truncation=20signal,=20ShowTagValues?= =?UTF-8?q?=20coverage=20completion,=20codegen=20reverse=20sweep,=20dashbo?= =?UTF-8?q?ard=20display=20gaps,=20probe=20retry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/2026-08-17-followup-closeout.md | 450 ++++++++++++++++++ ...2026-08-17-followup-closeout.md.tasks.json | 15 + 2 files changed, 465 insertions(+) create mode 100644 docs/plans/2026-08-17-followup-closeout.md create mode 100644 docs/plans/2026-08-17-followup-closeout.md.tasks.json diff --git a/docs/plans/2026-08-17-followup-closeout.md b/docs/plans/2026-08-17-followup-closeout.md new file mode 100644 index 0000000..98bf2ff --- /dev/null +++ b/docs/plans/2026-08-17-followup-closeout.md @@ -0,0 +1,450 @@ +# Follow-up Closeout Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development +> to implement this plan task-by-task in this session. + +**Goal:** Close the follow-ups recorded in `docs/plans/2026-08-17-deferred-closeout.md` +(as-built notes, "Follow-ups recorded, not started"): the feed-level alarm-truncation +signal on `StreamAlarms`/`AlarmFeedMessage`, the `ShowTagValues` redaction gaps +(alarms hub + `/browse` live values — TST-16 residual), the codegen guard's +one-directional Rust check (the recorded "no guard" note is stale — Check 3 exists), +the dashboard settings page's missing `GroupToTag`/`UntaggedSessionVisibility` rows, +the ApiKeysPage `DashboardTags` display/input gaps, and a bounded retry of the wnwrap +alarm probes using the secured-write verb the first attempt didn't use. + +**Architecture:** Same two-phase posture as the prior three plans. Gateway-side work +builds and tests on macOS via `NonWindows.slnx`; windev (`ssh windev`, PowerShell, CI +clone `C:\build\mxaccessgw-ci`) runs the probe retry and final full-matrix verification. +**This plan touches `.proto` contracts** (Task 1) — contracts regeneration and the +five-client rollout (Task 2) follow the exact pattern Task 9 of the previous plan used. +The gateway-side truncation edge needs **no worker change**: `GatewayAlarmMonitor` +already receives `snapshot_truncated` on every reconcile; the feed frame is raised +gateway-side (per-reconcile fidelity, matching the operator-facing caveat's semantics). + +**Tech stack:** .NET 10 gateway / protobuf contracts / five language clients / Blazor +Server dashboard / PowerShell codegen scripts / windev live rig. + +**Branch:** `feat/followup-closeout` off `main` (`ab3ff16`). + +--- + +## Ground rules for every implementer subagent + +- NEVER run `git stash`, `git reset`, `git clean`, or `git checkout `. + Commit with explicit **pathspecs on the commit itself**: + `git commit -m "..." -- ` — never `git add -A`, `git commit -a`, or a bare + `git commit` after add (a concurrent task's staged files would be swept in). +- 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. The feed truncation frame is a + gateway-status frame (like `provider_status`), not a synthesized MXAccess event. +- 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. +- `git commit` hitting `index.lock` contention → wait 5 s and retry. + +--- + +## Task 1: Feed-level alarm-truncation signal (proto + gateway) + +**Classification:** high-risk +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 3, Task 4, Task 5, Task 6 + +**Files:** +- Modify: `src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto` +- Modify: `src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs` +- Modify: `src/ZB.MOM.WW.MxGateway.Server/Alarms/IGatewayAlarmService.cs` (doc comment only) +- Test: `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Alarms/` (existing `GatewayAlarmMonitor` test class(es)) +- Docs: `gateway.md`, `docs/Grpc.md`, `docs/DesignDecisions.md` +- Build output: `src/ZB.MOM.WW.MxGateway.Contracts/Generated/` (regenerated, committed) + +Do NOT touch `docs/GatewayConfiguration.md` or `docs/GatewayDashboardDesign.md` (owned +by Task 3 this wave) or any `clients/**` path (Task 2). + +**Spec.** + +Proto (`mxaccess_gateway.proto`) — additive only: + +1. New message next to `AlarmProviderStatus` (`:1025-1030`): + +```proto +// Feed-level snapshot-completeness status. Emitted once on StreamAlarms open +// (after the initial provider_status frame, before the cached active_alarm frames) +// so late joiners learn the current verdict, and again on every change of the +// truncation verdict observed at reconcile. Mirrors the per-record +// ActiveAlarmSnapshot.from_truncated_snapshot caveat at feed level so live +// consumers (lmxopcua, ScadaBridge) can reason about completeness without +// polling QueryActiveAlarms. Additive in v1; absent frames mean "not truncated" +// only for streams opened against gateways that emit the frame at open. +message AlarmSnapshotStatus { + // True while the monitor's cached active-alarm set derives from a truncated + // (capped) worker fetch — the set may be missing alarms. Distinct from + // provider degradation (AlarmProviderStatus.degraded). + bool truncated = 1; +} +``` + +2. New oneof case in `AlarmFeedMessage` (`:1006-1023`, next free field 5): + `AlarmSnapshotStatus snapshot_status = 5;` + +Gateway (`GatewayAlarmMonitor.cs`): + +- `ApplyReconcile` (`:632-694`) currently records `_snapshotTruncated = snapshotTruncated` + at `:691` with a comment saying truncation "needs no special handling here". Change: + detect the edge (old value ≠ new value) and, when it changes, broadcast + `new AlarmFeedMessage { SnapshotStatus = new AlarmSnapshotStatus { Truncated = snapshotTruncated } }` + via `BroadcastToAll` (`:720-732` — the provider-status precedent at `:531`). Match the + existing locking discipline exactly: compute/record under `_sync` the way + `ApplyProviderModeChangeAsync` (`:513-566`) does, broadcast the way it does. Update the + now-stale comment at `:627-631`. +- Late-joiner priming in `StreamAsync` (`:748-800`): after the `provider_status` frame + (`:782`) and before the cached `active_alarm` frames (`:786`), yield one + `snapshot_status` frame carrying the current `_snapshotTruncated`, read under the same + lock that snapshots `_alarms` (`:763-780`) so the flag and the alarm set are a + consistent pair. +- `ClearCache` (`:742`) resets `_snapshotTruncated` to false — decide and document + whether the reset emits a frame (it should: a monitor restart that drops a truncated + verdict is a completeness change subscribers must see; emit via the same edge path). +- `MxAccessGatewayService.StreamAlarms` is a pass-through — no change. +- `IGatewayAlarmService.SnapshotTruncated` doc comment (`:41-59`): note the new + feed-level frame so the two surfaces cross-reference. + +Tests (extend the existing GatewayAlarmMonitor test class(es), matching their fake/ +reconcile-driving idiom): +1. Reconcile flipping truncated false→true emits exactly one `snapshot_status` frame + (truncated=true) to an attached subscriber; a second identical reconcile emits none. +2. true→false emits the clearing frame. +3. A subscriber attaching while truncated=true receives, in order: `provider_status`, + `snapshot_status(truncated=true)`, cached `active_alarm`s, `snapshot_complete`. +4. A subscriber attaching while truncated=false receives `snapshot_status(truncated=false)` + at open (the baseline frame is unconditional). + +Docs, same commit: +- `gateway.md` §"truncated-snapshot visibility" (`:243-262`): add the feed-level frame, + emission rules (open + edges), and ordering. +- `docs/Grpc.md` `:105-120` (provider_status oneof case rules): add the fifth case with + its emission rules; `:95-99` StreamAlarms handler contract. +- `docs/DesignDecisions.md` `:206-230` ("truncation is reported per record…", dated + 2026-08-17): extend the entry — per-record stays for `QueryActiveAlarms` (bare stream, + no envelope); the live feed now carries the set-level signal as a status frame. + +**Step 1:** Write the four monitor tests first (they fail: no `SnapshotStatus` case). +Building the Tests project will fail to compile until the proto field exists — so add +the proto change, rebuild Contracts (regenerates `Generated/`), then confirm the tests +fail for behavioral reasons (no frame emitted), not compile errors. +**Step 2:** Implement the monitor changes; run +`dotnet test src/ZB.MOM.WW.MxGateway.Tests/... --filter "FullyQualifiedName~GatewayAlarmMonitor"` → all pass. +**Step 3:** Full macOS build (`NonWindows.slnx`) 0W/0E + run the alarm-area test filter. +**Step 4:** Update the three docs. +**Step 5:** Commit with pathspecs (proto, Generated, monitor, interface, tests, 3 docs). + +--- + +## Task 2: Five-client rollout of `snapshot_status` + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 7 (windev-only; disjoint files) +**Blocked by:** Task 1, Task 3, Task 4 + +**Files:** +- Modify: `clients/rust/protos/mxaccess_gateway.proto` (byte-identical refresh from Contracts) +- Regenerate: `clients/proto/descriptors/mxaccessgw-client-v1.protoset` + (via `scripts/publish-client-proto-inputs.ps1`), Go + Python generated bindings + (per-client `generate-proto.ps1`) +- Modify: `clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayClientCli.cs:1552-1565` + (feed renderer — add BOTH the missing `ProviderStatus` case and the new `SnapshotStatus` case) +- Modify: `clients/go/cmd/mxgw-go/main.go:1101-1114` (`formatAlarmFeedMessage` — same two cases) +- Modify: `clients/rust/crates/mxgw-cli/src/main.rs:2233-2264` and `:2266+` + (`alarm_feed_message_summary` / `alarm_feed_message_to_json` — add `SnapshotStatus` arm) +- Modify: READMEs — `clients/dotnet/README.md` (~`:152-158`), `clients/python/README.md` + (~`:118-123`), `clients/rust/README.md` (~`:126`), `clients/go/README.md` (~`:148-154`), + `clients/java/README.md` (~`:120-126`): one paragraph each on the feed-level frame +- Modify: `docs/GatewayDashboardDesign.md:276` (AlarmsHub payload-case row: add the new case) + +**Spec.** Follow the exact rollout Task 9 of the prior plan used (recorded at +`docs/plans/2026-08-17-deferred-closeout.md:300-318`): regenerate everything from +Contracts, refresh the Rust vendored proto byte-identically, then verify with +`pwsh scripts/check-codegen.ps1` (all four checks green — note Task 4 may have added a +reverse-direction sweep to Check 3 by the time this runs; it must pass too). +Python/Java CLIs render generic protobuf-JSON — README paragraph only, no code. +Java's `build/resources/**` proto copies are untracked build output — do not commit. + +**Steps:** regenerate → renderer cases → build/test each touched client per the +CLAUDE.md verification matrix (dotnet client slnx + tests; `gofmt`+`go build`+`go test`; +`cargo fmt`+`check`+`test`+`clippy -D warnings`; python `pytest`; java `gradle test`) → +`pwsh scripts/check-codegen.ps1` green → READMEs + dashboard-design row → commit with pathspecs. + +--- + +## Task 3: Complete `ShowTagValues` coverage — alarms hub + `/browse` (TST-16 residual) + +**Classification:** high-risk (security posture) +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 1, Task 4, Task 5, Task 6 + +**Files:** +- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/AlarmsHubPublisher.cs` +- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardLiveDataService.cs` +- Modify (if value formatting sits there): `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardTagValue.cs` +- Test: `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/AlarmsHubPublisherTests.cs` (new) +- Test: existing `DashboardLiveDataService` test class (extend), else new file alongside +- Docs: `docs/GatewayConfiguration.md:195` (the residual sentence), + `docs/GatewayDashboardDesign.md` (AlarmsHub row `:276` — redaction note; do NOT edit the + payload-case list, Task 2 owns that), `archreview/remediation/00-tracking.md:231` + (TST-16 residual row), `archreview/2026-07-12/60-testing-docs-gaps.md` (residual lines) + +Do NOT touch `gateway.md` (owned by Task 1 this wave) or `SettingsPage.razor`/ +`EffectiveDashboardConfiguration` (Task 5). + +**Spec.** + +Seam A — `AlarmsHubPublisher` (`:14-68`): today it forwards the raw `AlarmFeedMessage` +verbatim (`:40-43`), leaking `current_value`/`limit_value` from BOTH the `transition` +arm (proto `:882`, `:886`) and the `active_alarm` snapshot arm (proto `:931-932`) to any +`/hubs/alarms` browser client. Inject the dashboard options (mirror how +`DashboardEventBroadcaster.cs:53` captures `_showTagValues`); when `ShowTagValues` is +false, deep-`Clone()` the message and clear the four value fields before `SendAsync`. +**Never mutate the source message** — it is fanned out to gRPC `StreamAlarms` subscribers +and the AlarmsPage status loop (`DashboardEventBroadcaster.RedactValues` at `:247-259` +is the exact pattern, including the clone-only rule). When `ShowTagValues` is true, +forward as today. Frames with no value fields (`snapshot_complete`, `provider_status`, +and Task 1's `snapshot_status` if already merged — handle via default: clone only when +the arm carries values) pass through untouched. + +Seam B — `/browse` live values: `BrowsePage.razor:133,:144,:147` renders +`value?.ValueText` unconditionally, fed by `DashboardLiveDataService.cs:99-101` → +`DashboardTagValue.ValueText` (`DashboardTagValue.cs:41-45`, +`DashboardMxValueFormatter.FormatValue`). Redact **at the service boundary** (per the +original TST-16 recommendation, `archreview/remediation/60-testing-docs-gaps.md:346-352`): +when `ShowTagValues` is false, `DashboardLiveDataService` produces `ValueText` as the +literal `"[redacted]"` instead of the formatted value; quality/timestamp columns stay. +No `BrowsePage.razor` change should be needed — if one turns out to be, that's a plan +defect to surface. Keep the redaction decision in one place; don't duplicate the check +in the page. + +Tests: +- `AlarmsHubPublisherTests` (new): reuse the `CapturingHubContext` shape from + `DashboardEventBroadcasterTests.cs:23` and the `DashboardSnapshotPublisherTests` + BackgroundService-driving template (fake stream service, internal ctor if needed). + Cases: ShowTagValues=false redacts both arms' value fields but keeps metadata; + source message not mutated; ShowTagValues=true passes values through; + valueless frames forwarded intact. +- `DashboardLiveDataService`: ShowTagValues=false → `ValueText == "[redacted]"`; + true → formatted value. + +Docs: rewrite `docs/GatewayConfiguration.md:195` — the flag now covers the events hub +mirror, the alarms hub, and `/browse`; state the "[redacted]" rendering. Mark the TST-16 +residual rows closed in both archreview files (match their existing status wording). + +**Steps:** tests first (fail) → implement both seams → targeted filters +(`~AlarmsHubPublisher`, `~DashboardLiveDataService`, plus `~DashboardEventBroadcaster` +regression) → macOS build 0W/0E → docs → commit with pathspecs. + +--- + +## Task 4: Codegen guard — reverse-direction Check 3 + stale-note correction + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** Task 1, Task 3, Task 5, Task 6 + +**Files:** +- Modify: `scripts/check-codegen.ps1:75-95` (Check 3) +- Docs: `docs/GatewayTesting.md:687-698` (Check 3 description), + `docs/ClientPackaging.md:199-213` (one-line note), `clients/rust/README.md:25-38` + (refresh-rule note if wording changes) + +**Spec.** The recorded follow-up ("no guard keeps `clients/rust/protos/` in sync") is +stale — Check 3 (`:75-95`) already SHA-256-compares vendored↔canonical and runs in CI +(`.gitea/workflows/ci.yml:76-78`). The real gap: `:79` iterates only the **vendored** +dir, so a newly added canonical proto with no vendored copy passes silently and would +break a standalone crate build later. Add the reverse sweep: iterate +`$canonicalProtoDir`; any canonical `*.proto` with no same-named vendored counterpart → +append to `$failures` with a copy-to instruction, same reporting style as `:81-89`. +Keep all-checks-always-run behavior. Do NOT edit the closeout plan's follow-ups block — +Task 9 owns that file. + +**Steps:** edit script → verify locally: `pwsh scripts/check-codegen.ps1` runs Check 3 +both directions green (macOS pwsh is fine for Check 3/hashing; if Checks 2/4 can't run +locally, run Check 3's logic standalone and say so) → prove the new failure mode by +temporarily copying a scratch proto into the canonical dir under the scratchpad — NOT +into the repo — or by dry-running the loop against a temp dir pair → docs → commit with +pathspecs. + +--- + +## Task 5: Settings page — show `GroupToTag` and `UntaggedSessionVisibility` + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** Task 1, Task 3, Task 4, Task 6 + +**Files:** +- Modify: `src/ZB.MOM.WW.MxGateway.Server/Configuration/EffectiveDashboardConfiguration.cs:3-10` +- Modify: `src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayConfigurationProvider.cs:58-65` +- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SettingsPage.razor` +- Test: `src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayConfigurationProviderTests.cs` (new) +- Test: page-render test alongside the existing dashboard render tests + (`AlarmsPageTruncationBannerTests.cs` is the template) + +**Spec.** Add `GroupToTag` (`IReadOnlyDictionary` or matching shape) +and `UntaggedSessionVisibility` to the `EffectiveDashboardConfiguration` record; populate +in `GatewayConfigurationProvider` (`:58-65`) from `DashboardOptions.GroupToTag` (`:78`) +and `.UntaggedSessionVisibility` (`:85`). Neither is a secret — no masking. Rendering: +`GroupToTag` row next to "Dashboard role mapping" (`SettingsPage.razor:50-67`), same +`
  • group → tag1, tag2
` idiom with the `(none configured)` +empty case; `UntaggedSessionVisibility` as a scalar row in the Dashboard cluster +(`:77-80`). Tests: provider projection test (new ground — assert both members copied, +including the empty-dictionary case); render test asserting a configured mapping and the +visibility value appear in the emitted markup (HtmlRenderer idiom, no bUnit). + +**Steps:** tests first → implement → `--filter` the two new test classes → macOS build +0W/0E → commit with pathspecs. (No doc change: `docs/GatewayConfiguration.md` already +documents both options; the settings page is self-describing.) + +--- + +## Task 6: ApiKeysPage — `DashboardTags` in ConstraintText + create-form input + +**Classification:** standard +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 1, Task 3, Task 4, Task 5 + +**Files:** +- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/ApiKeysPage.razor` +- Test: new render/behavior test alongside the dashboard render tests +- Docs: `docs/Authorization.md` (dashboard API-key management section, if it describes + the create form's constraint fields) + +**Spec.** Two defects, one page: + +(a) `ConstraintText` (`:504-533`) never lists `DashboardTags`, so a tags-only key falls +through to `string.Join` of an empty list → `""` → `DashboardDisplay.Text` renders `-`, +while a truly unconstrained key says `unconstrained` — two spellings of one meaning, +except they're NOT the same meaning: the tags-only key IS constrained +(`ApiKeyConstraints.IsEmpty` counts tags, `ApiKeyConstraints.cs:62-71`). Fix by listing +it: `AddList(parts, "dashboard_tags", constraints.DashboardTags)` alongside the other +snake_case labels (~`:516`). `AddList` (`:535-541`) preserves order — keep that. + +(b) The create form's Constraints section (`:97-133`) has no dashboard-tags input, so a +tags-only key can't be created from the dashboard at all. Add a textarea matching the +subtree/glob fields exactly: bind to a new `string DashboardTags` on `ApiKeyCreateModel` +(`:551-589`), clear it in `Reset()` (`:575-588`), split with the existing `ParseList` +(`:543-549`), and attach in `TryBuildCreateRequest` (`:415-455`) via object-initializer +on the `new ApiKeyConstraints(...)` at `:444-452` (init-only property): +`new ApiKeyConstraints(...) { DashboardTags = ParseList(CreateModel.DashboardTags) }`. +Help text: comma/newline-separated, matched case-insensitively against +`Dashboard:GroupToTag` grants; empty = untagged (visibility per +`UntaggedSessionVisibility`). Mirrors the CLI's `apikey create --dashboard-tags`. + +Tests (HtmlRenderer page-render idiom): tags-only key renders `dashboard_tags: …` (not +`-`); unconstrained key still renders `unconstrained`; create-model round-trip — +`TryBuildCreateRequest` with a tags input yields constraints whose `DashboardTags` +matches (if the method is private, follow whatever access pattern the page's existing +tests use; an `internal`-for-testing hook is acceptable only if the repo already does +that elsewhere — otherwise drive through the rendered form or refactor minimally). + +**Steps:** tests first → implement → `--filter` new test class → macOS build 0W/0E → +docs (only if `Authorization.md` enumerates the form fields) → commit with pathspecs. + +--- + +## Task 7: Wnwrap alarm-probe retry — secured-write path (windev) + +**Classification:** standard (investigation; may legitimately end blocked again) +**Estimated implement time:** ~10 min wall (windev round-trips) +**Parallelizable with:** Task 2 +**Blocked by:** none (but runs in Wave 2 to keep windev free for it) + +**Files:** +- Modify: `docs/AlarmProbeFindings.md` (append the attempt record + findings) +- Modify (only if findings answer the questions): comment-level updates in + `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs:425-435, :490-505, :655-670` +- NO probe code committed; NO `Skip=` flips committed; throwaway harness lives only in + the windev CI clone and is deleted after. + +**Spec.** The 2026-08-17 attempt failed because it used plain `Write` against classified +alarm UDAs (`SecurityError detail=1008`). The worker and live harness already implement +the right verb pair end-to-end (`WorkerLiveMxAccessSmokeTests.cs:445-583` — +`AuthenticateUser` then `WriteSecured`; on this rig `AuthenticateUser("Administrator","")` +is known to resolve to user id 1, `:649`). Retry on windev (`ssh windev`, CI clone): + +1. Pull the branch; build what the probe needs (worker x86 + integration tests). +2. Drive `AuthenticateUser("Administrator","")` → `WriteSecured(true)` against + `TestMachine_001.TestAlarm001` (env overrides + `MXGATEWAY_LIVE_MXACCESS_WRITE_SECURED_USER`/`_PASSWORD` exist if a real Galaxy + account is available — never echo credential values). A throwaway variant of + `WnWrapConsumerProbeTests` (`Worker.Tests/Probes/`, `Skip=null` locally only, + `GROUP` fixed to the findings run's `TestArea`, `MaxAlarmsPerFetch` droppable to 1–2) + is the vehicle. +3. If the secured write lands: answer **Q1** — GUID stability across `UNACK→ACK` + (`AlarmAckByGUID`) and across clear-then-re-raise; and **Q2** — `ALARM_RECORDS/@COUNT` + total-active vs records-in-reply under `maxAlmCnt` 1–2 with all three TestMachine + alarms active. Record both in `AlarmProbeFindings.md`; update the three + `WnWrapAlarmConsumer` comment blocks to "observed" with the answer. **Do not change + `IsTruncatedFetch` behavior in this task** — if Q2 says `@COUNT` is total-active, + record that an exact-detection follow-up is now unblocked; the heuristic change is + its own reviewed task later. +4. If still refused: append the attempt (verb used, identity resolved, status line — + no secrets), and record which unblock paths remain (flip script re-enable / + real Galaxy account / reclassification). That outcome completes this task. + +**Steps:** windev session → probe → findings written → clean up throwaway files on +windev → commit (docs + any comment updates) with pathspecs from the Mac tree. + +--- + +## Task 8: Windev full verification + +**Classification:** small (no review — verification gate) +**Estimated implement time:** ~15 min wall +**Blocked by:** Task 1, Task 2, Task 3, Task 4, Task 5, Task 6, Task 7 + +Pull branch into `C:\build\mxaccessgw-ci`, then: full `slnx` build 0W/0E → worker x86 +tests → gateway tests → live MXAccess smoke (`MXGATEWAY_RUN_LIVE_MXACCESS_TESTS=1`, +`~WorkerLiveMxAccessSmokeTests`) → `pwsh scripts/check-codegen.ps1` all green. +Known quirk: first build after pull may fail CS2001/CS0016 on stale Contracts obj — +clear obj/bin and rebuild, not a regression. Record all counts. + +--- + +## Task 9: Bookkeeping — follow-ups closure + plan record + +**Classification:** trivial +**Estimated implement time:** ~2 min +**Blocked by:** Task 8 + +**Files:** +- Modify: `docs/plans/2026-08-17-deferred-closeout.md` (follow-ups block `:419-434`): + annotate each closed item with its closing commit; REWRITE the stale Rust-guard bullet + to record that Check 3 already existed and only the reverse sweep was missing; leave + anything genuinely still open (e.g. probe questions if Task 7 ended blocked; + `IsTruncatedFetch` exactness if Q2 unblocked it) accurately stated. +- Modify: `docs/plans/2026-08-17-followup-closeout.md` (this file): as-built notes. +- Modify: `docs/plans/2026-08-17-followup-closeout.md.tasks.json`: statuses. + +--- + +## Execution notes for the orchestrator + +- Branch `feat/followup-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; trivial = none). +- Waves: **Wave 1:** 1, 3, 4, 5, 6 (files disjoint, including docs — ownership lines in + each task are the contract) · **Wave 2:** 2 (after 1+3+4), 7 (windev) · **Wave 3:** 8 → + final integration review → 9. +- Each implementer gets its full task text + the ground rules block. +- Doc-file ownership this wave matters more than usual: `gateway.md`→Task 1, + `GatewayConfiguration.md`+`GatewayDashboardDesign.md`→Task 3 (Task 2 later adds the + payload-case row), closeout-plan follow-ups→Task 9 only. +- Task 7/8 run against windev over `ssh windev` (PowerShell); psbridge is fallback. +- Do not merge to `main` without user instruction. diff --git a/docs/plans/2026-08-17-followup-closeout.md.tasks.json b/docs/plans/2026-08-17-followup-closeout.md.tasks.json new file mode 100644 index 0000000..e277eb8 --- /dev/null +++ b/docs/plans/2026-08-17-followup-closeout.md.tasks.json @@ -0,0 +1,15 @@ +{ + "planPath": "docs/plans/2026-08-17-followup-closeout.md", + "tasks": [ + {"id": 1, "subject": "Task 1: Feed-level alarm-truncation signal (proto + gateway)", "status": "pending"}, + {"id": 2, "subject": "Task 2: Five-client rollout of snapshot_status", "status": "pending", "blockedBy": [1, 3, 4]}, + {"id": 3, "subject": "Task 3: Complete ShowTagValues coverage — alarms hub + /browse", "status": "pending"}, + {"id": 4, "subject": "Task 4: Codegen guard — reverse-direction Check 3 + stale-note correction", "status": "pending"}, + {"id": 5, "subject": "Task 5: Settings page — GroupToTag + UntaggedSessionVisibility rows", "status": "pending"}, + {"id": 6, "subject": "Task 6: ApiKeysPage — DashboardTags in ConstraintText + create-form input", "status": "pending"}, + {"id": 7, "subject": "Task 7: Wnwrap alarm-probe retry — secured-write path (windev)", "status": "pending"}, + {"id": 8, "subject": "Task 8: Windev full verification", "status": "pending", "blockedBy": [1, 2, 3, 4, 5, 6, 7]}, + {"id": 9, "subject": "Task 9: Bookkeeping — follow-ups closure + plan record", "status": "pending", "blockedBy": [8]} + ], + "lastUpdated": "2026-08-17" +} From 711793ffdc441f088ef1cc8d4932b0cd10540219 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 17 Aug 2026 07:04:49 -0400 Subject: [PATCH 02/26] =?UTF-8?q?ci(codegen):=20Check=203=20sweeps=20both?= =?UTF-8?q?=20directions=20=E2=80=94=20canonical=20protos=20missing=20a=20?= =?UTF-8?q?vendored=20copy=20now=20fail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- clients/rust/README.md | 12 +++++++----- docs/ClientPackaging.md | 3 ++- docs/GatewayTesting.md | 5 +++++ scripts/check-codegen.ps1 | 17 +++++++++++++++-- 4 files changed, 29 insertions(+), 8 deletions(-) diff --git a/clients/rust/README.md b/clients/rust/README.md index cf2f0e1..bc72195 100644 --- a/clients/rust/README.md +++ b/clients/rust/README.md @@ -28,11 +28,13 @@ tarball, where the rest of the mxaccessgw repo does not exist. The vendored copies are shipped in the published `.crate` via `Cargo.toml`'s `include` list, which is what makes the crate buildable standalone; they are build inputs only, never a second source of truth. **Refresh rule:** any commit -that edits a Contracts proto (`mxaccess_gateway.proto`, `mxaccess_worker.proto`, -`galaxy_repository.proto`) must copy the changed file(s) into -`clients/rust/protos/` in that same commit — `scripts/check-codegen.ps1` -Check 3 fails the build on byte drift between the vendored copies and the -canonical Contracts protos. `tonic`/`prost` bindings are generated into +that edits *or adds* a Contracts proto (today `mxaccess_gateway.proto`, +`mxaccess_worker.proto`, `galaxy_repository.proto`) must copy the changed or +new file(s) into `clients/rust/protos/` in that same commit — +`scripts/check-codegen.ps1` Check 3 fails the build on byte drift between the +vendored copies and the canonical Contracts protos, and equally on a canonical +proto that has no vendored copy (which an in-repo build cannot catch, since +`build.rs` reads the canonical directory here). `tonic`/`prost` bindings are generated into Cargo build output. `src/generated.rs` declares the Rust modules that include those generated files. `src/generated` remains reserved for checked-in generator output if the crate later changes to source-tree generation. diff --git a/docs/ClientPackaging.md b/docs/ClientPackaging.md index 8833e72..419b5f9 100644 --- a/docs/ClientPackaging.md +++ b/docs/ClientPackaging.md @@ -206,7 +206,8 @@ publish` ship them inside the `.crate`, making the crate buildable standalone with no access to the rest of the mxaccessgw repo. Any Contracts proto change must refresh `clients/rust/protos/` in the same commit; `scripts/check-codegen.ps1` Check 3 byte-compares the vendored copies against the canonical protos and -fails on drift. Because the vendored protos make a standalone build possible, +fails on drift — in both directions, so a newly added canonical proto that +was never vendored fails there rather than at a consumer's standalone build. Because the vendored protos make a standalone build possible, `cargo package`/`cargo publish` run **with** verification (no `--no-verify`) — a `cargo package` that cannot build from the vendored tree alone would mean the vendored copies are stale, and verification is what catches that before diff --git a/docs/GatewayTesting.md b/docs/GatewayTesting.md index 8679b12..d87cd63 100644 --- a/docs/GatewayTesting.md +++ b/docs/GatewayTesting.md @@ -689,6 +689,11 @@ committed client descriptor set (Check 1), the C# `Generated/` (Check 2), the Ru protos (Check 3), or the Go/Python client bindings (Check 4, IPC-25) no longer match the current `.proto` sources — the codegen drift class this repo has hit repeatedly (stale client descriptors, net48 `CS0246` on unregenerated protos, silently stale Go/Python worker bindings). +Check 3 sweeps both directions: a vendored copy that drifted from (or has no) canonical proto +fails, and so does a canonical proto with no vendored copy at all. The second direction matters +because `clients/rust/build.rs` prefers the canonical directory whenever it exists, so an +unvendored proto builds fine in-repo and only breaks the standalone crate build a consumer runs +from the published tarball — drift no in-repo build can surface. Check 4 regenerates the Go and Python bindings with their pinned generators (`protoc-gen-go` v1.36.11 / `protoc-gen-go-grpc` 1.6.2, `grpcio-tools` 1.80.0) and fails on any diff; a missing generator fails the check rather than skipping it. The **primary** guard for the diff --git a/scripts/check-codegen.ps1 b/scripts/check-codegen.ps1 index ab19901..a7b7038 100644 --- a/scripts/check-codegen.ps1 +++ b/scripts/check-codegen.ps1 @@ -11,8 +11,11 @@ # (which breaks the net48 worker build with CS0246 — see docs/Contracts.md). # 3. The Rust crate's vendored protos (clients/rust/protos/*.proto — build inputs that make the # crate buildable outside the repo, CLI-02) are byte-identical to the canonical Contracts -# protos. A drift means a .proto was edited without refreshing the vendored copies, which would -# publish a stale wire contract to crate consumers while the in-repo build stays correct. +# protos, and the two directories hold the same set of files. A drift means a .proto was edited +# without refreshing the vendored copies, which would publish a stale wire contract to crate +# consumers while the in-repo build stays correct. The sweep runs both directions: a canonical +# proto with no vendored copy is just as broken (the published crate cannot build standalone), +# and it is invisible in-repo because build.rs prefers the canonical directory when it exists. # 4. The committed Go and Python client bindings match a fresh regeneration (IPC-25). The two # per-client generate-proto.ps1 scripts pin their generators (protoc-gen-go v1.36.11 / # protoc-gen-go-grpc v1.6.2 for Go; grpcio-tools 1.80.0 for Python), so a clean checkout @@ -88,6 +91,16 @@ try { $failures.Add("Rust vendored proto drifted from canonical: clients/rust/protos/$($vendored.Name). Refresh it from src/ZB.MOM.WW.MxGateway.Contracts/Protos/$($vendored.Name).") } } + + # Reverse direction: a canonical proto that was never vendored passes the loop above (it only + # walks the vendored dir) but breaks a standalone crate build, because build.rs falls back to + # clients/rust/protos/ only outside the repo — in-repo it reads the canonical dir and stays green. + foreach ($canonicalProto in Get-ChildItem -Path $canonicalProtoDir -Filter '*.proto' -File) { + $vendoredCounterpart = Join-Path $vendoredProtoDir $canonicalProto.Name + if (-not (Test-Path $vendoredCounterpart)) { + $failures.Add("Canonical proto is not vendored for the Rust crate: $($canonicalProto.Name). Copy src/ZB.MOM.WW.MxGateway.Contracts/Protos/$($canonicalProto.Name) to clients/rust/protos/$($canonicalProto.Name) (and add it to build.rs's input list).") + } + } } catch { $failures.Add("Rust vendored proto check failed: $($_.Exception.Message)") From 222b01f4883a2612a94f8f14c00fcba9e0fbbc64 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 17 Aug 2026 07:07:09 -0400 Subject: [PATCH 03/26] =?UTF-8?q?chore(plan):=20Task=204=20complete=20?= =?UTF-8?q?=E2=80=94=20Check=203=20reverse=20sweep=20approved?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/2026-08-17-followup-closeout.md.tasks.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-08-17-followup-closeout.md.tasks.json b/docs/plans/2026-08-17-followup-closeout.md.tasks.json index e277eb8..4d536c3 100644 --- a/docs/plans/2026-08-17-followup-closeout.md.tasks.json +++ b/docs/plans/2026-08-17-followup-closeout.md.tasks.json @@ -4,7 +4,7 @@ {"id": 1, "subject": "Task 1: Feed-level alarm-truncation signal (proto + gateway)", "status": "pending"}, {"id": 2, "subject": "Task 2: Five-client rollout of snapshot_status", "status": "pending", "blockedBy": [1, 3, 4]}, {"id": 3, "subject": "Task 3: Complete ShowTagValues coverage — alarms hub + /browse", "status": "pending"}, - {"id": 4, "subject": "Task 4: Codegen guard — reverse-direction Check 3 + stale-note correction", "status": "pending"}, + {"id": 4, "subject": "Task 4: Codegen guard — reverse-direction Check 3 + stale-note correction", "status": "completed", "commit": "711793f", "review": "Approved (code review, no issues)"}, {"id": 5, "subject": "Task 5: Settings page — GroupToTag + UntaggedSessionVisibility rows", "status": "pending"}, {"id": 6, "subject": "Task 6: ApiKeysPage — DashboardTags in ConstraintText + create-form input", "status": "pending"}, {"id": 7, "subject": "Task 7: Wnwrap alarm-probe retry — secured-write path (windev)", "status": "pending"}, From eff17d177c395da120956bd8643063af64c00755 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 17 Aug 2026 07:14:40 -0400 Subject: [PATCH 04/26] fix(dashboard): ShowTagValues now gates the alarms hub and /browse live values --- archreview/2026-07-12/60-testing-docs-gaps.md | 4 +- archreview/remediation/00-tracking.md | 3 +- docs/GatewayConfiguration.md | 2 +- docs/GatewayDashboardDesign.md | 17 +- .../Dashboard/DashboardLiveDataService.cs | 29 ++ .../Dashboard/DashboardTagValue.cs | 11 + .../Dashboard/Hubs/AlarmsHubPublisher.cs | 60 ++- .../Dashboard/AlarmsHubPublisherTests.cs | 343 ++++++++++++++++++ .../DashboardLiveDataServiceTests.cs | 78 +++- 9 files changed, 532 insertions(+), 15 deletions(-) create mode 100644 src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/AlarmsHubPublisherTests.cs diff --git a/archreview/2026-07-12/60-testing-docs-gaps.md b/archreview/2026-07-12/60-testing-docs-gaps.md index cd17c44..b4ee87a 100644 --- a/archreview/2026-07-12/60-testing-docs-gaps.md +++ b/archreview/2026-07-12/60-testing-docs-gaps.md @@ -25,7 +25,7 @@ | TST-10 | Not started | **Confirmed open** | No `docs/Deployment.md` (`ls docs/` — no deploy doc); no deploy script under `scripts/` | Deploy knowledge still lives only in operator memory notes. | | TST-14 | Not started | **Confirmed open** | Repo root still holds `MxAccessGateway-docs-{issues,fixed,final}.md`, `MxGatewayClient-docs-{issues,fixed}.md`, `oldtasks.md`, `stillpending.md` (root `ls`) | `oldtasks.md` did gain a legitimate new role as the epic-governance record (TST-04), so "delete when epic closes" needs re-deciding; the five untracked `*-docs-*.md` files remain pure clutter. | | TST-15 | Not started (design done) | **Confirmed open** | `Server/Dashboard/Hubs/EventsHub.cs:42` — `TODO(per-session-acl)` still present | Design now exists: `docs/plans/2026-07-10-dashboard-session-acl-tst15.md`. Mitigation shipped meanwhile: SEC-25 value redaction in the hub mirror (see TST-27). | -| TST-16 | Not started | **Partially resolved — see TST-27** | `Server/Dashboard/Hubs/DashboardEventBroadcaster.cs:16,29` — `ShowTagValues` now gates value redaction of the SignalR mirror (SEC-25) | The flag is no longer fully dead, but it still does not gate `/browse` live-value display, and the config doc still calls it "Reserved" — fresh drift (TST-27). | +| TST-16 | Not started | **Partially resolved — see TST-27** | `Server/Dashboard/Hubs/DashboardEventBroadcaster.cs:16,29` — `ShowTagValues` now gates value redaction of the SignalR mirror (SEC-25) | The flag is no longer fully dead, but it still does not gate `/browse` live-value display, and the config doc still calls it "Reserved" — fresh drift (TST-27). **Closed 2026-08-17** (branch `feat/followup-closeout`): both residuals shipped — `AlarmsHubPublisher` redacts `current_value`/`limit_value` from a deep clone on both value-bearing `AlarmFeedMessage` arms before the `/hubs/alarms` broadcast, and `DashboardLiveDataService` substitutes `[redacted]` for `/browse` value text at the service boundary (the recommended seam), so the flag now gates every dashboard value surface. Source never mutated on either hub, so gRPC subscribers are unaffected. Tests: `AlarmsHubPublisherTests`, two `DashboardLiveDataServiceTests` cases. TST-16 → `Done` in [../remediation/00-tracking.md](../remediation/00-tracking.md#change-log). | | TST-17 | Not started | **Confirmed open** | `Worker/MxAccess/WnWrapAlarmConsumer.cs:~264-275` — `_ = ackOperatorDomain; _ = ackOperatorFullName;` then 6-arg call; no diagnostic surfaced in the ack reply | Silent drop unchanged (comment block explains the −55 stub, but callers still can't see the degrade). | | TST-18 | Not started | **Confirmed open** | No `*HostedService*` test files under `Tests/Gateway/Sessions/` or `Tests/Gateway/Workers/` | Sweep cores remain covered indirectly (`SessionManagerTests.cs:985,1015` now also cover faulted-reap). | | TST-19 | Not started | **Confirmed open** | `Tests/Gateway/Workers/Fakes/FakeWorkerHarness.cs` — no lockstep note / `WorkerPipeSession` pointer (grep empty); no README in `Fakes/` | Risk is higher now that the real-worker suite has no scheduled run (TST-25). | @@ -53,7 +53,7 @@ ### TST-27 — `ShowTagValues` config doc still says "Reserved" after SEC-25 made the flag live — **Medium** · documentation currency - **Files:** `docs/GatewayConfiguration.md:185` ("Reserved display control for tag values"); `src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardEventBroadcaster.cs:16-29` (flag read and enforced); `docs/GatewayDashboardDesign.md:170` (current, documents the redaction) -- **Description:** SEC-25 wired `Dashboard:ShowTagValues` into `DashboardEventBroadcaster`: when `false` (default), tag values are blanked from the deep-cloned event before it is mirrored to SignalR. The flag is therefore no longer dead — but the authoritative config reference still labels it "Reserved", so an operator consulting the options table concludes toggling it does nothing, when it actually controls whether tag values leak to every dashboard hub subscriber. This is remediation-created drift: the dashboard design doc was updated in the same change, the config doc was not. (Residual of TST-16 remains separately: the flag still gates nothing in the `/browse` live-value path.) +- **Description:** SEC-25 wired `Dashboard:ShowTagValues` into `DashboardEventBroadcaster`: when `false` (default), tag values are blanked from the deep-cloned event before it is mirrored to SignalR. The flag is therefore no longer dead — but the authoritative config reference still labels it "Reserved", so an operator consulting the options table concludes toggling it does nothing, when it actually controls whether tag values leak to every dashboard hub subscriber. This is remediation-created drift: the dashboard design doc was updated in the same change, the config doc was not. (Residual of TST-16 remains separately: the flag still gates nothing in the `/browse` live-value path.) **Closed 2026-08-17** — the config row was rewritten again when TST-16's residuals shipped: it now states the three seams the flag covers (events-hub mirror, alarms hub, `/browse`), so neither the "Reserved" wording nor the `/browse` carve-out survives. See the TST-16 row above. - **Recommendation:** Update the `:185` row to describe the mirror-redaction behavior (and its security relevance given the missing per-session ACL, TST-15); note the `/browse` gap or close it when TST-16 is decided. ### TST-28 — Gateway-side `max_frame_bytes` handshake field has no test in the portable suite — **Low** · test coverage diff --git a/archreview/remediation/00-tracking.md b/archreview/remediation/00-tracking.md index 1bbeec2..258549d 100644 --- a/archreview/remediation/00-tracking.md +++ b/archreview/remediation/00-tracking.md @@ -228,7 +228,7 @@ Full design + implementation for each row lives in the linked domain doc under i | 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 | Done | Dashboard EventsHub has no per-session ACL | -| TST-16 | Medium | — | S | — | Not started | `Dashboard:ShowTagValues` is a dead flag | +| TST-16 | Medium | P1 | S | — | Done | `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 | | TST-19 | Low | — | S | — | Not started | Keep FakeWorkerHarness canned replies in lockstep | @@ -253,6 +253,7 @@ Findings the review flagged as one coordinated design pass — sequence them tog | Date | Change | |---|---| +| 2026-08-17 | **TST-16 → `Done`: `Dashboard:ShowTagValues` now gates every dashboard value surface** (branch `feat/followup-closeout`). The flag was live on one seam only (the events-hub mirror, shipped with SEC-25); the two recorded residuals are closed, so it is no longer a partly-dead flag. **Alarms hub:** `AlarmsHubPublisher` took `IOptions` and now clears `current_value`/`limit_value` from a **deep clone** of each `AlarmFeedMessage` on both value-bearing payload arms (`transition`, `active_alarm`) before the `/hubs/alarms` broadcast. Clone-only, matching `DashboardEventBroadcaster.RedactValues`: the source message fans out from one feed to gRPC `StreamAlarms` subscribers and the alarms page, none of which this dashboard-display flag governs. The redaction switches on the value-bearing arms and passes everything else through untouched and uncloned, so a payload arm added later (the `snapshot_status` arm landing alongside this change) is forwarded unbroken rather than silently dropped. **`/browse`:** the gate lives at the service boundary per TST-16's own recommendation — `DashboardLiveDataService` substitutes `DashboardTagValue.RedactedValueText` (`[redacted]`) for the formatted value text, so the value never enters the render tree and `BrowsePage.razor` needed no change; data type, quality, source timestamp, and read errors stay real, keeping the panel diagnostic. A visible marker, not a blank, so a suppressed value is distinguishable from a tag that read back empty. Tests: new `AlarmsHubPublisherTests` (4 — both arms redacted with metadata intact, source not mutated, `true` passes values through as the same instance, valueless arms forwarded as the same instance) and two `DashboardLiveDataServiceTests` cases (`false` → `[redacted]`, `true` → formatted); `DashboardEventBroadcasterTests` re-run as regression. Docs: `docs/GatewayConfiguration.md`'s `ShowTagValues` row rewritten from "gates only the events hub mirror, two still-open residuals" to the three seams it now covers, with the "no gRPC client is affected" scope statement; `docs/GatewayDashboardDesign.md` gained the redaction note on the `AlarmsHub` row and had its Browse-panel paragraph corrected (it claimed the panel "always shows values regardless of `Dashboard:ShowTagValues`"). | | 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. | diff --git a/docs/GatewayConfiguration.md b/docs/GatewayConfiguration.md index fc9a94f..ffc1c52 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 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:ShowTagValues` | `false` | Controls whether tag values reach the dashboard at all. `false` (the default) suppresses them on all three seams that carry one: (1) the **events hub mirror** — `DashboardEventBroadcaster` blanks `MxEvent.value` plus the alarm body's `current_value`/`limit_value` from a deep-cloned copy before it reaches any hub subscriber (see `docs/GatewayDashboardDesign.md`'s `EventsHub` row); (2) the **alarms hub** — `AlarmsHubPublisher` clears `current_value`/`limit_value` from a deep-cloned copy of each `AlarmFeedMessage`, on both value-bearing payload arms (`transition` and `active_alarm`), before broadcasting to `/hubs/alarms`; (3) the **`/browse` live-value panel** — `DashboardLiveDataService` substitutes the literal `[redacted]` for each tag's value text. Both hub redactions clone: the source `MxEvent` is shared with the gRPC event stream and the replay ring, and the source `AlarmFeedMessage` fans out to gRPC `StreamAlarms` subscribers, so neither is mutated in place and **no gRPC client is affected by this flag** — it is a dashboard-display control only. Everything that is not the value survives on every seam: tag reference, alarm reference/severity/state/operator, data type, quality, status, and timestamps still render, so the dashboard stays diagnostic without disclosing process values. This is one of two independent layers: `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` exposes tag values to everyone the ACL admits — every Administrator, plus each Viewer holding a matching tag — and, because the alarms hub is session-less, exposes alarm values to every dashboard client that can reach `/hubs/alarms`. | | `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/GatewayDashboardDesign.md b/docs/GatewayDashboardDesign.md index 572d70a..f121526 100644 --- a/docs/GatewayDashboardDesign.md +++ b/docs/GatewayDashboardDesign.md @@ -273,7 +273,7 @@ bearer). Each hub class is `[Authorize(Policy = HubClientsPolicy)]`. | Hub | Path | Producer | Payload | Routing | |---|---|---|---|---| | `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. | +| `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. The alarm value fields (`current_value` / `limit_value`) are stripped from a deep-cloned copy of the message when `Dashboard:ShowTagValues` is false (the default) — on both value-bearing payload arms, so neither a live transition nor a snapshot record leaks a process value; every other field still renders. The source message is never mutated: it fans out from the same feed to gRPC `StreamAlarms` subscribers, which this dashboard-display flag does not govern. Arms carrying no value are forwarded as-is, uncloned. | | `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 @@ -517,12 +517,15 @@ tree to a flat list of matching attributes. Right-clicking an attribute (or double-clicking it) adds it to the subscription panel. The panel shows each subscribed tag's live value, MXAccess data type, -quality and source timestamp, refreshed every two seconds. The subscription -panel is the explicit opt-in tag-value surface: it always shows values -regardless of `Dashboard:ShowTagValues`, which governs the diagnostic -session/worker views and the per-session event mirror — both its hub and -in-process audiences (values are redacted from the mirrored events when the flag -is false). +quality and source timestamp, refreshed every two seconds — but the value column +obeys `Dashboard:ShowTagValues` like every other dashboard value surface. With +the flag false (the default) `DashboardLiveDataService` hands the page +`[redacted]` in place of the formatted value; data type, quality, source +timestamp and any read error are untouched, so the panel still answers "is this +tag advising and healthy" without disclosing the value. The substitution happens +at the service, not in the page: one decision point, and the value never enters +the render tree. Set the flag true to make the panel the explicit opt-in +tag-value surface it was originally designed as. ### Alarms page diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardLiveDataService.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardLiveDataService.cs index 6e7e2c2..d5bea3c 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardLiveDataService.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardLiveDataService.cs @@ -1,5 +1,7 @@ +using Microsoft.Extensions.Options; 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.Sessions; namespace ZB.MOM.WW.MxGateway.Server.Dashboard; @@ -11,6 +13,16 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard; /// expires. All access is serialised through so the /// single backing worker only ever sees one in-flight command. /// +/// +/// This service is also where MxGateway:Dashboard:ShowTagValues is +/// applied to the Browse panel: with the flag false (the default) the +/// formatted value never leaves this boundary — the projected +/// carries +/// instead. Putting the +/// decision at the service rather than in the page keeps it to one place and +/// keeps a value out of the render tree entirely, rather than relying on +/// every current and future view to remember to suppress it. +/// public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsyncDisposable { private const string BackendName = "Galaxy"; @@ -38,6 +50,15 @@ public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsync private readonly ILogger _logger; private readonly SemaphoreSlim _gate = new(1, 1); + /// + /// MxGateway:Dashboard:ShowTagValues. False (the default) + /// substitutes for every + /// value this service hands the Browse panel; quality, data type, source + /// timestamp, and any error still describe the real read, so the panel + /// remains a diagnostic surface without being a value-disclosure one. + /// + private readonly bool _showTagValues; + // Least-recently-read-last advise set: the list holds every currently advised // tag ordered most- to least-recently read, the dictionary indexes into it. // Both are only ever touched under _gate, which already serialises all viewers. @@ -53,15 +74,19 @@ public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsync /// Initializes the live-data service. /// Gateway session manager. /// Gateway central alarm service. + /// Gateway options supplying Dashboard:ShowTagValues. /// Diagnostic logger. public DashboardLiveDataService( ISessionManager sessionManager, IGatewayAlarmService alarmService, + IOptions options, ILogger logger) { + ArgumentNullException.ThrowIfNull(options); _sessionManager = sessionManager ?? throw new ArgumentNullException(nameof(sessionManager)); _alarmService = alarmService ?? throw new ArgumentNullException(nameof(alarmService)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _showTagValues = options.Value.Dashboard.ShowTagValues; } /// @@ -96,8 +121,12 @@ public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsync .ReadBulkAsync(serverHandle, tagAddresses.ToArray(), ReadTimeout, cancellationToken) .ConfigureAwait(false); + // The only place the /browse live-value gate is evaluated: the page + // renders whatever ValueText it is handed, so a second check in the + // view could only ever disagree with this one. DashboardTagValue[] values = results .Select(DashboardTagValue.FromBulkReadResult) + .Select(value => _showTagValues ? value : value with { ValueText = DashboardTagValue.RedactedValueText }) .ToArray(); return new DashboardLiveReadResult(values, null, session.SessionId, session.WorkerProcessId); } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardTagValue.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardTagValue.cs index 343fe05..9718b34 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardTagValue.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardTagValue.cs @@ -17,6 +17,17 @@ public sealed record DashboardTagValue( DateTimeOffset? SourceTimestamp, string? Error) { + /// + /// Placeholder rendered in place of when + /// MxGateway:Dashboard:ShowTagValues is false (the default). The + /// substitution happens once, in DashboardLiveDataService, so the + /// Browse page renders whatever it is handed and no view has to repeat + /// the decision. Deliberately a visible marker rather than an empty + /// string: an operator must be able to tell a suppressed value from a + /// tag that read back blank. + /// + public const string RedactedValueText = "[redacted]"; + /// /// Classic OPC-DA "Good" quality. MXAccess surfaces 192 for a healthy /// advised value; anything lower is uncertain or bad. diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/AlarmsHubPublisher.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/AlarmsHubPublisher.cs index 312d934..7277985 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/AlarmsHubPublisher.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/AlarmsHubPublisher.cs @@ -1,6 +1,8 @@ using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Options; using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Server.Alarms; +using ZB.MOM.WW.MxGateway.Server.Configuration; namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs; @@ -11,11 +13,28 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs; /// client. The hub itself is session-less; clients filter / route messages /// in the browser. /// +/// +/// When MxGateway:Dashboard:ShowTagValues is false (the default), the +/// alarm value fields are stripped from a redacted copy before the message +/// reaches any browser client — the same rule +/// applies to the events-hub mirror, so +/// the two SignalR seams cannot disagree about whether values leave the gateway. +/// The source message is never mutated: it fans out from one feed to the gRPC +/// StreamAlarms subscribers and the alarms page as well, and none of +/// those audiences is subject to this dashboard-display flag. +/// +/// The gateway's central alarm feed. +/// Hub context used to broadcast to the alarms group. +/// Gateway options supplying Dashboard:ShowTagValues. +/// Logger for best-effort broadcast failures. public sealed class AlarmsHubPublisher( IGatewayAlarmService alarmService, IHubContext hubContext, + IOptions options, ILogger logger) : BackgroundService { + private readonly bool _showTagValues = options.Value.Dashboard.ShowTagValues; + /// protected override async Task ExecuteAsync(CancellationToken stoppingToken) { @@ -37,9 +56,10 @@ public sealed class AlarmsHubPublisher( try { + AlarmFeedMessage outbound = _showTagValues ? message : RedactValues(message); await hubContext.Clients .Group(AlarmsHub.AllAlarmsGroup) - .SendAsync(AlarmsHub.AlarmMessage, message, stoppingToken) + .SendAsync(AlarmsHub.AlarmMessage, outbound, stoppingToken) .ConfigureAwait(false); } catch (Exception ex) when (ex is not OperationCanceledException) @@ -66,4 +86,42 @@ public sealed class AlarmsHubPublisher( } } } + + /// + /// Produces a copy of with the alarm value fields + /// cleared, leaving every other field — reference, severity, state, category, + /// operator, timestamps — intact so the alarms page still renders the row. + /// + /// The message as the alarm feed produced it. + /// + /// A redacted deep clone for the two value-bearing payload arms; the source + /// instance itself for every other arm, which carries no value to strip. + /// New payload arms therefore pass through unchanged by default — the switch + /// names only the arms that have something to redact, so adding a valueless + /// arm to the contract needs no change here. + /// + private static AlarmFeedMessage RedactValues(AlarmFeedMessage source) + { + switch (source.PayloadCase) + { + case AlarmFeedMessage.PayloadOneofCase.Transition: + { + AlarmFeedMessage redacted = source.Clone(); + redacted.Transition.CurrentValue = null; + redacted.Transition.LimitValue = null; + return redacted; + } + + case AlarmFeedMessage.PayloadOneofCase.ActiveAlarm: + { + AlarmFeedMessage redacted = source.Clone(); + redacted.ActiveAlarm.CurrentValue = null; + redacted.ActiveAlarm.LimitValue = null; + return redacted; + } + + default: + return source; + } + } } diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/AlarmsHubPublisherTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/AlarmsHubPublisherTests.cs new file mode 100644 index 0000000..c5f0394 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/AlarmsHubPublisherTests.cs @@ -0,0 +1,343 @@ +using System.Runtime.CompilerServices; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +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.Dashboard.Hubs; + +namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard; + +/// +/// Verifies that honours +/// MxGateway:Dashboard:ShowTagValues (TST-16): the alarm value fields of +/// both value-bearing payload arms are stripped from the copy broadcast to +/// browser clients when the flag is off, present when it is on, and the source +/// — shared with the gRPC StreamAlarms +/// subscribers and the alarms page — is never mutated. +/// +public sealed class AlarmsHubPublisherTests +{ + private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5); + + /// Both value-bearing arms lose their values when the flag is off; metadata survives. + /// A task that represents the asynchronous operation. + [Fact] + public async Task ExecuteAsync_WhenShowTagValuesFalse_RedactsBothArmsButKeepsMetadata() + { + AlarmFeedMessage transition = BuildTransition(); + AlarmFeedMessage activeAlarm = BuildActiveAlarm(); + CapturingHubContext hubContext = await RunPublisherAsync( + showTagValues: false, + transition, + activeAlarm); + + Assert.Equal(2, hubContext.Sent.Count); + + AlarmFeedMessage sentTransition = hubContext.Sent[0]; + Assert.Null(sentTransition.Transition.CurrentValue); + Assert.Null(sentTransition.Transition.LimitValue); + Assert.Equal("Tank01.Level.HiHi", sentTransition.Transition.AlarmFullReference); + Assert.Equal("Tank01", sentTransition.Transition.SourceObjectReference); + Assert.Equal(AlarmTransitionKind.Raise, sentTransition.Transition.TransitionKind); + Assert.Equal(800, sentTransition.Transition.Severity); + Assert.Equal("Process", sentTransition.Transition.Category); + + AlarmFeedMessage sentActive = hubContext.Sent[1]; + Assert.Null(sentActive.ActiveAlarm.CurrentValue); + Assert.Null(sentActive.ActiveAlarm.LimitValue); + Assert.Equal("Tank02.Level.Lo", sentActive.ActiveAlarm.AlarmFullReference); + Assert.Equal(AlarmConditionState.Active, sentActive.ActiveAlarm.CurrentState); + Assert.Equal(500, sentActive.ActiveAlarm.Severity); + } + + /// + /// Redaction applies to a clone: the source message fans out to gRPC + /// StreamAlarms subscribers and the alarms page, so it must keep its + /// values. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task ExecuteAsync_WhenShowTagValuesFalse_DoesNotMutateSourceMessage() + { + AlarmFeedMessage transition = BuildTransition(); + AlarmFeedMessage activeAlarm = BuildActiveAlarm(); + CapturingHubContext hubContext = await RunPublisherAsync( + showTagValues: false, + transition, + activeAlarm); + + Assert.NotNull(transition.Transition.CurrentValue); + Assert.Equal(88.0, transition.Transition.CurrentValue.DoubleValue); + Assert.NotNull(transition.Transition.LimitValue); + Assert.NotNull(activeAlarm.ActiveAlarm.CurrentValue); + Assert.Equal(12.5, activeAlarm.ActiveAlarm.CurrentValue.DoubleValue); + Assert.NotNull(activeAlarm.ActiveAlarm.LimitValue); + + Assert.NotSame(transition, hubContext.Sent[0]); + Assert.NotSame(activeAlarm, hubContext.Sent[1]); + } + + /// Values pass through unredacted — and uncloned — when the flag is on. + /// A task that represents the asynchronous operation. + [Fact] + public async Task ExecuteAsync_WhenShowTagValuesTrue_KeepsValues() + { + AlarmFeedMessage transition = BuildTransition(); + AlarmFeedMessage activeAlarm = BuildActiveAlarm(); + CapturingHubContext hubContext = await RunPublisherAsync( + showTagValues: true, + transition, + activeAlarm); + + Assert.Same(transition, hubContext.Sent[0]); + Assert.Same(activeAlarm, hubContext.Sent[1]); + Assert.Equal(88.0, hubContext.Sent[0].Transition.CurrentValue.DoubleValue); + Assert.Equal(90.0, hubContext.Sent[0].Transition.LimitValue.DoubleValue); + Assert.Equal(12.5, hubContext.Sent[1].ActiveAlarm.CurrentValue.DoubleValue); + Assert.Equal(10.0, hubContext.Sent[1].ActiveAlarm.LimitValue.DoubleValue); + } + + /// + /// A payload arm that carries no value is forwarded as-is — the same + /// instance, no clone. This is also the contract for arms added later: the + /// switch names only the value-bearing arms, so a new arm passes through. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task ExecuteAsync_WithValuelessPayloads_ForwardsThemUntouched() + { + AlarmFeedMessage snapshotComplete = new() { SnapshotComplete = true }; + AlarmFeedMessage providerStatus = new() + { + ProviderStatus = new AlarmProviderStatus + { + Mode = AlarmProviderMode.Subtag, + Degraded = true, + Reason = "alarmmgr unavailable", + }, + }; + + CapturingHubContext hubContext = await RunPublisherAsync( + showTagValues: false, + snapshotComplete, + providerStatus); + + Assert.Same(snapshotComplete, hubContext.Sent[0]); + Assert.Same(providerStatus, hubContext.Sent[1]); + } + + /// + /// Starts the publisher over a scripted feed, waits until every scripted + /// message has been broadcast, and stops it. + /// + /// The Dashboard:ShowTagValues value under test. + /// The messages the fake alarm feed yields, in order. + /// The hub context holding everything the publisher sent. + private static async Task RunPublisherAsync( + bool showTagValues, + params AlarmFeedMessage[] messages) + { + ScriptedAlarmService alarmService = new(messages); + CapturingHubContext hubContext = new(); + GatewayOptions gatewayOptions = new() + { + Dashboard = new DashboardOptions { ShowTagValues = showTagValues }, + }; + + AlarmsHubPublisher publisher = new( + alarmService, + hubContext, + Options.Create(gatewayOptions), + NullLogger.Instance); + + using CancellationTokenSource cts = new(); + await publisher.StartAsync(cts.Token).WaitAsync(TestTimeout); + await WaitUntilAsync(() => hubContext.Sent.Count >= messages.Length); + await cts.CancelAsync(); + await publisher.StopAsync(CancellationToken.None); + + return hubContext; + } + + private static async Task WaitUntilAsync(Func predicate) + { + using CancellationTokenSource cancellationTokenSource = new(TestTimeout); + while (!predicate()) + { + await Task.Delay(TimeSpan.FromMilliseconds(5), cancellationTokenSource.Token); + } + } + + /// Builds a value-bearing transition feed message. + /// The message. + private static AlarmFeedMessage BuildTransition() + { + return new AlarmFeedMessage + { + Transition = new OnAlarmTransitionEvent + { + AlarmFullReference = "Tank01.Level.HiHi", + SourceObjectReference = "Tank01", + AlarmTypeName = "AnalogLimitAlarm.HiHi", + TransitionKind = AlarmTransitionKind.Raise, + Severity = 800, + Category = "Process", + Description = "Level high-high", + CurrentValue = new MxValue { DataType = MxDataType.Double, DoubleValue = 88.0 }, + LimitValue = new MxValue { DataType = MxDataType.Double, DoubleValue = 90.0 }, + }, + }; + } + + /// Builds a value-bearing active_alarm feed message. + /// The message. + private static AlarmFeedMessage BuildActiveAlarm() + { + return new AlarmFeedMessage + { + ActiveAlarm = new ActiveAlarmSnapshot + { + AlarmFullReference = "Tank02.Level.Lo", + SourceObjectReference = "Tank02", + AlarmTypeName = "AnalogLimitAlarm.Lo", + CurrentState = AlarmConditionState.Active, + Severity = 500, + Category = "Process", + Description = "Level low", + CurrentValue = new MxValue { DataType = MxDataType.Double, DoubleValue = 12.5 }, + LimitValue = new MxValue { DataType = MxDataType.Double, DoubleValue = 10.0 }, + }, + }; + } + + /// + /// Yields a scripted message list once and then stays open until cancelled, + /// so the publisher's reconnect loop never re-subscribes mid-test. + /// + /// The messages to yield, in order. + private sealed class ScriptedAlarmService(IReadOnlyList messages) : IGatewayAlarmService + { + /// + public GatewayAlarmMonitorState State => GatewayAlarmMonitorState.Monitoring; + + /// + public string? LastError => null; + + /// + public int? WorkerProcessId => null; + + /// + public IReadOnlyList CurrentAlarms => []; + + /// + public bool SnapshotTruncated => false; + + /// + public async IAsyncEnumerable StreamAsync( + string? alarmFilterPrefix, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + foreach (AlarmFeedMessage message in messages) + { + yield return message; + } + + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + } + + /// + public Task AcknowledgeAsync( + AcknowledgeAlarmRequest request, + CancellationToken cancellationToken) => throw new NotSupportedException(); + } + + private sealed class CapturingHubContext : IHubContext + { + private readonly CapturingHubClients _clients = new(); + + /// Gets the hub clients. + public IHubClients Clients => _clients; + + /// Gets the group manager. + public IGroupManager Groups { get; } = new NoopGroupManager(); + + /// Gets every message the publisher broadcast, in order. + public IReadOnlyList Sent => _clients.GroupProxy.Sent; + } + + private sealed class CapturingHubClients : IHubClients + { + /// Gets the capturing client proxy shared by this fake. + public CapturingClientProxy GroupProxy { get; } = new(); + + public IClientProxy All => GroupProxy; + + public IClientProxy AllExcept(IReadOnlyList excludedConnectionIds) => GroupProxy; + + public IClientProxy Client(string connectionId) => GroupProxy; + + public IClientProxy Clients(IReadOnlyList connectionIds) => GroupProxy; + + public IClientProxy Group(string groupName) => GroupProxy; + + public IClientProxy GroupExcept(string groupName, IReadOnlyList excludedConnectionIds) => GroupProxy; + + public IClientProxy Groups(IReadOnlyList groupNames) => GroupProxy; + + public IClientProxy User(string userId) => GroupProxy; + + public IClientProxy Users(IReadOnlyList userIds) => GroupProxy; + } + + private sealed class CapturingClientProxy : IClientProxy + { + private readonly List _sent = []; + + /// Gets every alarm message sent through this proxy, in order. + public IReadOnlyList Sent + { + get + { + lock (_sent) + { + return [.. _sent]; + } + } + } + + /// Records the broadcast message and completes synchronously. + /// The SignalR method name. + /// The method arguments. + /// Token to observe for cancellation. + /// A completed task. + public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) + { + if (args.Length > 0 && args[0] is AlarmFeedMessage message) + { + lock (_sent) + { + _sent.Add(message); + } + } + + return Task.CompletedTask; + } + } + + private sealed class NoopGroupManager : IGroupManager + { + public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public Task RemoveFromGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) + => Task.CompletedTask; + } +} 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 d03b4d9..6764124 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardLiveDataServiceTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardLiveDataServiceTests.cs @@ -1,7 +1,9 @@ using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using Microsoft.Extensions.Logging.Abstractions; +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; using ZB.MOM.WW.MxGateway.Server.Workers; @@ -186,11 +188,68 @@ public sealed class DashboardLiveDataServiceTests Assert.Equal(filler[0], worker.SubscribedTags[^1]); } - private static DashboardLiveDataService CreateService(ISessionManager sessionManager) + /// + /// Verifies the /browse live-value seam honours + /// MxGateway:Dashboard:ShowTagValues (TST-16): with the flag off — the + /// default — the value text the page renders is the redaction placeholder, + /// never the formatted tag value. + /// + [Fact] + public async Task ReadAsync_WhenShowTagValuesFalse_RedactsValueTextButKeepsMetadata() { + RecordingWorkerClient worker = new() + { + ReadValue = new MxValue { DataType = MxDataType.Double, DoubleValue = 42.5 }, + }; + await using FakeSessionManager sessionManager = new(worker); + await using DashboardLiveDataService service = CreateService(sessionManager, showTagValues: false); + + DashboardLiveReadResult result = await service.ReadAsync(["Tank_001.PV"], CancellationToken.None); + + DashboardTagValue value = Assert.Single(result.Values); + Assert.Equal(DashboardTagValue.RedactedValueText, value.ValueText); + Assert.DoesNotContain("42.5", value.ValueText, StringComparison.Ordinal); + + // Everything that is not the value still renders: the panel stays useful. + Assert.Equal("Tank_001.PV", value.TagAddress); + Assert.True(value.Ok); + Assert.Equal("Double", value.DataType); + Assert.Equal(192, value.Quality); + Assert.True(value.QualityGood); + Assert.Null(value.Error); + } + + /// Verifies the formatted value is served when the flag is on. + [Fact] + public async Task ReadAsync_WhenShowTagValuesTrue_ServesFormattedValue() + { + RecordingWorkerClient worker = new() + { + ReadValue = new MxValue { DataType = MxDataType.Double, DoubleValue = 42.5 }, + }; + await using FakeSessionManager sessionManager = new(worker); + await using DashboardLiveDataService service = CreateService(sessionManager, showTagValues: true); + + DashboardLiveReadResult result = await service.ReadAsync(["Tank_001.PV"], CancellationToken.None); + + DashboardTagValue value = Assert.Single(result.Values); + Assert.Equal("42.5", value.ValueText); + Assert.Equal("Double", value.DataType); + } + + private static DashboardLiveDataService CreateService( + ISessionManager sessionManager, + bool showTagValues = false) + { + GatewayOptions gatewayOptions = new() + { + Dashboard = new DashboardOptions { ShowTagValues = showTagValues }, + }; + return new DashboardLiveDataService( sessionManager, new FakeGatewayAlarmService(), + Options.Create(gatewayOptions), NullLogger.Instance); } @@ -322,6 +381,12 @@ public sealed class DashboardLiveDataServiceTests /// Gets or sets a value indicating whether unsubscribe commands throw. public bool FailUnsubscribe { get; set; } + /// + /// Gets or sets the value every bulk read returns. Null (the default) leaves + /// the read results value-less, which is all the advise-set tests need. + /// + public MxValue? ReadValue { get; set; } + /// Gets the item handle bound for a previously subscribed tag. /// Tag address to look up. /// The bound item handle. @@ -434,14 +499,21 @@ public sealed class DashboardLiveDataServiceTests BulkReadReply readReply = new(); foreach (string tagAddress in tagAddresses) { - readReply.Results.Add(new BulkReadResult + BulkReadResult readResult = new() { ServerHandle = RegisteredServerHandle, TagAddress = tagAddress, ItemHandle = _itemHandles.TryGetValue(tagAddress, out int itemHandle) ? itemHandle : 0, WasSuccessful = true, Quality = 192, - }); + }; + + if (ReadValue is not null) + { + readResult.Value = ReadValue.Clone(); + } + + readReply.Results.Add(readResult); } return readReply; From 094f2ffee4d8cc3a4c776c17276cf01ac38e9897 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 17 Aug 2026 07:15:24 -0400 Subject: [PATCH 05/26] feat(dashboard): settings page shows GroupToTag and UntaggedSessionVisibility --- .../EffectiveDashboardConfiguration.cs | 4 +- .../GatewayConfigurationProvider.cs | 10 +- .../Components/Pages/SettingsPage.razor | 22 +++ .../GatewayConfigurationProviderTests.cs | 164 ++++++++++++++++++ .../SettingsPageTagVisibilityRenderTests.cs | 136 +++++++++++++++ 5 files changed, 334 insertions(+), 2 deletions(-) create mode 100644 src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayConfigurationProviderTests.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Tests/Dashboard/SettingsPageTagVisibilityRenderTests.cs diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/EffectiveDashboardConfiguration.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/EffectiveDashboardConfiguration.cs index 9db3a8b..7e9de5f 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/EffectiveDashboardConfiguration.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/EffectiveDashboardConfiguration.cs @@ -7,4 +7,6 @@ public sealed record EffectiveDashboardConfiguration( int RecentFaultLimit, int RecentSessionLimit, bool ShowTagValues, - IReadOnlyDictionary GroupToRole); + IReadOnlyDictionary GroupToRole, + IReadOnlyDictionary> GroupToTag, + UntaggedSessionVisibility UntaggedSessionVisibility); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayConfigurationProvider.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayConfigurationProvider.cs index ae66052..0b30aa0 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayConfigurationProvider.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayConfigurationProvider.cs @@ -62,7 +62,15 @@ public sealed class GatewayConfigurationProvider(IOptions option RecentFaultLimit: value.Dashboard.RecentFaultLimit, RecentSessionLimit: value.Dashboard.RecentSessionLimit, ShowTagValues: value.Dashboard.ShowTagValues, - GroupToRole: value.Dashboard.GroupToRole), + GroupToRole: value.Dashboard.GroupToRole, + // Rebuilt rather than passed through because the value type widens from string[] + // to IReadOnlyList; the comparer is carried over so the projected map + // still matches LDAP group names in whatever case the directory returns them. + GroupToTag: value.Dashboard.GroupToTag.ToDictionary( + pair => pair.Key, + pair => (IReadOnlyList)pair.Value, + StringComparer.OrdinalIgnoreCase), + UntaggedSessionVisibility: value.Dashboard.UntaggedSessionVisibility), Protocol: new EffectiveProtocolConfiguration( value.Protocol.WorkerProtocolVersion, value.Protocol.MaxGrpcMessageBytes)); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SettingsPage.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SettingsPage.razor index 2d57f39..5600287 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SettingsPage.razor +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SettingsPage.razor @@ -65,6 +65,27 @@ else } + + Dashboard tag mapping + @* Group and tag NAMES are configuration, not tag values — the redaction + rule does not reach them, and an operator asking why a Viewer sees no + sessions needs this map as much as the role map above it. *@ + + @if (Snapshot.Configuration.Dashboard.GroupToTag.Count == 0) + { + (none configured) + } + else + { +
    + @foreach (KeyValuePair> pair in Snapshot.Configuration.Dashboard.GroupToTag) + { +
  • @pair.Key → @string.Join(", ", pair.Value)
  • + } +
+ } + + Worker executable@Snapshot.Configuration.Worker.ExecutablePath Worker architecture@Snapshot.Configuration.Worker.RequiredArchitecture Startup timeout@Snapshot.Configuration.Worker.StartupTimeoutSeconds seconds @@ -78,6 +99,7 @@ else Anonymous localhost@Snapshot.Configuration.Dashboard.AllowAnonymousLocalhost Snapshot interval@Snapshot.Configuration.Dashboard.SnapshotIntervalMilliseconds ms Show tag values@Snapshot.Configuration.Dashboard.ShowTagValues + Untagged session visibility@Snapshot.Configuration.Dashboard.UntaggedSessionVisibility Worker protocol@Snapshot.Configuration.Protocol.WorkerProtocolVersion diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayConfigurationProviderTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayConfigurationProviderTests.cs new file mode 100644 index 0000000..3b5a148 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayConfigurationProviderTests.cs @@ -0,0 +1,164 @@ +using Microsoft.Extensions.Options; +using ZB.MOM.WW.MxGateway.Server.Configuration; + +namespace ZB.MOM.WW.MxGateway.Tests.Configuration; + +/// +/// Covers the projection makes from bound +/// onto the effective-configuration record the dashboard renders. +/// +/// +/// The projection is hand-written member by member, so an option that exists and validates can +/// still be invisible on the settings page — which is exactly what happened to the two SEC-25 +/// dashboard-ACL options. These tests pin the dashboard-tag members to the options they come +/// from; the redaction assertions pin the opposite invariant, that the two secret-bearing members +/// are the only ones masked. +/// +public sealed class GatewayConfigurationProviderTests +{ + /// The group → tag map reaches the projection with its groups, tags and ordering intact. + [Fact] + public void GetEffectiveConfiguration_CopiesGroupToTagMapping() + { + GatewayOptions options = new() + { + Dashboard = new DashboardOptions + { + GroupToTag = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + // A multi-tag group is the interesting case: a projection that flattened the + // array to its first element, or to a joined string, would still satisfy a + // single-tag fixture. + ["GwOps"] = ["team-a", "team-b"], + ["GwViewers"] = ["team-a"], + }, + }, + }; + + EffectiveDashboardConfiguration dashboard = Project(options).Dashboard; + + Assert.Equal(2, dashboard.GroupToTag.Count); + Assert.Equal(["team-a", "team-b"], dashboard.GroupToTag["GwOps"]); + Assert.Equal(["team-a"], dashboard.GroupToTag["GwViewers"]); + } + + /// + /// The map's case-insensitive lookup survives the projection. LDAP group names arrive in + /// whatever case the directory returns them, so a projection that rebuilt the dictionary with + /// the default ordinal comparer would silently stop matching. + /// + [Fact] + public void GetEffectiveConfiguration_KeepsGroupToTagLookupCaseInsensitive() + { + GatewayOptions options = new() + { + Dashboard = new DashboardOptions + { + GroupToTag = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["GwOps"] = ["team-a"], + }, + }, + }; + + EffectiveDashboardConfiguration dashboard = Project(options).Dashboard; + + Assert.True(dashboard.GroupToTag.ContainsKey("gwops")); + } + + /// + /// An unconfigured map projects as empty rather than null — the settings page renders the row + /// either way, and "none configured" is the operationally interesting answer. + /// + [Fact] + public void GetEffectiveConfiguration_WhenNoTagsConfigured_ProjectsEmptyMapping() + { + EffectiveDashboardConfiguration dashboard = Project(new GatewayOptions()).Dashboard; + + Assert.Empty(dashboard.GroupToTag); + Assert.Empty(dashboard.GroupToRole); + } + + /// The untagged-session visibility policy is projected, defaulting to the fail-closed value. + [Fact] + public void GetEffectiveConfiguration_CopiesUntaggedSessionVisibility() + { + Assert.Equal( + UntaggedSessionVisibility.AdminOnly, + Project(new GatewayOptions()).Dashboard.UntaggedSessionVisibility); + + GatewayOptions widened = new() + { + Dashboard = new DashboardOptions + { + UntaggedSessionVisibility = UntaggedSessionVisibility.AllViewers, + }, + }; + + Assert.Equal( + UntaggedSessionVisibility.AllViewers, + Project(widened).Dashboard.UntaggedSessionVisibility); + } + + /// The pre-existing dashboard members keep coming from the options they always did. + [Fact] + public void GetEffectiveConfiguration_CopiesTheOtherDashboardMembers() + { + GatewayOptions options = new() + { + Dashboard = new DashboardOptions + { + Enabled = false, + AllowAnonymousLocalhost = false, + SnapshotIntervalMilliseconds = 2_500, + RecentFaultLimit = 7, + RecentSessionLimit = 11, + ShowTagValues = true, + GroupToRole = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["GwAdmins"] = "Administrator", + }, + }, + }; + + EffectiveDashboardConfiguration dashboard = Project(options).Dashboard; + + Assert.False(dashboard.Enabled); + Assert.False(dashboard.AllowAnonymousLocalhost); + Assert.Equal(2_500, dashboard.SnapshotIntervalMilliseconds); + Assert.Equal(7, dashboard.RecentFaultLimit); + Assert.Equal(11, dashboard.RecentSessionLimit); + Assert.True(dashboard.ShowTagValues); + Assert.Equal("Administrator", dashboard.GroupToRole["GwAdmins"]); + } + + /// + /// The masking boundary. Only the pepper name and the LDAP bind password are redacted; the + /// tag mapping is configuration, not a secret, so masking it would hide the very thing the + /// settings page was extended to show. + /// + [Fact] + public void GetEffectiveConfiguration_RedactsOnlyTheSecretBearingMembers() + { + GatewayOptions options = new() + { + Ldap = new LdapOptions { ServiceAccountPassword = "bind-password" }, + Dashboard = new DashboardOptions + { + GroupToTag = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["GwOps"] = ["team-a"], + }, + }, + }; + + EffectiveGatewayConfiguration configuration = Project(options); + + Assert.Equal(GatewayConfigurationProvider.RedactedValue, configuration.Authentication.PepperSecretName); + Assert.Equal(GatewayConfigurationProvider.RedactedValue, configuration.Ldap.ServiceAccountPassword); + Assert.Equal(["team-a"], configuration.Dashboard.GroupToTag["GwOps"]); + } + + private static EffectiveGatewayConfiguration Project(GatewayOptions options) => + new GatewayConfigurationProvider(Options.Create(options)).GetEffectiveConfiguration(); +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/SettingsPageTagVisibilityRenderTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/SettingsPageTagVisibilityRenderTests.cs new file mode 100644 index 0000000..5de301a --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/SettingsPageTagVisibilityRenderTests.cs @@ -0,0 +1,136 @@ +using System.Runtime.CompilerServices; +using Microsoft.AspNetCore.Components.Web.HtmlRendering; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Dashboard; +using ZB.MOM.WW.MxGateway.Server.Dashboard.Components.Pages; +using HtmlRenderer = Microsoft.AspNetCore.Components.Web.HtmlRenderer; + +namespace ZB.MOM.WW.MxGateway.Tests.Dashboard; + +/// +/// Renders and asserts the two SEC-25 dashboard-ACL options — +/// Dashboard:GroupToTag and Dashboard:UntaggedSessionVisibility — reach the page. +/// +/// +/// +/// The provider tests one folder over prove the projection carries the values; they would stay +/// green with no row on the page at all. An operator debugging why a Viewer sees no sessions +/// reads the settings page, not the record, so the markup is where the evidence has to be. +/// +/// +/// Tag NAMES are configuration, like the group → role mapping rendered beside them. No tag VALUE +/// is involved, so nothing here is subject to the value-redaction rule. +/// +/// +/// Static rendering via the framework's , the idiom used by +/// SecretsNavRenderTests and AlarmsPageTruncationBannerTests — the assertion is +/// about markup the server emits, so no component-testing dependency is warranted. +/// +/// +public sealed class SettingsPageTagVisibilityRenderTests +{ + private const string EmptyMarker = "(none configured)"; + + /// A configured mapping renders its group and every tag, plus the visibility policy. + /// A task that represents the asynchronous operation. + [Fact] + public async Task SettingsPage_WhenTagsConfigured_RendersGroupsTagsAndVisibility() + { + string html = await RenderAsync(new DashboardOptions + { + GroupToTag = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["GwOps"] = ["team-a", "team-b"], + }, + UntaggedSessionVisibility = UntaggedSessionVisibility.AllViewers, + }); + + Assert.Contains("GwOps", html, StringComparison.Ordinal); + // Both tags, not just the first: a row that rendered only the head of the array would be + // actively misleading about which sessions a group can observe. + Assert.Contains("team-a", html, StringComparison.Ordinal); + Assert.Contains("team-b", html, StringComparison.Ordinal); + Assert.Contains(nameof(UntaggedSessionVisibility.AllViewers), html, StringComparison.Ordinal); + } + + /// + /// The unconfigured deployment — the common one, and the one whose Viewers see nothing. The + /// row must still render, saying so, and the fail-closed default must be on the page. The + /// role-mapping heading is the control that keeps this from passing over a blank page. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task SettingsPage_WhenNoTagsConfigured_RendersTheEmptyStateAndDefaultVisibility() + { + string html = await RenderAsync(new DashboardOptions()); + + Assert.Contains("Dashboard tag mapping", html, StringComparison.Ordinal); + Assert.Contains(EmptyMarker, html, StringComparison.Ordinal); + Assert.Contains(nameof(UntaggedSessionVisibility.AdminOnly), html, StringComparison.Ordinal); + Assert.Contains("Dashboard role mapping", html, StringComparison.Ordinal); + } + + private static async Task RenderAsync(DashboardOptions dashboard) + { + EffectiveGatewayConfiguration configuration = + new GatewayConfigurationProvider(Options.Create(new GatewayOptions { Dashboard = dashboard })) + .GetEffectiveConfiguration(); + + ServiceCollection services = new(); + services.AddLogging(); + services.AddSingleton(new StubSnapshotService(configuration)); + services.AddSingleton(new IdleSnapshotFeed()); + + 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(); + }); + } + + // Seeds the page's first (and only) render. Everything except Configuration is inert here. + private sealed class StubSnapshotService(EffectiveGatewayConfiguration configuration) + : IDashboardSnapshotService + { + /// + public DashboardSnapshot GetSnapshot() => new( + GeneratedAt: DateTimeOffset.UnixEpoch, + GatewayStartedAt: DateTimeOffset.UnixEpoch, + GatewayUptime: TimeSpan.Zero, + GatewayStatus: "Healthy", + GatewayVersion: "test", + Sessions: [], + Workers: [], + Metrics: [], + Faults: [], + ApiKeys: [], + Configuration: configuration, + 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; + } + } +} From fccf75324b72cace5e1c16ff965a596a82908637 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 17 Aug 2026 07:16:51 -0400 Subject: [PATCH 06/26] feat(alarms): feed-level snapshot_status truncation frame on StreamAlarms --- docs/DesignDecisions.md | 19 +- docs/Grpc.md | 32 +- gateway.md | 13 +- .../Generated/MxaccessGateway.cs | 630 +++++++++++++----- .../Protos/mxaccess_gateway.proto | 18 + .../Alarms/GatewayAlarmMonitor.cs | 47 +- .../Alarms/IGatewayAlarmService.cs | 19 +- .../Alarms/AlarmTruncationSignalTests.cs | 339 +++++++++- 8 files changed, 920 insertions(+), 197 deletions(-) diff --git a/docs/DesignDecisions.md b/docs/DesignDecisions.md index f3fd10b..32055df 100644 --- a/docs/DesignDecisions.md +++ b/docs/DesignDecisions.md @@ -203,7 +203,7 @@ Consequences, and how this sits with the existing failover/reconcile design: 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 +### Alarms — truncation is reported per record on the snapshot stream, as a status frame on the live feed Decision (2026-08-17): the truncated-fetch verdict above is carried to clients as `QueryActiveAlarmsReplyPayload.snapshot_truncated` on the worker IPC reply and as @@ -239,6 +239,23 @@ 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. +The **live feed carries the verdict as set-level status**, not per record. +`StreamAlarms` has an envelope — `AlarmFeedMessage` — so the shape forced on +`QueryActiveAlarms` above is not forced here: the feed gets a fifth oneof case, +`snapshot_status` (`AlarmSnapshotStatus.truncated`), alongside `provider_status`. +The two carriers are therefore deliberately different shapes for the same verdict, +and each is the only additive option on its own surface. Emission is +**edge-triggered**, for the same reason the flag is not latched: a status frame +repeated on every reconcile is noise a consumer filters out, and a filtered-out +signal is no signal. The exception is the open-time frame, which is +unconditional — a late joiner cannot distinguish "not truncated" from "this +gateway does not send the frame" by silence, so it is told explicitly. It is +ordered after `provider_status` and before the cached `active_alarm` frames so a +consumer applying the snapshot as it streams holds the caveat while it applies +the records it qualifies. A monitor restart's `ClearCache` emits the clearing +frame as well: feed subscribers outlive the monitor's worker session, so a silent +re-seed would leave them caveating a set that is no longer truncated. + 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. diff --git a/docs/Grpc.md b/docs/Grpc.md index 271434c..15bd1c8 100644 --- a/docs/Grpc.md +++ b/docs/Grpc.md @@ -94,7 +94,7 @@ An accepted gRPC command payload can still be too large for the worker pipe: the ### `StreamAlarms` -`StreamAlarms` is a server-streaming, **session-less** RPC that attaches to the gateway's central alarm feed. The handler delegates to `IGatewayAlarmService.StreamAsync`. The stream opens with one `AlarmFeedMessage` carrying an `active_alarm` per currently-active alarm (the ConditionRefresh snapshot), then a single `snapshot_complete`, then a `transition` for every subsequent raise / acknowledge / clear. It is served by the always-on `GatewayAlarmMonitor`, which owns a single gateway-managed worker session and fans out to every attached client — clients no longer open a session of their own. `alarm_filter_prefix`, when set, scopes the stream to a sub-tree. +`StreamAlarms` is a server-streaming, **session-less** RPC that attaches to the gateway's central alarm feed. The handler delegates to `IGatewayAlarmService.StreamAsync`. The stream opens with a `provider_status` and a `snapshot_status` `AlarmFeedMessage` (the current provider mode and snapshot-completeness verdict), then one `AlarmFeedMessage` carrying an `active_alarm` per currently-active alarm (the ConditionRefresh snapshot), then a single `snapshot_complete`, then a `transition` for every subsequent raise / acknowledge / clear — interleaved with a further `provider_status` on each failover/failback and a further `snapshot_status` on each change of the truncation verdict. It is served by the always-on `GatewayAlarmMonitor`, which owns a single gateway-managed worker session and fans out to every attached client — clients no longer open a session of their own. `alarm_filter_prefix`, when set, scopes the stream to a sub-tree. ### `QueryActiveAlarms` @@ -119,6 +119,36 @@ The gateway emits `provider_status` once when a client first subscribes and again on every failover or failback. A late-joining client therefore always learns the current provider mode without waiting for the next switch. +#### Snapshot completeness on the alarm feed + +`AlarmFeedMessage` has a fifth `payload` case, `snapshot_status`, carrying +an `AlarmSnapshotStatus` message: + +```protobuf +message AlarmSnapshotStatus { + bool truncated = 1; // the cached active set may be missing alarms +} +``` + +It is the feed-level twin of the per-record +`ActiveAlarmSnapshot.from_truncated_snapshot` flag: `truncated` is true while the +monitor's cached active-alarm set derives from a capped worker fetch. Read it as +"this set may be incomplete", never as a statement about record fidelity — that +is what `degraded` / `source_provider` mean, and the two are independent. + +Emission mirrors `provider_status` but with one ordering rule of its own. The +gateway emits `snapshot_status` once when a client subscribes, **after** the +open-time `provider_status` and **before** the cached `active_alarm` frames, so a +consumer applying the snapshot as it streams has the completeness caveat in hand +while it applies it. The open-time frame is unconditional — an explicit +`truncated = false` is what distinguishes a complete set from a gateway that +never sends the frame. Afterwards it is emitted only on a *change* of verdict: +when a reconcile flips the verdict either way, and when a monitor restart drops a +truncated verdict with the cache generation it describes (feed subscribers +outlive the monitor's worker session, so they see that clear). Clients that do +not know the case see an unset `payload` oneof and ignore the message, exactly as +before. + `AlarmProviderMode` is an enum with three values: | Value | Meaning | diff --git a/gateway.md b/gateway.md index 1013b24..610904a 100644 --- a/gateway.md +++ b/gateway.md @@ -249,7 +249,18 @@ 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 +dashboard Alarms tab shows a warning banner while the flag is set. The live +`StreamAlarms` feed carries the same verdict at feed level as an +`AlarmSnapshotStatus` message (the `snapshot_status` oneof case), so a streaming +consumer learns it without polling `QueryActiveAlarms`. It is emitted on stream +open — after the `provider_status` frame and before the cached `active_alarm` +frames, so the caveat precedes the records it qualifies — and thereafter only on +a *change* of verdict (a status frame repeated every reconcile is one consumers +learn to ignore). The open-time frame is unconditional: an explicit +`truncated = false` is what separates a complete set from a gateway that never +sends the frame. A monitor restart drops the verdict with the cache generation it +describes, and because feed subscribers outlive the monitor's worker session that +drop is emitted as a clearing frame too. 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 diff --git a/src/ZB.MOM.WW.MxGateway.Contracts/Generated/MxaccessGateway.cs b/src/ZB.MOM.WW.MxGateway.Contracts/Generated/MxaccessGateway.cs index feadce8..2979802 100644 --- a/src/ZB.MOM.WW.MxGateway.Contracts/Generated/MxaccessGateway.cs +++ b/src/ZB.MOM.WW.MxGateway.Contracts/Generated/MxaccessGateway.cs @@ -361,173 +361,176 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { "cm94eRIaChJkaWFnbm9zdGljX21lc3NhZ2UYBiABKAlCCgoIX2hyZXN1bHRK", "BAgBEAJSCnNlc3Npb25faWQiUQoTU3RyZWFtQWxhcm1zUmVxdWVzdBIdChVj", "bGllbnRfY29ycmVsYXRpb25faWQYASABKAkSGwoTYWxhcm1fZmlsdGVyX3By", - "ZWZpeBgCIAEoCSKEAgoQQWxhcm1GZWVkTWVzc2FnZRJACgxhY3RpdmVfYWxh", + "ZWZpeBgCIAEoCSLJAgoQQWxhcm1GZWVkTWVzc2FnZRJACgxhY3RpdmVfYWxh", "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==")); + "Y2Vzc19nYXRld2F5LnYxLkFsYXJtUHJvdmlkZXJTdGF0dXNIABJDCg9zbmFw", + "c2hvdF9zdGF0dXMYBSABKAsyKC5teGFjY2Vzc19nYXRld2F5LnYxLkFsYXJt", + "U25hcHNob3RTdGF0dXNIAEIJCgdwYXlsb2FkIpgBChNBbGFybVByb3ZpZGVy", + "U3RhdHVzEjQKBG1vZGUYASABKA4yJi5teGFjY2Vzc19nYXRld2F5LnYxLkFs", + "YXJtUHJvdmlkZXJNb2RlEhAKCGRlZ3JhZGVkGAIgASgIEg4KBnJlYXNvbhgD", + "IAEoCRIpCgVzaW5jZRgEIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3Rh", + "bXAiKAoTQWxhcm1TbmFwc2hvdFN0YXR1cxIRCgl0cnVuY2F0ZWQYASABKAgi", + "6wEKDU14U3RhdHVzUHJveHkSDwoHc3VjY2VzcxgBIAEoBRI3CghjYXRlZ29y", + "eRgCIAEoDjIlLm14YWNjZXNzX2dhdGV3YXkudjEuTXhTdGF0dXNDYXRlZ29y", + "eRI4CgtkZXRlY3RlZF9ieRgDIAEoDjIjLm14YWNjZXNzX2dhdGV3YXkudjEu", + "TXhTdGF0dXNTb3VyY2USDgoGZGV0YWlsGAQgASgFEhQKDHJhd19jYXRlZ29y", + "eRgFIAEoBRIXCg9yYXdfZGV0ZWN0ZWRfYnkYBiABKAUSFwoPZGlhZ25vc3Rp", + "Y190ZXh0GAcgASgJIukDCgdNeFZhbHVlEjIKCWRhdGFfdHlwZRgBIAEoDjIf", + "Lm14YWNjZXNzX2dhdGV3YXkudjEuTXhEYXRhVHlwZRIUCgx2YXJpYW50X3R5", + "cGUYAiABKAkSDwoHaXNfbnVsbBgDIAEoCBIWCg5yYXdfZGlhZ25vc3RpYxgE", + "IAEoCRIVCg1yYXdfZGF0YV90eXBlGAUgASgFEhQKCmJvb2xfdmFsdWUYCiAB", + "KAhIABIVCgtpbnQzMl92YWx1ZRgLIAEoBUgAEhUKC2ludDY0X3ZhbHVlGAwg", + "ASgDSAASFQoLZmxvYXRfdmFsdWUYDSABKAJIABIWCgxkb3VibGVfdmFsdWUY", + "DiABKAFIABIWCgxzdHJpbmdfdmFsdWUYDyABKAlIABI1Cg90aW1lc3RhbXBf", + "dmFsdWUYECABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wSAASMwoL", + "YXJyYXlfdmFsdWUYESABKAsyHC5teGFjY2Vzc19nYXRld2F5LnYxLk14QXJy", + "YXlIABITCglyYXdfdmFsdWUYEiABKAxIABJAChJzcGFyc2VfYXJyYXlfdmFs", + "dWUYEyABKAsyIi5teGFjY2Vzc19nYXRld2F5LnYxLk14U3BhcnNlQXJyYXlI", + "AEIGCgRraW5kIv4ECgdNeEFycmF5EjoKEWVsZW1lbnRfZGF0YV90eXBlGAEg", + "ASgOMh8ubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeERhdGFUeXBlEhQKDHZhcmlh", + "bnRfdHlwZRgCIAEoCRISCgpkaW1lbnNpb25zGAMgAygNEhYKDnJhd19kaWFn", + "bm9zdGljGAQgASgJEh0KFXJhd19lbGVtZW50X2RhdGFfdHlwZRgFIAEoBRI1", + "Cgtib29sX3ZhbHVlcxgKIAEoCzIeLm14YWNjZXNzX2dhdGV3YXkudjEuQm9v", + "bEFycmF5SAASNwoMaW50MzJfdmFsdWVzGAsgASgLMh8ubXhhY2Nlc3NfZ2F0", + "ZXdheS52MS5JbnQzMkFycmF5SAASNwoMaW50NjRfdmFsdWVzGAwgASgLMh8u", + "bXhhY2Nlc3NfZ2F0ZXdheS52MS5JbnQ2NEFycmF5SAASNwoMZmxvYXRfdmFs", + "dWVzGA0gASgLMh8ubXhhY2Nlc3NfZ2F0ZXdheS52MS5GbG9hdEFycmF5SAAS", + "OQoNZG91YmxlX3ZhbHVlcxgOIAEoCzIgLm14YWNjZXNzX2dhdGV3YXkudjEu", + "RG91YmxlQXJyYXlIABI5Cg1zdHJpbmdfdmFsdWVzGA8gASgLMiAubXhhY2Nl", + "c3NfZ2F0ZXdheS52MS5TdHJpbmdBcnJheUgAEj8KEHRpbWVzdGFtcF92YWx1", + "ZXMYECABKAsyIy5teGFjY2Vzc19nYXRld2F5LnYxLlRpbWVzdGFtcEFycmF5", + "SAASMwoKcmF3X3ZhbHVlcxgRIAEoCzIdLm14YWNjZXNzX2dhdGV3YXkudjEu", + "UmF3QXJyYXlIAEIICgZ2YWx1ZXMimQEKDU14U3BhcnNlQXJyYXkSOgoRZWxl", + "bWVudF9kYXRhX3R5cGUYASABKA4yHy5teGFjY2Vzc19nYXRld2F5LnYxLk14", + "RGF0YVR5cGUSFAoMdG90YWxfbGVuZ3RoGAIgASgNEjYKCGVsZW1lbnRzGAMg", + "AygLMiQubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeFNwYXJzZUVsZW1lbnQiTQoP", + "TXhTcGFyc2VFbGVtZW50Eg0KBWluZGV4GAEgASgNEisKBXZhbHVlGAIgASgL", + "MhwubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeFZhbHVlIhsKCUJvb2xBcnJheRIO", + "CgZ2YWx1ZXMYASADKAgiHAoKSW50MzJBcnJheRIOCgZ2YWx1ZXMYASADKAUi", + "HAoKSW50NjRBcnJheRIOCgZ2YWx1ZXMYASADKAMiHAoKRmxvYXRBcnJheRIO", + "CgZ2YWx1ZXMYASADKAIiHQoLRG91YmxlQXJyYXkSDgoGdmFsdWVzGAEgAygB", + "Ih0KC1N0cmluZ0FycmF5Eg4KBnZhbHVlcxgBIAMoCSI8Cg5UaW1lc3RhbXBB", + "cnJheRIqCgZ2YWx1ZXMYASADKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0", + "YW1wIhoKCFJhd0FycmF5Eg4KBnZhbHVlcxgBIAMoDCJYCg5Qcm90b2NvbFN0", + "YXR1cxI1CgRjb2RlGAEgASgOMicubXhhY2Nlc3NfZ2F0ZXdheS52MS5Qcm90", + "b2NvbFN0YXR1c0NvZGUSDwoHbWVzc2FnZRgCIAEoCSqfCwoNTXhDb21tYW5k", + "S2luZBIfChtNWF9DT01NQU5EX0tJTkRfVU5TUEVDSUZJRUQQABIcChhNWF9D", + "T01NQU5EX0tJTkRfUkVHSVNURVIQARIeChpNWF9DT01NQU5EX0tJTkRfVU5S", + "RUdJU1RFUhACEhwKGE1YX0NPTU1BTkRfS0lORF9BRERfSVRFTRADEh0KGU1Y", + "X0NPTU1BTkRfS0lORF9BRERfSVRFTTIQBBIfChtNWF9DT01NQU5EX0tJTkRf", + "UkVNT1ZFX0lURU0QBRIaChZNWF9DT01NQU5EX0tJTkRfQURWSVNFEAYSHQoZ", + "TVhfQ09NTUFORF9LSU5EX1VOX0FEVklTRRAHEiYKIk1YX0NPTU1BTkRfS0lO", + "RF9BRFZJU0VfU1VQRVJWSVNPUlkQCBIlCiFNWF9DT01NQU5EX0tJTkRfQURE", + "X0JVRkZFUkVEX0lURU0QCRIwCixNWF9DT01NQU5EX0tJTkRfU0VUX0JVRkZF", + "UkVEX1VQREFURV9JTlRFUlZBTBAKEhsKF01YX0NPTU1BTkRfS0lORF9TVVNQ", + "RU5EEAsSHAoYTVhfQ09NTUFORF9LSU5EX0FDVElWQVRFEAwSGQoVTVhfQ09N", + "TUFORF9LSU5EX1dSSVRFEA0SGgoWTVhfQ09NTUFORF9LSU5EX1dSSVRFMhAO", + "EiEKHU1YX0NPTU1BTkRfS0lORF9XUklURV9TRUNVUkVEEA8SIgoeTVhfQ09N", + "TUFORF9LSU5EX1dSSVRFX1NFQ1VSRUQyEBASJQohTVhfQ09NTUFORF9LSU5E", + "X0FVVEhFTlRJQ0FURV9VU0VSEBESKAokTVhfQ09NTUFORF9LSU5EX0FSQ0hF", + "U1RSQV9VU0VSX1RPX0lEEBISIQodTVhfQ09NTUFORF9LSU5EX0FERF9JVEVN", + "X0JVTEsQExIkCiBNWF9DT01NQU5EX0tJTkRfQURWSVNFX0lURU1fQlVMSxAU", + "EiQKIE1YX0NPTU1BTkRfS0lORF9SRU1PVkVfSVRFTV9CVUxLEBUSJwojTVhf", + "Q09NTUFORF9LSU5EX1VOX0FEVklTRV9JVEVNX0JVTEsQFhIiCh5NWF9DT01N", + "QU5EX0tJTkRfU1VCU0NSSUJFX0JVTEsQFxIkCiBNWF9DT01NQU5EX0tJTkRf", + "VU5TVUJTQ1JJQkVfQlVMSxAYEiQKIE1YX0NPTU1BTkRfS0lORF9TVUJTQ1JJ", + "QkVfQUxBUk1TEBkSJgoiTVhfQ09NTUFORF9LSU5EX1VOU1VCU0NSSUJFX0FM", + "QVJNUxAaEiUKIU1YX0NPTU1BTkRfS0lORF9BQ0tOT1dMRURHRV9BTEFSTRAb", + "EicKI01YX0NPTU1BTkRfS0lORF9RVUVSWV9BQ1RJVkVfQUxBUk1TEBwSLQop", + "TVhfQ09NTUFORF9LSU5EX0FDS05PV0xFREdFX0FMQVJNX0JZX05BTUUQHRIe", + "ChpNWF9DT01NQU5EX0tJTkRfV1JJVEVfQlVMSxAeEh8KG01YX0NPTU1BTkRf", + "S0lORF9XUklURTJfQlVMSxAfEiYKIk1YX0NPTU1BTkRfS0lORF9XUklURV9T", + "RUNVUkVEX0JVTEsQIBInCiNNWF9DT01NQU5EX0tJTkRfV1JJVEVfU0VDVVJF", + "RDJfQlVMSxAhEh0KGU1YX0NPTU1BTkRfS0lORF9SRUFEX0JVTEsQIhIYChRN", + "WF9DT01NQU5EX0tJTkRfUElORxBkEiUKIU1YX0NPTU1BTkRfS0lORF9HRVRf", + "U0VTU0lPTl9TVEFURRBlEiMKH01YX0NPTU1BTkRfS0lORF9HRVRfV09SS0VS", + "X0lORk8QZhIgChxNWF9DT01NQU5EX0tJTkRfRFJBSU5fRVZFTlRTEGcSIwof", + "TVhfQ09NTUFORF9LSU5EX1NIVVRET1dOX1dPUktFUhBoKnoKEUFsYXJtUHJv", + "dmlkZXJNb2RlEiMKH0FMQVJNX1BST1ZJREVSX01PREVfVU5TUEVDSUZJRUQQ", + "ABIgChxBTEFSTV9QUk9WSURFUl9NT0RFX0FMQVJNTUdSEAESHgoaQUxBUk1f", + "UFJPVklERVJfTU9ERV9TVUJUQUcQAiqtAgoNTXhFdmVudEZhbWlseRIfChtN", + "WF9FVkVOVF9GQU1JTFlfVU5TUEVDSUZJRUQQABIiCh5NWF9FVkVOVF9GQU1J", + "TFlfT05fREFUQV9DSEFOR0UQARIlCiFNWF9FVkVOVF9GQU1JTFlfT05fV1JJ", + "VEVfQ09NUExFVEUQAhImCiJNWF9FVkVOVF9GQU1JTFlfT1BFUkFUSU9OX0NP", + "TVBMRVRFEAMSKwonTVhfRVZFTlRfRkFNSUxZX09OX0JVRkZFUkVEX0RBVEFf", + "Q0hBTkdFEAQSJwojTVhfRVZFTlRfRkFNSUxZX09OX0FMQVJNX1RSQU5TSVRJ", + "T04QBRIyCi5NWF9FVkVOVF9GQU1JTFlfT05fQUxBUk1fUFJPVklERVJfTU9E", + "RV9DSEFOR0VEEAYqygEKE0FsYXJtVHJhbnNpdGlvbktpbmQSJQohQUxBUk1f", + "VFJBTlNJVElPTl9LSU5EX1VOU1BFQ0lGSUVEEAASHwobQUxBUk1fVFJBTlNJ", + "VElPTl9LSU5EX1JBSVNFEAESJQohQUxBUk1fVFJBTlNJVElPTl9LSU5EX0FD", + "S05PV0xFREdFEAISHwobQUxBUk1fVFJBTlNJVElPTl9LSU5EX0NMRUFSEAMS", + "IwofQUxBUk1fVFJBTlNJVElPTl9LSU5EX1JFVFJJR0dFUhAEKqoBChNBbGFy", + "bUNvbmRpdGlvblN0YXRlEiUKIUFMQVJNX0NPTkRJVElPTl9TVEFURV9VTlNQ", + "RUNJRklFRBAAEiAKHEFMQVJNX0NPTkRJVElPTl9TVEFURV9BQ1RJVkUQARIm", + "CiJBTEFSTV9DT05ESVRJT05fU1RBVEVfQUNUSVZFX0FDS0VEEAISIgoeQUxB", + "Uk1fQ09ORElUSU9OX1NUQVRFX0lOQUNUSVZFEAMqpQMKEE14U3RhdHVzQ2F0", + "ZWdvcnkSIgoeTVhfU1RBVFVTX0NBVEVHT1JZX1VOU1BFQ0lGSUVEEAASHgoa", + "TVhfU1RBVFVTX0NBVEVHT1JZX1VOS05PV04QARIZChVNWF9TVEFUVVNfQ0FU", + "RUdPUllfT0sQAhIeChpNWF9TVEFUVVNfQ0FURUdPUllfUEVORElORxADEh4K", + "Gk1YX1NUQVRVU19DQVRFR09SWV9XQVJOSU5HEAQSKgomTVhfU1RBVFVTX0NB", + "VEVHT1JZX0NPTU1VTklDQVRJT05fRVJST1IQBRIqCiZNWF9TVEFUVVNfQ0FU", + "RUdPUllfQ09ORklHVVJBVElPTl9FUlJPUhAGEigKJE1YX1NUQVRVU19DQVRF", + "R09SWV9PUEVSQVRJT05BTF9FUlJPUhAHEiUKIU1YX1NUQVRVU19DQVRFR09S", + "WV9TRUNVUklUWV9FUlJPUhAIEiUKIU1YX1NUQVRVU19DQVRFR09SWV9TT0ZU", + "V0FSRV9FUlJPUhAJEiIKHk1YX1NUQVRVU19DQVRFR09SWV9PVEhFUl9FUlJP", + "UhAKKsoCCg5NeFN0YXR1c1NvdXJjZRIgChxNWF9TVEFUVVNfU09VUkNFX1VO", + "U1BFQ0lGSUVEEAASHAoYTVhfU1RBVFVTX1NPVVJDRV9VTktOT1dOEAESIwof", + "TVhfU1RBVFVTX1NPVVJDRV9SRVFVRVNUSU5HX0xNWBACEiMKH01YX1NUQVRV", + "U19TT1VSQ0VfUkVTUE9ORElOR19MTVgQAxIjCh9NWF9TVEFUVVNfU09VUkNF", + "X1JFUVVFU1RJTkdfTk1YEAQSIwofTVhfU1RBVFVTX1NPVVJDRV9SRVNQT05E", + "SU5HX05NWBAFEjEKLU1YX1NUQVRVU19TT1VSQ0VfUkVRVUVTVElOR19BVVRP", + "TUFUSU9OX09CSkVDVBAGEjEKLU1YX1NUQVRVU19TT1VSQ0VfUkVTUE9ORElO", + "R19BVVRPTUFUSU9OX09CSkVDVBAHKt0ECgpNeERhdGFUeXBlEhwKGE1YX0RB", + "VEFfVFlQRV9VTlNQRUNJRklFRBAAEhgKFE1YX0RBVEFfVFlQRV9VTktOT1dO", + "EAESGAoUTVhfREFUQV9UWVBFX05PX0RBVEEQAhIYChRNWF9EQVRBX1RZUEVf", + "Qk9PTEVBThADEhgKFE1YX0RBVEFfVFlQRV9JTlRFR0VSEAQSFgoSTVhfREFU", + "QV9UWVBFX0ZMT0FUEAUSFwoTTVhfREFUQV9UWVBFX0RPVUJMRRAGEhcKE01Y", + "X0RBVEFfVFlQRV9TVFJJTkcQBxIVChFNWF9EQVRBX1RZUEVfVElNRRAIEh0K", + "GU1YX0RBVEFfVFlQRV9FTEFQU0VEX1RJTUUQCRIfChtNWF9EQVRBX1RZUEVf", + "UkVGRVJFTkNFX1RZUEUQChIcChhNWF9EQVRBX1RZUEVfU1RBVFVTX1RZUEUQ", + "CxIVChFNWF9EQVRBX1RZUEVfRU5VTRAMEi0KKU1YX0RBVEFfVFlQRV9TRUNV", + "UklUWV9DTEFTU0lGSUNBVElPTl9FTlVNEA0SIgoeTVhfREFUQV9UWVBFX0RB", + "VEFfUVVBTElUWV9UWVBFEA4SHwobTVhfREFUQV9UWVBFX1FVQUxJRklFRF9F", + "TlVNEA8SIQodTVhfREFUQV9UWVBFX1FVQUxJRklFRF9TVFJVQ1QQEBIpCiVN", + "WF9EQVRBX1RZUEVfSU5URVJOQVRJT05BTElaRURfU1RSSU5HEBESGwoXTVhf", + "REFUQV9UWVBFX0JJR19TVFJJTkcQEhIUChBNWF9EQVRBX1RZUEVfRU5EEBMq", + "owMKElByb3RvY29sU3RhdHVzQ29kZRIkCiBQUk9UT0NPTF9TVEFUVVNfQ09E", + "RV9VTlNQRUNJRklFRBAAEhsKF1BST1RPQ09MX1NUQVRVU19DT0RFX09LEAES", + "KAokUFJPVE9DT0xfU1RBVFVTX0NPREVfSU5WQUxJRF9SRVFVRVNUEAISKgom", + "UFJPVE9DT0xfU1RBVFVTX0NPREVfU0VTU0lPTl9OT1RfRk9VTkQQAxIqCiZQ", + "Uk9UT0NPTF9TVEFUVVNfQ09ERV9TRVNTSU9OX05PVF9SRUFEWRAEEisKJ1BS", + "T1RPQ09MX1NUQVRVU19DT0RFX1dPUktFUl9VTkFWQUlMQUJMRRAFEiAKHFBS", + "T1RPQ09MX1NUQVRVU19DT0RFX1RJTUVPVVQQBhIhCh1QUk9UT0NPTF9TVEFU", + "VVNfQ09ERV9DQU5DRUxFRBAHEisKJ1BST1RPQ09MX1NUQVRVU19DT0RFX1BS", + "T1RPQ09MX1ZJT0xBVElPThAIEikKJVBST1RPQ09MX1NUQVRVU19DT0RFX01Y", + "QUNDRVNTX0ZBSUxVUkUQCSq/AgoMU2Vzc2lvblN0YXRlEh0KGVNFU1NJT05f", + "U1RBVEVfVU5TUEVDSUZJRUQQABIaChZTRVNTSU9OX1NUQVRFX0NSRUFUSU5H", + "EAESIQodU0VTU0lPTl9TVEFURV9TVEFSVElOR19XT1JLRVIQAhIiCh5TRVNT", + "SU9OX1NUQVRFX1dBSVRJTkdfRk9SX1BJUEUQAxIdChlTRVNTSU9OX1NUQVRF", + "X0hBTkRTSEFLSU5HEAQSJQohU0VTU0lPTl9TVEFURV9JTklUSUFMSVpJTkdf", + "V09SS0VSEAUSFwoTU0VTU0lPTl9TVEFURV9SRUFEWRAGEhkKFVNFU1NJT05f", + "U1RBVEVfQ0xPU0lORxAHEhgKFFNFU1NJT05fU1RBVEVfQ0xPU0VEEAgSGQoV", + "U0VTU0lPTl9TVEFURV9GQVVMVEVEEAkywwUKD014QWNjZXNzR2F0ZXdheRJd", + "CgtPcGVuU2Vzc2lvbhInLm14YWNjZXNzX2dhdGV3YXkudjEuT3BlblNlc3Np", + "b25SZXF1ZXN0GiUubXhhY2Nlc3NfZ2F0ZXdheS52MS5PcGVuU2Vzc2lvblJl", + "cGx5EmAKDENsb3NlU2Vzc2lvbhIoLm14YWNjZXNzX2dhdGV3YXkudjEuQ2xv", + "c2VTZXNzaW9uUmVxdWVzdBomLm14YWNjZXNzX2dhdGV3YXkudjEuQ2xvc2VT", + "ZXNzaW9uUmVwbHkSVAoGSW52b2tlEiUubXhhY2Nlc3NfZ2F0ZXdheS52MS5N", + "eENvbW1hbmRSZXF1ZXN0GiMubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeENvbW1h", + "bmRSZXBseRJYCgxTdHJlYW1FdmVudHMSKC5teGFjY2Vzc19nYXRld2F5LnYx", + "LlN0cmVhbUV2ZW50c1JlcXVlc3QaHC5teGFjY2Vzc19nYXRld2F5LnYxLk14", + "RXZlbnQwARJsChBBY2tub3dsZWRnZUFsYXJtEiwubXhhY2Nlc3NfZ2F0ZXdh", + "eS52MS5BY2tub3dsZWRnZUFsYXJtUmVxdWVzdBoqLm14YWNjZXNzX2dhdGV3", + "YXkudjEuQWNrbm93bGVkZ2VBbGFybVJlcGx5EmEKDFN0cmVhbUFsYXJtcxIo", + "Lm14YWNjZXNzX2dhdGV3YXkudjEuU3RyZWFtQWxhcm1zUmVxdWVzdBolLm14", + "YWNjZXNzX2dhdGV3YXkudjEuQWxhcm1GZWVkTWVzc2FnZTABEm4KEVF1ZXJ5", + "QWN0aXZlQWxhcm1zEi0ubXhhY2Nlc3NfZ2F0ZXdheS52MS5RdWVyeUFjdGl2", + "ZUFsYXJtc1JlcXVlc3QaKC5teGFjY2Vzc19nYXRld2F5LnYxLkFjdGl2ZUFs", + "YXJtU25hcHNob3QwAUImqgIjWkIuTU9NLldXLk14R2F0ZXdheS5Db250cmFj", + "dHMuUHJvdG9iBnByb3RvMw==")); 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[] { @@ -616,8 +619,9 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { 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), - new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmFeedMessage), global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmFeedMessage.Parser, new[]{ "ActiveAlarm", "SnapshotComplete", "Transition", "ProviderStatus" }, new[]{ "Payload" }, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmFeedMessage), global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmFeedMessage.Parser, new[]{ "ActiveAlarm", "SnapshotComplete", "Transition", "ProviderStatus", "SnapshotStatus" }, new[]{ "Payload" }, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmProviderStatus), global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmProviderStatus.Parser, new[]{ "Mode", "Degraded", "Reason", "Since" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmSnapshotStatus), global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmSnapshotStatus.Parser, new[]{ "Truncated" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy), global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy.Parser, new[]{ "Success", "Category", "DetectedBy", "Detail", "RawCategory", "RawDetectedBy", "DiagnosticText" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue), global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxValue.Parser, new[]{ "DataType", "VariantType", "IsNull", "RawDiagnostic", "RawDataType", "BoolValue", "Int32Value", "Int64Value", "FloatValue", "DoubleValue", "StringValue", "TimestampValue", "ArrayValue", "RawValue", "SparseArrayValue" }, new[]{ "Kind" }, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxArray), global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxArray.Parser, new[]{ "ElementDataType", "VariantType", "Dimensions", "RawDiagnostic", "RawElementDataType", "BoolValues", "Int32Values", "Int64Values", "FloatValues", "DoubleValues", "StringValues", "TimestampValues", "RawValues" }, new[]{ "Values" }, null, null, null), @@ -28561,6 +28565,9 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { case PayloadOneofCase.ProviderStatus: ProviderStatus = other.ProviderStatus.Clone(); break; + case PayloadOneofCase.SnapshotStatus: + SnapshotStatus = other.SnapshotStatus.Clone(); + break; } _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); @@ -28648,6 +28655,23 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { } } + /// Field number for the "snapshot_status" field. + public const int SnapshotStatusFieldNumber = 5; + /// + /// Snapshot-completeness status. Emitted once on stream open and again on + /// every change of the truncation verdict, so late joiners learn whether the + /// feed's active-alarm set may be incomplete. + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmSnapshotStatus SnapshotStatus { + get { return payloadCase_ == PayloadOneofCase.SnapshotStatus ? (global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmSnapshotStatus) payload_ : null; } + set { + payload_ = value; + payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.SnapshotStatus; + } + } + private object payload_; /// Enum of possible cases for the "payload" oneof. public enum PayloadOneofCase { @@ -28656,6 +28680,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { SnapshotComplete = 2, Transition = 3, ProviderStatus = 4, + SnapshotStatus = 5, } private PayloadOneofCase payloadCase_ = PayloadOneofCase.None; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -28690,6 +28715,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { if (SnapshotComplete != other.SnapshotComplete) return false; if (!object.Equals(Transition, other.Transition)) return false; if (!object.Equals(ProviderStatus, other.ProviderStatus)) return false; + if (!object.Equals(SnapshotStatus, other.SnapshotStatus)) return false; if (PayloadCase != other.PayloadCase) return false; return Equals(_unknownFields, other._unknownFields); } @@ -28702,6 +28728,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { if (HasSnapshotComplete) hash ^= SnapshotComplete.GetHashCode(); if (payloadCase_ == PayloadOneofCase.Transition) hash ^= Transition.GetHashCode(); if (payloadCase_ == PayloadOneofCase.ProviderStatus) hash ^= ProviderStatus.GetHashCode(); + if (payloadCase_ == PayloadOneofCase.SnapshotStatus) hash ^= SnapshotStatus.GetHashCode(); hash ^= (int) payloadCase_; if (_unknownFields != null) { hash ^= _unknownFields.GetHashCode(); @@ -28737,6 +28764,10 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { output.WriteRawTag(34); output.WriteMessage(ProviderStatus); } + if (payloadCase_ == PayloadOneofCase.SnapshotStatus) { + output.WriteRawTag(42); + output.WriteMessage(SnapshotStatus); + } if (_unknownFields != null) { _unknownFields.WriteTo(output); } @@ -28763,6 +28794,10 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { output.WriteRawTag(34); output.WriteMessage(ProviderStatus); } + if (payloadCase_ == PayloadOneofCase.SnapshotStatus) { + output.WriteRawTag(42); + output.WriteMessage(SnapshotStatus); + } if (_unknownFields != null) { _unknownFields.WriteTo(ref output); } @@ -28785,6 +28820,9 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { if (payloadCase_ == PayloadOneofCase.ProviderStatus) { size += 1 + pb::CodedOutputStream.ComputeMessageSize(ProviderStatus); } + if (payloadCase_ == PayloadOneofCase.SnapshotStatus) { + size += 1 + pb::CodedOutputStream.ComputeMessageSize(SnapshotStatus); + } if (_unknownFields != null) { size += _unknownFields.CalculateSize(); } @@ -28819,6 +28857,12 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { } ProviderStatus.MergeFrom(other.ProviderStatus); break; + case PayloadOneofCase.SnapshotStatus: + if (SnapshotStatus == null) { + SnapshotStatus = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmSnapshotStatus(); + } + SnapshotStatus.MergeFrom(other.SnapshotStatus); + break; } _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); @@ -28871,6 +28915,15 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { ProviderStatus = subBuilder; break; } + case 42: { + global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmSnapshotStatus subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmSnapshotStatus(); + if (payloadCase_ == PayloadOneofCase.SnapshotStatus) { + subBuilder.MergeFrom(SnapshotStatus); + } + input.ReadMessage(subBuilder); + SnapshotStatus = subBuilder; + break; + } } } #endif @@ -28921,6 +28974,15 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { ProviderStatus = subBuilder; break; } + case 42: { + global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmSnapshotStatus subBuilder = new global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmSnapshotStatus(); + if (payloadCase_ == PayloadOneofCase.SnapshotStatus) { + subBuilder.MergeFrom(SnapshotStatus); + } + input.ReadMessage(subBuilder); + SnapshotStatus = subBuilder; + break; + } } } } @@ -29252,6 +29314,218 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { } + /// + /// Feed-level snapshot-completeness status. Emitted once on StreamAlarms open + /// (after the initial provider_status frame, before the cached active_alarm + /// frames) so late joiners learn the current verdict, and again on every change + /// of the truncation verdict observed at reconcile. Mirrors the per-record + /// ActiveAlarmSnapshot.from_truncated_snapshot caveat at feed level so live + /// consumers can reason about completeness without polling QueryActiveAlarms. + /// + [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] + public sealed partial class AlarmSnapshotStatus : pb::IMessage + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + , pb::IBufferMessage + #endif + { + private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new AlarmSnapshotStatus()); + private pb::UnknownFieldSet _unknownFields; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pb::MessageParser Parser { get { return _parser; } } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pbr::MessageDescriptor Descriptor { + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[87]; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + pbr::MessageDescriptor pb::IMessage.Descriptor { + get { return Descriptor; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public AlarmSnapshotStatus() { + OnConstruction(); + } + + partial void OnConstruction(); + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public AlarmSnapshotStatus(AlarmSnapshotStatus other) : this() { + truncated_ = other.truncated_; + _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public AlarmSnapshotStatus Clone() { + return new AlarmSnapshotStatus(this); + } + + /// Field number for the "truncated" field. + public const int TruncatedFieldNumber = 1; + private bool truncated_; + /// + /// True while the monitor's cached active-alarm set derives from a truncated + /// (capped) worker fetch — the set may be missing alarms. Distinct from + /// provider degradation (AlarmProviderStatus.degraded), which describes the + /// fidelity of the records rather than the completeness of the set. + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool Truncated { + get { return truncated_; } + set { + truncated_ = value; + } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override bool Equals(object other) { + return Equals(other as AlarmSnapshotStatus); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool Equals(AlarmSnapshotStatus other) { + if (ReferenceEquals(other, null)) { + return false; + } + if (ReferenceEquals(other, this)) { + return true; + } + if (Truncated != other.Truncated) return false; + return Equals(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override int GetHashCode() { + int hash = 1; + if (Truncated != false) hash ^= Truncated.GetHashCode(); + if (_unknownFields != null) { + hash ^= _unknownFields.GetHashCode(); + } + return hash; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override string ToString() { + return pb::JsonFormatter.ToDiagnosticString(this); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void WriteTo(pb::CodedOutputStream output) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + output.WriteRawMessage(this); + #else + if (Truncated != false) { + output.WriteRawTag(8); + output.WriteBool(Truncated); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(output); + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { + if (Truncated != false) { + output.WriteRawTag(8); + output.WriteBool(Truncated); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(ref output); + } + } + #endif + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int CalculateSize() { + int size = 0; + if (Truncated != false) { + size += 1 + 1; + } + if (_unknownFields != null) { + size += _unknownFields.CalculateSize(); + } + return size; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(AlarmSnapshotStatus other) { + if (other == null) { + return; + } + if (other.Truncated != false) { + Truncated = other.Truncated; + } + _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(pb::CodedInputStream input) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + input.ReadRawMessage(this); + #else + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + case 8: { + Truncated = input.ReadBool(); + break; + } + } + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); + break; + case 8: { + Truncated = input.ReadBool(); + break; + } + } + } + } + #endif + + } + [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] public sealed partial class MxStatusProxy : pb::IMessage #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE @@ -29267,7 +29541,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[87]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[88]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -29698,7 +29972,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[88]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[89]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -30608,7 +30882,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[89]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[90]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -31402,7 +31676,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[90]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[91]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -31663,7 +31937,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[91]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[92]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -31910,7 +32184,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[92]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[93]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -32099,7 +32373,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[93]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[94]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -32288,7 +32562,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[94]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[95]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -32477,7 +32751,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[95]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[96]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -32666,7 +32940,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[96]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[97]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -32855,7 +33129,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[97]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[98]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -33042,7 +33316,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[98]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[99]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -33229,7 +33503,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[99]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[100]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -33416,7 +33690,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[100]; } + get { return global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[101]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] 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 0ee3fb2..85d13a7 100644 --- a/src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto +++ b/src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto @@ -1018,6 +1018,10 @@ message AlarmFeedMessage { // Provider-mode status. Emitted once on stream open and again on every // failover/failback so late joiners learn the current mode immediately. AlarmProviderStatus provider_status = 4; + // Snapshot-completeness status. Emitted once on stream open and again on + // every change of the truncation verdict, so late joiners learn whether the + // feed's active-alarm set may be incomplete. + AlarmSnapshotStatus snapshot_status = 5; } } @@ -1028,6 +1032,20 @@ message AlarmProviderStatus { google.protobuf.Timestamp since = 4; } +// Feed-level snapshot-completeness status. Emitted once on StreamAlarms open +// (after the initial provider_status frame, before the cached active_alarm +// frames) so late joiners learn the current verdict, and again on every change +// of the truncation verdict observed at reconcile. Mirrors the per-record +// ActiveAlarmSnapshot.from_truncated_snapshot caveat at feed level so live +// consumers can reason about completeness without polling QueryActiveAlarms. +message AlarmSnapshotStatus { + // True while the monitor's cached active-alarm set derives from a truncated + // (capped) worker fetch — the set may be missing alarms. Distinct from + // provider degradation (AlarmProviderStatus.degraded), which describes the + // fidelity of the records rather than the completeness of the set. + bool truncated = 1; +} + message MxStatusProxy { // Mirrors the `success` member of the MXAccess MXSTATUS_PROXY struct // (a 16-bit signed value in the COM struct, widened to int32 on the diff --git a/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs b/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs index 77aa58c..9058615 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs @@ -624,11 +624,13 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic // 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. // - // Truncation (`snapshotTruncated`) needs no special handling here, and that is worth saying + // Truncation (`snapshotTruncated`) needs no per-alarm 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. + // The flag is set-level status, not a delta: it is recorded, and a CHANGE of verdict is pushed + // to the feed as a snapshot_status frame (SetSnapshotTruncated) so live consumers learn the + // completeness caveat without polling QueryActiveAlarms. private void ApplyReconcile(IEnumerable snapshots, bool snapshotTruncated) { Dictionary next = new(StringComparer.Ordinal); @@ -688,11 +690,30 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic _alarms[incoming.Key] = incoming.Value; } - _snapshotTruncated = snapshotTruncated; + SetSnapshotTruncated(snapshotTruncated); _currentAlarmsProjection = null; } } + // Caller holds _sync. Records the truncation verdict and, on a CHANGE of verdict, pushes the + // feed-level snapshot_status frame. Edge-triggered rather than per-reconcile: a status frame + // repeated every reconcile interval is one consumers learn to ignore. The verdict describes the + // whole cached set, not one alarm, so — like provider status — it goes to every subscriber + // regardless of alarm-filter prefix. + private void SetSnapshotTruncated(bool truncated) + { + if (_snapshotTruncated == truncated) + { + return; + } + + _snapshotTruncated = truncated; + BroadcastToAll(new AlarmFeedMessage + { + SnapshotStatus = new AlarmSnapshotStatus { Truncated = truncated }, + }); + } + // Caller holds _sync. Pushes a feed message to every matching subscriber; // a subscriber that has fallen behind is completed with an error and dropped. private void Broadcast(AlarmFeedMessage message, string reference) @@ -738,8 +759,10 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic _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; + // incomplete" on evidence from a session that no longer exists. Dropping a truncated + // verdict IS a completeness change, and feed subscribers outlive the monitor's worker + // session, so this routes through the edge path and they see the clearing frame. + SetSnapshotTruncated(false); _currentAlarmsProjection = null; } } @@ -761,13 +784,16 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic ActiveAlarmSnapshot[] snapshot; AlarmProviderStatus providerStatus; + bool snapshotTruncated; lock (_sync) { // Register before snapshotting under the same lock so neither a // transition nor a provider-mode change can slip between the snapshot - // and the live stream. + // and the live stream. The truncation verdict is read here too, so the + // caveat and the set it qualifies are a consistent pair. _subscribers.Add(subscriber); providerStatus = BuildProviderStatus(); + snapshotTruncated = _snapshotTruncated; snapshot = _alarms.Values .Where(alarm => prefix.Length == 0 || alarm.AlarmFullReference.StartsWith(prefix, StringComparison.Ordinal)) @@ -781,6 +807,15 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic // learns the mode (and whether the feed is degraded) before any alarms. yield return new AlarmFeedMessage { ProviderStatus = providerStatus }; + // Then the completeness caveat, BEFORE the cached snapshot it qualifies: a consumer + // applying the snapshot as it streams needs to know whether the set may be missing + // alarms while it applies it, not after. Unconditional — an explicit false is what + // separates "the set is complete" from "this gateway never sends the frame". + yield return new AlarmFeedMessage + { + SnapshotStatus = new AlarmSnapshotStatus { Truncated = snapshotTruncated }, + }; + foreach (ActiveAlarmSnapshot alarm in snapshot) { yield return new AlarmFeedMessage { ActiveAlarm = alarm }; diff --git a/src/ZB.MOM.WW.MxGateway.Server/Alarms/IGatewayAlarmService.cs b/src/ZB.MOM.WW.MxGateway.Server/Alarms/IGatewayAlarmService.cs index 413fbcb..54bcb00 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Alarms/IGatewayAlarmService.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Alarms/IGatewayAlarmService.cs @@ -55,14 +55,25 @@ public interface IGatewayAlarmService /// the intended granularity for a completeness hint; pairing them exactly /// would need a combined accessor this seam deliberately does not have. /// + /// + /// This is the polled read of the verdict. The same verdict is pushed to + /// the live feed as the snapshot_status + /// (AlarmSnapshotStatus) case of — + /// once at open and again on every change — so + /// a streaming consumer need not poll this property. + /// /// bool SnapshotTruncated { get; } /// - /// Attaches to the central alarm feed. The returned stream yields one - /// per currently-active alarm, then a - /// single snapshot_complete sentinel, then a transition - /// for every subsequent change. + /// Attaches to the central alarm feed. The returned stream opens with a + /// provider_status and a snapshot_status + /// — the current provider mode and + /// completeness verdict, so the caveats precede the records they + /// qualify — then one message per currently-active alarm, then a single + /// snapshot_complete sentinel, then a transition for every + /// subsequent change (and a further status message on every provider-mode + /// or truncation-verdict change). /// /// Optional alarm-reference prefix scoping the feed. /// Token that ends the subscription. diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmTruncationSignalTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmTruncationSignalTests.cs index e8f8772..b226a22 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmTruncationSignalTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmTruncationSignalTests.cs @@ -16,7 +16,8 @@ namespace ZB.MOM.WW.MxGateway.Tests.Alarms; /// /// Carries the worker's truncated-fetch verdict across the gateway: worker /// reply payload → → the public -/// QueryActiveAlarms stream. +/// QueryActiveAlarms stream (per record) and the live +/// StreamAlarms feed (the snapshot_status frame). /// /// /// @@ -145,6 +146,302 @@ public sealed class AlarmTruncationSignalTests await monitor.StopAsync(CancellationToken.None); } + /// + /// The feed-level edge. A reconcile that flips the verdict to truncated + /// pushes exactly one snapshot_status frame to every attached + /// subscriber; a following reconcile carrying the same verdict pushes + /// none. Without the edge test the frame could be emitted per reconcile — + /// a status frame every few seconds forever, which consumers would learn + /// to ignore. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task ReconcileFlippingToTruncated_EmitsOneSnapshotStatusFrame() + { + using GatewayMetrics metrics = new(); + StubSessionManager sessions = new(); + using GatewayAlarmMonitor monitor = CreateMonitor(sessions, metrics); + + using CancellationTokenSource cts = new(); + await monitor.StartAsync(cts.Token); + await sessions.WaitForReconcileAsync(WaitTimeout); + + List received = []; + TaskCompletionSource attached = new(TaskCreationOptions.RunContinuationsAsynchronously); + using CancellationTokenSource streamCts = new(); + Task reader = ReadFeedAsync(monitor, received, attached, streamCts.Token); + await attached.Task.WaitAsync(WaitTimeout); + + // The worker's next fetch comes back capped. A provider-mode event forces the reconcile + // immediately, so the test never waits on the periodic timer; each probe also broadcasts a + // provider_status frame, which is the barrier the assertions below count on. + sessions.SnapshotTruncated = true; + sessions.EmitEvent(ProviderModeProbe(1)); + + // A second, identical reconcile: same verdict, so no second frame. The third probe is only + // a barrier — the monitor handles provider-mode events one at a time and awaits the + // reconcile inside each, so its provider_status proves probe 2's reconcile has completed. + sessions.EmitEvent(ProviderModeProbe(2)); + sessions.EmitEvent(ProviderModeProbe(3)); + await WaitUntilAsync(() => CountOf(received, AlarmFeedMessage.PayloadOneofCase.ProviderStatus) >= 4, WaitTimeout); + + lock (received) + { + bool[] verdicts = received + .Where(m => m.PayloadCase == AlarmFeedMessage.PayloadOneofCase.SnapshotStatus) + .Select(m => m.SnapshotStatus.Truncated) + .ToArray(); + + // The first is this subscriber's open-time baseline; the second is the edge. + Assert.Equal([false, true], verdicts); + } + + await streamCts.CancelAsync(); + await reader; + await cts.CancelAsync(); + await monitor.StopAsync(CancellationToken.None); + } + + /// + /// The clearing edge. A fetch that comes back under the cap restores + /// absence authority, and the feed says so — otherwise a consumer that + /// saw the truncated frame would caveat its alarm set forever. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task ReconcileClearingTruncation_EmitsTheClearingFrame() + { + using GatewayMetrics metrics = new(); + StubSessionManager sessions = new() { SnapshotTruncated = true }; + using GatewayAlarmMonitor monitor = CreateMonitor(sessions, metrics); + + using CancellationTokenSource cts = new(); + await monitor.StartAsync(cts.Token); + await sessions.WaitForReconcileAsync(WaitTimeout); + await WaitUntilAsync(() => monitor.SnapshotTruncated, WaitTimeout); + + List received = []; + TaskCompletionSource attached = new(TaskCreationOptions.RunContinuationsAsynchronously); + using CancellationTokenSource streamCts = new(); + Task reader = ReadFeedAsync(monitor, received, attached, streamCts.Token); + await attached.Task.WaitAsync(WaitTimeout); + + sessions.SnapshotTruncated = false; + sessions.EmitEvent(ProviderModeProbe(1)); + + await WaitUntilAsync( + () => CountOf(received, AlarmFeedMessage.PayloadOneofCase.SnapshotStatus) >= 2, + WaitTimeout); + + lock (received) + { + bool[] verdicts = received + .Where(m => m.PayloadCase == AlarmFeedMessage.PayloadOneofCase.SnapshotStatus) + .Select(m => m.SnapshotStatus.Truncated) + .ToArray(); + Assert.Equal([true, false], verdicts); + } + + await streamCts.CancelAsync(); + await reader; + await cts.CancelAsync(); + await monitor.StopAsync(CancellationToken.None); + } + + /// + /// Late-joiner priming while the verdict is truncated. The frame must + /// arrive after provider_status and before the cached + /// active_alarm frames: a consumer that applies the snapshot as it + /// streams needs the completeness caveat in hand before the records it + /// qualifies, not after. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task SubscriberAttachingWhileTruncated_IsPrimedBeforeTheCachedSnapshot() + { + 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.SnapshotTruncated && monitor.CurrentAlarms.Count == 1, WaitTimeout); + + AlarmFeedMessage[] preamble = await ReadPreambleAsync(monitor); + + Assert.Equal( + [ + AlarmFeedMessage.PayloadOneofCase.ProviderStatus, + AlarmFeedMessage.PayloadOneofCase.SnapshotStatus, + AlarmFeedMessage.PayloadOneofCase.ActiveAlarm, + AlarmFeedMessage.PayloadOneofCase.SnapshotComplete, + ], + preamble.Select(m => m.PayloadCase).ToArray()); + Assert.True(preamble[1].SnapshotStatus.Truncated); + + await cts.CancelAsync(); + await monitor.StopAsync(CancellationToken.None); + } + + /// + /// The control for the priming frame: it is unconditional, so a + /// subscriber attaching to a complete feed is told so explicitly rather + /// than having to infer "no frame means not truncated" — an inference + /// that is indistinguishable from a gateway that never sends the frame. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task SubscriberAttachingWhileComplete_IsPrimedWithTheFalseVerdict() + { + using GatewayMetrics metrics = new(); + StubSessionManager sessions = new(); + using GatewayAlarmMonitor monitor = CreateMonitor(sessions, metrics); + + using CancellationTokenSource cts = new(); + await monitor.StartAsync(cts.Token); + await sessions.WaitForReconcileAsync(WaitTimeout); + + AlarmFeedMessage[] preamble = await ReadPreambleAsync(monitor); + + Assert.Equal( + [ + AlarmFeedMessage.PayloadOneofCase.ProviderStatus, + AlarmFeedMessage.PayloadOneofCase.SnapshotStatus, + AlarmFeedMessage.PayloadOneofCase.SnapshotComplete, + ], + preamble.Select(m => m.PayloadCase).ToArray()); + Assert.False(preamble[1].SnapshotStatus.Truncated); + + await cts.CancelAsync(); + await monitor.StopAsync(CancellationToken.None); + } + + /// + /// A monitor restart drops the truncation verdict with the cache + /// generation it describes. That drop is a completeness change like any + /// other, so subscribers — which outlive the monitor's worker session — + /// must see the clearing frame; otherwise a feed that silently re-seeds + /// leaves them caveating a set that is no longer truncated. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task MonitorRestartClearingTruncation_EmitsTheClearingFrame() + { + using GatewayMetrics metrics = new(); + StubSessionManager sessions = new() { SnapshotTruncated = true }; + using GatewayAlarmMonitor monitor = CreateMonitor(sessions, metrics); + + using CancellationTokenSource cts = new(); + await monitor.StartAsync(cts.Token); + await sessions.WaitForReconcileAsync(WaitTimeout); + await WaitUntilAsync(() => monitor.SnapshotTruncated, WaitTimeout); + + List received = []; + TaskCompletionSource attached = new(TaskCreationOptions.RunContinuationsAsynchronously); + using CancellationTokenSource streamCts = new(); + Task reader = ReadFeedAsync(monitor, received, attached, streamCts.Token); + await attached.Task.WaitAsync(WaitTimeout); + + // Ending the worker event stream faults the monitor lifecycle, which clears the cache. + sessions.EndWorkerEventStream(); + + await WaitUntilAsync( + () => CountOf(received, AlarmFeedMessage.PayloadOneofCase.SnapshotStatus) >= 2, + WaitTimeout); + + lock (received) + { + // Only the first two: the supervisor re-opens the session after its backoff, and that + // second lifecycle legitimately re-reports the truncated verdict. + bool[] verdicts = received + .Where(m => m.PayloadCase == AlarmFeedMessage.PayloadOneofCase.SnapshotStatus) + .Select(m => m.SnapshotStatus.Truncated) + .Take(2) + .ToArray(); + Assert.Equal([true, false], verdicts); + } + + await streamCts.CancelAsync(); + await reader; + await cts.CancelAsync(); + await monitor.StopAsync(CancellationToken.None); + } + + private static int CountOf(List received, AlarmFeedMessage.PayloadOneofCase payloadCase) + { + lock (received) + { + return received.Count(m => m.PayloadCase == payloadCase); + } + } + + // Drains the monitor's feed into received (every frame, preamble included), signalling gate + // once the subscriber is registered — that is, on its first frame. + private static Task ReadFeedAsync( + GatewayAlarmMonitor monitor, + List received, + TaskCompletionSource gate, + CancellationToken cancellationToken) + { + return Task.Run( + async () => + { + try + { + await foreach (AlarmFeedMessage message in monitor.StreamAsync(null, cancellationToken)) + { + lock (received) + { + received.Add(message); + } + + gate.TrySetResult(); + } + } + catch (OperationCanceledException) + { + // Expected when the test cancels the stream. + } + }, + CancellationToken.None); + } + + // Reads one subscriber's open-time preamble: everything up to and including snapshot_complete. + private static async Task ReadPreambleAsync(GatewayAlarmMonitor monitor) + { + List preamble = []; + using CancellationTokenSource streamCts = new(WaitTimeout); + await foreach (AlarmFeedMessage message in monitor.StreamAsync(null, streamCts.Token)) + { + preamble.Add(message); + if (message.PayloadCase == AlarmFeedMessage.PayloadOneofCase.SnapshotComplete) + { + break; + } + } + + return [.. preamble]; + } + + // A no-op provider-mode event. The monitor forces an immediate reconcile after every one, + // which is how these tests drive a reconcile pass without waiting on the periodic timer. + private static MxEvent ProviderModeProbe(ulong sequence) => new() + { + Family = MxEventFamily.OnAlarmProviderModeChanged, + WorkerSequence = sequence, + OnAlarmProviderModeChanged = new OnAlarmProviderModeChangedEvent + { + Mode = AlarmProviderMode.Alarmmgr, + Reason = "probe", + At = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + }, + }; + private static ActiveAlarmSnapshot NewSnapshot(string reference, bool fromTruncatedSnapshot) { return new ActiveAlarmSnapshot @@ -226,18 +523,43 @@ public sealed class AlarmTruncationSignalTests private readonly Channel _events = Channel.CreateUnbounded(); private readonly TaskCompletionSource _reconciled = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly object _sync = new(); + private bool _snapshotTruncated; + private IReadOnlyList _snapshots = []; - /// Gets or sets the truncation verdict the scripted reply carries. - public bool SnapshotTruncated { get; init; } + /// + /// Gets or sets the truncation verdict the scripted reply carries. Settable mid-test so + /// a suite can drive the verdict across reconciles and observe the feed-level edge. + /// + public bool SnapshotTruncated + { + get { lock (_sync) { return _snapshotTruncated; } } + set { lock (_sync) { _snapshotTruncated = value; } } + } /// Gets or sets the snapshots the scripted reply carries. - public IReadOnlyList Snapshots { get; init; } = []; + public IReadOnlyList Snapshots + { + get { lock (_sync) { return _snapshots; } } + set { lock (_sync) { _snapshots = value; } } + } /// 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); + /// Pushes a worker event into the session's distributor pump. + /// The event to push. + public void EmitEvent(MxEvent mxEvent) => + _events.Writer.TryWrite(new WorkerEvent { Event = mxEvent }); + + /// + /// Ends the worker event stream, which faults the monitor's lifecycle exactly as a + /// worker exit would and takes it through its cache-clearing teardown. + /// + public void EndWorkerEventStream() => _events.Writer.TryComplete(); + /// public Task OpenSessionAsync( SessionOpenRequest request, @@ -275,8 +597,13 @@ public sealed class AlarmTruncationSignalTests if (command.Command?.Kind == MxCommandKind.QueryActiveAlarms) { - QueryActiveAlarmsReplyPayload payload = new() { SnapshotTruncated = SnapshotTruncated }; - payload.Snapshots.AddRange(Snapshots.Select(snapshot => snapshot.Clone())); + QueryActiveAlarmsReplyPayload payload = new(); + lock (_sync) + { + payload.SnapshotTruncated = _snapshotTruncated; + payload.Snapshots.AddRange(_snapshots.Select(snapshot => snapshot.Clone())); + } + reply.QueryActiveAlarms = payload; _reconciled.TrySetResult(); } From c037d9960df650116810f1d23e0642a733961b90 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 17 Aug 2026 07:17:43 -0400 Subject: [PATCH 07/26] feat(dashboard): ApiKeysPage lists and accepts dashboard_tags constraints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The constraints column enumerated only the eight positional ApiKeyConstraints members, so a key whose sole recorded policy was a dashboard tag summarised to an empty string and rendered as "-" — the same cell a key with no policy at all gets. ApiKeyConstraints.IsEmpty counts DashboardTags, so that key is not unconstrained, and the column was quietly telling operators otherwise about a grant that decides who can watch a session's events. The create form had no dashboard-tags input either, so tagged keys could only be minted from the apikey create-key CLI. Adds the field beside the other constraint lists (same ParseList separators) and attaches it through the record's init-only member, since it postdates the eight-member constructor. CreateModel, OpenCreateDialog and TryBuildCreateRequest widen to internal for the new render tests: the create form is behind a click and static rendering cannot dispatch one. That is the assembly's existing InternalsVisibleTo seam. --- docs/Authorization.md | 10 +- .../Components/Pages/ApiKeysPage.razor | 48 ++- .../ApiKeysPageDashboardTagsTests.cs | 292 ++++++++++++++++++ 3 files changed, 343 insertions(+), 7 deletions(-) create mode 100644 src/ZB.MOM.WW.MxGateway.Tests/Dashboard/ApiKeysPageDashboardTagsTests.cs diff --git a/docs/Authorization.md b/docs/Authorization.md index bc4e7c9..dc06ab5 100644 --- a/docs/Authorization.md +++ b/docs/Authorization.md @@ -195,8 +195,14 @@ 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. +trimmed and de-duplicated ordinal-ignore-case). The dashboard API Keys page sets +them too: its create form has a **Dashboard tags** field alongside the data-access +constraints, split on the same separators the other constraint fields use. + +That page's constraints column names `dashboard_tags` like any other member. It +has to: `IsEmpty` counts the tags, so a key whose only recorded policy is a +dashboard tag is not unconstrained, and leaving it out of the summary rendered +that key with the same empty cell as a key with no policy at all. The dashboard ACL that consumes the tag shipped on 2026-08-17 (SEC-25 / TST-15). `IDashboardSessionAcl.CanViewSession` is consulted at both dashboard subscribe diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/ApiKeysPage.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/ApiKeysPage.razor index 2cefe4e..870c6e6 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/ApiKeysPage.razor +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/ApiKeysPage.razor @@ -115,6 +115,19 @@ else +
+ + +
+ Comma- or newline-separated; mirrors apikey create-key --dashboard-tags. + Matched case-insensitively against the viewer grants in + Dashboard:GroupToTag. Scopes dashboard event visibility only — + never what the key may read, write, or browse. Empty leaves the key's sessions + untagged, whose visibility follows Dashboard:UntaggedSessionVisibility. +
+
@@ -238,7 +251,14 @@ else GatewayScopes.Admin ]; - private ApiKeyCreateModel CreateModel { get; } = new(); + /// + /// Backing state for the create dialog. Internal rather than private so + /// ApiKeysPageDashboardTagsTests can drive the model-to-request mapping + /// directly — the assembly's established test seam (see InternalsVisibleTo + /// in Properties/AssemblyInfo.cs), because a create form behind a click is + /// unreachable from static rendering. + /// + internal ApiKeyCreateModel CreateModel { get; } = new(); private bool CanManageApiKeys { get; set; } @@ -399,7 +419,8 @@ else LastGeneratedApiKey = result.ApiKey; } - private void OpenCreateDialog() + /// Opens the create dialog. Internal so a render test can reach the form's markup. + internal void OpenCreateDialog() { IsCreateDialogOpen = true; } @@ -412,7 +433,11 @@ else } } - private bool TryBuildCreateRequest( + /// Maps onto a create request, or reports why it cannot. + /// The built request when this returns true. + /// The reason the model is unusable when this returns false. + /// True when the model produced a request. + internal bool TryBuildCreateRequest( [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out DashboardApiKeyManagementRequest? request, out string? validationMessage) { @@ -449,7 +474,12 @@ else MaxWriteClassification: maxWriteClassification, BrowseSubtrees: ParseList(CreateModel.BrowseSubtrees), ReadAlarmOnly: CreateModel.ReadAlarmOnly, - ReadHistorizedOnly: CreateModel.ReadHistorizedOnly)); + ReadHistorizedOnly: CreateModel.ReadHistorizedOnly) + { + // Init-only rather than positional (it was bolted onto the record after the + // eight-member constructor shipped), so it is attached here instead. + DashboardTags = ParseList(CreateModel.DashboardTags), + }); return true; } @@ -514,6 +544,11 @@ else AddList(parts, "read_tag_globs", constraints.ReadTagGlobs); AddList(parts, "write_tag_globs", constraints.WriteTagGlobs); AddList(parts, "browse_subtrees", constraints.BrowseSubtrees); + + // Listed like the rest even though it restricts no data path: IsEmpty counts it, so a key + // whose only policy is a dashboard tag is not "unconstrained", and omitting it here left + // that key's cell empty — rendered as "-", the same cell a key with no policy at all gets. + AddList(parts, "dashboard_tags", constraints.DashboardTags); if (constraints.MaxWriteClassification is { } max) { parts.Add($"max_write_classification={max}"); @@ -548,7 +583,7 @@ else .ToArray(); } - private sealed class ApiKeyCreateModel + internal sealed class ApiKeyCreateModel { public string KeyId { get; set; } = string.Empty; @@ -568,6 +603,8 @@ else public string MaxWriteClassification { get; set; } = string.Empty; + public string DashboardTags { get; set; } = string.Empty; + public bool ReadAlarmOnly { get; set; } public bool ReadHistorizedOnly { get; set; } @@ -583,6 +620,7 @@ else WriteTagGlobs = string.Empty; BrowseSubtrees = string.Empty; MaxWriteClassification = string.Empty; + DashboardTags = string.Empty; ReadAlarmOnly = false; ReadHistorizedOnly = false; } diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/ApiKeysPageDashboardTagsTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/ApiKeysPageDashboardTagsTests.cs new file mode 100644 index 0000000..fd98b5a --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/ApiKeysPageDashboardTagsTests.cs @@ -0,0 +1,292 @@ +using System.Runtime.CompilerServices; +using System.Security.Claims; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Components.HtmlRendering.Infrastructure; +using Microsoft.AspNetCore.Components.Web.HtmlRendering; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ZB.MOM.WW.MxGateway.Server.Dashboard; +using ZB.MOM.WW.MxGateway.Server.Dashboard.Components.Pages; +using ZB.MOM.WW.MxGateway.Server.Security.Authentication; + +namespace ZB.MOM.WW.MxGateway.Tests.Dashboard; + +/// +/// Covers dashboard_tags on the dashboard API Keys page: the constraints +/// column must name it, and the create form must be able to set it. +/// +/// +/// +/// The column mattered first. ApiKeyConstraints.IsEmpty counts +/// DashboardTags, so a key whose only per-key policy is a dashboard tag is +/// not unconstrained — but the page's summary enumerated only the eight +/// positional members, produced an empty string, and rendered it as -. An +/// operator auditing keys saw the same cell for "no policy recorded" and "scoped to +/// team-a", which is the reading a tag grant can least afford. +/// +/// +/// Rendered through the framework's static rendering infrastructure, the idiom +/// AlarmsPageTruncationBannerTests and SessionDetailsPageEventAclTests +/// use — the assertions are about emitted markup, so no component-testing package is +/// warranted. +/// +/// +public sealed class ApiKeysPageDashboardTagsTests +{ + // Deliberately free of the words the assertions search for: a key id containing + // "unconstrained" would be counted as a summary and mask a regression in the cell. + private const string TagsOnlyKeyId = "key-tagged"; + private const string UnconstrainedKeyId = "key-plain"; + + /// + /// A key whose only constraint is a dashboard tag names the tags in its constraints + /// cell. Before the fix this cell read -. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task ApiKeysPage_WhenKeyOnlyHasDashboardTags_NamesThemInTheConstraintsColumn() + { + string html = await RenderAsync(openCreateDialog: false); + + Assert.Contains("dashboard_tags=[team-a, team-b]", html, StringComparison.Ordinal); + } + + /// + /// The control for the assertion above: a key with genuinely no constraints must still + /// read unconstrained, and only that key may. Without this, a summary that + /// labelled every key would satisfy the positive case while erasing the distinction the + /// column exists to draw. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task ApiKeysPage_WhenKeyHasNoConstraints_StillReadsUnconstrained() + { + string html = await RenderAsync(openCreateDialog: false); + + Assert.Contains(UnconstrainedKeyId, html, StringComparison.Ordinal); + Assert.Contains("unconstrained", html, StringComparison.Ordinal); + + // Exactly one row may claim it — the tags-only key is constrained, by IsEmpty's own reckoning. + Assert.Equal(1, CountOccurrences(html, "unconstrained")); + } + + /// + /// The create form offers a dashboard-tags field, so a tagged key can be minted from the + /// dashboard rather than only from the apikey CLI. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task ApiKeysPage_CreateForm_OffersADashboardTagsField() + { + string html = await RenderAsync(openCreateDialog: true); + + // The sibling constraint field is the control: it proves the form rendered at all, so a + // dialog that failed to open could not pass this by rendering nothing. + Assert.Contains("id=\"readSubtrees\"", html, StringComparison.Ordinal); + Assert.Contains("id=\"dashboardTags\"", html, StringComparison.Ordinal); + Assert.Contains("Dashboard tags", html, StringComparison.Ordinal); + } + + /// + /// The field is wired through to the request, not merely displayed: the CLI's comma + /// separation is honoured and each tag is trimmed. + /// + [Fact] + public void TryBuildCreateRequest_CarriesTheDashboardTagsField() + { + ApiKeysPage page = new(); + page.CreateModel.DashboardTags = "team-a, team-b"; + + bool built = page.TryBuildCreateRequest(out DashboardApiKeyManagementRequest? request, out string? error); + + Assert.True(built, error); + Assert.NotNull(request); + Assert.Equal(["team-a", "team-b"], request.Constraints.DashboardTags); + + // Nothing else may be inferred from a tags-only form: dashboard tags are a visibility + // grant, and turning one into a data-access constraint would be a silent policy change. + Assert.False(request.Constraints.IsEmpty); + Assert.False(request.Constraints.HasReadConstraints); + Assert.False(request.Constraints.HasWriteConstraints); + } + + /// An empty field leaves the key untagged rather than inventing a tag. + [Fact] + public void TryBuildCreateRequest_WhenDashboardTagsIsBlank_LeavesTheKeyUntagged() + { + ApiKeysPage page = new(); + + bool built = page.TryBuildCreateRequest(out DashboardApiKeyManagementRequest? request, out string? error); + + Assert.True(built, error); + Assert.NotNull(request); + Assert.Empty(request.Constraints.DashboardTags); + Assert.True(request.Constraints.IsEmpty); + } + + private static int CountOccurrences(string haystack, string needle) + { + int count = 0; + int index = haystack.IndexOf(needle, StringComparison.Ordinal); + while (index >= 0) + { + count++; + index = haystack.IndexOf(needle, index + needle.Length, StringComparison.Ordinal); + } + + return count; + } + + private static async Task RenderAsync(bool openCreateDialog) + { + ServiceCollection services = new(); + services.AddLogging(); + services.AddSingleton(new StubSnapshotService()); + services.AddSingleton(new IdleSnapshotFeed()); + services.AddSingleton(new ManagingApiKeyService()); + services.AddSingleton(new StubAuthenticationStateProvider()); + + await using ServiceProvider provider = services.BuildServiceProvider(); + await using InstanceMountingHtmlRenderer renderer = new( + provider, + provider.GetRequiredService()); + + return await renderer.Dispatcher.InvokeAsync(async () => + { + ApiKeysPage page = renderer.CreateComponent(); + if (openCreateDialog) + { + page.OpenCreateDialog(); + } + + HtmlRootComponent output = renderer.BeginRenderingComponent(page, ParameterView.Empty); + await output.QuiescenceTask; + + return output.ToHtmlString(); + }); + } + + // The create form lives behind a click, and static rendering has no way to dispatch one, so + // the dialog is opened on the instance before it is handed to the renderer. Reaching a + // pre-built instance needs Renderer.InstantiateComponent (which is what performs [Inject] + // property injection); the sealed HtmlRenderer used by the sibling tests exposes no such seam, + // so this subclasses the same static-rendering infrastructure HtmlRenderer itself wraps. + // + // BL0006 warns that RenderTree types are not for use outside the Blazor framework. Suppressed + // here and only here, exactly as SessionDetailsPageEventAclTests does: this 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. Production code must keep honouring BL0006. +#pragma warning disable BL0006 + private sealed class InstanceMountingHtmlRenderer(IServiceProvider services, ILoggerFactory loggerFactory) + : StaticHtmlRenderer(services, loggerFactory) + { + /// Instantiates a component with its [Inject] properties resolved. + /// Component type to create. + /// The component instance, not yet attached to the renderer. + public TComponent CreateComponent() + where TComponent : IComponent => + (TComponent)InstantiateComponent(typeof(TComponent)); + } +#pragma warning restore BL0006 + + private sealed class StubSnapshotService : IDashboardSnapshotService + { + /// + public DashboardSnapshot GetSnapshot() => new( + GeneratedAt: DateTimeOffset.UnixEpoch, + GatewayStartedAt: DateTimeOffset.UnixEpoch, + GatewayUptime: TimeSpan.Zero, + GatewayStatus: "Healthy", + GatewayVersion: "test", + Sessions: [], + Workers: [], + Metrics: [], + Faults: [], + ApiKeys: + [ + new DashboardApiKeySummary( + KeyId: TagsOnlyKeyId, + DisplayName: "Tags only", + Scopes: new HashSet(StringComparer.Ordinal), + Constraints: ApiKeyConstraints.Empty with { DashboardTags = ["team-a", "team-b"] }, + CreatedUtc: DateTimeOffset.UnixEpoch, + LastUsedUtc: null, + RevokedUtc: null), + new DashboardApiKeySummary( + KeyId: UnconstrainedKeyId, + DisplayName: "Unconstrained", + Scopes: new HashSet(StringComparer.Ordinal), + Constraints: ApiKeyConstraints.Empty, + CreatedUtc: DateTimeOffset.UnixEpoch, + LastUsedUtc: null, + RevokedUtc: null), + ], + 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-render. + private sealed class IdleSnapshotFeed : IDashboardSnapshotFeed + { + /// + public async IAsyncEnumerable WatchAsync( + [EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false); + + yield break; + } + } + + // Grants management so the create dialog is reachable; no test here exercises a mutation. + private sealed class ManagingApiKeyService : IDashboardApiKeyManagementService + { + /// + public bool CanManage(ClaimsPrincipal user) => true; + + /// + public Task CreateAsync( + ClaimsPrincipal user, + DashboardApiKeyManagementRequest request, + CancellationToken cancellationToken) => + Task.FromResult(DashboardApiKeyManagementResult.Fail("not exercised")); + + /// + public Task RevokeAsync( + ClaimsPrincipal user, + string keyId, + CancellationToken cancellationToken) => + Task.FromResult(DashboardApiKeyManagementResult.Fail("not exercised")); + + /// + public Task RotateAsync( + ClaimsPrincipal user, + string keyId, + CancellationToken cancellationToken) => + Task.FromResult(DashboardApiKeyManagementResult.Fail("not exercised")); + + /// + public Task DeleteAsync( + ClaimsPrincipal user, + string keyId, + CancellationToken cancellationToken) => + Task.FromResult(DashboardApiKeyManagementResult.Fail("not exercised")); + } + + private sealed class StubAuthenticationStateProvider : AuthenticationStateProvider + { + /// + public override Task GetAuthenticationStateAsync() => + Task.FromResult(new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity( + [new Claim(ClaimTypes.Name, "admin-user"), new Claim(ClaimTypes.Role, DashboardRoles.Admin)], + authenticationType: "test", + nameType: ClaimTypes.Name, + roleType: ClaimTypes.Role)))); + } +} From ff16c69d59f55ae545dbca85632a5ddc080807c7 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 18 Aug 2026 05:14:58 -0400 Subject: [PATCH 08/26] fix(config): propagate GroupToTag's comparer into the effective-config projection --- .../Configuration/GatewayConfigurationProvider.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayConfigurationProvider.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayConfigurationProvider.cs index 0b30aa0..169769a 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayConfigurationProvider.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayConfigurationProvider.cs @@ -64,12 +64,13 @@ public sealed class GatewayConfigurationProvider(IOptions option ShowTagValues: value.Dashboard.ShowTagValues, GroupToRole: value.Dashboard.GroupToRole, // Rebuilt rather than passed through because the value type widens from string[] - // to IReadOnlyList; the comparer is carried over so the projected map - // still matches LDAP group names in whatever case the directory returns them. + // to IReadOnlyList; the source dictionary's own comparer is carried over + // (case-insensitive by default) so the projected map still matches LDAP group + // names in whatever case the directory returns them. GroupToTag: value.Dashboard.GroupToTag.ToDictionary( pair => pair.Key, pair => (IReadOnlyList)pair.Value, - StringComparer.OrdinalIgnoreCase), + value.Dashboard.GroupToTag.Comparer), UntaggedSessionVisibility: value.Dashboard.UntaggedSessionVisibility), Protocol: new EffectiveProtocolConfiguration( value.Protocol.WorkerProtocolVersion, From bf7b44c2d73f24f7c6a611420aba35729deccb59 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 18 Aug 2026 05:15:37 -0400 Subject: [PATCH 09/26] =?UTF-8?q?chore(plan):=20Task=205=20complete=20?= =?UTF-8?q?=E2=80=94=20settings=20rows=20approved=20with=20comparer=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/2026-08-17-followup-closeout.md.tasks.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-08-17-followup-closeout.md.tasks.json b/docs/plans/2026-08-17-followup-closeout.md.tasks.json index 4d536c3..ba8c196 100644 --- a/docs/plans/2026-08-17-followup-closeout.md.tasks.json +++ b/docs/plans/2026-08-17-followup-closeout.md.tasks.json @@ -5,7 +5,7 @@ {"id": 2, "subject": "Task 2: Five-client rollout of snapshot_status", "status": "pending", "blockedBy": [1, 3, 4]}, {"id": 3, "subject": "Task 3: Complete ShowTagValues coverage — alarms hub + /browse", "status": "pending"}, {"id": 4, "subject": "Task 4: Codegen guard — reverse-direction Check 3 + stale-note correction", "status": "completed", "commit": "711793f", "review": "Approved (code review, no issues)"}, - {"id": 5, "subject": "Task 5: Settings page — GroupToTag + UntaggedSessionVisibility rows", "status": "pending"}, + {"id": 5, "subject": "Task 5: Settings page — GroupToTag + UntaggedSessionVisibility rows", "status": "completed", "commits": ["094f2ff", "ff16c69"], "review": "Approved; comparer-propagation fix applied and re-verified"}, {"id": 6, "subject": "Task 6: ApiKeysPage — DashboardTags in ConstraintText + create-form input", "status": "pending"}, {"id": 7, "subject": "Task 7: Wnwrap alarm-probe retry — secured-write path (windev)", "status": "pending"}, {"id": 8, "subject": "Task 8: Windev full verification", "status": "pending", "blockedBy": [1, 2, 3, 4, 5, 6, 7]}, From 1ea6f60ea290a631bd007a547baf51865082e428 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 18 Aug 2026 05:18:47 -0400 Subject: [PATCH 10/26] fix(dashboard): keep the '-' placeholder on failed /browse reads instead of '[redacted]' --- .../Dashboard/DashboardLiveDataService.cs | 19 +++++-- .../DashboardLiveDataServiceTests.cs | 49 +++++++++++++++++++ 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardLiveDataService.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardLiveDataService.cs index d5bea3c..2239c65 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardLiveDataService.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardLiveDataService.cs @@ -53,9 +53,12 @@ public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsync /// /// MxGateway:Dashboard:ShowTagValues. False (the default) /// substitutes for every - /// value this service hands the Browse panel; quality, data type, source - /// timestamp, and any error still describe the real read, so the panel - /// remains a diagnostic surface without being a value-disclosure one. + /// successfully read value this service hands the Browse panel; quality, + /// data type, source timestamp, and any error still describe the real + /// read, so the panel remains a diagnostic surface without being a + /// value-disclosure one. Failed reads keep their existing "-" placeholder: + /// there was no value to suppress, so claiming one was withheld would + /// misreport the failure. /// private readonly bool _showTagValues; @@ -124,9 +127,17 @@ public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsync // The only place the /browse live-value gate is evaluated: the page // renders whatever ValueText it is handed, so a second check in the // view could only ever disagree with this one. + // + // A failed read is left alone: it has no value to suppress, and its + // ValueText is already the "-" placeholder. Substituting "[redacted]" + // there would tell the operator a value was withheld when the read + // never produced one — the Error column says why it failed, and the + // two must not contradict each other. DashboardTagValue[] values = results .Select(DashboardTagValue.FromBulkReadResult) - .Select(value => _showTagValues ? value : value with { ValueText = DashboardTagValue.RedactedValueText }) + .Select(value => _showTagValues || !value.Ok + ? value + : value with { ValueText = DashboardTagValue.RedactedValueText }) .ToArray(); return new DashboardLiveReadResult(values, null, session.SessionId, session.WorkerProcessId); } 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 6764124..9079e5f 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardLiveDataServiceTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardLiveDataServiceTests.cs @@ -237,6 +237,38 @@ public sealed class DashboardLiveDataServiceTests Assert.Equal("Double", value.DataType); } + /// + /// Verifies redaction is scoped to reads that actually produced a value: a + /// failed read keeps the "-" placeholder rather than claiming a value was + /// withheld, and its diagnostic still reaches the panel. Redacting it would + /// contradict the error the same row displays. + /// + [Fact] + public async Task ReadAsync_WhenReadFailedAndShowTagValuesFalse_LeavesPlaceholderUnredacted() + { + RecordingWorkerClient worker = new() + { + ReadValue = new MxValue { DataType = MxDataType.Double, DoubleValue = 42.5 }, + }; + worker.FailReadFor.Add("Bad.PV"); + await using FakeSessionManager sessionManager = new(worker); + await using DashboardLiveDataService service = CreateService(sessionManager, showTagValues: false); + + DashboardLiveReadResult result = await service.ReadAsync( + ["Bad.PV", "Tank_001.PV"], + CancellationToken.None); + + DashboardTagValue failed = result.Values.Single(value => value.TagAddress == "Bad.PV"); + Assert.False(failed.Ok); + Assert.Equal("-", failed.ValueText); + Assert.Equal("Simulated read failure.", failed.Error); + Assert.False(failed.QualityGood); + + // The successful row in the same read is still redacted. + DashboardTagValue succeeded = result.Values.Single(value => value.TagAddress == "Tank_001.PV"); + Assert.Equal(DashboardTagValue.RedactedValueText, succeeded.ValueText); + } + private static DashboardLiveDataService CreateService( ISessionManager sessionManager, bool showTagValues = false) @@ -387,6 +419,9 @@ public sealed class DashboardLiveDataServiceTests /// public MxValue? ReadValue { get; set; } + /// Gets the tag addresses whose bulk read comes back unsuccessful. + public HashSet FailReadFor { get; } = new(StringComparer.OrdinalIgnoreCase); + /// Gets the item handle bound for a previously subscribed tag. /// Tag address to look up. /// The bound item handle. @@ -499,6 +534,20 @@ public sealed class DashboardLiveDataServiceTests BulkReadReply readReply = new(); foreach (string tagAddress in tagAddresses) { + if (FailReadFor.Contains(tagAddress)) + { + readReply.Results.Add(new BulkReadResult + { + ServerHandle = RegisteredServerHandle, + TagAddress = tagAddress, + ItemHandle = _itemHandles.TryGetValue(tagAddress, out int failedHandle) ? failedHandle : 0, + WasSuccessful = false, + Quality = 0, + ErrorMessage = "Simulated read failure.", + }); + continue; + } + BulkReadResult readResult = new() { ServerHandle = RegisteredServerHandle, From fab600d3b04a18955709225d0c1912d32ca4db6a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 18 Aug 2026 05:19:02 -0400 Subject: [PATCH 11/26] fix(dashboard): de-duplicate dashboard_tags typed into the API-key create form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The form split tags with the shared ParseList and attached the result verbatim, so "team-a, TEAM-A" persisted as two entries and the constraints column read dashboard_tags=[team-a, TEAM-A] — one grant reported as two on the page whose job is to show what a key was granted. Enforcement never saw it (a session holds its tags in a case-insensitive set), which is exactly why the display was the only place it could surface. De-duplicates ordinal-ignore-case at the attach point only, first spelling winning, matching ApiKeyAdminCommandLineParser.ParseDashboardTags. ParseList is untouched: the five glob lists are matched literally, so near-duplicates there are not necessarily the same rule and must survive verbatim — pinned by a test. The help text claimed to mirror the CLI flag; it now claims only the shared separators and the dedupe, since the form still drops an empty segment silently where the CLI hard-fails. A browser form has no exit code to fail with, so that difference stays, and Authorization.md now records it. --- docs/Authorization.md | 5 +- .../Components/Pages/ApiKeysPage.razor | 17 ++++- .../ApiKeysPageDashboardTagsTests.cs | 66 +++++++++++++++++++ 3 files changed, 85 insertions(+), 3 deletions(-) diff --git a/docs/Authorization.md b/docs/Authorization.md index dc06ab5..b1fb269 100644 --- a/docs/Authorization.md +++ b/docs/Authorization.md @@ -197,7 +197,10 @@ 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). The dashboard API Keys page sets them too: its create form has a **Dashboard tags** field alongside the data-access -constraints, split on the same separators the other constraint fields use. +constraints, split on the same separators the other constraint fields use and +de-duplicated ordinal-ignore-case as the CLI does. The two differ on one point: a +stray separator drops an empty segment silently on the form rather than failing +the command, because a browser form has no exit code to fail with. That page's constraints column names `dashboard_tags` like any other member. It has to: `IsEmpty` counts the tags, so a key whose only recorded policy is a diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/ApiKeysPage.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/ApiKeysPage.razor index 870c6e6..62009d2 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/ApiKeysPage.razor +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/ApiKeysPage.razor @@ -121,7 +121,8 @@ else aria-describedby="dashboardTagsHelp" @bind="CreateModel.DashboardTags" @bind:event="oninput">
- Comma- or newline-separated; mirrors apikey create-key --dashboard-tags. + Split on the same separators as the constraint fields above, and + de-duplicated case-insensitively — two spellings of a tag are one grant. Matched case-insensitively against the viewer grants in Dashboard:GroupToTag. Scopes dashboard event visibility only — never what the key may read, write, or browse. Empty leaves the key's sessions @@ -478,7 +479,7 @@ else { // Init-only rather than positional (it was bolted onto the record after the // eight-member constructor shipped), so it is attached here instead. - DashboardTags = ParseList(CreateModel.DashboardTags), + DashboardTags = ParseDashboardTags(CreateModel.DashboardTags), }); return true; @@ -575,6 +576,18 @@ else } } + // Dashboard tags alone are de-duplicated ordinal-ignore-case, which is how the enforcement site + // compares them — two spellings are one grant, and ApiKeyAdminCommandLineParser.ParseDashboardTags + // collapses them the same way, first spelling winning. Persisting both would not change who can + // see what (GatewaySession holds its tags in a case-insensitive set) but the constraints column + // would report one grant twice, and a security grant that reads as two is the wrong kind of + // wrong on an audit surface. The other five list fields keep ParseList's verbatim behaviour: + // their globs are matched literally, so near-duplicates there are not necessarily the same rule. + private static IReadOnlyList ParseDashboardTags(string? value) + { + return [.. ParseList(value).Distinct(StringComparer.OrdinalIgnoreCase)]; + } + private static IReadOnlyList ParseList(string? value) { return (value ?? string.Empty) diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/ApiKeysPageDashboardTagsTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/ApiKeysPageDashboardTagsTests.cs index fd98b5a..be5659a 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/ApiKeysPageDashboardTagsTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/ApiKeysPageDashboardTagsTests.cs @@ -111,6 +111,72 @@ public sealed class ApiKeysPageDashboardTagsTests Assert.False(request.Constraints.HasWriteConstraints); } + /// + /// Two spellings of one tag persist as one grant, matching how + /// ApiKeyAdminCommandLineParser.ParseDashboardTags collapses them and how the + /// enforcement site compares them. + /// + /// + /// Enforcement would survive the duplicate — a session holds its tags in a + /// case-insensitive set — but the API Keys page's constraints column would render + /// dashboard_tags=[team-a, TEAM-A] and report one grant as two, which is the + /// reading an audit surface can least afford. + /// + [Fact] + public void TryBuildCreateRequest_DeduplicatesDashboardTagsIgnoringCase() + { + ApiKeysPage page = new(); + page.CreateModel.DashboardTags = "team-a, TEAM-A, team-a"; + + bool built = page.TryBuildCreateRequest(out DashboardApiKeyManagementRequest? request, out string? error); + + Assert.True(built, error); + Assert.NotNull(request); + + // First spelling typed wins, so what the operator wrote is what the audit surface shows. + Assert.Equal(["team-a"], request.Constraints.DashboardTags); + } + + /// + /// A dedupe that spanned fields would be a bug of its own: the five glob lists are matched + /// literally, so near-duplicates there are not necessarily the same rule and must survive + /// verbatim. + /// + [Fact] + public void TryBuildCreateRequest_DoesNotDeduplicateTheOtherConstraintLists() + { + ApiKeysPage page = new(); + page.CreateModel.ReadSubtrees = "Area1, AREA1"; + + bool built = page.TryBuildCreateRequest(out DashboardApiKeyManagementRequest? request, out string? error); + + Assert.True(built, error); + Assert.NotNull(request); + Assert.Equal(["Area1", "AREA1"], request.Constraints.ReadSubtrees); + } + + /// + /// Reset clears the new field, so the next key minted in the same dialog does not + /// inherit the previous key's tag grant. + /// + [Fact] + public void Reset_ClearsDashboardTagsAlongsideTheOtherConstraintFields() + { + ApiKeysPage page = new(); + page.CreateModel.DashboardTags = "team-a"; + + // A sibling field pins the assertion to Reset itself: were Reset a no-op, both would + // survive and the failure would name the method rather than the one line. + page.CreateModel.ReadSubtrees = "Area1"; + page.CreateModel.KeyId = "leftover"; + + page.CreateModel.Reset(); + + Assert.Equal(string.Empty, page.CreateModel.DashboardTags); + Assert.Equal(string.Empty, page.CreateModel.ReadSubtrees); + Assert.Equal(string.Empty, page.CreateModel.KeyId); + } + /// An empty field leaves the key untagged rather than inventing a tag. [Fact] public void TryBuildCreateRequest_WhenDashboardTagsIsBlank_LeavesTheKeyUntagged() From 312e072070bd2500559fd23d44b0d05ccb188462 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 18 Aug 2026 05:19:31 -0400 Subject: [PATCH 12/26] docs(proto): spell out the monitor-restart clearing emission on AlarmSnapshotStatus --- .../Generated/MxaccessGateway.cs | 5 ++++- .../Protos/mxaccess_gateway.proto | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/ZB.MOM.WW.MxGateway.Contracts/Generated/MxaccessGateway.cs b/src/ZB.MOM.WW.MxGateway.Contracts/Generated/MxaccessGateway.cs index 2979802..d171283 100644 --- a/src/ZB.MOM.WW.MxGateway.Contracts/Generated/MxaccessGateway.cs +++ b/src/ZB.MOM.WW.MxGateway.Contracts/Generated/MxaccessGateway.cs @@ -29318,7 +29318,10 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { /// Feed-level snapshot-completeness status. Emitted once on StreamAlarms open /// (after the initial provider_status frame, before the cached active_alarm /// frames) so late joiners learn the current verdict, and again on every change - /// of the truncation verdict observed at reconcile. Mirrors the per-record + /// of the truncation verdict — when a reconcile reports a different verdict, and + /// when the gateway's alarm monitor restarts and drops a truncated verdict with + /// the cache generation it described (feed subscribers outlive that monitor + /// session, so they are sent the clearing frame). Mirrors the per-record /// ActiveAlarmSnapshot.from_truncated_snapshot caveat at feed level so live /// consumers can reason about completeness without polling QueryActiveAlarms. /// 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 85d13a7..b5e2a6b 100644 --- a/src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto +++ b/src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto @@ -1035,7 +1035,10 @@ message AlarmProviderStatus { // Feed-level snapshot-completeness status. Emitted once on StreamAlarms open // (after the initial provider_status frame, before the cached active_alarm // frames) so late joiners learn the current verdict, and again on every change -// of the truncation verdict observed at reconcile. Mirrors the per-record +// of the truncation verdict — when a reconcile reports a different verdict, and +// when the gateway's alarm monitor restarts and drops a truncated verdict with +// the cache generation it described (feed subscribers outlive that monitor +// session, so they are sent the clearing frame). Mirrors the per-record // ActiveAlarmSnapshot.from_truncated_snapshot caveat at feed level so live // consumers can reason about completeness without polling QueryActiveAlarms. message AlarmSnapshotStatus { From e50ec493cbfc20f7a33cb56b7220d44aac8c1c9b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 18 Aug 2026 05:20:25 -0400 Subject: [PATCH 13/26] =?UTF-8?q?docs(config):=20ShowTagValues=20/browse?= =?UTF-8?q?=20clause=20=E2=80=94=20redaction=20applies=20to=20successful?= =?UTF-8?q?=20reads=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/GatewayConfiguration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/GatewayConfiguration.md b/docs/GatewayConfiguration.md index ffc1c52..4ce63b8 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 at all. `false` (the default) suppresses them on all three seams that carry one: (1) the **events hub mirror** — `DashboardEventBroadcaster` blanks `MxEvent.value` plus the alarm body's `current_value`/`limit_value` from a deep-cloned copy before it reaches any hub subscriber (see `docs/GatewayDashboardDesign.md`'s `EventsHub` row); (2) the **alarms hub** — `AlarmsHubPublisher` clears `current_value`/`limit_value` from a deep-cloned copy of each `AlarmFeedMessage`, on both value-bearing payload arms (`transition` and `active_alarm`), before broadcasting to `/hubs/alarms`; (3) the **`/browse` live-value panel** — `DashboardLiveDataService` substitutes the literal `[redacted]` for each tag's value text. Both hub redactions clone: the source `MxEvent` is shared with the gRPC event stream and the replay ring, and the source `AlarmFeedMessage` fans out to gRPC `StreamAlarms` subscribers, so neither is mutated in place and **no gRPC client is affected by this flag** — it is a dashboard-display control only. Everything that is not the value survives on every seam: tag reference, alarm reference/severity/state/operator, data type, quality, status, and timestamps still render, so the dashboard stays diagnostic without disclosing process values. This is one of two independent layers: `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` exposes tag values to everyone the ACL admits — every Administrator, plus each Viewer holding a matching tag — and, because the alarms hub is session-less, exposes alarm values to every dashboard client that can reach `/hubs/alarms`. | +| `MxGateway:Dashboard:ShowTagValues` | `false` | Controls whether tag values reach the dashboard at all. `false` (the default) suppresses them on all three seams that carry one: (1) the **events hub mirror** — `DashboardEventBroadcaster` blanks `MxEvent.value` plus the alarm body's `current_value`/`limit_value` from a deep-cloned copy before it reaches any hub subscriber (see `docs/GatewayDashboardDesign.md`'s `EventsHub` row); (2) the **alarms hub** — `AlarmsHubPublisher` clears `current_value`/`limit_value` from a deep-cloned copy of each `AlarmFeedMessage`, on both value-bearing payload arms (`transition` and `active_alarm`), before broadcasting to `/hubs/alarms`; (3) the **`/browse` live-value panel** — `DashboardLiveDataService` substitutes the literal `[redacted]` for the value text of each **successfully read** tag; a failed read keeps its `-` placeholder (there was no value to suppress, and the row's error is left untouched, so the two cannot contradict each other). Both hub redactions clone: the source `MxEvent` is shared with the gRPC event stream and the replay ring, and the source `AlarmFeedMessage` fans out to gRPC `StreamAlarms` subscribers, so neither is mutated in place and **no gRPC client is affected by this flag** — it is a dashboard-display control only. Everything that is not the value survives on every seam: tag reference, alarm reference/severity/state/operator, data type, quality, status, and timestamps still render, so the dashboard stays diagnostic without disclosing process values. This is one of two independent layers: `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` exposes tag values to everyone the ACL admits — every Administrator, plus each Viewer holding a matching tag — and, because the alarms hub is session-less, exposes alarm values to every dashboard client that can reach `/hubs/alarms`. | | `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. | From 51b0a8f55f51ffe5cad59cbf0bb89ffa3fa00df7 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 18 Aug 2026 05:21:11 -0400 Subject: [PATCH 14/26] =?UTF-8?q?chore(plan):=20Task=201=20complete=20?= =?UTF-8?q?=E2=80=94=20feed-level=20snapshot=5Fstatus=20approved?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/2026-08-17-followup-closeout.md.tasks.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-08-17-followup-closeout.md.tasks.json b/docs/plans/2026-08-17-followup-closeout.md.tasks.json index ba8c196..e72d25b 100644 --- a/docs/plans/2026-08-17-followup-closeout.md.tasks.json +++ b/docs/plans/2026-08-17-followup-closeout.md.tasks.json @@ -1,7 +1,7 @@ { "planPath": "docs/plans/2026-08-17-followup-closeout.md", "tasks": [ - {"id": 1, "subject": "Task 1: Feed-level alarm-truncation signal (proto + gateway)", "status": "pending"}, + {"id": 1, "subject": "Task 1: Feed-level alarm-truncation signal (proto + gateway)", "status": "completed", "commits": ["fccf753", "312e072"], "review": "Spec compliant; code review Approved, proto-comment Minor fixed"}, {"id": 2, "subject": "Task 2: Five-client rollout of snapshot_status", "status": "pending", "blockedBy": [1, 3, 4]}, {"id": 3, "subject": "Task 3: Complete ShowTagValues coverage — alarms hub + /browse", "status": "pending"}, {"id": 4, "subject": "Task 4: Codegen guard — reverse-direction Check 3 + stale-note correction", "status": "completed", "commit": "711793f", "review": "Approved (code review, no issues)"}, From 155c9d619ffcbf1cccdeaf0d38e1eac07e385646 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 18 Aug 2026 05:21:21 -0400 Subject: [PATCH 15/26] =?UTF-8?q?chore(plan):=20Task=206=20complete=20?= =?UTF-8?q?=E2=80=94=20dashboard=5Ftags=20display=20and=20create=20input?= =?UTF-8?q?=20approved?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/2026-08-17-followup-closeout.md.tasks.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-08-17-followup-closeout.md.tasks.json b/docs/plans/2026-08-17-followup-closeout.md.tasks.json index e72d25b..0910a27 100644 --- a/docs/plans/2026-08-17-followup-closeout.md.tasks.json +++ b/docs/plans/2026-08-17-followup-closeout.md.tasks.json @@ -6,7 +6,7 @@ {"id": 3, "subject": "Task 3: Complete ShowTagValues coverage — alarms hub + /browse", "status": "pending"}, {"id": 4, "subject": "Task 4: Codegen guard — reverse-direction Check 3 + stale-note correction", "status": "completed", "commit": "711793f", "review": "Approved (code review, no issues)"}, {"id": 5, "subject": "Task 5: Settings page — GroupToTag + UntaggedSessionVisibility rows", "status": "completed", "commits": ["094f2ff", "ff16c69"], "review": "Approved; comparer-propagation fix applied and re-verified"}, - {"id": 6, "subject": "Task 6: ApiKeysPage — DashboardTags in ConstraintText + create-form input", "status": "pending"}, + {"id": 6, "subject": "Task 6: ApiKeysPage — DashboardTags in ConstraintText + create-form input", "status": "completed", "commits": ["c037d99", "fab600d"], "review": "Spec compliant; code review Approved after dedupe/help-text fix, re-review Approved"}, {"id": 7, "subject": "Task 7: Wnwrap alarm-probe retry — secured-write path (windev)", "status": "pending"}, {"id": 8, "subject": "Task 8: Windev full verification", "status": "pending", "blockedBy": [1, 2, 3, 4, 5, 6, 7]}, {"id": 9, "subject": "Task 9: Bookkeeping — follow-ups closure + plan record", "status": "pending", "blockedBy": [8]} From d13144a9d72244e5bb000b93ace4121c844ba587 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 18 Aug 2026 05:21:45 -0400 Subject: [PATCH 16/26] =?UTF-8?q?chore(plan):=20Task=203=20complete=20?= =?UTF-8?q?=E2=80=94=20ShowTagValues=20coverage=20approved=20across=20all?= =?UTF-8?q?=20three=20seams?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/2026-08-17-followup-closeout.md.tasks.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-08-17-followup-closeout.md.tasks.json b/docs/plans/2026-08-17-followup-closeout.md.tasks.json index 0910a27..4c9b09c 100644 --- a/docs/plans/2026-08-17-followup-closeout.md.tasks.json +++ b/docs/plans/2026-08-17-followup-closeout.md.tasks.json @@ -3,7 +3,7 @@ "tasks": [ {"id": 1, "subject": "Task 1: Feed-level alarm-truncation signal (proto + gateway)", "status": "completed", "commits": ["fccf753", "312e072"], "review": "Spec compliant; code review Approved, proto-comment Minor fixed"}, {"id": 2, "subject": "Task 2: Five-client rollout of snapshot_status", "status": "pending", "blockedBy": [1, 3, 4]}, - {"id": 3, "subject": "Task 3: Complete ShowTagValues coverage — alarms hub + /browse", "status": "pending"}, + {"id": 3, "subject": "Task 3: Complete ShowTagValues coverage — alarms hub + /browse", "status": "completed", "commits": ["eff17d1", "1ea6f60", "e50ec49"], "review": "Spec compliant; code review Approved, failed-read placeholder fix re-review Approved"}, {"id": 4, "subject": "Task 4: Codegen guard — reverse-direction Check 3 + stale-note correction", "status": "completed", "commit": "711793f", "review": "Approved (code review, no issues)"}, {"id": 5, "subject": "Task 5: Settings page — GroupToTag + UntaggedSessionVisibility rows", "status": "completed", "commits": ["094f2ff", "ff16c69"], "review": "Approved; comparer-propagation fix applied and re-verified"}, {"id": 6, "subject": "Task 6: ApiKeysPage — DashboardTags in ConstraintText + create-form input", "status": "completed", "commits": ["c037d99", "fab600d"], "review": "Spec compliant; code review Approved after dedupe/help-text fix, re-review Approved"}, From c7483615cf63dace172df4f399282b3e6f8b4c55 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 18 Aug 2026 05:26:06 -0400 Subject: [PATCH 17/26] chore(clients): roll out feed-level snapshot_status to all five clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 1 added AlarmSnapshotStatus and AlarmFeedMessage.snapshot_status = 5. Carry it downstream from the canonical Contracts protos: - Rust vendored protos under clients/rust/protos, refreshed byte-identical (build.rs falls back to them for out-of-repo tarball builds) - 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) .NET needs no regeneration: the client compiles against the Contracts Generated/ output committed with the proto change. The hand-written CLI feed renderers switch on the payload oneof, so codegen alone does not carry the arm. Add snapshot-status to the .NET, Go, Rust, and Java renderers; the .NET and Go renderers were also missing provider-status, which has been on the wire since the provider-mode work, so add it there too. Java's renderer is an exhaustive switch expression and did not compile until the new case landed. The Python CLI renders generic protobuf-JSON and needs no change. Each client README gains a paragraph on the feed-level frame next to its existing from_truncated_snapshot paragraph: it arrives at stream open after provider_status and before the cached active_alarm frames, then on every verdict change including the clearing frame a monitor restart emits, so a live consumer can track set completeness without polling QueryActiveAlarms. GatewayDashboardDesign: list the two new payload cases the AlarmsHub forwards, and — separately — record the GroupToRole / GroupToTag / UntaggedSessionVisibility rows the settings page already renders but the bullet list omitted. --- clients/dotnet/README.md | 10 + .../MxGatewayClientCli.cs | 9 +- clients/go/README.md | 9 + clients/go/cmd/mxgw-go/main.go | 8 +- .../internal/generated/mxaccess_gateway.pb.go | 382 ++++-- clients/java/README.md | 10 + .../mxaccess_gateway/v1/MxaccessGateway.java | 1217 ++++++++++++++--- .../zb/mom/ww/mxgateway/cli/MxGatewayCli.java | 8 +- .../descriptors/mxaccessgw-client-v1.protoset | Bin 120969 -> 122424 bytes clients/python/README.md | 10 + .../generated/mxaccess_gateway_pb2.py | 110 +- clients/rust/README.md | 9 + clients/rust/crates/mxgw-cli/src/main.rs | 10 +- clients/rust/protos/mxaccess_gateway.proto | 21 + docs/GatewayDashboardDesign.md | 7 +- 15 files changed, 1412 insertions(+), 408 deletions(-) diff --git a/clients/dotnet/README.md b/clients/dotnet/README.md index bad5b6e..6a2864d 100644 --- a/clients/dotnet/README.md +++ b/clients/dotnet/README.md @@ -156,6 +156,16 @@ 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). +`StreamAlarmsAsync` also carries that completeness verdict at feed level, as an +`AlarmFeedMessage.PayloadOneofCase.SnapshotStatus` frame whose +`SnapshotStatus.Truncated` is true while the monitor's cached set derives from a +truncated fetch. One arrives at stream open (after the `ProviderStatus` frame, +before the cached `ActiveAlarm` frames) so a late joiner learns the current +verdict, then one on every verdict change — including the clearing frame sent +when the gateway's alarm monitor restarts and drops a truncated verdict. Track +it if you need set completeness on a live feed without polling +`QueryActiveAlarmsAsync`. + `MxGatewaySession.CloseAsync` is explicit and idempotent. Repeated calls return the first `CloseSessionReply` instead of sending another close request. diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayClientCli.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayClientCli.cs index cdd6e7d..353c21a 100644 --- a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayClientCli.cs +++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayClientCli.cs @@ -1546,8 +1546,9 @@ public static class MxGatewayClientCli /// /// Renders one for the human-readable /// (non-JSON) stream-alarms output, distinguishing the payload oneof - /// arms: a snapshot active alarm, the snapshot-complete sentinel, or a live - /// transition. + /// arms: a snapshot active alarm, the snapshot-complete sentinel, a live + /// transition, the provider-mode status, or the feed-level + /// snapshot-completeness status. /// private static string FormatAlarmFeedMessage(AlarmFeedMessage feedMessage) { @@ -1559,6 +1560,10 @@ public static class MxGatewayClientCli $"snapshot-complete {feedMessage.SnapshotComplete}", AlarmFeedMessage.PayloadOneofCase.Transition => $"transition {ProtobufJsonFormatter.Format(feedMessage.Transition)}", + AlarmFeedMessage.PayloadOneofCase.ProviderStatus => + $"provider-status {ProtobufJsonFormatter.Format(feedMessage.ProviderStatus)}", + AlarmFeedMessage.PayloadOneofCase.SnapshotStatus => + $"snapshot-status {ProtobufJsonFormatter.Format(feedMessage.SnapshotStatus)}", _ => $"unknown-payload {feedMessage.PayloadCase}", }; } diff --git a/clients/go/README.md b/clients/go/README.md index 86f9f62..84ea96a 100644 --- a/clients/go/README.md +++ b/clients/go/README.md @@ -153,6 +153,15 @@ 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). +`StreamAlarms` also carries that completeness verdict at feed level, as a frame +whose `GetSnapshotStatus()` is non-nil and whose `GetTruncated()` is true while +the monitor's cached set derives from a truncated fetch. One arrives at stream +open (after the `GetProviderStatus()` frame, before the cached +`GetActiveAlarm()` frames) so a late joiner learns the current verdict, then one +on every verdict change — including the clearing frame sent when the gateway's +alarm monitor restarts and drops a truncated verdict. Track it if you need set +completeness on a live feed without polling `QueryActiveAlarms`. + ## Write Semantics And Common Pitfalls These are MXAccess parity behaviors that surprise new callers. The gateway diff --git a/clients/go/cmd/mxgw-go/main.go b/clients/go/cmd/mxgw-go/main.go index 95898d1..159d3a2 100644 --- a/clients/go/cmd/mxgw-go/main.go +++ b/clients/go/cmd/mxgw-go/main.go @@ -1097,7 +1097,8 @@ func runStreamAlarms(ctx context.Context, args []string, stdout, stderr io.Write // formatAlarmFeedMessage renders one AlarmFeedMessage in the CLI's plain-text // output style, distinguishing the active-alarm snapshot, snapshot-complete -// sentinel, and transition cases of the message's payload oneof. +// sentinel, transition, provider-status, and snapshot-status cases of the +// message's payload oneof. func formatAlarmFeedMessage(message *mxgateway.AlarmFeedMessage) string { switch { case message.GetActiveAlarm() != nil: @@ -1108,6 +1109,11 @@ func formatAlarmFeedMessage(message *mxgateway.AlarmFeedMessage) string { case message.GetTransition() != nil: transition := message.GetTransition() return fmt.Sprintf("transition %s kind=%s severity=%d", transition.GetAlarmFullReference(), transition.GetTransitionKind(), transition.GetSeverity()) + case message.GetProviderStatus() != nil: + status := message.GetProviderStatus() + return fmt.Sprintf("provider-status mode=%s degraded=%t reason=%q", status.GetMode(), status.GetDegraded(), status.GetReason()) + case message.GetSnapshotStatus() != nil: + return fmt.Sprintf("snapshot-status truncated=%t", message.GetSnapshotStatus().GetTruncated()) default: return "unknown" } diff --git a/clients/go/internal/generated/mxaccess_gateway.pb.go b/clients/go/internal/generated/mxaccess_gateway.pb.go index f183bb9..4ef7d3a 100644 --- a/clients/go/internal/generated/mxaccess_gateway.pb.go +++ b/clients/go/internal/generated/mxaccess_gateway.pb.go @@ -7353,6 +7353,7 @@ type AlarmFeedMessage struct { // *AlarmFeedMessage_SnapshotComplete // *AlarmFeedMessage_Transition // *AlarmFeedMessage_ProviderStatus + // *AlarmFeedMessage_SnapshotStatus Payload isAlarmFeedMessage_Payload `protobuf_oneof:"payload"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -7431,6 +7432,15 @@ func (x *AlarmFeedMessage) GetProviderStatus() *AlarmProviderStatus { return nil } +func (x *AlarmFeedMessage) GetSnapshotStatus() *AlarmSnapshotStatus { + if x != nil { + if x, ok := x.Payload.(*AlarmFeedMessage_SnapshotStatus); ok { + return x.SnapshotStatus + } + } + return nil +} + type isAlarmFeedMessage_Payload interface { isAlarmFeedMessage_Payload() } @@ -7457,6 +7467,13 @@ type AlarmFeedMessage_ProviderStatus struct { ProviderStatus *AlarmProviderStatus `protobuf:"bytes,4,opt,name=provider_status,json=providerStatus,proto3,oneof"` } +type AlarmFeedMessage_SnapshotStatus struct { + // Snapshot-completeness status. Emitted once on stream open and again on + // every change of the truncation verdict, so late joiners learn whether the + // feed's active-alarm set may be incomplete. + SnapshotStatus *AlarmSnapshotStatus `protobuf:"bytes,5,opt,name=snapshot_status,json=snapshotStatus,proto3,oneof"` +} + func (*AlarmFeedMessage_ActiveAlarm) isAlarmFeedMessage_Payload() {} func (*AlarmFeedMessage_SnapshotComplete) isAlarmFeedMessage_Payload() {} @@ -7465,6 +7482,8 @@ func (*AlarmFeedMessage_Transition) isAlarmFeedMessage_Payload() {} func (*AlarmFeedMessage_ProviderStatus) isAlarmFeedMessage_Payload() {} +func (*AlarmFeedMessage_SnapshotStatus) isAlarmFeedMessage_Payload() {} + type AlarmProviderStatus struct { state protoimpl.MessageState `protogen:"open.v1"` Mode AlarmProviderMode `protobuf:"varint,1,opt,name=mode,proto3,enum=mxaccess_gateway.v1.AlarmProviderMode" json:"mode,omitempty"` @@ -7533,6 +7552,63 @@ func (x *AlarmProviderStatus) GetSince() *timestamppb.Timestamp { return nil } +// Feed-level snapshot-completeness status. Emitted once on StreamAlarms open +// (after the initial provider_status frame, before the cached active_alarm +// frames) so late joiners learn the current verdict, and again on every change +// of the truncation verdict — when a reconcile reports a different verdict, and +// when the gateway's alarm monitor restarts and drops a truncated verdict with +// the cache generation it described (feed subscribers outlive that monitor +// session, so they are sent the clearing frame). Mirrors the per-record +// ActiveAlarmSnapshot.from_truncated_snapshot caveat at feed level so live +// consumers can reason about completeness without polling QueryActiveAlarms. +type AlarmSnapshotStatus struct { + state protoimpl.MessageState `protogen:"open.v1"` + // True while the monitor's cached active-alarm set derives from a truncated + // (capped) worker fetch — the set may be missing alarms. Distinct from + // provider degradation (AlarmProviderStatus.degraded), which describes the + // fidelity of the records rather than the completeness of the set. + Truncated bool `protobuf:"varint,1,opt,name=truncated,proto3" json:"truncated,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AlarmSnapshotStatus) Reset() { + *x = AlarmSnapshotStatus{} + mi := &file_mxaccess_gateway_proto_msgTypes[87] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AlarmSnapshotStatus) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AlarmSnapshotStatus) ProtoMessage() {} + +func (x *AlarmSnapshotStatus) ProtoReflect() protoreflect.Message { + mi := &file_mxaccess_gateway_proto_msgTypes[87] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AlarmSnapshotStatus.ProtoReflect.Descriptor instead. +func (*AlarmSnapshotStatus) Descriptor() ([]byte, []int) { + return file_mxaccess_gateway_proto_rawDescGZIP(), []int{87} +} + +func (x *AlarmSnapshotStatus) GetTruncated() bool { + if x != nil { + return x.Truncated + } + return false +} + type MxStatusProxy struct { state protoimpl.MessageState `protogen:"open.v1"` // Mirrors the `success` member of the MXAccess MXSTATUS_PROXY struct @@ -7557,7 +7633,7 @@ type MxStatusProxy struct { func (x *MxStatusProxy) Reset() { *x = MxStatusProxy{} - mi := &file_mxaccess_gateway_proto_msgTypes[87] + mi := &file_mxaccess_gateway_proto_msgTypes[88] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7569,7 +7645,7 @@ func (x *MxStatusProxy) String() string { func (*MxStatusProxy) ProtoMessage() {} func (x *MxStatusProxy) ProtoReflect() protoreflect.Message { - mi := &file_mxaccess_gateway_proto_msgTypes[87] + mi := &file_mxaccess_gateway_proto_msgTypes[88] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7582,7 +7658,7 @@ func (x *MxStatusProxy) ProtoReflect() protoreflect.Message { // Deprecated: Use MxStatusProxy.ProtoReflect.Descriptor instead. func (*MxStatusProxy) Descriptor() ([]byte, []int) { - return file_mxaccess_gateway_proto_rawDescGZIP(), []int{87} + return file_mxaccess_gateway_proto_rawDescGZIP(), []int{88} } func (x *MxStatusProxy) GetSuccess() int32 { @@ -7660,7 +7736,7 @@ type MxValue struct { func (x *MxValue) Reset() { *x = MxValue{} - mi := &file_mxaccess_gateway_proto_msgTypes[88] + mi := &file_mxaccess_gateway_proto_msgTypes[89] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7672,7 +7748,7 @@ func (x *MxValue) String() string { func (*MxValue) ProtoMessage() {} func (x *MxValue) ProtoReflect() protoreflect.Message { - mi := &file_mxaccess_gateway_proto_msgTypes[88] + mi := &file_mxaccess_gateway_proto_msgTypes[89] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7685,7 +7761,7 @@ func (x *MxValue) ProtoReflect() protoreflect.Message { // Deprecated: Use MxValue.ProtoReflect.Descriptor instead. func (*MxValue) Descriptor() ([]byte, []int) { - return file_mxaccess_gateway_proto_rawDescGZIP(), []int{88} + return file_mxaccess_gateway_proto_rawDescGZIP(), []int{89} } func (x *MxValue) GetDataType() MxDataType { @@ -7908,7 +7984,7 @@ type MxArray struct { func (x *MxArray) Reset() { *x = MxArray{} - mi := &file_mxaccess_gateway_proto_msgTypes[89] + mi := &file_mxaccess_gateway_proto_msgTypes[90] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7920,7 +7996,7 @@ func (x *MxArray) String() string { func (*MxArray) ProtoMessage() {} func (x *MxArray) ProtoReflect() protoreflect.Message { - mi := &file_mxaccess_gateway_proto_msgTypes[89] + mi := &file_mxaccess_gateway_proto_msgTypes[90] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7933,7 +8009,7 @@ func (x *MxArray) ProtoReflect() protoreflect.Message { // Deprecated: Use MxArray.ProtoReflect.Descriptor instead. func (*MxArray) Descriptor() ([]byte, []int) { - return file_mxaccess_gateway_proto_rawDescGZIP(), []int{89} + return file_mxaccess_gateway_proto_rawDescGZIP(), []int{90} } func (x *MxArray) GetElementDataType() MxDataType { @@ -8117,7 +8193,7 @@ type MxSparseArray struct { func (x *MxSparseArray) Reset() { *x = MxSparseArray{} - mi := &file_mxaccess_gateway_proto_msgTypes[90] + mi := &file_mxaccess_gateway_proto_msgTypes[91] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8129,7 +8205,7 @@ func (x *MxSparseArray) String() string { func (*MxSparseArray) ProtoMessage() {} func (x *MxSparseArray) ProtoReflect() protoreflect.Message { - mi := &file_mxaccess_gateway_proto_msgTypes[90] + mi := &file_mxaccess_gateway_proto_msgTypes[91] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8142,7 +8218,7 @@ func (x *MxSparseArray) ProtoReflect() protoreflect.Message { // Deprecated: Use MxSparseArray.ProtoReflect.Descriptor instead. func (*MxSparseArray) Descriptor() ([]byte, []int) { - return file_mxaccess_gateway_proto_rawDescGZIP(), []int{90} + return file_mxaccess_gateway_proto_rawDescGZIP(), []int{91} } func (x *MxSparseArray) GetElementDataType() MxDataType { @@ -8176,7 +8252,7 @@ type MxSparseElement struct { func (x *MxSparseElement) Reset() { *x = MxSparseElement{} - mi := &file_mxaccess_gateway_proto_msgTypes[91] + mi := &file_mxaccess_gateway_proto_msgTypes[92] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8188,7 +8264,7 @@ func (x *MxSparseElement) String() string { func (*MxSparseElement) ProtoMessage() {} func (x *MxSparseElement) ProtoReflect() protoreflect.Message { - mi := &file_mxaccess_gateway_proto_msgTypes[91] + mi := &file_mxaccess_gateway_proto_msgTypes[92] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8201,7 +8277,7 @@ func (x *MxSparseElement) ProtoReflect() protoreflect.Message { // Deprecated: Use MxSparseElement.ProtoReflect.Descriptor instead. func (*MxSparseElement) Descriptor() ([]byte, []int) { - return file_mxaccess_gateway_proto_rawDescGZIP(), []int{91} + return file_mxaccess_gateway_proto_rawDescGZIP(), []int{92} } func (x *MxSparseElement) GetIndex() uint32 { @@ -8227,7 +8303,7 @@ type BoolArray struct { func (x *BoolArray) Reset() { *x = BoolArray{} - mi := &file_mxaccess_gateway_proto_msgTypes[92] + mi := &file_mxaccess_gateway_proto_msgTypes[93] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8239,7 +8315,7 @@ func (x *BoolArray) String() string { func (*BoolArray) ProtoMessage() {} func (x *BoolArray) ProtoReflect() protoreflect.Message { - mi := &file_mxaccess_gateway_proto_msgTypes[92] + mi := &file_mxaccess_gateway_proto_msgTypes[93] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8252,7 +8328,7 @@ func (x *BoolArray) ProtoReflect() protoreflect.Message { // Deprecated: Use BoolArray.ProtoReflect.Descriptor instead. func (*BoolArray) Descriptor() ([]byte, []int) { - return file_mxaccess_gateway_proto_rawDescGZIP(), []int{92} + return file_mxaccess_gateway_proto_rawDescGZIP(), []int{93} } func (x *BoolArray) GetValues() []bool { @@ -8271,7 +8347,7 @@ type Int32Array struct { func (x *Int32Array) Reset() { *x = Int32Array{} - mi := &file_mxaccess_gateway_proto_msgTypes[93] + mi := &file_mxaccess_gateway_proto_msgTypes[94] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8283,7 +8359,7 @@ func (x *Int32Array) String() string { func (*Int32Array) ProtoMessage() {} func (x *Int32Array) ProtoReflect() protoreflect.Message { - mi := &file_mxaccess_gateway_proto_msgTypes[93] + mi := &file_mxaccess_gateway_proto_msgTypes[94] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8296,7 +8372,7 @@ func (x *Int32Array) ProtoReflect() protoreflect.Message { // Deprecated: Use Int32Array.ProtoReflect.Descriptor instead. func (*Int32Array) Descriptor() ([]byte, []int) { - return file_mxaccess_gateway_proto_rawDescGZIP(), []int{93} + return file_mxaccess_gateway_proto_rawDescGZIP(), []int{94} } func (x *Int32Array) GetValues() []int32 { @@ -8315,7 +8391,7 @@ type Int64Array struct { func (x *Int64Array) Reset() { *x = Int64Array{} - mi := &file_mxaccess_gateway_proto_msgTypes[94] + mi := &file_mxaccess_gateway_proto_msgTypes[95] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8327,7 +8403,7 @@ func (x *Int64Array) String() string { func (*Int64Array) ProtoMessage() {} func (x *Int64Array) ProtoReflect() protoreflect.Message { - mi := &file_mxaccess_gateway_proto_msgTypes[94] + mi := &file_mxaccess_gateway_proto_msgTypes[95] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8340,7 +8416,7 @@ func (x *Int64Array) ProtoReflect() protoreflect.Message { // Deprecated: Use Int64Array.ProtoReflect.Descriptor instead. func (*Int64Array) Descriptor() ([]byte, []int) { - return file_mxaccess_gateway_proto_rawDescGZIP(), []int{94} + return file_mxaccess_gateway_proto_rawDescGZIP(), []int{95} } func (x *Int64Array) GetValues() []int64 { @@ -8359,7 +8435,7 @@ type FloatArray struct { func (x *FloatArray) Reset() { *x = FloatArray{} - mi := &file_mxaccess_gateway_proto_msgTypes[95] + mi := &file_mxaccess_gateway_proto_msgTypes[96] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8371,7 +8447,7 @@ func (x *FloatArray) String() string { func (*FloatArray) ProtoMessage() {} func (x *FloatArray) ProtoReflect() protoreflect.Message { - mi := &file_mxaccess_gateway_proto_msgTypes[95] + mi := &file_mxaccess_gateway_proto_msgTypes[96] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8384,7 +8460,7 @@ func (x *FloatArray) ProtoReflect() protoreflect.Message { // Deprecated: Use FloatArray.ProtoReflect.Descriptor instead. func (*FloatArray) Descriptor() ([]byte, []int) { - return file_mxaccess_gateway_proto_rawDescGZIP(), []int{95} + return file_mxaccess_gateway_proto_rawDescGZIP(), []int{96} } func (x *FloatArray) GetValues() []float32 { @@ -8403,7 +8479,7 @@ type DoubleArray struct { func (x *DoubleArray) Reset() { *x = DoubleArray{} - mi := &file_mxaccess_gateway_proto_msgTypes[96] + mi := &file_mxaccess_gateway_proto_msgTypes[97] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8415,7 +8491,7 @@ func (x *DoubleArray) String() string { func (*DoubleArray) ProtoMessage() {} func (x *DoubleArray) ProtoReflect() protoreflect.Message { - mi := &file_mxaccess_gateway_proto_msgTypes[96] + mi := &file_mxaccess_gateway_proto_msgTypes[97] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8428,7 +8504,7 @@ func (x *DoubleArray) ProtoReflect() protoreflect.Message { // Deprecated: Use DoubleArray.ProtoReflect.Descriptor instead. func (*DoubleArray) Descriptor() ([]byte, []int) { - return file_mxaccess_gateway_proto_rawDescGZIP(), []int{96} + return file_mxaccess_gateway_proto_rawDescGZIP(), []int{97} } func (x *DoubleArray) GetValues() []float64 { @@ -8447,7 +8523,7 @@ type StringArray struct { func (x *StringArray) Reset() { *x = StringArray{} - mi := &file_mxaccess_gateway_proto_msgTypes[97] + mi := &file_mxaccess_gateway_proto_msgTypes[98] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8459,7 +8535,7 @@ func (x *StringArray) String() string { func (*StringArray) ProtoMessage() {} func (x *StringArray) ProtoReflect() protoreflect.Message { - mi := &file_mxaccess_gateway_proto_msgTypes[97] + mi := &file_mxaccess_gateway_proto_msgTypes[98] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8472,7 +8548,7 @@ func (x *StringArray) ProtoReflect() protoreflect.Message { // Deprecated: Use StringArray.ProtoReflect.Descriptor instead. func (*StringArray) Descriptor() ([]byte, []int) { - return file_mxaccess_gateway_proto_rawDescGZIP(), []int{97} + return file_mxaccess_gateway_proto_rawDescGZIP(), []int{98} } func (x *StringArray) GetValues() []string { @@ -8491,7 +8567,7 @@ type TimestampArray struct { func (x *TimestampArray) Reset() { *x = TimestampArray{} - mi := &file_mxaccess_gateway_proto_msgTypes[98] + mi := &file_mxaccess_gateway_proto_msgTypes[99] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8503,7 +8579,7 @@ func (x *TimestampArray) String() string { func (*TimestampArray) ProtoMessage() {} func (x *TimestampArray) ProtoReflect() protoreflect.Message { - mi := &file_mxaccess_gateway_proto_msgTypes[98] + mi := &file_mxaccess_gateway_proto_msgTypes[99] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8516,7 +8592,7 @@ func (x *TimestampArray) ProtoReflect() protoreflect.Message { // Deprecated: Use TimestampArray.ProtoReflect.Descriptor instead. func (*TimestampArray) Descriptor() ([]byte, []int) { - return file_mxaccess_gateway_proto_rawDescGZIP(), []int{98} + return file_mxaccess_gateway_proto_rawDescGZIP(), []int{99} } func (x *TimestampArray) GetValues() []*timestamppb.Timestamp { @@ -8535,7 +8611,7 @@ type RawArray struct { func (x *RawArray) Reset() { *x = RawArray{} - mi := &file_mxaccess_gateway_proto_msgTypes[99] + mi := &file_mxaccess_gateway_proto_msgTypes[100] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8547,7 +8623,7 @@ func (x *RawArray) String() string { func (*RawArray) ProtoMessage() {} func (x *RawArray) ProtoReflect() protoreflect.Message { - mi := &file_mxaccess_gateway_proto_msgTypes[99] + mi := &file_mxaccess_gateway_proto_msgTypes[100] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8560,7 +8636,7 @@ func (x *RawArray) ProtoReflect() protoreflect.Message { // Deprecated: Use RawArray.ProtoReflect.Descriptor instead. func (*RawArray) Descriptor() ([]byte, []int) { - return file_mxaccess_gateway_proto_rawDescGZIP(), []int{99} + return file_mxaccess_gateway_proto_rawDescGZIP(), []int{100} } func (x *RawArray) GetValues() [][]byte { @@ -8580,7 +8656,7 @@ type ProtocolStatus struct { func (x *ProtocolStatus) Reset() { *x = ProtocolStatus{} - mi := &file_mxaccess_gateway_proto_msgTypes[100] + mi := &file_mxaccess_gateway_proto_msgTypes[101] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8592,7 +8668,7 @@ func (x *ProtocolStatus) String() string { func (*ProtocolStatus) ProtoMessage() {} func (x *ProtocolStatus) ProtoReflect() protoreflect.Message { - mi := &file_mxaccess_gateway_proto_msgTypes[100] + mi := &file_mxaccess_gateway_proto_msgTypes[101] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8605,7 +8681,7 @@ func (x *ProtocolStatus) ProtoReflect() protoreflect.Message { // Deprecated: Use ProtocolStatus.ProtoReflect.Descriptor instead. func (*ProtocolStatus) Descriptor() ([]byte, []int) { - return file_mxaccess_gateway_proto_rawDescGZIP(), []int{100} + return file_mxaccess_gateway_proto_rawDescGZIP(), []int{101} } func (x *ProtocolStatus) GetCode() ProtocolStatusCode { @@ -9115,20 +9191,23 @@ const file_mxaccess_gateway_proto_rawDesc = "" + "session_id\"y\n" + "\x13StreamAlarmsRequest\x122\n" + "\x15client_correlation_id\x18\x01 \x01(\tR\x13clientCorrelationId\x12.\n" + - "\x13alarm_filter_prefix\x18\x02 \x01(\tR\x11alarmFilterPrefix\"\xbf\x02\n" + + "\x13alarm_filter_prefix\x18\x02 \x01(\tR\x11alarmFilterPrefix\"\x94\x03\n" + "\x10AlarmFeedMessage\x12M\n" + "\factive_alarm\x18\x01 \x01(\v2(.mxaccess_gateway.v1.ActiveAlarmSnapshotH\x00R\vactiveAlarm\x12-\n" + "\x11snapshot_complete\x18\x02 \x01(\bH\x00R\x10snapshotComplete\x12M\n" + "\n" + "transition\x18\x03 \x01(\v2+.mxaccess_gateway.v1.OnAlarmTransitionEventH\x00R\n" + "transition\x12S\n" + - "\x0fprovider_status\x18\x04 \x01(\v2(.mxaccess_gateway.v1.AlarmProviderStatusH\x00R\x0eproviderStatusB\t\n" + + "\x0fprovider_status\x18\x04 \x01(\v2(.mxaccess_gateway.v1.AlarmProviderStatusH\x00R\x0eproviderStatus\x12S\n" + + "\x0fsnapshot_status\x18\x05 \x01(\v2(.mxaccess_gateway.v1.AlarmSnapshotStatusH\x00R\x0esnapshotStatusB\t\n" + "\apayload\"\xb7\x01\n" + "\x13AlarmProviderStatus\x12:\n" + "\x04mode\x18\x01 \x01(\x0e2&.mxaccess_gateway.v1.AlarmProviderModeR\x04mode\x12\x1a\n" + "\bdegraded\x18\x02 \x01(\bR\bdegraded\x12\x16\n" + "\x06reason\x18\x03 \x01(\tR\x06reason\x120\n" + - "\x05since\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\x05since\"\xbe\x02\n" + + "\x05since\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\x05since\"3\n" + + "\x13AlarmSnapshotStatus\x12\x1c\n" + + "\ttruncated\x18\x01 \x01(\bR\ttruncated\"\xbe\x02\n" + "\rMxStatusProxy\x12\x18\n" + "\asuccess\x18\x01 \x01(\x05R\asuccess\x12A\n" + "\bcategory\x18\x02 \x01(\x0e2%.mxaccess_gateway.v1.MxStatusCategoryR\bcategory\x12D\n" + @@ -9364,7 +9443,7 @@ func file_mxaccess_gateway_proto_rawDescGZIP() []byte { } var file_mxaccess_gateway_proto_enumTypes = make([]protoimpl.EnumInfo, 10) -var file_mxaccess_gateway_proto_msgTypes = make([]protoimpl.MessageInfo, 101) +var file_mxaccess_gateway_proto_msgTypes = make([]protoimpl.MessageInfo, 102) var file_mxaccess_gateway_proto_goTypes = []any{ (MxCommandKind)(0), // 0: mxaccess_gateway.v1.MxCommandKind (AlarmProviderMode)(0), // 1: mxaccess_gateway.v1.AlarmProviderMode @@ -9463,29 +9542,30 @@ var file_mxaccess_gateway_proto_goTypes = []any{ (*StreamAlarmsRequest)(nil), // 94: mxaccess_gateway.v1.StreamAlarmsRequest (*AlarmFeedMessage)(nil), // 95: mxaccess_gateway.v1.AlarmFeedMessage (*AlarmProviderStatus)(nil), // 96: mxaccess_gateway.v1.AlarmProviderStatus - (*MxStatusProxy)(nil), // 97: mxaccess_gateway.v1.MxStatusProxy - (*MxValue)(nil), // 98: mxaccess_gateway.v1.MxValue - (*MxArray)(nil), // 99: mxaccess_gateway.v1.MxArray - (*MxSparseArray)(nil), // 100: mxaccess_gateway.v1.MxSparseArray - (*MxSparseElement)(nil), // 101: mxaccess_gateway.v1.MxSparseElement - (*BoolArray)(nil), // 102: mxaccess_gateway.v1.BoolArray - (*Int32Array)(nil), // 103: mxaccess_gateway.v1.Int32Array - (*Int64Array)(nil), // 104: mxaccess_gateway.v1.Int64Array - (*FloatArray)(nil), // 105: mxaccess_gateway.v1.FloatArray - (*DoubleArray)(nil), // 106: mxaccess_gateway.v1.DoubleArray - (*StringArray)(nil), // 107: mxaccess_gateway.v1.StringArray - (*TimestampArray)(nil), // 108: mxaccess_gateway.v1.TimestampArray - (*RawArray)(nil), // 109: mxaccess_gateway.v1.RawArray - (*ProtocolStatus)(nil), // 110: mxaccess_gateway.v1.ProtocolStatus - (*durationpb.Duration)(nil), // 111: google.protobuf.Duration - (*timestamppb.Timestamp)(nil), // 112: google.protobuf.Timestamp + (*AlarmSnapshotStatus)(nil), // 97: mxaccess_gateway.v1.AlarmSnapshotStatus + (*MxStatusProxy)(nil), // 98: mxaccess_gateway.v1.MxStatusProxy + (*MxValue)(nil), // 99: mxaccess_gateway.v1.MxValue + (*MxArray)(nil), // 100: mxaccess_gateway.v1.MxArray + (*MxSparseArray)(nil), // 101: mxaccess_gateway.v1.MxSparseArray + (*MxSparseElement)(nil), // 102: mxaccess_gateway.v1.MxSparseElement + (*BoolArray)(nil), // 103: mxaccess_gateway.v1.BoolArray + (*Int32Array)(nil), // 104: mxaccess_gateway.v1.Int32Array + (*Int64Array)(nil), // 105: mxaccess_gateway.v1.Int64Array + (*FloatArray)(nil), // 106: mxaccess_gateway.v1.FloatArray + (*DoubleArray)(nil), // 107: mxaccess_gateway.v1.DoubleArray + (*StringArray)(nil), // 108: mxaccess_gateway.v1.StringArray + (*TimestampArray)(nil), // 109: mxaccess_gateway.v1.TimestampArray + (*RawArray)(nil), // 110: mxaccess_gateway.v1.RawArray + (*ProtocolStatus)(nil), // 111: mxaccess_gateway.v1.ProtocolStatus + (*durationpb.Duration)(nil), // 112: google.protobuf.Duration + (*timestamppb.Timestamp)(nil), // 113: google.protobuf.Timestamp } var file_mxaccess_gateway_proto_depIdxs = []int32{ - 111, // 0: mxaccess_gateway.v1.OpenSessionRequest.command_timeout:type_name -> google.protobuf.Duration - 111, // 1: mxaccess_gateway.v1.OpenSessionReply.default_command_timeout:type_name -> google.protobuf.Duration - 110, // 2: mxaccess_gateway.v1.OpenSessionReply.protocol_status:type_name -> mxaccess_gateway.v1.ProtocolStatus + 112, // 0: mxaccess_gateway.v1.OpenSessionRequest.command_timeout:type_name -> google.protobuf.Duration + 112, // 1: mxaccess_gateway.v1.OpenSessionReply.default_command_timeout:type_name -> google.protobuf.Duration + 111, // 2: mxaccess_gateway.v1.OpenSessionReply.protocol_status:type_name -> mxaccess_gateway.v1.ProtocolStatus 9, // 3: mxaccess_gateway.v1.CloseSessionReply.final_state:type_name -> mxaccess_gateway.v1.SessionState - 110, // 4: mxaccess_gateway.v1.CloseSessionReply.protocol_status:type_name -> mxaccess_gateway.v1.ProtocolStatus + 111, // 4: mxaccess_gateway.v1.CloseSessionReply.protocol_status:type_name -> mxaccess_gateway.v1.ProtocolStatus 17, // 5: mxaccess_gateway.v1.MxCommandRequest.command:type_name -> mxaccess_gateway.v1.MxCommand 0, // 6: mxaccess_gateway.v1.MxCommand.kind:type_name -> mxaccess_gateway.v1.MxCommandKind 18, // 7: mxaccess_gateway.v1.MxCommand.register:type_name -> mxaccess_gateway.v1.RegisterCommand @@ -9527,30 +9607,30 @@ var file_mxaccess_gateway_proto_depIdxs = []int32{ 60, // 43: mxaccess_gateway.v1.MxCommand.get_worker_info:type_name -> mxaccess_gateway.v1.GetWorkerInfoCommand 61, // 44: mxaccess_gateway.v1.MxCommand.drain_events:type_name -> mxaccess_gateway.v1.DrainEventsCommand 62, // 45: mxaccess_gateway.v1.MxCommand.shutdown_worker:type_name -> mxaccess_gateway.v1.ShutdownWorkerCommand - 98, // 46: mxaccess_gateway.v1.WriteCommand.value:type_name -> mxaccess_gateway.v1.MxValue - 98, // 47: mxaccess_gateway.v1.Write2Command.value:type_name -> mxaccess_gateway.v1.MxValue - 98, // 48: mxaccess_gateway.v1.Write2Command.timestamp_value:type_name -> mxaccess_gateway.v1.MxValue - 98, // 49: mxaccess_gateway.v1.WriteSecuredCommand.value:type_name -> mxaccess_gateway.v1.MxValue - 98, // 50: mxaccess_gateway.v1.WriteSecured2Command.value:type_name -> mxaccess_gateway.v1.MxValue - 98, // 51: mxaccess_gateway.v1.WriteSecured2Command.timestamp_value:type_name -> mxaccess_gateway.v1.MxValue + 99, // 46: mxaccess_gateway.v1.WriteCommand.value:type_name -> mxaccess_gateway.v1.MxValue + 99, // 47: mxaccess_gateway.v1.Write2Command.value:type_name -> mxaccess_gateway.v1.MxValue + 99, // 48: mxaccess_gateway.v1.Write2Command.timestamp_value:type_name -> mxaccess_gateway.v1.MxValue + 99, // 49: mxaccess_gateway.v1.WriteSecuredCommand.value:type_name -> mxaccess_gateway.v1.MxValue + 99, // 50: mxaccess_gateway.v1.WriteSecured2Command.value:type_name -> mxaccess_gateway.v1.MxValue + 99, // 51: mxaccess_gateway.v1.WriteSecured2Command.timestamp_value:type_name -> mxaccess_gateway.v1.MxValue 1, // 52: mxaccess_gateway.v1.SubscribeAlarmsCommand.forced_mode:type_name -> mxaccess_gateway.v1.AlarmProviderMode 43, // 53: mxaccess_gateway.v1.SubscribeAlarmsCommand.watch_list:type_name -> mxaccess_gateway.v1.AlarmSubtagTarget 44, // 54: mxaccess_gateway.v1.SubscribeAlarmsCommand.failover:type_name -> mxaccess_gateway.v1.AlarmFailoverConfig 50, // 55: mxaccess_gateway.v1.WriteBulkCommand.entries:type_name -> mxaccess_gateway.v1.WriteBulkEntry - 98, // 56: mxaccess_gateway.v1.WriteBulkEntry.value:type_name -> mxaccess_gateway.v1.MxValue + 99, // 56: mxaccess_gateway.v1.WriteBulkEntry.value:type_name -> mxaccess_gateway.v1.MxValue 52, // 57: mxaccess_gateway.v1.Write2BulkCommand.entries:type_name -> mxaccess_gateway.v1.Write2BulkEntry - 98, // 58: mxaccess_gateway.v1.Write2BulkEntry.value:type_name -> mxaccess_gateway.v1.MxValue - 98, // 59: mxaccess_gateway.v1.Write2BulkEntry.timestamp_value:type_name -> mxaccess_gateway.v1.MxValue + 99, // 58: mxaccess_gateway.v1.Write2BulkEntry.value:type_name -> mxaccess_gateway.v1.MxValue + 99, // 59: mxaccess_gateway.v1.Write2BulkEntry.timestamp_value:type_name -> mxaccess_gateway.v1.MxValue 54, // 60: mxaccess_gateway.v1.WriteSecuredBulkCommand.entries:type_name -> mxaccess_gateway.v1.WriteSecuredBulkEntry - 98, // 61: mxaccess_gateway.v1.WriteSecuredBulkEntry.value:type_name -> mxaccess_gateway.v1.MxValue + 99, // 61: mxaccess_gateway.v1.WriteSecuredBulkEntry.value:type_name -> mxaccess_gateway.v1.MxValue 56, // 62: mxaccess_gateway.v1.WriteSecured2BulkCommand.entries:type_name -> mxaccess_gateway.v1.WriteSecured2BulkEntry - 98, // 63: mxaccess_gateway.v1.WriteSecured2BulkEntry.value:type_name -> mxaccess_gateway.v1.MxValue - 98, // 64: mxaccess_gateway.v1.WriteSecured2BulkEntry.timestamp_value:type_name -> mxaccess_gateway.v1.MxValue - 111, // 65: mxaccess_gateway.v1.ShutdownWorkerCommand.grace_period:type_name -> google.protobuf.Duration + 99, // 63: mxaccess_gateway.v1.WriteSecured2BulkEntry.value:type_name -> mxaccess_gateway.v1.MxValue + 99, // 64: mxaccess_gateway.v1.WriteSecured2BulkEntry.timestamp_value:type_name -> mxaccess_gateway.v1.MxValue + 112, // 65: mxaccess_gateway.v1.ShutdownWorkerCommand.grace_period:type_name -> google.protobuf.Duration 0, // 66: mxaccess_gateway.v1.MxCommandReply.kind:type_name -> mxaccess_gateway.v1.MxCommandKind - 110, // 67: mxaccess_gateway.v1.MxCommandReply.protocol_status:type_name -> mxaccess_gateway.v1.ProtocolStatus - 98, // 68: mxaccess_gateway.v1.MxCommandReply.return_value:type_name -> mxaccess_gateway.v1.MxValue - 97, // 69: mxaccess_gateway.v1.MxCommandReply.statuses:type_name -> mxaccess_gateway.v1.MxStatusProxy + 111, // 67: mxaccess_gateway.v1.MxCommandReply.protocol_status:type_name -> mxaccess_gateway.v1.ProtocolStatus + 99, // 68: mxaccess_gateway.v1.MxCommandReply.return_value:type_name -> mxaccess_gateway.v1.MxValue + 98, // 69: mxaccess_gateway.v1.MxCommandReply.statuses:type_name -> mxaccess_gateway.v1.MxStatusProxy 64, // 70: mxaccess_gateway.v1.MxCommandReply.register:type_name -> mxaccess_gateway.v1.RegisterReply 65, // 71: mxaccess_gateway.v1.MxCommandReply.add_item:type_name -> mxaccess_gateway.v1.AddItemReply 66, // 72: mxaccess_gateway.v1.MxCommandReply.add_item2:type_name -> mxaccess_gateway.v1.AddItem2Reply @@ -9575,24 +9655,24 @@ var file_mxaccess_gateway_proto_depIdxs = []int32{ 78, // 91: mxaccess_gateway.v1.MxCommandReply.session_state:type_name -> mxaccess_gateway.v1.SessionStateReply 79, // 92: mxaccess_gateway.v1.MxCommandReply.worker_info:type_name -> mxaccess_gateway.v1.WorkerInfoReply 80, // 93: mxaccess_gateway.v1.MxCommandReply.drain_events:type_name -> mxaccess_gateway.v1.DrainEventsReply - 97, // 94: mxaccess_gateway.v1.SuspendReply.status:type_name -> mxaccess_gateway.v1.MxStatusProxy - 97, // 95: mxaccess_gateway.v1.ActivateReply.status:type_name -> mxaccess_gateway.v1.MxStatusProxy + 98, // 94: mxaccess_gateway.v1.SuspendReply.status:type_name -> mxaccess_gateway.v1.MxStatusProxy + 98, // 95: mxaccess_gateway.v1.ActivateReply.status:type_name -> mxaccess_gateway.v1.MxStatusProxy 72, // 96: mxaccess_gateway.v1.BulkSubscribeReply.results:type_name -> mxaccess_gateway.v1.SubscribeResult - 97, // 97: mxaccess_gateway.v1.BulkWriteResult.statuses:type_name -> mxaccess_gateway.v1.MxStatusProxy + 98, // 97: mxaccess_gateway.v1.BulkWriteResult.statuses:type_name -> mxaccess_gateway.v1.MxStatusProxy 74, // 98: mxaccess_gateway.v1.BulkWriteReply.results:type_name -> mxaccess_gateway.v1.BulkWriteResult - 98, // 99: mxaccess_gateway.v1.BulkReadResult.value:type_name -> mxaccess_gateway.v1.MxValue - 112, // 100: mxaccess_gateway.v1.BulkReadResult.source_timestamp:type_name -> google.protobuf.Timestamp - 97, // 101: mxaccess_gateway.v1.BulkReadResult.statuses:type_name -> mxaccess_gateway.v1.MxStatusProxy + 99, // 99: mxaccess_gateway.v1.BulkReadResult.value:type_name -> mxaccess_gateway.v1.MxValue + 113, // 100: mxaccess_gateway.v1.BulkReadResult.source_timestamp:type_name -> google.protobuf.Timestamp + 98, // 101: mxaccess_gateway.v1.BulkReadResult.statuses:type_name -> mxaccess_gateway.v1.MxStatusProxy 76, // 102: mxaccess_gateway.v1.BulkReadReply.results:type_name -> mxaccess_gateway.v1.BulkReadResult 9, // 103: mxaccess_gateway.v1.SessionStateReply.state:type_name -> mxaccess_gateway.v1.SessionState 83, // 104: mxaccess_gateway.v1.DrainEventsReply.events:type_name -> mxaccess_gateway.v1.MxEvent 91, // 105: mxaccess_gateway.v1.QueryActiveAlarmsReplyPayload.snapshots:type_name -> mxaccess_gateway.v1.ActiveAlarmSnapshot 2, // 106: mxaccess_gateway.v1.MxEvent.family:type_name -> mxaccess_gateway.v1.MxEventFamily - 98, // 107: mxaccess_gateway.v1.MxEvent.value:type_name -> mxaccess_gateway.v1.MxValue - 112, // 108: mxaccess_gateway.v1.MxEvent.source_timestamp:type_name -> google.protobuf.Timestamp - 97, // 109: mxaccess_gateway.v1.MxEvent.statuses:type_name -> mxaccess_gateway.v1.MxStatusProxy - 112, // 110: mxaccess_gateway.v1.MxEvent.worker_timestamp:type_name -> google.protobuf.Timestamp - 112, // 111: mxaccess_gateway.v1.MxEvent.gateway_receive_timestamp:type_name -> google.protobuf.Timestamp + 99, // 107: mxaccess_gateway.v1.MxEvent.value:type_name -> mxaccess_gateway.v1.MxValue + 113, // 108: mxaccess_gateway.v1.MxEvent.source_timestamp:type_name -> google.protobuf.Timestamp + 98, // 109: mxaccess_gateway.v1.MxEvent.statuses:type_name -> mxaccess_gateway.v1.MxStatusProxy + 113, // 110: mxaccess_gateway.v1.MxEvent.worker_timestamp:type_name -> google.protobuf.Timestamp + 113, // 111: mxaccess_gateway.v1.MxEvent.gateway_receive_timestamp:type_name -> google.protobuf.Timestamp 84, // 112: mxaccess_gateway.v1.MxEvent.replay_gap:type_name -> mxaccess_gateway.v1.ReplayGap 85, // 113: mxaccess_gateway.v1.MxEvent.on_data_change:type_name -> mxaccess_gateway.v1.OnDataChangeEvent 86, // 114: mxaccess_gateway.v1.MxEvent.on_write_complete:type_name -> mxaccess_gateway.v1.OnWriteCompleteEvent @@ -9601,68 +9681,69 @@ var file_mxaccess_gateway_proto_depIdxs = []int32{ 89, // 117: mxaccess_gateway.v1.MxEvent.on_alarm_transition:type_name -> mxaccess_gateway.v1.OnAlarmTransitionEvent 90, // 118: mxaccess_gateway.v1.MxEvent.on_alarm_provider_mode_changed:type_name -> mxaccess_gateway.v1.OnAlarmProviderModeChangedEvent 7, // 119: mxaccess_gateway.v1.OnBufferedDataChangeEvent.data_type:type_name -> mxaccess_gateway.v1.MxDataType - 99, // 120: mxaccess_gateway.v1.OnBufferedDataChangeEvent.quality_values:type_name -> mxaccess_gateway.v1.MxArray - 99, // 121: mxaccess_gateway.v1.OnBufferedDataChangeEvent.timestamp_values:type_name -> mxaccess_gateway.v1.MxArray + 100, // 120: mxaccess_gateway.v1.OnBufferedDataChangeEvent.quality_values:type_name -> mxaccess_gateway.v1.MxArray + 100, // 121: mxaccess_gateway.v1.OnBufferedDataChangeEvent.timestamp_values:type_name -> mxaccess_gateway.v1.MxArray 3, // 122: mxaccess_gateway.v1.OnAlarmTransitionEvent.transition_kind:type_name -> mxaccess_gateway.v1.AlarmTransitionKind - 112, // 123: mxaccess_gateway.v1.OnAlarmTransitionEvent.original_raise_timestamp:type_name -> google.protobuf.Timestamp - 112, // 124: mxaccess_gateway.v1.OnAlarmTransitionEvent.transition_timestamp:type_name -> google.protobuf.Timestamp - 98, // 125: mxaccess_gateway.v1.OnAlarmTransitionEvent.current_value:type_name -> mxaccess_gateway.v1.MxValue - 98, // 126: mxaccess_gateway.v1.OnAlarmTransitionEvent.limit_value:type_name -> mxaccess_gateway.v1.MxValue + 113, // 123: mxaccess_gateway.v1.OnAlarmTransitionEvent.original_raise_timestamp:type_name -> google.protobuf.Timestamp + 113, // 124: mxaccess_gateway.v1.OnAlarmTransitionEvent.transition_timestamp:type_name -> google.protobuf.Timestamp + 99, // 125: mxaccess_gateway.v1.OnAlarmTransitionEvent.current_value:type_name -> mxaccess_gateway.v1.MxValue + 99, // 126: mxaccess_gateway.v1.OnAlarmTransitionEvent.limit_value:type_name -> mxaccess_gateway.v1.MxValue 1, // 127: mxaccess_gateway.v1.OnAlarmTransitionEvent.source_provider:type_name -> mxaccess_gateway.v1.AlarmProviderMode 1, // 128: mxaccess_gateway.v1.OnAlarmProviderModeChangedEvent.mode:type_name -> mxaccess_gateway.v1.AlarmProviderMode - 112, // 129: mxaccess_gateway.v1.OnAlarmProviderModeChangedEvent.at:type_name -> google.protobuf.Timestamp - 112, // 130: mxaccess_gateway.v1.ActiveAlarmSnapshot.original_raise_timestamp:type_name -> google.protobuf.Timestamp + 113, // 129: mxaccess_gateway.v1.OnAlarmProviderModeChangedEvent.at:type_name -> google.protobuf.Timestamp + 113, // 130: mxaccess_gateway.v1.ActiveAlarmSnapshot.original_raise_timestamp:type_name -> google.protobuf.Timestamp 4, // 131: mxaccess_gateway.v1.ActiveAlarmSnapshot.current_state:type_name -> mxaccess_gateway.v1.AlarmConditionState - 112, // 132: mxaccess_gateway.v1.ActiveAlarmSnapshot.last_transition_timestamp:type_name -> google.protobuf.Timestamp - 98, // 133: mxaccess_gateway.v1.ActiveAlarmSnapshot.current_value:type_name -> mxaccess_gateway.v1.MxValue - 98, // 134: mxaccess_gateway.v1.ActiveAlarmSnapshot.limit_value:type_name -> mxaccess_gateway.v1.MxValue + 113, // 132: mxaccess_gateway.v1.ActiveAlarmSnapshot.last_transition_timestamp:type_name -> google.protobuf.Timestamp + 99, // 133: mxaccess_gateway.v1.ActiveAlarmSnapshot.current_value:type_name -> mxaccess_gateway.v1.MxValue + 99, // 134: mxaccess_gateway.v1.ActiveAlarmSnapshot.limit_value:type_name -> mxaccess_gateway.v1.MxValue 1, // 135: mxaccess_gateway.v1.ActiveAlarmSnapshot.source_provider:type_name -> mxaccess_gateway.v1.AlarmProviderMode - 110, // 136: mxaccess_gateway.v1.AcknowledgeAlarmReply.protocol_status:type_name -> mxaccess_gateway.v1.ProtocolStatus - 97, // 137: mxaccess_gateway.v1.AcknowledgeAlarmReply.status:type_name -> mxaccess_gateway.v1.MxStatusProxy + 111, // 136: mxaccess_gateway.v1.AcknowledgeAlarmReply.protocol_status:type_name -> mxaccess_gateway.v1.ProtocolStatus + 98, // 137: mxaccess_gateway.v1.AcknowledgeAlarmReply.status:type_name -> mxaccess_gateway.v1.MxStatusProxy 91, // 138: mxaccess_gateway.v1.AlarmFeedMessage.active_alarm:type_name -> mxaccess_gateway.v1.ActiveAlarmSnapshot 89, // 139: mxaccess_gateway.v1.AlarmFeedMessage.transition:type_name -> mxaccess_gateway.v1.OnAlarmTransitionEvent 96, // 140: mxaccess_gateway.v1.AlarmFeedMessage.provider_status:type_name -> mxaccess_gateway.v1.AlarmProviderStatus - 1, // 141: mxaccess_gateway.v1.AlarmProviderStatus.mode:type_name -> mxaccess_gateway.v1.AlarmProviderMode - 112, // 142: mxaccess_gateway.v1.AlarmProviderStatus.since:type_name -> google.protobuf.Timestamp - 5, // 143: mxaccess_gateway.v1.MxStatusProxy.category:type_name -> mxaccess_gateway.v1.MxStatusCategory - 6, // 144: mxaccess_gateway.v1.MxStatusProxy.detected_by:type_name -> mxaccess_gateway.v1.MxStatusSource - 7, // 145: mxaccess_gateway.v1.MxValue.data_type:type_name -> mxaccess_gateway.v1.MxDataType - 112, // 146: mxaccess_gateway.v1.MxValue.timestamp_value:type_name -> google.protobuf.Timestamp - 99, // 147: mxaccess_gateway.v1.MxValue.array_value:type_name -> mxaccess_gateway.v1.MxArray - 100, // 148: mxaccess_gateway.v1.MxValue.sparse_array_value:type_name -> mxaccess_gateway.v1.MxSparseArray - 7, // 149: mxaccess_gateway.v1.MxArray.element_data_type:type_name -> mxaccess_gateway.v1.MxDataType - 102, // 150: mxaccess_gateway.v1.MxArray.bool_values:type_name -> mxaccess_gateway.v1.BoolArray - 103, // 151: mxaccess_gateway.v1.MxArray.int32_values:type_name -> mxaccess_gateway.v1.Int32Array - 104, // 152: mxaccess_gateway.v1.MxArray.int64_values:type_name -> mxaccess_gateway.v1.Int64Array - 105, // 153: mxaccess_gateway.v1.MxArray.float_values:type_name -> mxaccess_gateway.v1.FloatArray - 106, // 154: mxaccess_gateway.v1.MxArray.double_values:type_name -> mxaccess_gateway.v1.DoubleArray - 107, // 155: mxaccess_gateway.v1.MxArray.string_values:type_name -> mxaccess_gateway.v1.StringArray - 108, // 156: mxaccess_gateway.v1.MxArray.timestamp_values:type_name -> mxaccess_gateway.v1.TimestampArray - 109, // 157: mxaccess_gateway.v1.MxArray.raw_values:type_name -> mxaccess_gateway.v1.RawArray - 7, // 158: mxaccess_gateway.v1.MxSparseArray.element_data_type:type_name -> mxaccess_gateway.v1.MxDataType - 101, // 159: mxaccess_gateway.v1.MxSparseArray.elements:type_name -> mxaccess_gateway.v1.MxSparseElement - 98, // 160: mxaccess_gateway.v1.MxSparseElement.value:type_name -> mxaccess_gateway.v1.MxValue - 112, // 161: mxaccess_gateway.v1.TimestampArray.values:type_name -> google.protobuf.Timestamp - 8, // 162: mxaccess_gateway.v1.ProtocolStatus.code:type_name -> mxaccess_gateway.v1.ProtocolStatusCode - 11, // 163: mxaccess_gateway.v1.MxAccessGateway.OpenSession:input_type -> mxaccess_gateway.v1.OpenSessionRequest - 13, // 164: mxaccess_gateway.v1.MxAccessGateway.CloseSession:input_type -> mxaccess_gateway.v1.CloseSessionRequest - 16, // 165: mxaccess_gateway.v1.MxAccessGateway.Invoke:input_type -> mxaccess_gateway.v1.MxCommandRequest - 15, // 166: mxaccess_gateway.v1.MxAccessGateway.StreamEvents:input_type -> mxaccess_gateway.v1.StreamEventsRequest - 92, // 167: mxaccess_gateway.v1.MxAccessGateway.AcknowledgeAlarm:input_type -> mxaccess_gateway.v1.AcknowledgeAlarmRequest - 94, // 168: mxaccess_gateway.v1.MxAccessGateway.StreamAlarms:input_type -> mxaccess_gateway.v1.StreamAlarmsRequest - 10, // 169: mxaccess_gateway.v1.MxAccessGateway.QueryActiveAlarms:input_type -> mxaccess_gateway.v1.QueryActiveAlarmsRequest - 12, // 170: mxaccess_gateway.v1.MxAccessGateway.OpenSession:output_type -> mxaccess_gateway.v1.OpenSessionReply - 14, // 171: mxaccess_gateway.v1.MxAccessGateway.CloseSession:output_type -> mxaccess_gateway.v1.CloseSessionReply - 63, // 172: mxaccess_gateway.v1.MxAccessGateway.Invoke:output_type -> mxaccess_gateway.v1.MxCommandReply - 83, // 173: mxaccess_gateway.v1.MxAccessGateway.StreamEvents:output_type -> mxaccess_gateway.v1.MxEvent - 93, // 174: mxaccess_gateway.v1.MxAccessGateway.AcknowledgeAlarm:output_type -> mxaccess_gateway.v1.AcknowledgeAlarmReply - 95, // 175: mxaccess_gateway.v1.MxAccessGateway.StreamAlarms:output_type -> mxaccess_gateway.v1.AlarmFeedMessage - 91, // 176: mxaccess_gateway.v1.MxAccessGateway.QueryActiveAlarms:output_type -> mxaccess_gateway.v1.ActiveAlarmSnapshot - 170, // [170:177] is the sub-list for method output_type - 163, // [163:170] is the sub-list for method input_type - 163, // [163:163] is the sub-list for extension type_name - 163, // [163:163] is the sub-list for extension extendee - 0, // [0:163] is the sub-list for field type_name + 97, // 141: mxaccess_gateway.v1.AlarmFeedMessage.snapshot_status:type_name -> mxaccess_gateway.v1.AlarmSnapshotStatus + 1, // 142: mxaccess_gateway.v1.AlarmProviderStatus.mode:type_name -> mxaccess_gateway.v1.AlarmProviderMode + 113, // 143: mxaccess_gateway.v1.AlarmProviderStatus.since:type_name -> google.protobuf.Timestamp + 5, // 144: mxaccess_gateway.v1.MxStatusProxy.category:type_name -> mxaccess_gateway.v1.MxStatusCategory + 6, // 145: mxaccess_gateway.v1.MxStatusProxy.detected_by:type_name -> mxaccess_gateway.v1.MxStatusSource + 7, // 146: mxaccess_gateway.v1.MxValue.data_type:type_name -> mxaccess_gateway.v1.MxDataType + 113, // 147: mxaccess_gateway.v1.MxValue.timestamp_value:type_name -> google.protobuf.Timestamp + 100, // 148: mxaccess_gateway.v1.MxValue.array_value:type_name -> mxaccess_gateway.v1.MxArray + 101, // 149: mxaccess_gateway.v1.MxValue.sparse_array_value:type_name -> mxaccess_gateway.v1.MxSparseArray + 7, // 150: mxaccess_gateway.v1.MxArray.element_data_type:type_name -> mxaccess_gateway.v1.MxDataType + 103, // 151: mxaccess_gateway.v1.MxArray.bool_values:type_name -> mxaccess_gateway.v1.BoolArray + 104, // 152: mxaccess_gateway.v1.MxArray.int32_values:type_name -> mxaccess_gateway.v1.Int32Array + 105, // 153: mxaccess_gateway.v1.MxArray.int64_values:type_name -> mxaccess_gateway.v1.Int64Array + 106, // 154: mxaccess_gateway.v1.MxArray.float_values:type_name -> mxaccess_gateway.v1.FloatArray + 107, // 155: mxaccess_gateway.v1.MxArray.double_values:type_name -> mxaccess_gateway.v1.DoubleArray + 108, // 156: mxaccess_gateway.v1.MxArray.string_values:type_name -> mxaccess_gateway.v1.StringArray + 109, // 157: mxaccess_gateway.v1.MxArray.timestamp_values:type_name -> mxaccess_gateway.v1.TimestampArray + 110, // 158: mxaccess_gateway.v1.MxArray.raw_values:type_name -> mxaccess_gateway.v1.RawArray + 7, // 159: mxaccess_gateway.v1.MxSparseArray.element_data_type:type_name -> mxaccess_gateway.v1.MxDataType + 102, // 160: mxaccess_gateway.v1.MxSparseArray.elements:type_name -> mxaccess_gateway.v1.MxSparseElement + 99, // 161: mxaccess_gateway.v1.MxSparseElement.value:type_name -> mxaccess_gateway.v1.MxValue + 113, // 162: mxaccess_gateway.v1.TimestampArray.values:type_name -> google.protobuf.Timestamp + 8, // 163: mxaccess_gateway.v1.ProtocolStatus.code:type_name -> mxaccess_gateway.v1.ProtocolStatusCode + 11, // 164: mxaccess_gateway.v1.MxAccessGateway.OpenSession:input_type -> mxaccess_gateway.v1.OpenSessionRequest + 13, // 165: mxaccess_gateway.v1.MxAccessGateway.CloseSession:input_type -> mxaccess_gateway.v1.CloseSessionRequest + 16, // 166: mxaccess_gateway.v1.MxAccessGateway.Invoke:input_type -> mxaccess_gateway.v1.MxCommandRequest + 15, // 167: mxaccess_gateway.v1.MxAccessGateway.StreamEvents:input_type -> mxaccess_gateway.v1.StreamEventsRequest + 92, // 168: mxaccess_gateway.v1.MxAccessGateway.AcknowledgeAlarm:input_type -> mxaccess_gateway.v1.AcknowledgeAlarmRequest + 94, // 169: mxaccess_gateway.v1.MxAccessGateway.StreamAlarms:input_type -> mxaccess_gateway.v1.StreamAlarmsRequest + 10, // 170: mxaccess_gateway.v1.MxAccessGateway.QueryActiveAlarms:input_type -> mxaccess_gateway.v1.QueryActiveAlarmsRequest + 12, // 171: mxaccess_gateway.v1.MxAccessGateway.OpenSession:output_type -> mxaccess_gateway.v1.OpenSessionReply + 14, // 172: mxaccess_gateway.v1.MxAccessGateway.CloseSession:output_type -> mxaccess_gateway.v1.CloseSessionReply + 63, // 173: mxaccess_gateway.v1.MxAccessGateway.Invoke:output_type -> mxaccess_gateway.v1.MxCommandReply + 83, // 174: mxaccess_gateway.v1.MxAccessGateway.StreamEvents:output_type -> mxaccess_gateway.v1.MxEvent + 93, // 175: mxaccess_gateway.v1.MxAccessGateway.AcknowledgeAlarm:output_type -> mxaccess_gateway.v1.AcknowledgeAlarmReply + 95, // 176: mxaccess_gateway.v1.MxAccessGateway.StreamAlarms:output_type -> mxaccess_gateway.v1.AlarmFeedMessage + 91, // 177: mxaccess_gateway.v1.MxAccessGateway.QueryActiveAlarms:output_type -> mxaccess_gateway.v1.ActiveAlarmSnapshot + 171, // [171:178] is the sub-list for method output_type + 164, // [164:171] is the sub-list for method input_type + 164, // [164:164] is the sub-list for extension type_name + 164, // [164:164] is the sub-list for extension extendee + 0, // [0:164] is the sub-list for field type_name } func init() { file_mxaccess_gateway_proto_init() } @@ -9752,8 +9833,9 @@ func file_mxaccess_gateway_proto_init() { (*AlarmFeedMessage_SnapshotComplete)(nil), (*AlarmFeedMessage_Transition)(nil), (*AlarmFeedMessage_ProviderStatus)(nil), + (*AlarmFeedMessage_SnapshotStatus)(nil), } - file_mxaccess_gateway_proto_msgTypes[88].OneofWrappers = []any{ + file_mxaccess_gateway_proto_msgTypes[89].OneofWrappers = []any{ (*MxValue_BoolValue)(nil), (*MxValue_Int32Value)(nil), (*MxValue_Int64Value)(nil), @@ -9765,7 +9847,7 @@ func file_mxaccess_gateway_proto_init() { (*MxValue_RawValue)(nil), (*MxValue_SparseArrayValue)(nil), } - file_mxaccess_gateway_proto_msgTypes[89].OneofWrappers = []any{ + file_mxaccess_gateway_proto_msgTypes[90].OneofWrappers = []any{ (*MxArray_BoolValues)(nil), (*MxArray_Int32Values)(nil), (*MxArray_Int64Values)(nil), @@ -9781,7 +9863,7 @@ func file_mxaccess_gateway_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_mxaccess_gateway_proto_rawDesc), len(file_mxaccess_gateway_proto_rawDesc)), NumEnums: 10, - NumMessages: 101, + NumMessages: 102, NumExtensions: 0, NumServices: 1, }, diff --git a/clients/java/README.md b/clients/java/README.md index 6c89443..d16cd0e 100644 --- a/clients/java/README.md +++ b/clients/java/README.md @@ -125,6 +125,16 @@ 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). +`streamAlarms` also carries that completeness verdict at feed level, as a +message whose `getPayloadCase()` is `SNAPSHOT_STATUS` and whose +`getSnapshotStatus().getTruncated()` is true while the monitor's cached set +derives from a truncated fetch. One arrives at stream open (after the +`PROVIDER_STATUS` frame, before the cached `ACTIVE_ALARM` frames) so a late +joiner learns the current verdict, then one on every verdict change — including +the clearing frame sent when the gateway's alarm monitor restarts and drops a +truncated verdict. Track it if you need set completeness on a live feed without +polling `queryActiveAlarms`. + ## 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 defa7c5..b49bf8b 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 @@ -89166,6 +89166,39 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { */ mxaccess_gateway.v1.MxaccessGateway.AlarmProviderStatusOrBuilder getProviderStatusOrBuilder(); + /** + *
+     * Snapshot-completeness status. Emitted once on stream open and again on
+     * every change of the truncation verdict, so late joiners learn whether the
+     * feed's active-alarm set may be incomplete.
+     * 
+ * + * .mxaccess_gateway.v1.AlarmSnapshotStatus snapshot_status = 5; + * @return Whether the snapshotStatus field is set. + */ + boolean hasSnapshotStatus(); + /** + *
+     * Snapshot-completeness status. Emitted once on stream open and again on
+     * every change of the truncation verdict, so late joiners learn whether the
+     * feed's active-alarm set may be incomplete.
+     * 
+ * + * .mxaccess_gateway.v1.AlarmSnapshotStatus snapshot_status = 5; + * @return The snapshotStatus. + */ + mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus getSnapshotStatus(); + /** + *
+     * Snapshot-completeness status. Emitted once on stream open and again on
+     * every change of the truncation verdict, so late joiners learn whether the
+     * feed's active-alarm set may be incomplete.
+     * 
+ * + * .mxaccess_gateway.v1.AlarmSnapshotStatus snapshot_status = 5; + */ + mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatusOrBuilder getSnapshotStatusOrBuilder(); + mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage.PayloadCase getPayloadCase(); } /** @@ -89221,6 +89254,7 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { SNAPSHOT_COMPLETE(2), TRANSITION(3), PROVIDER_STATUS(4), + SNAPSHOT_STATUS(5), PAYLOAD_NOT_SET(0); private final int value; private PayloadCase(int value) { @@ -89242,6 +89276,7 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { case 2: return SNAPSHOT_COMPLETE; case 3: return TRANSITION; case 4: return PROVIDER_STATUS; + case 5: return SNAPSHOT_STATUS; case 0: return PAYLOAD_NOT_SET; default: return null; } @@ -89420,6 +89455,55 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { return mxaccess_gateway.v1.MxaccessGateway.AlarmProviderStatus.getDefaultInstance(); } + public static final int SNAPSHOT_STATUS_FIELD_NUMBER = 5; + /** + *
+     * Snapshot-completeness status. Emitted once on stream open and again on
+     * every change of the truncation verdict, so late joiners learn whether the
+     * feed's active-alarm set may be incomplete.
+     * 
+ * + * .mxaccess_gateway.v1.AlarmSnapshotStatus snapshot_status = 5; + * @return Whether the snapshotStatus field is set. + */ + @java.lang.Override + public boolean hasSnapshotStatus() { + return payloadCase_ == 5; + } + /** + *
+     * Snapshot-completeness status. Emitted once on stream open and again on
+     * every change of the truncation verdict, so late joiners learn whether the
+     * feed's active-alarm set may be incomplete.
+     * 
+ * + * .mxaccess_gateway.v1.AlarmSnapshotStatus snapshot_status = 5; + * @return The snapshotStatus. + */ + @java.lang.Override + public mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus getSnapshotStatus() { + if (payloadCase_ == 5) { + return (mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus) payload_; + } + return mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus.getDefaultInstance(); + } + /** + *
+     * Snapshot-completeness status. Emitted once on stream open and again on
+     * every change of the truncation verdict, so late joiners learn whether the
+     * feed's active-alarm set may be incomplete.
+     * 
+ * + * .mxaccess_gateway.v1.AlarmSnapshotStatus snapshot_status = 5; + */ + @java.lang.Override + public mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatusOrBuilder getSnapshotStatusOrBuilder() { + if (payloadCase_ == 5) { + return (mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus) payload_; + } + return mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus.getDefaultInstance(); + } + private byte memoizedIsInitialized = -1; @java.lang.Override public final boolean isInitialized() { @@ -89447,6 +89531,9 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { if (payloadCase_ == 4) { output.writeMessage(4, (mxaccess_gateway.v1.MxaccessGateway.AlarmProviderStatus) payload_); } + if (payloadCase_ == 5) { + output.writeMessage(5, (mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus) payload_); + } getUnknownFields().writeTo(output); } @@ -89473,6 +89560,10 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { size += com.google.protobuf.CodedOutputStream .computeMessageSize(4, (mxaccess_gateway.v1.MxaccessGateway.AlarmProviderStatus) payload_); } + if (payloadCase_ == 5) { + size += com.google.protobuf.CodedOutputStream + .computeMessageSize(5, (mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus) payload_); + } size += getUnknownFields().getSerializedSize(); memoizedSize = size; return size; @@ -89506,6 +89597,10 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { if (!getProviderStatus() .equals(other.getProviderStatus())) return false; break; + case 5: + if (!getSnapshotStatus() + .equals(other.getSnapshotStatus())) return false; + break; case 0: default: } @@ -89538,6 +89633,10 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { hash = (37 * hash) + PROVIDER_STATUS_FIELD_NUMBER; hash = (53 * hash) + getProviderStatus().hashCode(); break; + case 5: + hash = (37 * hash) + SNAPSHOT_STATUS_FIELD_NUMBER; + hash = (53 * hash) + getSnapshotStatus().hashCode(); + break; case 0: default: } @@ -89687,6 +89786,9 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { if (providerStatusBuilder_ != null) { providerStatusBuilder_.clear(); } + if (snapshotStatusBuilder_ != null) { + snapshotStatusBuilder_.clear(); + } payloadCase_ = 0; payload_ = null; return this; @@ -89740,6 +89842,10 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { providerStatusBuilder_ != null) { result.payload_ = providerStatusBuilder_.build(); } + if (payloadCase_ == 5 && + snapshotStatusBuilder_ != null) { + result.payload_ = snapshotStatusBuilder_.build(); + } } @java.lang.Override @@ -89771,6 +89877,10 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { mergeProviderStatus(other.getProviderStatus()); break; } + case SNAPSHOT_STATUS: { + mergeSnapshotStatus(other.getSnapshotStatus()); + break; + } case PAYLOAD_NOT_SET: { break; } @@ -89827,6 +89937,13 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { payloadCase_ = 4; break; } // case 34 + case 42: { + input.readMessage( + internalGetSnapshotStatusFieldBuilder().getBuilder(), + extensionRegistry); + payloadCase_ = 5; + break; + } // case 42 default: { if (!super.parseUnknownField(input, extensionRegistry, tag)) { done = true; // was an endgroup tag @@ -90464,6 +90581,202 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { return providerStatusBuilder_; } + private com.google.protobuf.SingleFieldBuilder< + mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus, mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus.Builder, mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatusOrBuilder> snapshotStatusBuilder_; + /** + *
+       * Snapshot-completeness status. Emitted once on stream open and again on
+       * every change of the truncation verdict, so late joiners learn whether the
+       * feed's active-alarm set may be incomplete.
+       * 
+ * + * .mxaccess_gateway.v1.AlarmSnapshotStatus snapshot_status = 5; + * @return Whether the snapshotStatus field is set. + */ + @java.lang.Override + public boolean hasSnapshotStatus() { + return payloadCase_ == 5; + } + /** + *
+       * Snapshot-completeness status. Emitted once on stream open and again on
+       * every change of the truncation verdict, so late joiners learn whether the
+       * feed's active-alarm set may be incomplete.
+       * 
+ * + * .mxaccess_gateway.v1.AlarmSnapshotStatus snapshot_status = 5; + * @return The snapshotStatus. + */ + @java.lang.Override + public mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus getSnapshotStatus() { + if (snapshotStatusBuilder_ == null) { + if (payloadCase_ == 5) { + return (mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus) payload_; + } + return mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus.getDefaultInstance(); + } else { + if (payloadCase_ == 5) { + return snapshotStatusBuilder_.getMessage(); + } + return mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus.getDefaultInstance(); + } + } + /** + *
+       * Snapshot-completeness status. Emitted once on stream open and again on
+       * every change of the truncation verdict, so late joiners learn whether the
+       * feed's active-alarm set may be incomplete.
+       * 
+ * + * .mxaccess_gateway.v1.AlarmSnapshotStatus snapshot_status = 5; + */ + public Builder setSnapshotStatus(mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus value) { + if (snapshotStatusBuilder_ == null) { + if (value == null) { + throw new NullPointerException(); + } + payload_ = value; + onChanged(); + } else { + snapshotStatusBuilder_.setMessage(value); + } + payloadCase_ = 5; + return this; + } + /** + *
+       * Snapshot-completeness status. Emitted once on stream open and again on
+       * every change of the truncation verdict, so late joiners learn whether the
+       * feed's active-alarm set may be incomplete.
+       * 
+ * + * .mxaccess_gateway.v1.AlarmSnapshotStatus snapshot_status = 5; + */ + public Builder setSnapshotStatus( + mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus.Builder builderForValue) { + if (snapshotStatusBuilder_ == null) { + payload_ = builderForValue.build(); + onChanged(); + } else { + snapshotStatusBuilder_.setMessage(builderForValue.build()); + } + payloadCase_ = 5; + return this; + } + /** + *
+       * Snapshot-completeness status. Emitted once on stream open and again on
+       * every change of the truncation verdict, so late joiners learn whether the
+       * feed's active-alarm set may be incomplete.
+       * 
+ * + * .mxaccess_gateway.v1.AlarmSnapshotStatus snapshot_status = 5; + */ + public Builder mergeSnapshotStatus(mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus value) { + if (snapshotStatusBuilder_ == null) { + if (payloadCase_ == 5 && + payload_ != mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus.getDefaultInstance()) { + payload_ = mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus.newBuilder((mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus) payload_) + .mergeFrom(value).buildPartial(); + } else { + payload_ = value; + } + onChanged(); + } else { + if (payloadCase_ == 5) { + snapshotStatusBuilder_.mergeFrom(value); + } else { + snapshotStatusBuilder_.setMessage(value); + } + } + payloadCase_ = 5; + return this; + } + /** + *
+       * Snapshot-completeness status. Emitted once on stream open and again on
+       * every change of the truncation verdict, so late joiners learn whether the
+       * feed's active-alarm set may be incomplete.
+       * 
+ * + * .mxaccess_gateway.v1.AlarmSnapshotStatus snapshot_status = 5; + */ + public Builder clearSnapshotStatus() { + if (snapshotStatusBuilder_ == null) { + if (payloadCase_ == 5) { + payloadCase_ = 0; + payload_ = null; + onChanged(); + } + } else { + if (payloadCase_ == 5) { + payloadCase_ = 0; + payload_ = null; + } + snapshotStatusBuilder_.clear(); + } + return this; + } + /** + *
+       * Snapshot-completeness status. Emitted once on stream open and again on
+       * every change of the truncation verdict, so late joiners learn whether the
+       * feed's active-alarm set may be incomplete.
+       * 
+ * + * .mxaccess_gateway.v1.AlarmSnapshotStatus snapshot_status = 5; + */ + public mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus.Builder getSnapshotStatusBuilder() { + return internalGetSnapshotStatusFieldBuilder().getBuilder(); + } + /** + *
+       * Snapshot-completeness status. Emitted once on stream open and again on
+       * every change of the truncation verdict, so late joiners learn whether the
+       * feed's active-alarm set may be incomplete.
+       * 
+ * + * .mxaccess_gateway.v1.AlarmSnapshotStatus snapshot_status = 5; + */ + @java.lang.Override + public mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatusOrBuilder getSnapshotStatusOrBuilder() { + if ((payloadCase_ == 5) && (snapshotStatusBuilder_ != null)) { + return snapshotStatusBuilder_.getMessageOrBuilder(); + } else { + if (payloadCase_ == 5) { + return (mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus) payload_; + } + return mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus.getDefaultInstance(); + } + } + /** + *
+       * Snapshot-completeness status. Emitted once on stream open and again on
+       * every change of the truncation verdict, so late joiners learn whether the
+       * feed's active-alarm set may be incomplete.
+       * 
+ * + * .mxaccess_gateway.v1.AlarmSnapshotStatus snapshot_status = 5; + */ + private com.google.protobuf.SingleFieldBuilder< + mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus, mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus.Builder, mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatusOrBuilder> + internalGetSnapshotStatusFieldBuilder() { + if (snapshotStatusBuilder_ == null) { + if (!(payloadCase_ == 5)) { + payload_ = mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus.getDefaultInstance(); + } + snapshotStatusBuilder_ = new com.google.protobuf.SingleFieldBuilder< + mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus, mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus.Builder, mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatusOrBuilder>( + (mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus) payload_, + getParentForChildren(), + isClean()); + payload_ = null; + } + payloadCase_ = 5; + onChanged(); + return snapshotStatusBuilder_; + } + // @@protoc_insertion_point(builder_scope:mxaccess_gateway.v1.AlarmFeedMessage) } @@ -91465,6 +91778,500 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { } + public interface AlarmSnapshotStatusOrBuilder extends + // @@protoc_insertion_point(interface_extends:mxaccess_gateway.v1.AlarmSnapshotStatus) + com.google.protobuf.MessageOrBuilder { + + /** + *
+     * True while the monitor's cached active-alarm set derives from a truncated
+     * (capped) worker fetch — the set may be missing alarms. Distinct from
+     * provider degradation (AlarmProviderStatus.degraded), which describes the
+     * fidelity of the records rather than the completeness of the set.
+     * 
+ * + * bool truncated = 1; + * @return The truncated. + */ + boolean getTruncated(); + } + /** + *
+   * Feed-level snapshot-completeness status. Emitted once on StreamAlarms open
+   * (after the initial provider_status frame, before the cached active_alarm
+   * frames) so late joiners learn the current verdict, and again on every change
+   * of the truncation verdict — when a reconcile reports a different verdict, and
+   * when the gateway's alarm monitor restarts and drops a truncated verdict with
+   * the cache generation it described (feed subscribers outlive that monitor
+   * session, so they are sent the clearing frame). Mirrors the per-record
+   * ActiveAlarmSnapshot.from_truncated_snapshot caveat at feed level so live
+   * consumers can reason about completeness without polling QueryActiveAlarms.
+   * 
+ * + * Protobuf type {@code mxaccess_gateway.v1.AlarmSnapshotStatus} + */ + public static final class AlarmSnapshotStatus extends + com.google.protobuf.GeneratedMessage implements + // @@protoc_insertion_point(message_implements:mxaccess_gateway.v1.AlarmSnapshotStatus) + AlarmSnapshotStatusOrBuilder { + private static final long serialVersionUID = 0L; + static { + com.google.protobuf.RuntimeVersion.validateProtobufGencodeVersion( + com.google.protobuf.RuntimeVersion.RuntimeDomain.PUBLIC, + /* major= */ 4, + /* minor= */ 33, + /* patch= */ 1, + /* suffix= */ "", + "AlarmSnapshotStatus"); + } + // Use AlarmSnapshotStatus.newBuilder() to construct. + private AlarmSnapshotStatus(com.google.protobuf.GeneratedMessage.Builder builder) { + super(builder); + } + private AlarmSnapshotStatus() { + } + + public static final com.google.protobuf.Descriptors.Descriptor + getDescriptor() { + return mxaccess_gateway.v1.MxaccessGateway.internal_static_mxaccess_gateway_v1_AlarmSnapshotStatus_descriptor; + } + + @java.lang.Override + protected com.google.protobuf.GeneratedMessage.FieldAccessorTable + internalGetFieldAccessorTable() { + return mxaccess_gateway.v1.MxaccessGateway.internal_static_mxaccess_gateway_v1_AlarmSnapshotStatus_fieldAccessorTable + .ensureFieldAccessorsInitialized( + mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus.class, mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus.Builder.class); + } + + public static final int TRUNCATED_FIELD_NUMBER = 1; + private boolean truncated_ = false; + /** + *
+     * True while the monitor's cached active-alarm set derives from a truncated
+     * (capped) worker fetch — the set may be missing alarms. Distinct from
+     * provider degradation (AlarmProviderStatus.degraded), which describes the
+     * fidelity of the records rather than the completeness of the set.
+     * 
+ * + * bool truncated = 1; + * @return The truncated. + */ + @java.lang.Override + public boolean getTruncated() { + return truncated_; + } + + private byte memoizedIsInitialized = -1; + @java.lang.Override + public final boolean isInitialized() { + byte isInitialized = memoizedIsInitialized; + if (isInitialized == 1) return true; + if (isInitialized == 0) return false; + + memoizedIsInitialized = 1; + return true; + } + + @java.lang.Override + public void writeTo(com.google.protobuf.CodedOutputStream output) + throws java.io.IOException { + if (truncated_ != false) { + output.writeBool(1, truncated_); + } + getUnknownFields().writeTo(output); + } + + @java.lang.Override + public int getSerializedSize() { + int size = memoizedSize; + if (size != -1) return size; + + size = 0; + if (truncated_ != false) { + size += com.google.protobuf.CodedOutputStream + .computeBoolSize(1, truncated_); + } + size += getUnknownFields().getSerializedSize(); + memoizedSize = size; + return size; + } + + @java.lang.Override + public boolean equals(final java.lang.Object obj) { + if (obj == this) { + return true; + } + if (!(obj instanceof mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus)) { + return super.equals(obj); + } + mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus other = (mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus) obj; + + if (getTruncated() + != other.getTruncated()) return false; + if (!getUnknownFields().equals(other.getUnknownFields())) return false; + return true; + } + + @java.lang.Override + public int hashCode() { + if (memoizedHashCode != 0) { + return memoizedHashCode; + } + int hash = 41; + hash = (19 * hash) + getDescriptor().hashCode(); + hash = (37 * hash) + TRUNCATED_FIELD_NUMBER; + hash = (53 * hash) + com.google.protobuf.Internal.hashBoolean( + getTruncated()); + hash = (29 * hash) + getUnknownFields().hashCode(); + memoizedHashCode = hash; + return hash; + } + + public static mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus parseFrom( + java.nio.ByteBuffer data) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data); + } + public static mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus parseFrom( + java.nio.ByteBuffer data, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data, extensionRegistry); + } + public static mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus parseFrom( + com.google.protobuf.ByteString data) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data); + } + public static mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus parseFrom( + com.google.protobuf.ByteString data, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data, extensionRegistry); + } + public static mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus parseFrom(byte[] data) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data); + } + public static mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus parseFrom( + byte[] data, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data, extensionRegistry); + } + public static mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus parseFrom(java.io.InputStream input) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessage + .parseWithIOException(PARSER, input); + } + public static mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus parseFrom( + java.io.InputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessage + .parseWithIOException(PARSER, input, extensionRegistry); + } + + public static mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus parseDelimitedFrom(java.io.InputStream input) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessage + .parseDelimitedWithIOException(PARSER, input); + } + + public static mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus parseDelimitedFrom( + java.io.InputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessage + .parseDelimitedWithIOException(PARSER, input, extensionRegistry); + } + public static mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus parseFrom( + com.google.protobuf.CodedInputStream input) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessage + .parseWithIOException(PARSER, input); + } + public static mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus parseFrom( + com.google.protobuf.CodedInputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return com.google.protobuf.GeneratedMessage + .parseWithIOException(PARSER, input, extensionRegistry); + } + + @java.lang.Override + public Builder newBuilderForType() { return newBuilder(); } + public static Builder newBuilder() { + return DEFAULT_INSTANCE.toBuilder(); + } + public static Builder newBuilder(mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus prototype) { + return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype); + } + @java.lang.Override + public Builder toBuilder() { + return this == DEFAULT_INSTANCE + ? new Builder() : new Builder().mergeFrom(this); + } + + @java.lang.Override + protected Builder newBuilderForType( + com.google.protobuf.GeneratedMessage.BuilderParent parent) { + Builder builder = new Builder(parent); + return builder; + } + /** + *
+     * Feed-level snapshot-completeness status. Emitted once on StreamAlarms open
+     * (after the initial provider_status frame, before the cached active_alarm
+     * frames) so late joiners learn the current verdict, and again on every change
+     * of the truncation verdict — when a reconcile reports a different verdict, and
+     * when the gateway's alarm monitor restarts and drops a truncated verdict with
+     * the cache generation it described (feed subscribers outlive that monitor
+     * session, so they are sent the clearing frame). Mirrors the per-record
+     * ActiveAlarmSnapshot.from_truncated_snapshot caveat at feed level so live
+     * consumers can reason about completeness without polling QueryActiveAlarms.
+     * 
+ * + * Protobuf type {@code mxaccess_gateway.v1.AlarmSnapshotStatus} + */ + public static final class Builder extends + com.google.protobuf.GeneratedMessage.Builder implements + // @@protoc_insertion_point(builder_implements:mxaccess_gateway.v1.AlarmSnapshotStatus) + mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatusOrBuilder { + public static final com.google.protobuf.Descriptors.Descriptor + getDescriptor() { + return mxaccess_gateway.v1.MxaccessGateway.internal_static_mxaccess_gateway_v1_AlarmSnapshotStatus_descriptor; + } + + @java.lang.Override + protected com.google.protobuf.GeneratedMessage.FieldAccessorTable + internalGetFieldAccessorTable() { + return mxaccess_gateway.v1.MxaccessGateway.internal_static_mxaccess_gateway_v1_AlarmSnapshotStatus_fieldAccessorTable + .ensureFieldAccessorsInitialized( + mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus.class, mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus.Builder.class); + } + + // Construct using mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus.newBuilder() + private Builder() { + + } + + private Builder( + com.google.protobuf.GeneratedMessage.BuilderParent parent) { + super(parent); + + } + @java.lang.Override + public Builder clear() { + super.clear(); + bitField0_ = 0; + truncated_ = false; + return this; + } + + @java.lang.Override + public com.google.protobuf.Descriptors.Descriptor + getDescriptorForType() { + return mxaccess_gateway.v1.MxaccessGateway.internal_static_mxaccess_gateway_v1_AlarmSnapshotStatus_descriptor; + } + + @java.lang.Override + public mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus getDefaultInstanceForType() { + return mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus.getDefaultInstance(); + } + + @java.lang.Override + public mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus build() { + mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus result = buildPartial(); + if (!result.isInitialized()) { + throw newUninitializedMessageException(result); + } + return result; + } + + @java.lang.Override + public mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus buildPartial() { + mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus result = new mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus(this); + if (bitField0_ != 0) { buildPartial0(result); } + onBuilt(); + return result; + } + + private void buildPartial0(mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus result) { + int from_bitField0_ = bitField0_; + if (((from_bitField0_ & 0x00000001) != 0)) { + result.truncated_ = truncated_; + } + } + + @java.lang.Override + public Builder mergeFrom(com.google.protobuf.Message other) { + if (other instanceof mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus) { + return mergeFrom((mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus)other); + } else { + super.mergeFrom(other); + return this; + } + } + + public Builder mergeFrom(mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus other) { + if (other == mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus.getDefaultInstance()) return this; + if (other.getTruncated() != false) { + setTruncated(other.getTruncated()); + } + this.mergeUnknownFields(other.getUnknownFields()); + onChanged(); + return this; + } + + @java.lang.Override + public final boolean isInitialized() { + return true; + } + + @java.lang.Override + public Builder mergeFrom( + com.google.protobuf.CodedInputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + if (extensionRegistry == null) { + throw new java.lang.NullPointerException(); + } + try { + boolean done = false; + while (!done) { + int tag = input.readTag(); + switch (tag) { + case 0: + done = true; + break; + case 8: { + truncated_ = input.readBool(); + bitField0_ |= 0x00000001; + break; + } // case 8 + default: { + if (!super.parseUnknownField(input, extensionRegistry, tag)) { + done = true; // was an endgroup tag + } + break; + } // default: + } // switch (tag) + } // while (!done) + } catch (com.google.protobuf.InvalidProtocolBufferException e) { + throw e.unwrapIOException(); + } finally { + onChanged(); + } // finally + return this; + } + private int bitField0_; + + private boolean truncated_ ; + /** + *
+       * True while the monitor's cached active-alarm set derives from a truncated
+       * (capped) worker fetch — the set may be missing alarms. Distinct from
+       * provider degradation (AlarmProviderStatus.degraded), which describes the
+       * fidelity of the records rather than the completeness of the set.
+       * 
+ * + * bool truncated = 1; + * @return The truncated. + */ + @java.lang.Override + public boolean getTruncated() { + return truncated_; + } + /** + *
+       * True while the monitor's cached active-alarm set derives from a truncated
+       * (capped) worker fetch — the set may be missing alarms. Distinct from
+       * provider degradation (AlarmProviderStatus.degraded), which describes the
+       * fidelity of the records rather than the completeness of the set.
+       * 
+ * + * bool truncated = 1; + * @param value The truncated to set. + * @return This builder for chaining. + */ + public Builder setTruncated(boolean value) { + + truncated_ = value; + bitField0_ |= 0x00000001; + onChanged(); + return this; + } + /** + *
+       * True while the monitor's cached active-alarm set derives from a truncated
+       * (capped) worker fetch — the set may be missing alarms. Distinct from
+       * provider degradation (AlarmProviderStatus.degraded), which describes the
+       * fidelity of the records rather than the completeness of the set.
+       * 
+ * + * bool truncated = 1; + * @return This builder for chaining. + */ + public Builder clearTruncated() { + bitField0_ = (bitField0_ & ~0x00000001); + truncated_ = false; + onChanged(); + return this; + } + + // @@protoc_insertion_point(builder_scope:mxaccess_gateway.v1.AlarmSnapshotStatus) + } + + // @@protoc_insertion_point(class_scope:mxaccess_gateway.v1.AlarmSnapshotStatus) + private static final mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus DEFAULT_INSTANCE; + static { + DEFAULT_INSTANCE = new mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus(); + } + + public static mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus getDefaultInstance() { + return DEFAULT_INSTANCE; + } + + private static final com.google.protobuf.Parser + PARSER = new com.google.protobuf.AbstractParser() { + @java.lang.Override + public AlarmSnapshotStatus parsePartialFrom( + com.google.protobuf.CodedInputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + Builder builder = newBuilder(); + try { + builder.mergeFrom(input, extensionRegistry); + } catch (com.google.protobuf.InvalidProtocolBufferException e) { + throw e.setUnfinishedMessage(builder.buildPartial()); + } catch (com.google.protobuf.UninitializedMessageException e) { + throw e.asInvalidProtocolBufferException().setUnfinishedMessage(builder.buildPartial()); + } catch (java.io.IOException e) { + throw new com.google.protobuf.InvalidProtocolBufferException(e) + .setUnfinishedMessage(builder.buildPartial()); + } + return builder.buildPartial(); + } + }; + + public static com.google.protobuf.Parser parser() { + return PARSER; + } + + @java.lang.Override + public com.google.protobuf.Parser getParserForType() { + return PARSER; + } + + @java.lang.Override + public mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus getDefaultInstanceForType() { + return DEFAULT_INSTANCE; + } + + } + public interface MxStatusProxyOrBuilder extends // @@protoc_insertion_point(interface_extends:mxaccess_gateway.v1.MxStatusProxy) com.google.protobuf.MessageOrBuilder { @@ -105185,6 +105992,11 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { private static final com.google.protobuf.GeneratedMessage.FieldAccessorTable internal_static_mxaccess_gateway_v1_AlarmProviderStatus_fieldAccessorTable; + private static final com.google.protobuf.Descriptors.Descriptor + internal_static_mxaccess_gateway_v1_AlarmSnapshotStatus_descriptor; + private static final + com.google.protobuf.GeneratedMessage.FieldAccessorTable + internal_static_mxaccess_gateway_v1_AlarmSnapshotStatus_fieldAccessorTable; private static final com.google.protobuf.Descriptors.Descriptor internal_static_mxaccess_gateway_v1_MxStatusProxy_descriptor; private static final @@ -105643,194 +106455,197 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { "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" + + "er_prefix\030\002 \001(\t\"\311\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" + "ay.v1.AlarmProviderStatusH\000\022C\n\017snapshot_" + + "status\030\005 \001(\0132(.mxaccess_gateway.v1.Alarm" + + "SnapshotStatusH\000B\t\n\007payload\"\230\001\n\023AlarmPro" + + "viderStatus\0224\n\004mode\030\001 \001(\0162&.mxaccess_gat" + + "eway.v1.AlarmProviderMode\022\020\n\010degraded\030\002 " + + "\001(\010\022\016\n\006reason\030\003 \001(\t\022)\n\005since\030\004 \001(\0132\032.goo" + + "gle.protobuf.Timestamp\"(\n\023AlarmSnapshotS" + + "tatus\022\021\n\ttruncated\030\001 \001(\010\"\353\001\n\rMxStatusPro" + + "xy\022\017\n\007success\030\001 \001(\005\0227\n\010category\030\002 \001(\0162%." + + "mxaccess_gateway.v1.MxStatusCategory\0228\n\013" + + "detected_by\030\003 \001(\0162#.mxaccess_gateway.v1." + + "MxStatusSource\022\016\n\006detail\030\004 \001(\005\022\024\n\014raw_ca" + + "tegory\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\tda" + + "ta_type\030\001 \001(\0162\037.mxaccess_gateway.v1.MxDa", + "taType\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_dat" + + "a_type\030\005 \001(\005\022\024\n\nbool_value\030\n \001(\010H\000\022\025\n\013in" + + "t32_value\030\013 \001(\005H\000\022\025\n\013int64_value\030\014 \001(\003H\000" + + "\022\025\n\013float_value\030\r \001(\002H\000\022\026\n\014double_value\030" + + "\016 \001(\001H\000\022\026\n\014string_value\030\017 \001(\tH\000\0225\n\017times" + + "tamp_value\030\020 \001(\0132\032.google.protobuf.Times" + + "tampH\000\0223\n\013array_value\030\021 \001(\0132\034.mxaccess_g" + + "ateway.v1.MxArrayH\000\022\023\n\traw_value\030\022 \001(\014H\000" + + "\022@\n\022sparse_array_value\030\023 \001(\0132\".mxaccess_" + + "gateway.v1.MxSparseArrayH\000B\006\n\004kind\"\376\004\n\007M" + + "xArray\022:\n\021element_data_type\030\001 \001(\0162\037.mxac" + + "cess_gateway.v1.MxDataType\022\024\n\014variant_ty" + + "pe\030\002 \001(\t\022\022\n\ndimensions\030\003 \003(\r\022\026\n\016raw_diag" + + "nostic\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_gate" + + "way.v1.BoolArrayH\000\0227\n\014int32_values\030\013 \001(\013" + + "2\037.mxaccess_gateway.v1.Int32ArrayH\000\0227\n\014i" + + "nt64_values\030\014 \001(\0132\037.mxaccess_gateway.v1." + + "Int64ArrayH\000\0227\n\014float_values\030\r \001(\0132\037.mxa" + + "ccess_gateway.v1.FloatArrayH\000\0229\n\rdouble_" + + "values\030\016 \001(\0132 .mxaccess_gateway.v1.Doubl" + + "eArrayH\000\0229\n\rstring_values\030\017 \001(\0132 .mxacce" + + "ss_gateway.v1.StringArrayH\000\022?\n\020timestamp" + + "_values\030\020 \001(\0132#.mxaccess_gateway.v1.Time" + + "stampArrayH\000\0223\n\nraw_values\030\021 \001(\0132\035.mxacc" + + "ess_gateway.v1.RawArrayH\000B\010\n\006values\"\231\001\n\r" + + "MxSparseArray\022:\n\021element_data_type\030\001 \001(\016" + + "2\037.mxaccess_gateway.v1.MxDataType\022\024\n\014tot" + + "al_length\030\002 \001(\r\0226\n\010elements\030\003 \003(\0132$.mxac" + + "cess_gateway.v1.MxSparseElement\"M\n\017MxSpa" + + "rseElement\022\r\n\005index\030\001 \001(\r\022+\n\005value\030\002 \001(\013" + + "2\034.mxaccess_gateway.v1.MxValue\"\033\n\tBoolAr" + + "ray\022\016\n\006values\030\001 \003(\010\"\034\n\nInt32Array\022\016\n\006val" + + "ues\030\001 \003(\005\"\034\n\nInt64Array\022\016\n\006values\030\001 \003(\003\"" + + "\034\n\nFloatArray\022\016\n\006values\030\001 \003(\002\"\035\n\013DoubleA" + + "rray\022\016\n\006values\030\001 \003(\001\"\035\n\013StringArray\022\016\n\006v" + + "alues\030\001 \003(\t\"<\n\016TimestampArray\022*\n\006values\030" + + "\001 \003(\0132\032.google.protobuf.Timestamp\"\032\n\010Raw" + + "Array\022\016\n\006values\030\001 \003(\014\"X\n\016ProtocolStatus\022" + + "5\n\004code\030\001 \001(\0162\'.mxaccess_gateway.v1.Prot" + + "ocolStatusCode\022\017\n\007message\030\002 \001(\t*\237\013\n\rMxCo" + + "mmandKind\022\037\n\033MX_COMMAND_KIND_UNSPECIFIED" + + "\020\000\022\034\n\030MX_COMMAND_KIND_REGISTER\020\001\022\036\n\032MX_C" + + "OMMAND_KIND_UNREGISTER\020\002\022\034\n\030MX_COMMAND_K" + + "IND_ADD_ITEM\020\003\022\035\n\031MX_COMMAND_KIND_ADD_IT" + + "EM2\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_ADVISE\020\007\022&\n\"MX_COMMAND_KIND_ADV" + + "ISE_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\027MX_COMMAND" + + "_KIND_SUSPEND\020\013\022\034\n\030MX_COMMAND_KIND_ACTIV" + + "ATE\020\014\022\031\n\025MX_COMMAND_KIND_WRITE\020\r\022\032\n\026MX_C" + + "OMMAND_KIND_WRITE2\020\016\022!\n\035MX_COMMAND_KIND_" + + "WRITE_SECURED\020\017\022\"\n\036MX_COMMAND_KIND_WRITE" + + "_SECURED2\020\020\022%\n!MX_COMMAND_KIND_AUTHENTIC" + + "ATE_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 MX_COMMAND_KIND_ADVISE_ITEM_B" + + "ULK\020\024\022$\n MX_COMMAND_KIND_REMOVE_ITEM_BUL" + + "K\020\025\022\'\n#MX_COMMAND_KIND_UN_ADVISE_ITEM_BU" + + "LK\020\026\022\"\n\036MX_COMMAND_KIND_SUBSCRIBE_BULK\020\027" + + "\022$\n MX_COMMAND_KIND_UNSUBSCRIBE_BULK\020\030\022$" + + "\n MX_COMMAND_KIND_SUBSCRIBE_ALARMS\020\031\022&\n\"" + + "MX_COMMAND_KIND_UNSUBSCRIBE_ALARMS\020\032\022%\n!" + + "MX_COMMAND_KIND_ACKNOWLEDGE_ALARM\020\033\022\'\n#M" + + "X_COMMAND_KIND_QUERY_ACTIVE_ALARMS\020\034\022-\n)" + + "MX_COMMAND_KIND_ACKNOWLEDGE_ALARM_BY_NAM" + + "E\020\035\022\036\n\032MX_COMMAND_KIND_WRITE_BULK\020\036\022\037\n\033M" + + "X_COMMAND_KIND_WRITE2_BULK\020\037\022&\n\"MX_COMMA" + + "ND_KIND_WRITE_SECURED_BULK\020 \022\'\n#MX_COMMA" + + "ND_KIND_WRITE_SECURED2_BULK\020!\022\035\n\031MX_COMM" + + "AND_KIND_READ_BULK\020\"\022\030\n\024MX_COMMAND_KIND_" + + "PING\020d\022%\n!MX_COMMAND_KIND_GET_SESSION_ST" + + "ATE\020e\022#\n\037MX_COMMAND_KIND_GET_WORKER_INFO" + + "\020f\022 \n\034MX_COMMAND_KIND_DRAIN_EVENTS\020g\022#\n\037" + + "MX_COMMAND_KIND_SHUTDOWN_WORKER\020h*z\n\021Ala" + + "rmProviderMode\022#\n\037ALARM_PROVIDER_MODE_UN" + + "SPECIFIED\020\000\022 \n\034ALARM_PROVIDER_MODE_ALARM" + + "MGR\020\001\022\036\n\032ALARM_PROVIDER_MODE_SUBTAG\020\002*\255\002" + + "\n\rMxEventFamily\022\037\n\033MX_EVENT_FAMILY_UNSPE" + + "CIFIED\020\000\022\"\n\036MX_EVENT_FAMILY_ON_DATA_CHAN" + + "GE\020\001\022%\n!MX_EVENT_FAMILY_ON_WRITE_COMPLET" + + "E\020\002\022&\n\"MX_EVENT_FAMILY_OPERATION_COMPLET" + + "E\020\003\022+\n\'MX_EVENT_FAMILY_ON_BUFFERED_DATA_" + + "CHANGE\020\004\022\'\n#MX_EVENT_FAMILY_ON_ALARM_TRA" + + "NSITION\020\005\0222\n.MX_EVENT_FAMILY_ON_ALARM_PR" + + "OVIDER_MODE_CHANGED\020\006*\312\001\n\023AlarmTransitio" + + "nKind\022%\n!ALARM_TRANSITION_KIND_UNSPECIFI" + + "ED\020\000\022\037\n\033ALARM_TRANSITION_KIND_RAISE\020\001\022%\n" + + "!ALARM_TRANSITION_KIND_ACKNOWLEDGE\020\002\022\037\n\033" + + "ALARM_TRANSITION_KIND_CLEAR\020\003\022#\n\037ALARM_T" + + "RANSITION_KIND_RETRIGGER\020\004*\252\001\n\023AlarmCond" + + "itionState\022%\n!ALARM_CONDITION_STATE_UNSP" + + "ECIFIED\020\000\022 \n\034ALARM_CONDITION_STATE_ACTIV" + + "E\020\001\022&\n\"ALARM_CONDITION_STATE_ACTIVE_ACKE" + + "D\020\002\022\"\n\036ALARM_CONDITION_STATE_INACTIVE\020\003*" + + "\245\003\n\020MxStatusCategory\022\"\n\036MX_STATUS_CATEGO" + + "RY_UNSPECIFIED\020\000\022\036\n\032MX_STATUS_CATEGORY_U" + + "NKNOWN\020\001\022\031\n\025MX_STATUS_CATEGORY_OK\020\002\022\036\n\032M" + + "X_STATUS_CATEGORY_PENDING\020\003\022\036\n\032MX_STATUS" + + "_CATEGORY_WARNING\020\004\022*\n&MX_STATUS_CATEGOR" + + "Y_COMMUNICATION_ERROR\020\005\022*\n&MX_STATUS_CAT" + + "EGORY_CONFIGURATION_ERROR\020\006\022(\n$MX_STATUS" + + "_CATEGORY_OPERATIONAL_ERROR\020\007\022%\n!MX_STAT" + + "US_CATEGORY_SECURITY_ERROR\020\010\022%\n!MX_STATU" + + "S_CATEGORY_SOFTWARE_ERROR\020\t\022\"\n\036MX_STATUS" + + "_CATEGORY_OTHER_ERROR\020\n*\312\002\n\016MxStatusSour" + + "ce\022 \n\034MX_STATUS_SOURCE_UNSPECIFIED\020\000\022\034\n\030" + + "MX_STATUS_SOURCE_UNKNOWN\020\001\022#\n\037MX_STATUS_" + + "SOURCE_REQUESTING_LMX\020\002\022#\n\037MX_STATUS_SOU" + + "RCE_RESPONDING_LMX\020\003\022#\n\037MX_STATUS_SOURCE" + + "_REQUESTING_NMX\020\004\022#\n\037MX_STATUS_SOURCE_RE" + + "SPONDING_NMX\020\005\0221\n-MX_STATUS_SOURCE_REQUE" + + "STING_AUTOMATION_OBJECT\020\006\0221\n-MX_STATUS_S" + + "OURCE_RESPONDING_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_T" + + "YPE_NO_DATA\020\002\022\030\n\024MX_DATA_TYPE_BOOLEAN\020\003\022" + + "\030\n\024MX_DATA_TYPE_INTEGER\020\004\022\026\n\022MX_DATA_TYP" + + "E_FLOAT\020\005\022\027\n\023MX_DATA_TYPE_DOUBLE\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\030MX_DATA_TY" + + "PE_STATUS_TYPE\020\013\022\025\n\021MX_DATA_TYPE_ENUM\020\014\022" + + "-\n)MX_DATA_TYPE_SECURITY_CLASSIFICATION_" + + "ENUM\020\r\022\"\n\036MX_DATA_TYPE_DATA_QUALITY_TYPE" + + "\020\016\022\037\n\033MX_DATA_TYPE_QUALIFIED_ENUM\020\017\022!\n\035M" + + "X_DATA_TYPE_QUALIFIED_STRUCT\020\020\022)\n%MX_DAT" + + "A_TYPE_INTERNATIONALIZED_STRING\020\021\022\033\n\027MX_" + + "DATA_TYPE_BIG_STRING\020\022\022\024\n\020MX_DATA_TYPE_E" + + "ND\020\023*\243\003\n\022ProtocolStatusCode\022$\n PROTOCOL_" + + "STATUS_CODE_UNSPECIFIED\020\000\022\033\n\027PROTOCOL_ST" + + "ATUS_CODE_OK\020\001\022(\n$PROTOCOL_STATUS_CODE_I" + + "NVALID_REQUEST\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_STA" + + "TUS_CODE_WORKER_UNAVAILABLE\020\005\022 \n\034PROTOCO" + + "L_STATUS_CODE_TIMEOUT\020\006\022!\n\035PROTOCOL_STAT" + + "US_CODE_CANCELED\020\007\022+\n\'PROTOCOL_STATUS_CO" + + "DE_PROTOCOL_VIOLATION\020\010\022)\n%PROTOCOL_STAT" + + "US_CODE_MXACCESS_FAILURE\020\t*\277\002\n\014SessionSt" + + "ate\022\035\n\031SESSION_STATE_UNSPECIFIED\020\000\022\032\n\026SE" + + "SSION_STATE_CREATING\020\001\022!\n\035SESSION_STATE_" + + "STARTING_WORKER\020\002\022\"\n\036SESSION_STATE_WAITI" + + "NG_FOR_PIPE\020\003\022\035\n\031SESSION_STATE_HANDSHAKI" + + "NG\020\004\022%\n!SESSION_STATE_INITIALIZING_WORKE" + + "R\020\005\022\027\n\023SESSION_STATE_READY\020\006\022\031\n\025SESSION_" + + "STATE_CLOSING\020\007\022\030\n\024SESSION_STATE_CLOSED\020" + + "\010\022\031\n\025SESSION_STATE_FAULTED\020\t2\303\005\n\017MxAcces" + + "sGateway\022]\n\013OpenSession\022\'.mxaccess_gatew" + + "ay.v1.OpenSessionRequest\032%.mxaccess_gate" + + "way.v1.OpenSessionReply\022`\n\014CloseSession\022" + + "(.mxaccess_gateway.v1.CloseSessionReques" + + "t\032&.mxaccess_gateway.v1.CloseSessionRepl" + + "y\022T\n\006Invoke\022%.mxaccess_gateway.v1.MxComm" + + "andRequest\032#.mxaccess_gateway.v1.MxComma" + + "ndReply\022X\n\014StreamEvents\022(.mxaccess_gatew" + + "ay.v1.StreamEventsRequest\032\034.mxaccess_gat" + + "eway.v1.MxEvent0\001\022l\n\020AcknowledgeAlarm\022,." + + "mxaccess_gateway.v1.AcknowledgeAlarmRequ" + + "est\032*.mxaccess_gateway.v1.AcknowledgeAla" + + "rmReply\022a\n\014StreamAlarms\022(.mxaccess_gatew" + + "ay.v1.StreamAlarmsRequest\032%.mxaccess_gat" + + "eway.v1.AlarmFeedMessage0\001\022n\n\021QueryActiv" + + "eAlarms\022-.mxaccess_gateway.v1.QueryActiv" + + "eAlarmsRequest\032(.mxaccess_gateway.v1.Act" + + "iveAlarmSnapshot0\001B&\252\002#ZB.MOM.WW.MxGatew" + + "ay.Contracts.Protob\006proto3" }; descriptor = com.google.protobuf.Descriptors.FileDescriptor .internalBuildGeneratedFileFrom(descriptorData, @@ -106353,93 +107168,99 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { internal_static_mxaccess_gateway_v1_AlarmFeedMessage_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( internal_static_mxaccess_gateway_v1_AlarmFeedMessage_descriptor, - new java.lang.String[] { "ActiveAlarm", "SnapshotComplete", "Transition", "ProviderStatus", "Payload", }); + new java.lang.String[] { "ActiveAlarm", "SnapshotComplete", "Transition", "ProviderStatus", "SnapshotStatus", "Payload", }); internal_static_mxaccess_gateway_v1_AlarmProviderStatus_descriptor = getDescriptor().getMessageType(86); internal_static_mxaccess_gateway_v1_AlarmProviderStatus_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( internal_static_mxaccess_gateway_v1_AlarmProviderStatus_descriptor, new java.lang.String[] { "Mode", "Degraded", "Reason", "Since", }); - internal_static_mxaccess_gateway_v1_MxStatusProxy_descriptor = + internal_static_mxaccess_gateway_v1_AlarmSnapshotStatus_descriptor = getDescriptor().getMessageType(87); + internal_static_mxaccess_gateway_v1_AlarmSnapshotStatus_fieldAccessorTable = new + com.google.protobuf.GeneratedMessage.FieldAccessorTable( + internal_static_mxaccess_gateway_v1_AlarmSnapshotStatus_descriptor, + new java.lang.String[] { "Truncated", }); + internal_static_mxaccess_gateway_v1_MxStatusProxy_descriptor = + getDescriptor().getMessageType(88); internal_static_mxaccess_gateway_v1_MxStatusProxy_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( internal_static_mxaccess_gateway_v1_MxStatusProxy_descriptor, new java.lang.String[] { "Success", "Category", "DetectedBy", "Detail", "RawCategory", "RawDetectedBy", "DiagnosticText", }); internal_static_mxaccess_gateway_v1_MxValue_descriptor = - getDescriptor().getMessageType(88); + getDescriptor().getMessageType(89); internal_static_mxaccess_gateway_v1_MxValue_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( internal_static_mxaccess_gateway_v1_MxValue_descriptor, new java.lang.String[] { "DataType", "VariantType", "IsNull", "RawDiagnostic", "RawDataType", "BoolValue", "Int32Value", "Int64Value", "FloatValue", "DoubleValue", "StringValue", "TimestampValue", "ArrayValue", "RawValue", "SparseArrayValue", "Kind", }); internal_static_mxaccess_gateway_v1_MxArray_descriptor = - getDescriptor().getMessageType(89); + getDescriptor().getMessageType(90); internal_static_mxaccess_gateway_v1_MxArray_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( internal_static_mxaccess_gateway_v1_MxArray_descriptor, new java.lang.String[] { "ElementDataType", "VariantType", "Dimensions", "RawDiagnostic", "RawElementDataType", "BoolValues", "Int32Values", "Int64Values", "FloatValues", "DoubleValues", "StringValues", "TimestampValues", "RawValues", "Values", }); internal_static_mxaccess_gateway_v1_MxSparseArray_descriptor = - getDescriptor().getMessageType(90); + getDescriptor().getMessageType(91); internal_static_mxaccess_gateway_v1_MxSparseArray_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( internal_static_mxaccess_gateway_v1_MxSparseArray_descriptor, new java.lang.String[] { "ElementDataType", "TotalLength", "Elements", }); internal_static_mxaccess_gateway_v1_MxSparseElement_descriptor = - getDescriptor().getMessageType(91); + getDescriptor().getMessageType(92); internal_static_mxaccess_gateway_v1_MxSparseElement_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( internal_static_mxaccess_gateway_v1_MxSparseElement_descriptor, new java.lang.String[] { "Index", "Value", }); internal_static_mxaccess_gateway_v1_BoolArray_descriptor = - getDescriptor().getMessageType(92); + getDescriptor().getMessageType(93); internal_static_mxaccess_gateway_v1_BoolArray_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( internal_static_mxaccess_gateway_v1_BoolArray_descriptor, new java.lang.String[] { "Values", }); internal_static_mxaccess_gateway_v1_Int32Array_descriptor = - getDescriptor().getMessageType(93); + getDescriptor().getMessageType(94); internal_static_mxaccess_gateway_v1_Int32Array_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( internal_static_mxaccess_gateway_v1_Int32Array_descriptor, new java.lang.String[] { "Values", }); internal_static_mxaccess_gateway_v1_Int64Array_descriptor = - getDescriptor().getMessageType(94); + getDescriptor().getMessageType(95); internal_static_mxaccess_gateway_v1_Int64Array_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( internal_static_mxaccess_gateway_v1_Int64Array_descriptor, new java.lang.String[] { "Values", }); internal_static_mxaccess_gateway_v1_FloatArray_descriptor = - getDescriptor().getMessageType(95); + getDescriptor().getMessageType(96); internal_static_mxaccess_gateway_v1_FloatArray_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( internal_static_mxaccess_gateway_v1_FloatArray_descriptor, new java.lang.String[] { "Values", }); internal_static_mxaccess_gateway_v1_DoubleArray_descriptor = - getDescriptor().getMessageType(96); + getDescriptor().getMessageType(97); internal_static_mxaccess_gateway_v1_DoubleArray_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( internal_static_mxaccess_gateway_v1_DoubleArray_descriptor, new java.lang.String[] { "Values", }); internal_static_mxaccess_gateway_v1_StringArray_descriptor = - getDescriptor().getMessageType(97); + getDescriptor().getMessageType(98); internal_static_mxaccess_gateway_v1_StringArray_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( internal_static_mxaccess_gateway_v1_StringArray_descriptor, new java.lang.String[] { "Values", }); internal_static_mxaccess_gateway_v1_TimestampArray_descriptor = - getDescriptor().getMessageType(98); + getDescriptor().getMessageType(99); internal_static_mxaccess_gateway_v1_TimestampArray_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( internal_static_mxaccess_gateway_v1_TimestampArray_descriptor, new java.lang.String[] { "Values", }); internal_static_mxaccess_gateway_v1_RawArray_descriptor = - getDescriptor().getMessageType(99); + getDescriptor().getMessageType(100); internal_static_mxaccess_gateway_v1_RawArray_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( internal_static_mxaccess_gateway_v1_RawArray_descriptor, new java.lang.String[] { "Values", }); internal_static_mxaccess_gateway_v1_ProtocolStatus_descriptor = - getDescriptor().getMessageType(100); + getDescriptor().getMessageType(101); internal_static_mxaccess_gateway_v1_ProtocolStatus_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( internal_static_mxaccess_gateway_v1_ProtocolStatus_descriptor, diff --git a/clients/java/zb-mom-ww-mxgateway-cli/src/main/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCli.java b/clients/java/zb-mom-ww-mxgateway-cli/src/main/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCli.java index 3fe632e..e4e7649 100644 --- a/clients/java/zb-mom-ww-mxgateway-cli/src/main/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCli.java +++ b/clients/java/zb-mom-ww-mxgateway-cli/src/main/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCli.java @@ -48,6 +48,7 @@ import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply; import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest; import mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot; import mxaccess_gateway.v1.MxaccessGateway.AlarmProviderStatus; +import mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus; import mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage; import mxaccess_gateway.v1.MxaccessGateway.BulkReadResult; import mxaccess_gateway.v1.MxaccessGateway.BulkWriteResult; @@ -2282,7 +2283,8 @@ public final class MxGatewayCli implements Callable { /** * Renders one {@link AlarmFeedMessage} in the CLI's plain-text output * style, distinguishing the active-alarm snapshot, snapshot-complete - * sentinel, and transition cases of the message's {@code payload} oneof. + * sentinel, transition, provider-status, and snapshot-status cases of the + * message's {@code payload} oneof. */ private static String formatAlarmFeedMessage(AlarmFeedMessage message) { return switch (message.getPayloadCase()) { @@ -2307,6 +2309,10 @@ public final class MxGatewayCli implements Callable { "provider-status mode=%s degraded=%b reason=%s", status.getMode().name(), status.getDegraded(), status.getReason()); } + case SNAPSHOT_STATUS -> { + AlarmSnapshotStatus status = message.getSnapshotStatus(); + yield String.format("snapshot-status truncated=%b", status.getTruncated()); + } case PAYLOAD_NOT_SET -> "unknown"; }; } diff --git a/clients/proto/descriptors/mxaccessgw-client-v1.protoset b/clients/proto/descriptors/mxaccessgw-client-v1.protoset index fe740013a3b4bef4f16385b73edb061763e136d3..30a8c97c340245ff050d2dc488b98ae93ab7cfd7 100644 GIT binary patch delta 7819 zcmZ{p{cl{=na5|Y@64QY?Y#Ip3Bh^ck`R)BZD^r?K&7feRBWrV3yuSr@pv51V$WDJ z6WHvky0n3$Evsf#vo&p2z_v|75(qCTNuYu7RzYc+H=wX9Ah6|~R{H~#s$tda=Q%IA zpw&wGA)h(l=RD`-d7g9b@%J7q9XMEe;G(|p)b|R}%pY&apSAb$zL6_}+dKB|*>Guo zetcy#r&TXcwZ|1Gy7rhj(_^^CDFI{bVQ}NkjtIed-snoe=zvAn)$=! z`8R@_)&3)2%YQ3ac_bf&;ok*e9G)(PeZ7bBiFeXpX-rN{RNK{hwbh!r^NRcz(kQ9k zSZ!`dD&yt)x@yuGOWNbr#EqfWs3)|J)++73q}50!7*_K4jat3hY$X%bazbx}hTGe(kS>BM0RFhiW`yYs+Sy6#OreVIknwxlyS_G=p2hl6o;}PvGYTiPR z^m>b0v^eUEl-SaG>&))I$q)43s)C{u<>gmZn&T0hTUFeHjhF;+xM`;N_547(Nd?Mj zq@ztrdy@c@O=4n42yIru`A*xx2*E`jG2N^dpXDsI_b5iDatTxUn`` zZMx7+#+v2HY9D(w)@Tw1L_(!p8Ly7IEgd#n%07#%wR+~gEAq?JGXYYrqgz+5)f2+z zvr!fO|6!H<*Uh&}4Aqlz(yUe*^-67`%KxSs%{GCQjMm1+s?BP>oy8$;B5NY!TbB}( zHTqgifCRTA4tYyF7*f%tE%Z%+{BDaK_&+`5g3_f`vQlP?T#LsDiXTQUQ)oRT%Q`Z~LW zs|S)RYRzVY-lTD=+U%D_XfnYsn;bIx=d*WUtl5|xJ~M@3+5Xn}V57|@-B@J|{4=9; z(Lug4z9?Z;Tk9t!Wh>=6>r!qpx$+3(rzxukW+~*ysm8CkRt8wZn(8-J!Y{yRM|{?NH|=&KA1ISK`5-gAs#%#2uL4qtt9?+F``qu`}fa z+o^)HU1NwT=uN{E+nq|y^Uc`qRA()8wupDBz>le5(c2a<-KCU0bOwV0rn^+HJxT^; zdb|Bm0$~c;8V_Q+TWOAxlmiULcDI}*oKScv?oqi%?cNo{%N|vjFC!`xWasWz!3r6! z5RIVO4ndaqezp8Or={@$x8O9&Q3RUF2%>#Jos*gshQSB3R@n{E%uO0itDPIjXdlWt zmgNV1#~o-Nayy5X<=>lWyUmDMrC+F^-_1^@4zBV@$G=b)_PN=yzz=6y zSy9kyi)bItw52%Qr-BPzM}YP z$g~uM9a$#uB9CaFR$sit4VVykCeum?fEPu*vie060?$}+5Uz=f&#K&i&b&U9Uz}ew z^XX82$=n6fYq-Xg!{^l5y)&!U<}VwtSHm@d1n%?x)(6u2^QvSE63Ajbue#%z`>;vP z>eG`{qQkxtf!G|D5^ep2*c?_1&yHqOu-B+uTs)$J|4<#aS;?*+QL0NOM_+VxRE4(2 zK^ASX0nr{++Kn|eh3cqUU~AkODOAT)XlpivEvVfvWKEB$$d5|u)G@WN*STd)k7uSO zOhIodOME;tEn#{b)706LWhXLQg)Qi9CFxFNwhG%5nOl)8$rZ1>osjRY4Q9K#GnAt|yg5Fkg^2N+H!uG|?wlfZ2QsE_TWja|m(2t@M)0fmz zQ@ZfeiRnw~;xEpeyf(i$?Q}L>h<>NK{Mfn>{VwZ&Rvf;ZZQ?A32Kp75h3U&G@&Pvs z)0eY7oE?X+sPF>kW;R0uyFJPo_lkcJ zq|14Fk`n~9dj!(cN!4j52h!6?#TT!n4M_;J0>kXytb|5xt zcYqIx+c#CP%ylf;3fi-uMoiyS%I-dh>6u>rmqAS5R!b9` zazg>>`c7tB!WQ(llCJM$wvw*zVCzOqw!WL0DojCdD%tvOW-8hGZf2_EG<;8m=1^qF z8e7m_FF|bIQ*-@7O1i$M5_5bZ=E!_MGmS6>y{TmD`-(80+4_MBSIeTN^d#vD z+nXwg?FXvI4@%PY19ko?H`}f_{LrWNP|$@b=;L0J_(L_zM~x)$hmypb0@7)1T>MDo z{>(@ApYzGp&04$K->A=g>AL(!>00bQk^(O=uvT*GW1m|PK0xaOMEkMNEf60c``lV9 zA^#_zTM*F%+Motat4l2q(?3aSrPeLE^@+Q%FbD~9(B>A%C_Yi0_JjiI>JzodE)qyr zpV;e65MC1(|DtmLbct4`Z3i0x_R4u23Yr#bxTDG0BNi{H_?Tb!5cP<}@%`_XZo zIN7M(hXaI@6t-Sqylm8+_R|N%cBAgL5e4F9qc(LY2#4e1CY}4P^D>NbllL+#UN(C# z5MDs*1jNZ^?_@Yg#mi>zg=V~L_Fk@!i{IC|e|BE3NBMp4<$CdQoA(0Y1+-p3yxiu! zfSBIqoq#yG%{t+tSB{H6(7AtcUdl*+pp|{tDvOsb+C9BNcmb^!5HDM_EBYX&TXbw+ z;y}D?(e~>p9EpoR)VbT8lM$3ZG)_{YYec+EdoK`PK%e40bVms}=N$iHFtuJ_0(nMv+S3@sc1Eu--zFi5ml-{9kqNQU zxcC#D`#0xh6y;B}nrpm_ikGc^TOhoE)(OZ|w)$-W^AzE&eqUg`Y;~JoD8;U+@;+I1VkffKOjJ=$z9rgKa`|g+^y|nB$kq)pt(4MseEwWtxfWT zF!b-4SZqNrwykHhjP|FQR*r4ZYvp6(r&+(O!_P9UoYNp$KD6;F9~(c*#H`ZKwS60) zQBGdayafoN{kgVp0U+D9Et|R=wV=7dNF&;9+TLJ5PTK96R+e;onu+Cmb9*M{Y~7&) z6U>;(VG5dyo*>#CdV#5|AXndewENzN(1PZ?mqxVrX#3s*a(M34?qihom*WyPC#|$1 z-l^?L3vyiU$~uVjgnPay;(NR!z=B&=0r^?QU(qF3@tRy;lcc@ar$v zSlE7v(~9_BJ#evvG-9r?_i6iGE`%1eAIKou`?UE$7VR9KMY7q=K@0lH%|W{-o7`NU zMOc6PYEla3BECNh>bZ#T*URjyNh#>&T|~P#2na2>Qj!_3-Dn@s7uYul_K{bTtfLq$ zXl`z3W3&%ydvhz4(x8WTk!+uP5QFAIg186qLwdgKa}ZYJ;=?+3z~y}v<-@+ZR3#(# zY5Tn&LWCt~->*Ql`}BN!c?8LveR}Z{dQC&rwC;CtC`9;!_T~VZR&7ru5ZnFQz9$w` zTzpjLe&uR}#OR~CV1rW0h<@pp2%-@*7v}=V#D1w)oNq3nFs%pO@P#beB?A#3wBrqF zg&y<40-*)XvyC*GR`+V7xXNrB7*PKn*B9D-Q*!M&l!>KCfL<&$;!vh# zk)F`Ofa^(W1ZeJ+3_@ze6S}Y81TYT(JZ1QxgwTTaX(ojcKdIf@4BryGW$3`11N0g5#Y+yGva5o{bN``$HixL z?u6S`kv^*n=Au;4vaQeg^n+*wZLI<6<~d($v~10->uQRL delta 6423 zcmYM2S&&x67017?=id9B{=ONQxdX^D!zzm~ftN+HVY;T(Ern2~|QTwgbo2_PBc2m8~zG}#nW_vTsZZfqp zyG=}Qv!j_S6%M;2qPI}Bp^G50FiHPk+nKnJxLYu_+rRg_q zL=47=+c4cEGA5X|IdNAROtUiEEM`(@%rFI`X;xvoS!7&n#&)w@H8I#C-XbQ(lzWV} zIi_1gt_r3(rdwpP&#itP-qx5~5T>ACbr937V#%$F)Pu3zs>#LL;iY&$YPlU8RGjM@V2_M~l;*$y!aLPrg>p!=;VyfzT;kZb0Lj-|{VOFao4Ei?Y})wP!*K=-75k z?utTQ=+FCPbOO-`PIcXYbn~RlnCapxbQzx#Gc7b~3V@R%qOG2i-l;(h?uh{wMr}d& zzv5L>V2`-+l7Xba-lWwO0Han@U~kfr0{fCyQveLw%4CT5B{3=RbP{U{fU)Bo?bAt1 z3hWniLl}-u6SycM+WoTl`YQfuhj!4IZh|JQ6Oqv`_X*?8WA8nnH9+E251lTp(@xfM$=OLLm zDcCZY!?6Gk82wQ|zwdyU9+rkLfFP!aB^1B`r6b{&!>!g%&{v`XQ|Zzj5$mfM%}yPv z*ns}MqbX!9o}e#)G-5ic+ln82hUutAJVqx%_@qDwv*3rqqe&CG#hZ0b?-*n?zh`+GyQ) zURrStEVj?fWZexkguy2Zh5dzrnM#O>IEtFO0dpy+}Qs-IUSb46K+A%Y!l zOd#4K2ZJtI^78EVjw`mZJ12Bu&% z)y#h_nQG?0mQ2a~*ORGW3XXBblb|&9Ur)B0`mZNjQvVIf+@6TmVhj4yC5Y)8(i!Jb zGye^l?shN4Wd57Uw23JgP09Qpz+c;H>c5$6N&UAZyC%%4ratHos)I&T|1If`L22f{ zC3EHtpIK4w>GQ>W&}fdoEge3`K}2tBj;}Lh+s$S1j@14&`u3W7yu8ny@!)AzLCR^Bap#My8d zp(o95&}TPD6lbO7_I_=ptFto2&j3hQXZ=ydWJ}88eW`sI_P-@4-`8WVI$5G)I~O-1 zhyc%hy@|ny&x!R}1=8&~-H9eRTN*zQGj66#|Jj;VKU(!uAKMb6*9aPXg#ewd=@%NL zlMnn2#AHj$;v=bj9AaCF@*|O|%LTVogF7ELXNVa9y%&&J&r8d93u1d-I{jV^;^n;G zt4+47EIyIizk`=$C_fSL7m8)-UXDmjj4K&|eNfs>xk8+zlF9F7CGe z=>bdqLqYfNfR$b*@3#J+3q!w8VzmXM*tdSICE9zER!^f~)apgzp42b%aBtG;@e@SL zB^s}Kk+?UBnWg)zzxUIq+bZbp`HE=ov;LkBvTWs@j~60>n`w8kYfrn*sr?&zb4cB7r(Dl5oFxygpRJcJf>d$~5E-DLe+3dqj5 z*@lZI^RK5s*lnZQig>g2+bGCZxFvP0dmk9rxrKI%?RAedXgT$5O|zz3AQ%H~N4wSf zV>nuldBfIxBhJ4b>|lS(rWNt9U3i_QG-A$m_gjDGRzeH<%P)xbe(Ns3svXOdM;hH& zv|t?EShNqM!FBTF!TkFdk%s9+ye%blC*o~(rhgG>m@eKtYT4ktdeJtdm66p{6lLP2l zt>2YEY#+1T<20gN%3_zTJrin#=IAb)`=k_&=!rN*5RKqWH)9|Jd&171Q@J%34Pkc( zKZiW}DFYGj_VH%4LQlqIfzX2PNk$u8Yj}|nJ)ArhmkmM-&I>_MBPw`-UpLWX&z>aK zA^}FR)`&ex%Ovf!W?|?_YXs=dj0B-IVz2GHHVlr%*cX$Mt2bKEKf6>g;(a!}yKqJ3 zox&Qo573hq3D7^ofM}n#GiQa4sS*2A5IX;03_@$f{uBf?;(#@MAz-Z$;9Pf!#*Y?> z19tubIySj2i-WdyGAye~58B)vlya-fIud_Bh(^%Y8jx;AVy&^dG>78v2YK`*4McmW zvhL|Jqhs)J95{p+^aBT7YZ$n74-@gvh0q>JF=^>PV#8aebq^7#(Y-vRfGb_r(G)Su z`dnOd>q Value { } /// Render a streamed [`AlarmFeedMessage`] as a terse one-line summary that -/// distinguishes the four `payload` oneof cases. +/// distinguishes the five `payload` oneof cases. fn alarm_feed_message_summary(message: &AlarmFeedMessage) -> String { match &message.payload { Some(alarm_feed_message::Payload::ActiveAlarm(snapshot)) => { @@ -2259,6 +2259,9 @@ fn alarm_feed_message_summary(message: &AlarmFeedMessage) -> String { status.reason ) } + Some(alarm_feed_message::Payload::SnapshotStatus(status)) => { + format!("snapshot-status truncated={}", status.truncated) + } None => "(empty)".to_owned(), } } @@ -2308,6 +2311,11 @@ fn alarm_feed_message_to_json(message: &AlarmFeedMessage) -> Value { })), } }), + Some(alarm_feed_message::Payload::SnapshotStatus(status)) => json!({ + "snapshotStatus": { + "truncated": status.truncated, + } + }), None => Value::Null, } } diff --git a/clients/rust/protos/mxaccess_gateway.proto b/clients/rust/protos/mxaccess_gateway.proto index 0ee3fb2..b5e2a6b 100644 --- a/clients/rust/protos/mxaccess_gateway.proto +++ b/clients/rust/protos/mxaccess_gateway.proto @@ -1018,6 +1018,10 @@ message AlarmFeedMessage { // Provider-mode status. Emitted once on stream open and again on every // failover/failback so late joiners learn the current mode immediately. AlarmProviderStatus provider_status = 4; + // Snapshot-completeness status. Emitted once on stream open and again on + // every change of the truncation verdict, so late joiners learn whether the + // feed's active-alarm set may be incomplete. + AlarmSnapshotStatus snapshot_status = 5; } } @@ -1028,6 +1032,23 @@ message AlarmProviderStatus { google.protobuf.Timestamp since = 4; } +// Feed-level snapshot-completeness status. Emitted once on StreamAlarms open +// (after the initial provider_status frame, before the cached active_alarm +// frames) so late joiners learn the current verdict, and again on every change +// of the truncation verdict — when a reconcile reports a different verdict, and +// when the gateway's alarm monitor restarts and drops a truncated verdict with +// the cache generation it described (feed subscribers outlive that monitor +// session, so they are sent the clearing frame). Mirrors the per-record +// ActiveAlarmSnapshot.from_truncated_snapshot caveat at feed level so live +// consumers can reason about completeness without polling QueryActiveAlarms. +message AlarmSnapshotStatus { + // True while the monitor's cached active-alarm set derives from a truncated + // (capped) worker fetch — the set may be missing alarms. Distinct from + // provider degradation (AlarmProviderStatus.degraded), which describes the + // fidelity of the records rather than the completeness of the set. + bool truncated = 1; +} + message MxStatusProxy { // Mirrors the `success` member of the MXAccess MXSTATUS_PROXY struct // (a 16-bit signed value in the COM struct, widened to int32 on the diff --git a/docs/GatewayDashboardDesign.md b/docs/GatewayDashboardDesign.md index f121526..bfc7fdb 100644 --- a/docs/GatewayDashboardDesign.md +++ b/docs/GatewayDashboardDesign.md @@ -273,7 +273,7 @@ bearer). Each hub class is `[Authorize(Policy = HubClientsPolicy)]`. | Hub | Path | Producer | Payload | Routing | |---|---|---|---|---| | `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. The alarm value fields (`current_value` / `limit_value`) are stripped from a deep-cloned copy of the message when `Dashboard:ShowTagValues` is false (the default) — on both value-bearing payload arms, so neither a live transition nor a snapshot record leaks a process value; every other field still renders. The source message is never mutated: it fans out from the same feed to gRPC `StreamAlarms` subscribers, which this dashboard-display flag does not govern. Arms carrying no value are forwarded as-is, uncloned. | +| `AlarmsHub` | `/hubs/alarms` | `AlarmsHubPublisher` (BackgroundService consuming `IGatewayAlarmService.StreamAsync(filter: null)`) | `AlarmFeedMessage` (`active_alarm` / `snapshot_complete` / `transition` / `provider_status` / `snapshot_status`) | Connected clients auto-join `__alarms__`; all clients receive every message. Publisher auto-reconnects every 5s on stream faults. The alarm value fields (`current_value` / `limit_value`) are stripped from a deep-cloned copy of the message when `Dashboard:ShowTagValues` is false (the default) — on both value-bearing payload arms, so neither a live transition nor a snapshot record leaks a process value; every other field still renders. The source message is never mutated: it fans out from the same feed to gRPC `StreamAlarms` subscribers, which this dashboard-display flag does not govern. Arms carrying no value are forwarded as-is, uncloned. | | `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 @@ -652,6 +652,11 @@ Show read-only effective configuration: - auth mode, - SQLite auth database path with sensitive parts redacted if needed, - dashboard enabled state, +- the LDAP group mappings that decide what a signed-in user may see — + `Dashboard:GroupToRole` (group → `Administrator` / `Viewer`) and + `Dashboard:GroupToTag` (group → the session tags it grants), +- `Dashboard:UntaggedSessionVisibility`, which decides who sees a session whose + owning API key carries no dashboard tags, - protocol version. Do not show API key secrets or pepper values. From 4d4f443c279700ee4ff0714f48b3e752d93c03fe Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 18 Aug 2026 05:33:00 -0400 Subject: [PATCH 18/26] =?UTF-8?q?fix(clients):=20review=20nits=20=E2=80=94?= =?UTF-8?q?=20alphabetize=20the=20Java=20CLI=20alarm=20imports,=20cover=20?= =?UTF-8?q?the=20Rust=20snapshot-status=20render?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Java import block's AlarmFeedMessage already sorted after AlarmProviderStatus before c748361; adding AlarmSnapshotStatus widened the gap. Order all three alphabetically. The Rust CLI tests the sibling ProviderStatus render path but not the new SnapshotStatus arm, so the summary string and the JSON shape were both uncovered. Add the matching test over alarm_feed_message_summary and alarm_feed_message_to_json. --- .../zb/mom/ww/mxgateway/cli/MxGatewayCli.java | 2 +- clients/rust/crates/mxgw-cli/src/main.rs | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/clients/java/zb-mom-ww-mxgateway-cli/src/main/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCli.java b/clients/java/zb-mom-ww-mxgateway-cli/src/main/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCli.java index e4e7649..b40fe5b 100644 --- a/clients/java/zb-mom-ww-mxgateway-cli/src/main/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCli.java +++ b/clients/java/zb-mom-ww-mxgateway-cli/src/main/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCli.java @@ -47,9 +47,9 @@ import java.util.function.Consumer; import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply; import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest; import mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot; +import mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage; import mxaccess_gateway.v1.MxaccessGateway.AlarmProviderStatus; import mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus; -import mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage; import mxaccess_gateway.v1.MxaccessGateway.BulkReadResult; import mxaccess_gateway.v1.MxaccessGateway.BulkWriteResult; import mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest; diff --git a/clients/rust/crates/mxgw-cli/src/main.rs b/clients/rust/crates/mxgw-cli/src/main.rs index 1529835..41393ec 100644 --- a/clients/rust/crates/mxgw-cli/src/main.rs +++ b/clients/rust/crates/mxgw-cli/src/main.rs @@ -2969,4 +2969,23 @@ mod tests { assert_eq!(provider["reason"], "alarmmgr unavailable"); assert_eq!(provider["since"]["seconds"], 1_777_995_000_i64); } + + #[test] + fn alarm_feed_snapshot_status_renders_in_summary_and_json() { + use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::{ + alarm_feed_message, AlarmFeedMessage, AlarmSnapshotStatus, + }; + + let message = AlarmFeedMessage { + payload: Some(alarm_feed_message::Payload::SnapshotStatus( + AlarmSnapshotStatus { truncated: true }, + )), + }; + + let summary = super::alarm_feed_message_summary(&message); + assert_eq!(summary, "snapshot-status truncated=true"); + + let value = super::alarm_feed_message_to_json(&message); + assert_eq!(value["snapshotStatus"]["truncated"], true); + } } From ad30adca2f13534c335a63819e3c88560ed53c86 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 18 Aug 2026 05:33:25 -0400 Subject: [PATCH 19/26] =?UTF-8?q?chore(plan):=20Task=202=20complete=20?= =?UTF-8?q?=E2=80=94=20five-client=20snapshot=5Fstatus=20rollout=20approve?= =?UTF-8?q?d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/2026-08-17-followup-closeout.md.tasks.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-08-17-followup-closeout.md.tasks.json b/docs/plans/2026-08-17-followup-closeout.md.tasks.json index 4c9b09c..a36297d 100644 --- a/docs/plans/2026-08-17-followup-closeout.md.tasks.json +++ b/docs/plans/2026-08-17-followup-closeout.md.tasks.json @@ -2,7 +2,7 @@ "planPath": "docs/plans/2026-08-17-followup-closeout.md", "tasks": [ {"id": 1, "subject": "Task 1: Feed-level alarm-truncation signal (proto + gateway)", "status": "completed", "commits": ["fccf753", "312e072"], "review": "Spec compliant; code review Approved, proto-comment Minor fixed"}, - {"id": 2, "subject": "Task 2: Five-client rollout of snapshot_status", "status": "pending", "blockedBy": [1, 3, 4]}, + {"id": 2, "subject": "Task 2: Five-client rollout of snapshot_status", "status": "completed", "commits": ["c748361", "4d4f443"], "review": "Spec compliant; code review Approved, Java import + Rust render-test nits fixed; full five-toolchain matrix ran locally"}, {"id": 3, "subject": "Task 3: Complete ShowTagValues coverage — alarms hub + /browse", "status": "completed", "commits": ["eff17d1", "1ea6f60", "e50ec49"], "review": "Spec compliant; code review Approved, failed-read placeholder fix re-review Approved"}, {"id": 4, "subject": "Task 4: Codegen guard — reverse-direction Check 3 + stale-note correction", "status": "completed", "commit": "711793f", "review": "Approved (code review, no issues)"}, {"id": 5, "subject": "Task 5: Settings page — GroupToTag + UntaggedSessionVisibility rows", "status": "completed", "commits": ["094f2ff", "ff16c69"], "review": "Approved; comparer-propagation fix applied and re-verified"}, From 90331b622cf22969ddb99dd67be49c14ad33f27b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 18 Aug 2026 05:40:11 -0400 Subject: [PATCH 20/26] =?UTF-8?q?docs(alarms):=20second=20wnwrap=20probe?= =?UTF-8?q?=20attempt=20=E2=80=94=20secured-write=20verb=20unblocked=20the?= =?UTF-8?q?=20rig,=20answering=20@COUNT=20and=20the=20re-raise=20GUID=20le?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AuthenticateUser("Administrator", "") + WriteSecured raises the alarm UDAs that plain Write could not touch (SecurityError detail=1008), so the 2026-08-17 blocker was the verb, exactly as that run's own Unblocking list predicted. Two of the three open questions are now observed rather than assumed: - ALARM_RECORDS/@COUNT reports the records in the reply, not the total active count. With three alarms active it read 1 at cap 1 and 2 at cap 2. There is no exact truncation signal to switch to, so IsTruncatedFetch's conservative rule is the design rather than a placeholder — behaviour unchanged, only the comments. - Clear-then-re-raise mints a new GUID; the ALM->RTN leg keeps its GUID (reconfirming the 2026-05-01 capture). ComputeTransitions already reads the re-raise correctly as one instance ending and another beginning. The acknowledge leg stays unobserved for a narrower reason: every wnwrap ack surface is inert on this rig. AlarmAckByName returns 0 from the ack-only consumer and -55 from the SetXmlAlarmQuery-applied one, for both the 6-arg and 8-arg forms, and neither the snapshot STATE, OPERATOR_NAME, nor the extension's own .Acked attribute moves. That corrects AlarmClientDiscovery.md, which read the zero return as a working ack. Comment- and prose-only; no behaviour change. The three throwaway probes ran from the windev CI clone and were deleted; that clone is a clean tree at ab3ff16. --- docs/AlarmClientDiscovery.md | 11 + docs/AlarmProbeFindings.md | 208 +++++++++++++++--- docs/DesignDecisions.md | 15 +- gateway.md | 7 +- .../MxAccess/WnWrapAlarmConsumer.cs | 39 ++-- 5 files changed, 224 insertions(+), 56 deletions(-) diff --git a/docs/AlarmClientDiscovery.md b/docs/AlarmClientDiscovery.md index f43fff8..411a500 100644 --- a/docs/AlarmClientDiscovery.md +++ b/docs/AlarmClientDiscovery.md @@ -764,6 +764,17 @@ The v2 8-arg method returns -55 on this AVEVA build regardless of operator-identity inputs — looks like a stub. The v1 6-arg method works. Production `WnWrapAlarmConsumer.AcknowledgeByName` calls the 6-arg overload and discards the proto's `domain` + `full_name` fields. + +**Correction, 2026-08-18** (`docs/AlarmProbeFindings.md`). Both halves +of the paragraph above are narrower than they read. The -55 tracks the +*consumer*, not the overload: against the `SetXmlAlarmQuery`-applied +reader both the 6-arg and 8-arg forms return -55, and against the +ack-only consumer both return 0. And "works" means "returns 0" only — +a probe that raised a real alarm and then acked it six ways watched the +snapshot stay `UNACK_ALM`, `OPERATOR_NAME` stay empty, and the alarm +extension's own `.Acked` attribute stay `False` for 16 s after each +call. On this rig the wnwrap ack is accepted and then inert, so a zero +return code must not be read as an applied acknowledgement. The proto contract keeps the 8 fields for forward compatibility if AVEVA fixes the v2 method later. diff --git a/docs/AlarmProbeFindings.md b/docs/AlarmProbeFindings.md index f67e360..26f74d9 100644 --- a/docs/AlarmProbeFindings.md +++ b/docs/AlarmProbeFindings.md @@ -1,7 +1,8 @@ # 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: +`WnWrapAlarmConsumer` rested on two assumptions that no unit test can settle, because both +are properties of AVEVA's alarm provider rather than of our code. The second is now settled +and one half of the first is; the questions are stated here as they were asked: 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 @@ -13,23 +14,27 @@ are properties of AVEVA's alarm provider rather than of our code: 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. +This document records what live probe runs against the dev rig (`DESKTOP-6JL3KKO`, +2026-08-17 and 2026-08-18) 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** | +| GUID stable across polls and `ALM → RTN` | Answered — yes (2026-05-01 capture in `AlarmClientDiscovery.md`, reconfirmed 2026-08-18) | +| GUID stable across clear-then-re-raise | Answered 2026-08-18 — **no**, a re-raise mints a new GUID | +| GUID stable across `UNACK → ACK` | **Open** — the rig cannot be driven into an acknowledged state at all | +| `COUNT` = total active vs records-in-reply under a capped fetch | Answered 2026-08-18 — **records in the reply** | -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. +The 2026-08-17 run below is kept because it is the record of the wrong-verb blocker. The +2026-08-18 run cleared that blocker with `AuthenticateUser` + `WriteSecured` and answered +two of the three questions; the acknowledge leg is now blocked on something narrower and +different, described in "Second attempt". -## Why The Rig Cannot Raise An Alarm +## First attempt (2026-08-17): plain `Write` + +### Why The Rig Could Not Raise An Alarm The rig is otherwise healthy, which is what makes the blocker specific rather than a general "nothing works": @@ -64,7 +69,8 @@ for the whole probe session. ### Unblocking -Any one of these makes both questions answerable, in rough order of cost: +Any one of these makes both questions answerable, in rough order of cost. The second one +is what the 2026-08-18 run did, and it worked: - Re-enable the System Platform script that flips `TestMachine_001.TestAlarm001` (referenced throughout `AlarmClientDiscovery.md`). It writes from inside the engine, so @@ -78,7 +84,7 @@ Three separate objects are wired to the same alarm UDA name, so once writes land `maxAlmCnt` of 1 or 2 forces truncation against three active alarms and answers the `COUNT` question in the same run. -## Evidence +### Evidence Snapshot payload, identical at every cap (1, 2, and 1024) and at every poll across the ~100-second session: @@ -98,26 +104,164 @@ The probe used for the run was a throwaway file in the windev CI clone harness it was modelled on is `src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/WnWrapConsumerProbeTests.cs`. -## Implications +## Second attempt (2026-08-18): secured-write verb -### Transition identity +The findings above named the fix in their own "Unblocking" list, and it holds: +`AuthenticateUser` + `WriteSecured` raises the alarms that plain `Write` could not touch. +The rig's alarm attributes are not unreachable — they are secured, and the 2026-08-17 probe +used the wrong verb. -`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. +### Method -### Truncation detection +Three throwaway probes in the windev CI clone (`C:\build\mxaccessgw-ci`, detached at +`ab3ff16`), each an x86 net48 xUnit fact running on a dedicated STA with a +`PeekMessage`/`DispatchMessage` pump so MXAccess events deliver: -`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. +1. secured write + both GUID legs + the capped-fetch question, +2. the acknowledge surface, exhaustively, +3. the acknowledge question re-asked through an independent observer. -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. +All three used `LMXProxyServerClass` directly for the write path and a +`wwAlarmConsumerClass` pair — a reader with `SetXmlAlarmQuery` applied and an ack-only +consumer without it — mirroring `WnWrapAlarmConsumer`'s own two-consumer arrangement. The +subscription was `\\DESKTOP-6JL3KKO\Galaxy!TestArea`, matching the 2026-08-17 run. +`MXGATEWAY_LIVE_MXACCESS_WRITE_SECURED_USER` / `_PASSWORD` are set at neither machine nor +user scope on the box, so the probes used the default rig identity, `Administrator` with an +empty password. All three files were deleted afterwards and the clone is a clean tree. + +### The secured write lands + +Same session, same item, one after the other — the control reproduces 2026-08-17 exactly +and the secured verb succeeds: + +``` +Write 'TestMachine_001.TestAlarm001' <- True +WRITE-COMPLETE hLMX=1 hItem=1 statuses=[success=0 category=SecurityError detectedBy=RespondingAutomationObject detail=1008 text=] + observed 'TestMachine_001.TestAlarm001' = False + +AuthenticateUser -> userId=1 +WriteSecured 'TestMachine_001.TestAlarm001' <- True currentUserId=1 verifierUserId=0 +DATA-CHANGE hItem=1 value=True quality=192 +WRITE-COMPLETE hLMX=1 hItem=1 statuses=[success=-1 category=Ok detectedBy=RespondingAutomationObject detail=0 text=] + observed 'TestMachine_001.TestAlarm001' = True +``` + +`AuthenticateUser("Administrator", "")` resolves to user id 1, matching the value +`WorkerLiveMxAccessSmokeTests` records. No verifier is needed: `verifierUserId=0` is +accepted. All six subsequent raise/clear writes across the three objects behaved the same +way, so the path is reliable rather than a one-off. + +### Q2 — `ALARM_RECORDS/@COUNT` reports the reply, not the total + +With all three `TestMachine_00{1,2,3}.TestAlarm001` alarms active, the same subscription +fetched at three caps back to back: + +``` +FETCH three-active cap=1024 ALARM_RECORDS/@COUNT=3 elementCount=3 +FETCH three-active cap=1 ALARM_RECORDS/@COUNT=1 elementCount=1 +FETCH three-active cap=2 ALARM_RECORDS/@COUNT=2 elementCount=2 +``` + +`@COUNT` tracked the cap, not the population. It equals the element count in every reply, +including the two that are provably truncated — the galaxy held three active alarms while +`@COUNT` read 1 and 2. The attribute therefore carries no "more available" information, and +the hypothesis that it might report the total is refuted rather than merely untested. + +`IsTruncatedFetch` stays exactly as written, and this is now settled rather than deferred: +there is no exact truncation signal to switch to. Tightening it is **not** a future task. +The conservative rule keeps its original 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. + +### Q1 — clear-then-re-raise mints a new GUID + +`TestMachine_001.TestAlarm001` was raised, cleared, and re-raised through `WriteSecured`, +with a full snapshot fetch after each leg: + +``` +after-raise TAG=TestMachine_001.TestAlarm001 GUID=72B84A6E7BA74D42B611EB5393626F79 STATE=UNACK_ALM VALUE=true +after-clear-001 TAG=TestMachine_001.TestAlarm001 GUID=72B84A6E7BA74D42B611EB5393626F79 STATE=UNACK_RTN VALUE=false +after-reraise TAG=TestMachine_001.TestAlarm001 GUID=2394FEAA94774154AAA97C3AC29CA51E STATE=UNACK_ALM VALUE=true +``` + +Two facts, one confirming and one new. The `ALM → RTN` leg holds its GUID, reconfirming the +2026-05-01 capture against a write-driven rather than script-driven transition. The re-raise +does not: the returned record keeps the old GUID and the new active condition arrives as a +separate record under a fresh one. + +That is the correct reading for `ComputeTransitions` rather than a problem for it. The old +GUID leaving the active set is a real Clear and the new GUID is a real Raise, because they +are two alarm instances. The diff needed no change. + +### Q1 — the acknowledge leg is still unobserved, for a new reason + +The blocker moved. The rig can now be driven into an alarm; it cannot be driven out of +`UNACK_ALM`. Every acknowledge surface `wwAlarmConsumerClass` exposes was tried against a +freshly raised alarm, each followed by 16 seconds of polling: + +| Attempt | Result | +|---|---| +| `AlarmAckByName` 6-arg on the ack-only consumer, node = machine | `rc=0`, state unchanged | +| same, node empty | `rc=0`, state unchanged | +| same, name as `Galaxy!TestArea.TestMachine_001.TestAlarm001` | `rc=0`, state unchanged | +| same, operator name `Administrator` | `rc=0`, state unchanged | +| `AlarmAckByName` 6-arg on the reader consumer (`SetXmlAlarmQuery` applied) | `rc=-55`, state unchanged | +| `AlarmAckByName` 8-arg (v2) on the ack-only consumer | `rc=0`, state unchanged | + +The `rc=-55` on the `SetXmlAlarmQuery`-applied consumer reproduces the 2026-05-01 finding +that motivated the two-consumer split, so the ack-only consumer was correctly provisioned; +the `rc=0` returns are the ones that go nowhere. + +A third probe removed the last doubt by watching the alarm extension's own `.Acked` +attribute over MXAccess — an observer entirely independent of the wnwrap snapshot: + +``` +VALUES after-raise: 'TestMachine_001.TestAlarm001.Acked' = False +AlarmAckByName -> rc=0 +VALUES after-wnwrap-ack: 'TestMachine_001.TestAlarm001.Acked' = False +XML after-wnwrap-ack: STATE=UNACK_ALM OPRNAME= OPRNODE= +``` + +Nothing moves: not the extension attribute, not the snapshot `STATE`, not `OPERATOR_NAME`. +`rc=0` from wnwrap means the call was accepted, not that an acknowledgement was applied. + +Acknowledging by writing the extension attribute directly is not an alternative — the +attribute is not writable, and says so with an operational rather than a security failure, +for both verbs: + +``` +WriteSecured 'TestMachine_001.TestAlarm001.Acked' <- true +WRITE-COMPLETE statuses=[success=0 category=MxCategoryOperationalError detectedBy=MxSourceRespondingAutomationObject detail=1007] +Write 'TestMachine_001.TestAlarm001.Acked' <- true +WRITE-COMPLETE statuses=[success=0 category=MxCategoryOperationalError detectedBy=MxSourceRespondingAutomationObject detail=1007] +``` + +`detail=1007` from the responding automation object, unchanged by authentication, is a +read-only attribute rather than a permission refusal — the `1008` `SecurityError` the alarm +UDA itself returns is what a permission refusal looks like on this rig. So the ack has no +MXAccess-side entry point and the wnwrap-side entry point is inert. + +#### Remaining unblock paths for the acknowledge leg + +- Acknowledge from the System Platform side — the IDE's alarm client, InTouch, or an + ArchestrA graphic bound to the alarm — and watch the snapshot from a running probe. This + proves whether wnwrap's `STATE` ever reports `ACK_ALM` at all, which is the actual + question; the ack API being inert may be a wnwrap defect layered on top of a snapshot + that would report the state correctly. +- Check whether `alarmmgr` on this rig is configured with an alarm-acknowledgement security + requirement that the wnwrap consumer, which passes an operator *name* string and no + authenticated identity, cannot meet. If so, ack over wnwrap is not merely untested here + but unavailable by configuration, and the gateway's `AcknowledgeByName` path needs the + same treatment on any customer galaxy configured that way. +- If neither lands, the acknowledge leg stays assumed. It is worth restating that this is a + documentation gap, not a correctness one: a re-minted GUID on acknowledge would produce a + spurious Clear plus a spurious Raise, which is the same shape the now-observed re-raise + behaviour produces and which `ComputeTransitions` already handles as two instances. + +### Rig state left behind + +The three `TestMachine_00{1,2,3}.TestAlarm001` UDAs are back to `false` and their +`.InAlarm` subtags read `false`, but each leaves a `UNACK_RTN` record in the wnwrap +snapshot, since nothing can acknowledge them away. `SnapshotActiveAlarms` counts only +`UNACK_ALM` and `ACK_ALM` as active, so these are inert for the gateway; they will clear on +the next `alarmmgr` restart. diff --git a/docs/DesignDecisions.md b/docs/DesignDecisions.md index 32055df..9e9848f 100644 --- a/docs/DesignDecisions.md +++ b/docs/DesignDecisions.md @@ -226,13 +226,14 @@ payload carries the flag as well because a prefix filter (or an empty galaxy) ca 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 **detection heuristic is unchanged, and now permanently**: `IsTruncatedFetch` +remains `fetchedRecordCount >= maxAlarmsPerFetch`. The 2026-08-18 probe settled +what the earlier run could not: with three alarms active, `ALARM_RECORDS/@COUNT` +read 1 at a cap of 1 and 2 at a cap of 2, so it reports the records in the reply +rather than the total active count (`docs/AlarmProbeFindings.md`). There is no +exact signal to switch to — `@COUNT` cannot distinguish a capped fetch from a +complete one — so the heuristic is the design rather than a placeholder waiting +on 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 diff --git a/gateway.md b/gateway.md index 610904a..c46b102 100644 --- a/gateway.md +++ b/gateway.md @@ -265,9 +265,10 @@ 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`). +the reply's `ALARM_RECORDS/@COUNT` attribute would have made the test exact only +if it reported the total active count, and the 2026-08-18 live probe observed it +reporting the records in the reply instead (see `docs/AlarmProbeFindings.md`), so +there is no exact alternative. Forced modes are available via `MxGateway:Alarms:Fallback:Mode`: `ForceAlarmManager` disables failover; `ForceSubtag` forces the standby diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs index 8280e13..072916a 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs @@ -429,10 +429,12 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer // 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.) + // as cleared. (The reply's ALARM_RECORDS/@COUNT attribute was the + // candidate exact signal; the 2026-08-18 live probe settled it — with + // three alarms active, @COUNT read 3, 1, and 2 at caps 1024, 1, and 2, + // matching the records in the reply every time. It reports the reply, + // not the total, so it cannot tell a capped fetch from a complete one. + // See docs/AlarmProbeFindings.md.) bool truncated = IsTruncatedFetch(fetchedRecordCount, maxAlarmsPerFetch); IReadOnlyList transitions = @@ -491,12 +493,14 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer /// 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. + /// root carries an ALARM_RECORDS/@COUNT attribute that would have + /// made the test exact had it reported the total active count; the + /// 2026-08-18 live probe observed it tracking the records in the reply + /// instead (three alarms active returned @COUNT 1 at cap 1 and 2 at cap + /// 2), so it carries no "more available" information and is deliberately + /// not trusted here (see docs/AlarmProbeFindings.md). 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. @@ -660,10 +664,17 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer /// 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 + /// capture confirms stability across the active→returned leg + /// (docs/AlarmClientDiscovery.md, reconfirmed 2026-08-18). + /// Re-raise-after-clear was observed on 2026-08-18 and does + /// not reuse the GUID: clearing left the returned record in + /// place under its original GUID and the re-raise replaced it with a + /// freshly minted one. That is the intended reading — the old + /// instance ended and a new one began — so the diff emits a Clear + /// plus a Raise, which is what happened. The acknowledge leg remains + /// assumed rather than observed: on the dev rig every ack surface is + /// inert (AlarmAckByName returns 0 and nothing moves), so no + /// record can be driven into an acknowledged state — see /// docs/AlarmProbeFindings.md. /// /// From 540a1188aa49f195db95fc89063e9ea33b7b3f06 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 18 Aug 2026 05:46:35 -0400 Subject: [PATCH 21/26] docs(alarms): stop describing the wnwrap ack as working, and say the re-raise replaces the record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to 90331b6. Three comment/prose corrections, no behaviour change. AcknowledgeByName's comment still said the 6-arg overload "works and reaches the alarm-history path correctly", which the same commit's own findings contradict in three other places. It now says what was observed: rc=0 means accepted, not applied — the 2026-08-18 probe acked a live alarm six ways and the snapshot, the OPERATOR_NAME field, and the extension's .Acked attribute all stayed put. The -55 tracks the consumer, not the overload. Subscribe's comment gets the same treatment: "lets AlarmAckByName succeed" becomes "return rc=0". AlarmProbeFindings.md said the re-raise arrives as "a separate record" alongside the returned one, which reads as coexistence and is wrong. The snapshot carries one record per tag: the cap=1024 replies bracketing the re-raise are both elementCount=3 (one per TestMachine_00{1,2,3}) at an identical 1613 bytes, and the old GUID is absent from the later one. The re-raise replaces the record, so a single poll spanning it sees the Clear and the Raise together. Worker diff verified strictly comment-only; builds x86 on windev, 0W/0E. --- docs/AlarmProbeFindings.md | 10 +++++-- .../MxAccess/WnWrapAlarmConsumer.cs | 26 ++++++++++++++----- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/docs/AlarmProbeFindings.md b/docs/AlarmProbeFindings.md index 26f74d9..91afcb9 100644 --- a/docs/AlarmProbeFindings.md +++ b/docs/AlarmProbeFindings.md @@ -186,8 +186,14 @@ after-reraise TAG=TestMachine_001.TestAlarm001 GUID=2394FEAA94774154AAA97C3AC Two facts, one confirming and one new. The `ALM → RTN` leg holds its GUID, reconfirming the 2026-05-01 capture against a write-driven rather than script-driven transition. The re-raise -does not: the returned record keeps the old GUID and the new active condition arrives as a -separate record under a fresh one. +does not: the record comes back under a GUID that has never been seen before. + +The two records do **not** coexist — the snapshot carries one record per tag, and the +re-raise replaces the returned record rather than joining it. Both fetches above are the +full `cap=1024` reply, byte-identical in length (1613) at `@COUNT=3`/`elementCount=3`, the +three records being one per `TestMachine_00{1,2,3}`; `72B84A6E…` is simply absent from the +later one. So a single poll spanning the re-raise sees the old GUID disappear and the new +one appear together. That is the correct reading for `ComputeTransitions` rather than a problem for it. The old GUID leaving the active set is a real Clear and the new GUID is a real Raise, because they diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs index 072916a..94e51f7 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs @@ -194,11 +194,13 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer ?? throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer)); // Use the IwwAlarmConsumer (v1) prefix-named methods for the - // lifecycle. Empirically (live dev-rig 2026-05-01) this is the - // only path that lets AlarmAckByName succeed afterwards. The - // v2 Initialize/Register/Subscribe methods on the class - // succeed (return 0) but acks against that consumer state - // return -55. + // lifecycle. Empirically (live dev-rig 2026-05-01, reconfirmed + // 2026-08-18) this is the only path that lets AlarmAckByName + // return rc=0 afterwards. The v2 Initialize/Register/Subscribe + // methods on the class succeed (return 0) but acks against that + // consumer state return -55. Note rc=0 means the call was + // accepted, not that an acknowledgement was applied — see + // AcknowledgeByName below and docs/AlarmProbeFindings.md. int init = com.IwwAlarmConsumer_InitializeConsumer(DefaultApplicationName); if (init != 0) { @@ -345,8 +347,18 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer // Empirically (live dev-rig 2026-05-01): the IwwAlarmConsumer2 // 8-arg AlarmAckByName returns -55 on this AVEVA build (looks like - // a stub). The legacy 6-arg IwwAlarmConsumer.AlarmAckByName works - // and reaches the alarm-history path correctly. Operator-domain + // a stub) and the legacy 6-arg IwwAlarmConsumer.AlarmAckByName + // returns 0, which is why the 6-arg overload is the one called here. + // A zero return is NOT evidence the acknowledgement was applied: the + // 2026-08-18 probe acked a real, freshly raised alarm six ways and + // watched the snapshot stay UNACK_ALM, OPERATOR_NAME stay empty, and + // the alarm extension's own .Acked attribute stay False for 16 s after + // each call. On that rig the ack is accepted and then inert, and the + // -55 tracks the consumer rather than the overload — both overloads + // return -55 against a SetXmlAlarmQuery-applied consumer and 0 against + // the ack-only one. Whether any wnwrap ack reaches the alarm-history + // path is therefore unverified; see docs/AlarmProbeFindings.md. + // Operator-domain // and operator-full-name fields are accepted by the proto contract // for forward-compat but are not propagated to AVEVA today — // wrapped in the 6-arg call so domain/full-name go to the From 47d74032e6751170389b19f9b90b1ff216e7133c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 18 Aug 2026 05:48:10 -0400 Subject: [PATCH 22/26] =?UTF-8?q?chore(plan):=20Task=207=20complete=20?= =?UTF-8?q?=E2=80=94=20probe=20retry=20answered=20Q2=20and=20the=20re-rais?= =?UTF-8?q?e=20leg,=20ack=20leg=20re-blocked=20with=20evidence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/2026-08-17-followup-closeout.md.tasks.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-08-17-followup-closeout.md.tasks.json b/docs/plans/2026-08-17-followup-closeout.md.tasks.json index a36297d..d0a89b6 100644 --- a/docs/plans/2026-08-17-followup-closeout.md.tasks.json +++ b/docs/plans/2026-08-17-followup-closeout.md.tasks.json @@ -7,7 +7,7 @@ {"id": 4, "subject": "Task 4: Codegen guard — reverse-direction Check 3 + stale-note correction", "status": "completed", "commit": "711793f", "review": "Approved (code review, no issues)"}, {"id": 5, "subject": "Task 5: Settings page — GroupToTag + UntaggedSessionVisibility rows", "status": "completed", "commits": ["094f2ff", "ff16c69"], "review": "Approved; comparer-propagation fix applied and re-verified"}, {"id": 6, "subject": "Task 6: ApiKeysPage — DashboardTags in ConstraintText + create-form input", "status": "completed", "commits": ["c037d99", "fab600d"], "review": "Spec compliant; code review Approved after dedupe/help-text fix, re-review Approved"}, - {"id": 7, "subject": "Task 7: Wnwrap alarm-probe retry — secured-write path (windev)", "status": "pending"}, + {"id": 7, "subject": "Task 7: Wnwrap alarm-probe retry — secured-write path (windev)", "status": "completed", "commits": ["90331b6", "540a118"], "review": "Approved after ack-comment fix; Q2 answered (@COUNT = records-in-reply, exact detection refuted), clear/re-raise GUID answered (new instance), ack leg re-blocked (accepted-but-inert)"}, {"id": 8, "subject": "Task 8: Windev full verification", "status": "pending", "blockedBy": [1, 2, 3, 4, 5, 6, 7]}, {"id": 9, "subject": "Task 9: Bookkeeping — follow-ups closure + plan record", "status": "pending", "blockedBy": [8]} ], From a95e510015d8fdfa6240774fc0ebd9c345b37d5a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 18 Aug 2026 05:56:45 -0400 Subject: [PATCH 23/26] =?UTF-8?q?chore(plan):=20Task=208=20verification-gr?= =?UTF-8?q?een=20at=2090331b6=20=E2=80=94=20residuals=20recorded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/2026-08-17-followup-closeout.md.tasks.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-08-17-followup-closeout.md.tasks.json b/docs/plans/2026-08-17-followup-closeout.md.tasks.json index d0a89b6..a5c67ce 100644 --- a/docs/plans/2026-08-17-followup-closeout.md.tasks.json +++ b/docs/plans/2026-08-17-followup-closeout.md.tasks.json @@ -8,7 +8,7 @@ {"id": 5, "subject": "Task 5: Settings page — GroupToTag + UntaggedSessionVisibility rows", "status": "completed", "commits": ["094f2ff", "ff16c69"], "review": "Approved; comparer-propagation fix applied and re-verified"}, {"id": 6, "subject": "Task 6: ApiKeysPage — DashboardTags in ConstraintText + create-form input", "status": "completed", "commits": ["c037d99", "fab600d"], "review": "Spec compliant; code review Approved after dedupe/help-text fix, re-review Approved"}, {"id": 7, "subject": "Task 7: Wnwrap alarm-probe retry — secured-write path (windev)", "status": "completed", "commits": ["90331b6", "540a118"], "review": "Approved after ack-comment fix; Q2 answered (@COUNT = records-in-reply, exact detection refuted), clear/re-raise GUID answered (new instance), ack leg re-blocked (accepted-but-inert)"}, - {"id": 8, "subject": "Task 8: Windev full verification", "status": "pending", "blockedBy": [1, 2, 3, 4, 5, 6, 7]}, + {"id": 8, "subject": "Task 8: Windev full verification", "status": "completed", "verifiedAt": "90331b6", "result": "slnx 0W/0E; worker x86 523 pass + 1 pre-existing deterministic failure (reproduces on main); gateway 1151/1151 on rerun; live smoke 8/8; check-codegen 1-3 pass, Check 4 blocked by pre-existing Windows .exe pin bug (substance validated out-of-band); final comment-only tip re-check pending after bookkeeping"}, {"id": 9, "subject": "Task 9: Bookkeeping — follow-ups closure + plan record", "status": "pending", "blockedBy": [8]} ], "lastUpdated": "2026-08-17" From c3c603f169baad1590985b860b08a7ff1682468a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 18 Aug 2026 06:09:06 -0400 Subject: [PATCH 24/26] docs(alarms): describe the real StreamAlarms open sequence, and close TST-16 The final integration review's non-blocker reservations, all documentation or comment truth except one test arm. The alarm feed opens provider_status -> snapshot_status -> cached active_alarm -> snapshot_complete, which is what GatewayAlarmMonitor has done since the snapshot_status frame landed. Two places still described the old order: docs/Grpc.md said provider_status arrived *after* the initial snapshot, contradicting its own snapshot_status section two paragraphs down, and AlarmFeedMessage's leading proto comment named neither status frame at all. Both now state the sequence the monitor emits, so a client author reading either one gets the frame order right. The proto comment change flows through the generated trees (Contracts, Go, Java) and the client descriptor set; the Rust vendored copy stays byte-identical to canonical. Python's generator does not carry proto comments into its output, so it has no delta. AlarmsHubPublisherTests' valueless-payload case covered snapshot_complete and provider_status but not snapshot_status, leaving the newest arm unpinned against the redaction switch that must ignore it. Added. WnWrapAlarmConsumer's ack comment led with the 2026-05-01 reading that -55 tracks the 8-arg overload, then refuted itself six lines later with the 2026-08-18 probe. It now leads with the observation labelled as narrower than it reads -- mirroring the correction already in docs/AlarmClientDiscovery.md -- so the block argues one thing: the 6-arg call site stays for parity, and rc semantics are per the probe. A paragraph orphaned by an earlier splice is rewrapped. Comment interior only; the file compiles on Windows. TST-16 gets a dated closure note rather than a rewrite: the flag it called dead was implemented 2026-08-18. GatewayDashboardDesign's /browse paragraph gains the failed-read carve-out GatewayConfiguration already documented, so the two agree that a failed read keeps its - placeholder. --- .../remediation/60-testing-docs-gaps.md | 2 + .../internal/generated/mxaccess_gateway.pb.go | 10 +++-- .../mxaccess_gateway/v1/MxaccessGateway.java | 20 ++++++--- .../descriptors/mxaccessgw-client-v1.protoset | Bin 122424 -> 122737 bytes clients/rust/protos/mxaccess_gateway.proto | 10 +++-- docs/GatewayDashboardDesign.md | 4 +- docs/Grpc.md | 9 ++-- .../Generated/MxaccessGateway.cs | 10 +++-- .../Protos/mxaccess_gateway.proto | 10 +++-- .../Dashboard/AlarmsHubPublisherTests.cs | 10 ++++- .../MxAccess/WnWrapAlarmConsumer.cs | 39 ++++++++++-------- 11 files changed, 83 insertions(+), 41 deletions(-) diff --git a/archreview/remediation/60-testing-docs-gaps.md b/archreview/remediation/60-testing-docs-gaps.md index f376539..6ebf9cd 100644 --- a/archreview/remediation/60-testing-docs-gaps.md +++ b/archreview/remediation/60-testing-docs-gaps.md @@ -355,6 +355,8 @@ If TST-02's interim mitigation (flip retention off) is chosen instead of impleme **Verification.** `dotnet build src/ZB.MOM.WW.MxGateway.Server`; test toggling the flag suppresses/shows values; docs match. +**Closed 2026-08-18 — implemented (the Design's recommended path).** The flag is no longer dead: it now gates value display on all three seams that carry a tag value. `Dashboard/DashboardLiveDataService.cs` substitutes `[redacted]` at the service boundary for each successfully read `/browse` tag (a failed read keeps its `-` placeholder, so the error row and the redaction cannot contradict each other); `Dashboard/Hubs/AlarmsHubPublisher.cs` clears `current_value`/`limit_value` from a **deep-cloned** `AlarmFeedMessage` before broadcasting to `/hubs/alarms`, and `DashboardEventBroadcaster` does the same for the events-hub mirror — clones, because the sources fan out to gRPC `StreamAlarms`/`StreamEvents` and the replay ring, so no gRPC client is affected by this dashboard-display flag. The flag is **kept**, default still `false`. The stale prose the Finding flagged is gone: `docs/GatewayConfiguration.md` and `docs/GatewayDashboardDesign.md` now describe the real behaviour. Status tracked in `00-tracking.md` (already **Done**). + --- ## TST-17 — Vendor-gated alarm parity residuals silently lossy `Medium` · `—` diff --git a/clients/go/internal/generated/mxaccess_gateway.pb.go b/clients/go/internal/generated/mxaccess_gateway.pb.go index 4ef7d3a..99596e8 100644 --- a/clients/go/internal/generated/mxaccess_gateway.pb.go +++ b/clients/go/internal/generated/mxaccess_gateway.pb.go @@ -7342,9 +7342,13 @@ func (x *StreamAlarmsRequest) GetAlarmFilterPrefix() string { return "" } -// One message on the StreamAlarms feed. The stream opens with one -// `active_alarm` per currently-active alarm, then a single -// `snapshot_complete`, then a `transition` for every subsequent change. +// One message on the StreamAlarms feed. The stream opens with a +// `provider_status` (the current provider mode), then a `snapshot_status` +// (whether the cached active set may be incomplete), then one `active_alarm` +// per currently-active alarm, then a single `snapshot_complete`. After that +// come the live frames: a `transition` for every subsequent alarm change, +// interleaved with a further `provider_status` on each failover/failback and a +// further `snapshot_status` on each change of the truncation verdict. type AlarmFeedMessage struct { state protoimpl.MessageState `protogen:"open.v1"` // Types that are valid to be assigned to Payload: 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 b49bf8b..153aea4 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 @@ -89203,9 +89203,13 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { } /** *
-   * One message on the StreamAlarms feed. The stream opens with one
-   * `active_alarm` per currently-active alarm, then a single
-   * `snapshot_complete`, then a `transition` for every subsequent change.
+   * One message on the StreamAlarms feed. The stream opens with a
+   * `provider_status` (the current provider mode), then a `snapshot_status`
+   * (whether the cached active set may be incomplete), then one `active_alarm`
+   * per currently-active alarm, then a single `snapshot_complete`. After that
+   * come the live frames: a `transition` for every subsequent alarm change,
+   * interleaved with a further `provider_status` on each failover/failback and a
+   * further `snapshot_status` on each change of the truncation verdict.
    * 
* * Protobuf type {@code mxaccess_gateway.v1.AlarmFeedMessage} @@ -89739,9 +89743,13 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { } /** *
-     * One message on the StreamAlarms feed. The stream opens with one
-     * `active_alarm` per currently-active alarm, then a single
-     * `snapshot_complete`, then a `transition` for every subsequent change.
+     * One message on the StreamAlarms feed. The stream opens with a
+     * `provider_status` (the current provider mode), then a `snapshot_status`
+     * (whether the cached active set may be incomplete), then one `active_alarm`
+     * per currently-active alarm, then a single `snapshot_complete`. After that
+     * come the live frames: a `transition` for every subsequent alarm change,
+     * interleaved with a further `provider_status` on each failover/failback and a
+     * further `snapshot_status` on each change of the truncation verdict.
      * 
* * Protobuf type {@code mxaccess_gateway.v1.AlarmFeedMessage} diff --git a/clients/proto/descriptors/mxaccessgw-client-v1.protoset b/clients/proto/descriptors/mxaccessgw-client-v1.protoset index 30a8c97c340245ff050d2dc488b98ae93ab7cfd7..c92518c68a038b07015e508d549be769b01dfcb6 100644 GIT binary patch delta 7369 zcmZXZX^>q-702J}x$oZ7cV@EOkT4`6c`P9dSwO6^)UqtUSSs!okQtWD%jCtR%z~34 zmVWSBMhPSoSjJ*3h{OU^vIl~M1VnaFLK4X$o0fo-?2#(0U_|2Y^uo3D+x%YtPoG}S zIo-GC?t8Ph@5^?~nQ3-jv-8}UN0;V%c68)f(Wupq?3{7KCvP9UCdo~5L6N?b)qW`T z@&D=={?7UJPn4~^*?;-q@|BAhv)uIdH|`V0+n6GqyIDE?>}| zKC?;NvSzE^$$x zJCY0PMe$7KFEhY^jU8Sof8yOLstECtd zY*?i=!`EU`t`XxG_Clkpv_@<+(N$WbCVqs_S}|vZ{yG>TI3XgYYh~hT5uNzVDf#XO z6ThfPho$y2vUF3U`RR6h!K@|imF*=>jkR4L|4RK9Giq2@Vw+(;7wJ;0-}Tnl>gUuy zyFSi`U-7VBvQf?hh==tu<|MbWbBlC?)OLiGor`jV$k_b#g#PAge;apw{et>ZQ*IQK zxeoXh)kYyWHC93T+ZZQ1S65<_m}Y3KGg+{R=vv44xueB&lT4cI)@iO`X*Y}UD*<5& z`eBzawwq=2#L$(l@n)Ia47Sh~F@D?UnvRCUIARUREg~a=sRra08C?WZ;&H2(Q$k~c zDHu&PM7D~QC&gxLx5~*q!4~m0F>y={kI}Y)?KTmguyaiV(`_=|$L(C5-u4)`5T>9H zC=k=_Vtw3#CAQl&a7hQeq<2W|KEG)V^>T+a#_EU~4c)VyVqCr|(FnTjP-KaB${A+{ zEsb}E1*cKt9(0pYM0;mg;6~Zd-T8IZs=EQYxgqwger_P6y{qb2mmiEBchJ*avEzne z`FB-XU2iaI6STX~mSM!K(%oWa>1NN=yhmT8T9Tz$k7YezejO0*{ILaG0Gg6F4U#+Q;NW z=Y;_i0*_Z(4FND}OJV?aVw3b1=@U}>bEulVNT1MpsMWq#SATCT?+`-(yVR#PqTMUq z<07(3d*zhz!IZ`)!|J20GgHu&gi_fx+p)n`i|3Qlb4svfeEVVn>@@~k&>v|aru!uG z1rWq^Uo3#VO8Y~~;YMpG=qnM3>3%7#uVS!Mhbq>q*?XWGvKCL!7eE@ZJ)q}_pS+&w z2Xw@G>FL!=&r@Rl6fQk5>Ze4y!i4DRpd`L5_m*1ql;f~;-w%rQ1;5wT)`K$6m*rlq zt%oG>#|(riXu~ix3=T;iN0lZ% z(XG~rBbBLOdZaR?PW(|4e~alg7E>?}UTwWp?u>h2vHhcr*V8~Fj=J%5Wt(FQMq4c- zPgkZnrcYOc80FH zuzgm#;)uI2eYSGjT_n$au(zh05rc7|x-orD^0>9#m_Aoldpn{?o|j}s@G}Cp;OL0h zJ}-s86!)4D*gh{)r!!)c^cCq}r1o~m+CG$jiCNpHS^Gk$nF&O%pwC)R*Z&3Sb~RL+ z>Gp+CLqU3aG3EfoY(O8Vplc0T3u5|W%-TM!nJo?enpog3>9)748- z_}d0ZS1-jIP@mFYV-`c$f^ig@#ebEM#I!nbNa8+q`?8qR-5Nref zSEhm~SVmjT;@2x%&EnT9TeA2KN!;BPNO8tW0xE z!DyP7*uGiWY8Jm)*^U&Db8*EWVQ4NOEzJ9+TR?b{!e0H|pgvt@FBZ7i%uP8*>SwPtf-X z(&xJ|l|Z_DH>T3X8sYzpsRR){pbuiuwT4szG5u#urSf9UrQ_kM#UM1OL7z(?qc|>| z{<9XOtK%}kFA7Lk$NfdjB$pKFzoqs)>%TTHLHTdJ#OvHH(Yd`Jk6DNTf(&pf3j`y6 zU#t%nkZ#}Cb9S7wrSZh|3+p}Q2{FFdUE=0+LUO;+AQmV5$CODfEz1K|bqUO>F8ww?Y94a9V{9qmg#h?CXU)fkh^E7CQ#c8zyZCi9T4 ziC*TZm$lIggcs0z0r9dndI2$A8@+&dSsT6d7wNUO_MED7rlVkUKhQ9c)2cmX%*>@ZS5z)OAFxd!)pYD6VN*Wak9>Kmj1MY zv0Y~i|CI;gWu5gOdCB}Dz24S-8obO$d42RUU%d=RFA!cp?*+umuyw~qBLOiTj!wW1 zK4ylamw_U^!PeFXF9S$#urkht*noPu(VBByS%L5ZdM_Xeb))U{CoPESjdqIrf=WQ9 za-*F+hn`Hbph$1BwVwwk3-mC($;v3_Wr2Fx5H}yf3+TOo%w#_Lr(H_3ITx3`enk6aBL4n05G7)v+GJVASe2(XT2k zi*%dyFBtTzrz+@PEELh+X8nr=$hMUmtEuZT3cAa%wj$nW{YM4JF}kVJ>Y9R4tDi)h zDlO;cW@}t5W2xsP=q_=JXgAw&u8KDJnBt(^V#BvBgcx*ZIb!`R-eUc;5Xi~6)rN0o z2rcMNQEf!K)%sHuMBN7X=-2 zW8GoRxta?~XhHv80HWPtXP*<|ha2kcHoP8WW&~Q$f5(IRm~gvw-|-qiBYC~4W;YTs z7-u(1+qgy$ji9>~H$Z0gTRY_}x5uJGxIc`Z zM)e8$6$8=UABIaS^gs+2Nb4W$UzcFl93GcMi^PL*-yk{$r@4It(LQL;_S+_U#d)aG zYK;J+R%^sVm6k<%*qYheQOTIJNWkvSsEGJsJ9Ac;9h>n;3`$-R(1QL22}Jvd4KGN1 zC-CB8jXMYENNWV>gAzphs6G9R&@VOOF>6XU3dFkpa2$jdiN~rzP$M3@RG?uWhH8B0`jkU(=wmuot5Ax`1 z8i@8ue{v>t4DE{(htPt4;-G5{6Sumn`(x(X(m$$HgVJr?Z^N^!)ondcHM&QXYQVa! z2dV+Ht$&DnZr!ee{(TWd`v)7|BCVGGr?eWn^9N+aurKEz;-_Lcx4Nwd!?Day_I5^U?CYJ|G0pg*f;cCShw}?uG79*KY8hY0K%b% AZU6uP delta 7036 zcmYM3S&&x67017?=PswenSq%*fXF6-?5mopJf-q#7Bxu~$PCCT4@pT{7x_4$KTig)2EknPWSEai#_?S z-T9VD0Bn-+!k4RJ~!6DMj{qUb{!?1NPUufA^#M z_d0&{%lVC=v**nF&Ch>3f7YBk=iIemcK0nmu8&=D=C3z>x6#~aN~Y9{^?2FvoElrKqMygEn7rFFq+^o%}RwNg?H{D#nb;5-_xyoydYA)pcuZ!5EIBIm` z_s)dIi`}Ddsoy=}VxCCQ)b-cg4v2`!#eDFP#!b$|6v?ITv$xcLIPp@RM2*4ZQa*4* zFfo{1%14iBJW_Wy;Fa#1Z>>K%=}I-SCTyrL&cix4F(hI$J3necU2WE3#Em+Y}aY8p>57eH-T! z`kSWxt={?A)cOKbt`?KK4)_(-Y9S6aEJ6BP9p^PoC%s0@(9l?CUSJW?wRZSvqQ!KL z47tWF$uz?%t`*}a4PgrUVV5wrYo&cq=t`G!tz0uS*h1^X_#K{Rno@^x#2S0+M0y2N zjlFf!UIbG@Z@rkSLt}y|7)>=A){B&VVl%eu<*NR{7V!o#aZIVlXq#fYLB!|gG?QYw zK?e8`ou<><7(*1o6!ftJV!BbR4^gnhcB959k&l;bv(%pVo0h7V&64)j5v8f_+0$ZN zYAVqPy6sS8iJz9?*99$&Tf&0Vs38uz$ta@T5*9cuQ{A0ss#e_%(9I38Z}oEn8SU1p zV_kkQcHBfyTVuzmVfnXJT3v52Y7?~E(3WAutkSb$Cg^71MyC#rj)?eK88<#?S>WxJ zR#y~^+6?XXN}CtS4l(0GM>$&1B`0ojQ14FND}H3as`w^~AAU!~O$0HanzU|(5@34#5USVI7e;uP`zN=pbF5c8cd zJ6$GlQbe=|qEg0Quoj6^Y3Z|zkQ|iQPlK2Ztr?HrVjo{VROXcRc2Nv7cWPqLq8gbN( zGnH)vTQJ&c897s#HZVO?nKl>6SxIgTewtY~Fpi=b)3Y)}t8J;l+l=j5nS4WV+fpR2 zSK=0I!RF9a3%0LIOB`_vrmt6STZ`n4Z}--;GGZ_;R4b-$NF#1-E2eLh)!z0hk~bw8 z8~pUbE!Z9r+c%}~_uo#_3)?qk)EGu=lCC0qOKSfPS=)v3t(di4nze6-nwdcK3i_-C zb^YI#R#!u{nQq?>H58<$cVZ4e%m(y<3cA*iwIHVN#H{Vont3i%FFMj}0{zy3^mI;| zb#AObOjqZm@V5<+uFl09P?yrXF^eH=!8i)d;&&w^F|AG3gM!j|v#u_hjg`E{nS~i{BUXuaLzs^7r+wrLoziQ}{pD2EGq&e7iQ>m^?2HbdujIUchR0|Xgh z$pXQMFN^iT0@CeeJ!ktlTN46wgLHDme_xs8 z_9FX6YIj@z(R(|}Z$#P|TbU%c>)?9g)dOM$K<@=)tUc1~y9KfBkv4x50rAq~Zz3j{ zS!DOx+M?iPCdzxQ_|K)8>SUn}pF9vwz=l5{VZ1D~&Hk$m#CD-=_s0N;mxb2V9h1x| zvL&{5U+^*u<&x-SmU>wly+C*Y{Zv4lER9Y;Ov|Ow3yhbg(aY>2yWiIS7`)6zdVlmX zTfHocULd@H-V2DAWzh?W>9Xhq#L2SgWKL0L57^qDf|of+AF$#-_2;OU59LEv`nm9*r%t-9nd~YJ#8g0a!T|*n$lbQtpS~cr z-FAfgs?t`xblZuO=*lE_6xqYJ_DJw@2g--7v^g(#sFzi7TOhoEekveSSrxYh#B`Ny zFMWu?cv%(pCC!WMQCr*Qy`)BHAduI1t} z>z`+oJi6RbL|Z;){WTs2|6YmJ7K~!w`mL7HK3-|{m`ehxSsI+=6gF#yw z{Z4wK60=HATK}rSko2Sl-7AJ7+9$1l#Q@p1)z#GXWCh**SR2uMmGmUz7g6?9ch<2^*=PE16*}2Y!&s_*D=#F=7M7z%Vmm-kE za=i_o(Gc1ab|CaE9qgW<&~hAZtX56WKQIos1?@)b zKQhpAiQQz)^_mN5UK+i&!u}0`R>Yg^#K|FmxV1iI!;3<0dZ7jVw>^mVDeFG%HQ0Ld zpi|ARH(D@Gt~c7v)#TcE(6K!Cg!OMj4Eyi-+_WKn+79#YJ-KP;MaPEM69_Fh%1^+w zqupZ1`qvZok@uadqXI4HZfcCMK>LjKH#M+>_1{|Ua|dG3T|l%I@mAYc_t_-#i)_2C z?FxB6ALaH~UFK^>?y&x)B7q1?(7(KaXm{AY{;~*?IXmp2!LDS?H+n(Z8G;sDy*YsX z<^Z}@>rW*R+nv@wfu>Sq&)eE7p+;zoK5tVWl%gYgAubU_Bj_&5DagcLup>t55K5TV z-C_7j9{rMmhR00hjNc(Jf zRpQ%$R~T#DIe@7a3D5^6NXPr_u;HO&YQ%wR5W4v|LhVc4!YJbajV;UR8v>$2Smr9F9{&pqc*(MTHV!SRio}I7zeDo zS{|!L%(lK7_uRVD5BfJp5b>)vyiZ!)*5k1nLUauJ(hs6N9;><4T|E(wWd^Lf3c7PQ zwKLtep6~~+E-~ABvKpdpE9eg^8tM3CJgcmh{!>-Qx>sQ6*zM}6on3d;uUhbbHE{@A diff --git a/clients/rust/protos/mxaccess_gateway.proto b/clients/rust/protos/mxaccess_gateway.proto index b5e2a6b..daa3e8d 100644 --- a/clients/rust/protos/mxaccess_gateway.proto +++ b/clients/rust/protos/mxaccess_gateway.proto @@ -1003,9 +1003,13 @@ message StreamAlarmsRequest { string alarm_filter_prefix = 2; } -// One message on the StreamAlarms feed. The stream opens with one -// `active_alarm` per currently-active alarm, then a single -// `snapshot_complete`, then a `transition` for every subsequent change. +// One message on the StreamAlarms feed. The stream opens with a +// `provider_status` (the current provider mode), then a `snapshot_status` +// (whether the cached active set may be incomplete), then one `active_alarm` +// per currently-active alarm, then a single `snapshot_complete`. After that +// come the live frames: a `transition` for every subsequent alarm change, +// interleaved with a further `provider_status` on each failover/failback and a +// further `snapshot_status` on each change of the truncation verdict. message AlarmFeedMessage { oneof payload { // Part of the initial active-alarm snapshot (ConditionRefresh). diff --git a/docs/GatewayDashboardDesign.md b/docs/GatewayDashboardDesign.md index bfc7fdb..f69b8c0 100644 --- a/docs/GatewayDashboardDesign.md +++ b/docs/GatewayDashboardDesign.md @@ -520,7 +520,9 @@ panel. The panel shows each subscribed tag's live value, MXAccess data type, quality and source timestamp, refreshed every two seconds — but the value column obeys `Dashboard:ShowTagValues` like every other dashboard value surface. With the flag false (the default) `DashboardLiveDataService` hands the page -`[redacted]` in place of the formatted value; data type, quality, source +`[redacted]` in place of the formatted value of each **successfully read** tag — +a failed read keeps its `-` placeholder, since there was no value to suppress — +while data type, quality, source timestamp and any read error are untouched, so the panel still answers "is this tag advising and healthy" without disclosing the value. The substitution happens at the service, not in the page: one decision point, and the value never enters diff --git a/docs/Grpc.md b/docs/Grpc.md index 15bd1c8..6d7794e 100644 --- a/docs/Grpc.md +++ b/docs/Grpc.md @@ -114,10 +114,11 @@ message AlarmProviderStatus { } ``` -The gateway emits `provider_status` once when a client first subscribes -(immediately after the initial snapshot and before the first live transition) -and again on every failover or failback. A late-joining client therefore -always learns the current provider mode without waiting for the next switch. +The gateway emits `provider_status` once when a client first subscribes — as +the **first** frame on the stream, before the `snapshot_status` frame and +before any cached `active_alarm` — and again on every failover or failback. A +late-joining client therefore learns the current provider mode before it sees a +single alarm, without waiting for the next switch. #### Snapshot completeness on the alarm feed diff --git a/src/ZB.MOM.WW.MxGateway.Contracts/Generated/MxaccessGateway.cs b/src/ZB.MOM.WW.MxGateway.Contracts/Generated/MxaccessGateway.cs index d171283..b5d06b2 100644 --- a/src/ZB.MOM.WW.MxGateway.Contracts/Generated/MxaccessGateway.cs +++ b/src/ZB.MOM.WW.MxGateway.Contracts/Generated/MxaccessGateway.cs @@ -28513,9 +28513,13 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { } /// - /// One message on the StreamAlarms feed. The stream opens with one - /// `active_alarm` per currently-active alarm, then a single - /// `snapshot_complete`, then a `transition` for every subsequent change. + /// One message on the StreamAlarms feed. The stream opens with a + /// `provider_status` (the current provider mode), then a `snapshot_status` + /// (whether the cached active set may be incomplete), then one `active_alarm` + /// per currently-active alarm, then a single `snapshot_complete`. After that + /// come the live frames: a `transition` for every subsequent alarm change, + /// interleaved with a further `provider_status` on each failover/failback and a + /// further `snapshot_status` on each change of the truncation verdict. /// [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] public sealed partial class AlarmFeedMessage : pb::IMessage 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 b5e2a6b..daa3e8d 100644 --- a/src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto +++ b/src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto @@ -1003,9 +1003,13 @@ message StreamAlarmsRequest { string alarm_filter_prefix = 2; } -// One message on the StreamAlarms feed. The stream opens with one -// `active_alarm` per currently-active alarm, then a single -// `snapshot_complete`, then a `transition` for every subsequent change. +// One message on the StreamAlarms feed. The stream opens with a +// `provider_status` (the current provider mode), then a `snapshot_status` +// (whether the cached active set may be incomplete), then one `active_alarm` +// per currently-active alarm, then a single `snapshot_complete`. After that +// come the live frames: a `transition` for every subsequent alarm change, +// interleaved with a further `provider_status` on each failover/failback and a +// further `snapshot_status` on each change of the truncation verdict. message AlarmFeedMessage { oneof payload { // Part of the initial active-alarm snapshot (ConditionRefresh). diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/AlarmsHubPublisherTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/AlarmsHubPublisherTests.cs index c5f0394..7eb18b3 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/AlarmsHubPublisherTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/AlarmsHubPublisherTests.cs @@ -119,13 +119,21 @@ public sealed class AlarmsHubPublisherTests }, }; + AlarmFeedMessage snapshotStatus = new() + { + SnapshotStatus = new AlarmSnapshotStatus { Truncated = true }, + }; + CapturingHubContext hubContext = await RunPublisherAsync( showTagValues: false, snapshotComplete, - providerStatus); + providerStatus, + snapshotStatus); Assert.Same(snapshotComplete, hubContext.Sent[0]); Assert.Same(providerStatus, hubContext.Sent[1]); + Assert.Same(snapshotStatus, hubContext.Sent[2]); + Assert.True(hubContext.Sent[2].SnapshotStatus.Truncated); } /// diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs index 94e51f7..55bf215 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs @@ -345,23 +345,28 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer ?? throw new InvalidOperationException( "Cannot acknowledge: WnWrapAlarmConsumer was disposed or has not been subscribed yet."); - // Empirically (live dev-rig 2026-05-01): the IwwAlarmConsumer2 - // 8-arg AlarmAckByName returns -55 on this AVEVA build (looks like - // a stub) and the legacy 6-arg IwwAlarmConsumer.AlarmAckByName - // returns 0, which is why the 6-arg overload is the one called here. - // A zero return is NOT evidence the acknowledgement was applied: the - // 2026-08-18 probe acked a real, freshly raised alarm six ways and - // watched the snapshot stay UNACK_ALM, OPERATOR_NAME stay empty, and - // the alarm extension's own .Acked attribute stay False for 16 s after - // each call. On that rig the ack is accepted and then inert, and the - // -55 tracks the consumer rather than the overload — both overloads - // return -55 against a SetXmlAlarmQuery-applied consumer and 0 against - // the ack-only one. Whether any wnwrap ack reaches the alarm-history - // path is therefore unverified; see docs/AlarmProbeFindings.md. - // Operator-domain - // and operator-full-name fields are accepted by the proto contract - // for forward-compat but are not propagated to AVEVA today — - // wrapped in the 6-arg call so domain/full-name go to the + // The original observation (live dev-rig 2026-05-01) was narrower than + // it read: the IwwAlarmConsumer2 8-arg AlarmAckByName returned -55 + // "and looked like a stub" while the legacy 6-arg + // IwwAlarmConsumer.AlarmAckByName returned 0, which is how the 6-arg + // overload came to be the one called here. The 2026-08-18 probe + // corrected both halves. The -55 tracks the *consumer*, not the + // overload: both overloads return -55 against a + // SetXmlAlarmQuery-applied consumer and 0 against the ack-only one + // used above. And a zero return is NOT evidence the acknowledgement + // was applied — the probe acked a real, freshly raised alarm six ways + // and watched the snapshot stay UNACK_ALM, OPERATOR_NAME stay empty, + // and the alarm extension's own .Acked attribute stay False for 16 s + // after each call. On that rig the ack is accepted and then inert, so + // whether any wnwrap ack reaches the alarm-history path is + // unverified; see docs/AlarmProbeFindings.md and the 2026-08-18 + // correction in docs/AlarmClientDiscovery.md. The 6-arg call site + // below therefore stays as-is for MXAccess parity — the choice is no + // longer justified by the rc, and rc semantics are per the probe. + // + // Operator-domain and operator-full-name fields are accepted by the + // proto contract for forward-compat but are not propagated to AVEVA + // today — wrapped in the 6-arg call so domain/full-name go to the // alarm-history operator-name field via the szOprName parameter. // Suppress unused-warning explicitly: _ = ackOperatorDomain; From a5f843cbfba90a2dc511553125e7daa66455b74e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 18 Aug 2026 06:12:31 -0400 Subject: [PATCH 25/26] =?UTF-8?q?chore(plan):=20close=20the=20record=20?= =?UTF-8?q?=E2=80=94=20prior=20follow-ups=20annotated,=20closeout=20as-bui?= =?UTF-8?q?lt=20noted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior plan's "Follow-ups recorded, not started" block described pre-branch behavior; every item is now closed, narrowed, or restated with its evidence, so the block no longer misleads a reader who lands on it first. The stale Rust-guard bullet is corrected in place rather than deleted: Check 3 always existed, and saying so is the only way the reader learns what the real (one-directional) gap was. Also fixes the CLAUDE.md apikey sample, which named a verb the parser has never accepted ('create'; only 'create-key' exists, no alias), omitted the required --key-id, and listed non-canonical scope strings that GatewayScopes now rejects at create time — the sample could not have run. --- CLAUDE.md | 2 +- docs/plans/2026-08-17-deferred-closeout.md | 54 +++++++++++++------ docs/plans/2026-08-17-followup-closeout.md | 53 ++++++++++++++++++ ...2026-08-17-followup-closeout.md.tasks.json | 6 +-- 4 files changed, 95 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 853d0aa..4cf30eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,7 +71,7 @@ dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Test dotnet run --project src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj # API-key admin CLI (same exe, "apikey" subcommand) -dotnet run --project src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj -- apikey create --display-name "dev" --scopes session,invoke,event,metadata,admin +dotnet run --project src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj -- apikey create-key --key-id dev --display-name "dev" --scopes session:open,session:close,invoke:read,invoke:write,invoke:secure,events:read,metadata:read,admin ``` Single test by name (xUnit `--filter`): diff --git a/docs/plans/2026-08-17-deferred-closeout.md b/docs/plans/2026-08-17-deferred-closeout.md index 97cb50e..c0a7e49 100644 --- a/docs/plans/2026-08-17-deferred-closeout.md +++ b/docs/plans/2026-08-17-deferred-closeout.md @@ -416,22 +416,44 @@ gateway 1123/1123, live MXAccess smoke 8/8 — green first try, no stale-obj qui 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`. -- 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`. +Follow-ups recorded here — all closed or narrowed on 2026-08-18 (branch +`feat/followup-closeout`, plan `docs/plans/2026-08-17-followup-closeout.md`). +Each bullet states what it said when written, then its closure: + +- `IGatewayAlarmService.StreamAsync` / `AlarmFeedMessage` did not carry the truncation + signal — live central-feed consumers (lmxopcua, ScadaBridge) could not see snapshot + degradation. **Closed 2026-08-18 — shipped.** `AlarmSnapshotStatus` with + `snapshot_status = 5` on the feed: gateway-side edge emission plus open-time priming + (`fccf753`, `312e072`), rolled out to all five clients (`c748361`, `4d4f443`). +- "No guard keeps `clients/rust/protos/` in sync with Contracts." **Was stale when + written.** `scripts/check-codegen.ps1` Check 3 already existed and already byte-compared + by SHA-256 in CI. The real gap was narrower — the sweep ran in one direction only, so a + canonical proto with no vendored copy passed. **Closed 2026-08-18** by `711793f`: Check 3 + now sweeps both directions and a canonical proto missing its vendored copy fails. +- `EffectiveDashboardConfiguration` (dashboard settings page) didn't display + `GroupToTag` / `UntaggedSessionVisibility`, though it showed `GroupToRole`. + **Closed 2026-08-18 — shipped** (`094f2ff`; comparer propagation fixed in `ff16c69`). +- ApiKeysPage's `ConstraintText` neither offered tag input nor listed `DashboardTags`, + and since `IsEmpty` counts tags, a tags-only key rendered `-` where a truly + unconstrained key rendered `unconstrained`. **Closed 2026-08-18 — shipped** (`c037d99`), + with the create form de-duplicating typed tags ordinal-ignore-case to match the CLI + (`fab600d`). +- `AlarmsHubPublisher` broadcast alarm transitions with `current_value`/`limit_value` + unredacted — the `ShowTagValues` redaction covered only the events hub mirror. + **Closed 2026-08-18** together with the `/browse` residual: `ShowTagValues` now gates + all three seams (`eff17d1`, `1ea6f60`, `e50ec49`). +- The alarm probes' remaining questions (ack-leg GUID stability, `@COUNT` semantics). + **Narrowed 2026-08-18** by a second probe attempt that used the secured-write verb the + first attempt lacked (`90331b6`, `540a118`): + - `@COUNT` — **answered.** It reports records in the reply, not matching records in the + provider, so exact truncation detection through it is permanently refuted; the + heuristic in `IsTruncatedFetch` stays as the only available signal. + - clear-then-re-raise GUID — **answered.** The re-raise carries a new GUID that replaces + the record rather than coexisting with it, which makes the existing `Clear`+`Raise` + handling correct as written. + - ack leg — **still blocked**, now with a sharper cause: the ack returns `rc=0` and is + accepted but inert, so the GUID never moves. Remaining unblock paths are recorded in + `docs/AlarmProbeFindings.md`. --- diff --git a/docs/plans/2026-08-17-followup-closeout.md b/docs/plans/2026-08-17-followup-closeout.md index 98bf2ff..8420480 100644 --- a/docs/plans/2026-08-17-followup-closeout.md +++ b/docs/plans/2026-08-17-followup-closeout.md @@ -448,3 +448,56 @@ clear obj/bin and rebuild, not a regression. Record all counts. payload-case row), closeout-plan follow-ups→Task 9 only. - Task 7/8 run against windev over `ssh windev` (PowerShell); psbridge is fallback. - Do not merge to `main` without user instruction. + +--- + +## As-built notes (execution record, 2026-08-18) + +All 9 tasks completed on `feat/followup-closeout`; every classification-driven review +chain resolved **Approved**. The final integration review came back *Ready with +reservations* — all of its non-blocking findings were fixed in `c3c603f`, leaving no +open review item. + +Verification: macOS `NonWindows.slnx` 0W/0E; gateway filtered suites green (dashboard +276/276, alarm suites green); `scripts/check-codegen.ps1` 4/4 on macOS. **The full +five-language client matrix ran locally for the first time** — dotnet 133 passed /1 +skipped, Go clean, Rust 108, Python 168 passed /1 skipped, Java 131. Windev at `90331b6`: +full `slnx` 0W/0E (one transient MSB4166 node crash, clean on retry), worker x86 523 +passed / 11 skipped plus **one pre-existing deterministic failure that reproduces on +`main`** (`WorkerPipeSessionTests.RunAsync_LongInFlightCommandThatKeepsPumping_DoesNotFaultAndDeliversReply`), +gateway 1151/1151 on rerun (the known windev load flake), live MXAccess smoke 8/8. The +comment- and prose-only commits landed after `90331b6` are unverified on windev by +design; they need only a cheap tip re-build there. + +- **A session-limit outage interrupted four reviewers mid-run.** All four resumed + cleanly on retry; no review was lost or silently truncated. +- **Task 2 surfaced two plan defects, both absorbed rather than deferred.** The spec + claimed the Java CLI renders alarm frames as generic JSON; it is in fact an exhaustive + `switch` that does not compile without an arm for the new case. The spec's work list + also omitted the Java generated-bindings tree. +- **A reviewer's Minor-2 was refuted, and the doc defect behind it fixed instead.** The + finding assumed alarm records coexist after a re-raise; fetch evidence from the probe + rig shows the new GUID replaces the record. The prose that implied coexistence was + corrected rather than the code. + +Follow-ups recorded, not started: + +- `WorkerPipeSessionTests.RunAsync_LongInFlightCommandThatKeepsPumping_DoesNotFaultAndDeliversReply` + fails deterministically on windev **and on `main`** — pre-existing, needs its own + investigation. +- `check-codegen.ps1` Check 4 is unrunnable on Windows: the `protoc-gen-go` version banner + carries a `.exe` suffix that the exact-string compare in + `clients/go/generate-proto.ps1:10,55` does not tolerate. +- Windev has `protoc-gen-go-grpc` 1.6.1 against the repo's pinned 1.6.2. +- `clients/java`'s `checkGeneratedClean` is dead under Gradle 9 (`Project.exec` was + removed); it needs `ExecOperations` injection to work again. +- `SettingsPage` renders every `EffectiveDashboardConfiguration` member except + `RecentFaultLimit` / `RecentSessionLimit` (pre-existing, predates this branch). +- The ack-leg probe stays blocked; unblock paths are in `docs/AlarmProbeFindings.md`. +- The dashboard `AlarmsPage` truncation banner is still poll-driven — it could consume + the new `snapshot_status` feed frame instead. + +Explicitly decided, not an omission: **`../scadaproj/CLAUDE.md` needs no update.** The +umbrella index records the *set* of `.proto` files this repo owns, and that set is +unchanged — Task 1 added a message and a field inside an existing proto, not a new +contract file. diff --git a/docs/plans/2026-08-17-followup-closeout.md.tasks.json b/docs/plans/2026-08-17-followup-closeout.md.tasks.json index a5c67ce..3acff2d 100644 --- a/docs/plans/2026-08-17-followup-closeout.md.tasks.json +++ b/docs/plans/2026-08-17-followup-closeout.md.tasks.json @@ -8,8 +8,8 @@ {"id": 5, "subject": "Task 5: Settings page — GroupToTag + UntaggedSessionVisibility rows", "status": "completed", "commits": ["094f2ff", "ff16c69"], "review": "Approved; comparer-propagation fix applied and re-verified"}, {"id": 6, "subject": "Task 6: ApiKeysPage — DashboardTags in ConstraintText + create-form input", "status": "completed", "commits": ["c037d99", "fab600d"], "review": "Spec compliant; code review Approved after dedupe/help-text fix, re-review Approved"}, {"id": 7, "subject": "Task 7: Wnwrap alarm-probe retry — secured-write path (windev)", "status": "completed", "commits": ["90331b6", "540a118"], "review": "Approved after ack-comment fix; Q2 answered (@COUNT = records-in-reply, exact detection refuted), clear/re-raise GUID answered (new instance), ack leg re-blocked (accepted-but-inert)"}, - {"id": 8, "subject": "Task 8: Windev full verification", "status": "completed", "verifiedAt": "90331b6", "result": "slnx 0W/0E; worker x86 523 pass + 1 pre-existing deterministic failure (reproduces on main); gateway 1151/1151 on rerun; live smoke 8/8; check-codegen 1-3 pass, Check 4 blocked by pre-existing Windows .exe pin bug (substance validated out-of-band); final comment-only tip re-check pending after bookkeeping"}, - {"id": 9, "subject": "Task 9: Bookkeeping — follow-ups closure + plan record", "status": "pending", "blockedBy": [8]} + {"id": 8, "subject": "Task 8: Windev full verification", "status": "completed", "verifiedAt": "90331b6", "result": "slnx 0W/0E; worker x86 523 pass + 1 pre-existing deterministic failure (reproduces on main); gateway 1151/1151 on rerun; live smoke 8/8; check-codegen 1-3 pass, Check 4 blocked by pre-existing Windows .exe pin bug (substance validated out-of-band); final comment-only tip re-check pending after bookkeeping", "residual": "pending-external: cheap windev tip re-build covering the comment/prose-only commits landed after 90331b6"}, + {"id": 9, "subject": "Task 9: Bookkeeping — follow-ups closure + plan record", "status": "completed", "commit": "self (this bookkeeping commit)", "review": "None (trivial classification); closes the prior plan's follow-up block, appends the as-built record, and corrects the CLAUDE.md apikey sample"} ], - "lastUpdated": "2026-08-17" + "lastUpdated": "2026-08-18" } From f2a422b02f912f9d54aebeb3034c7c6a53df2367 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 18 Aug 2026 06:13:52 -0400 Subject: [PATCH 26/26] docs(auth): canonical scope strings in the two remaining stale lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both lists predate the scope rename and would mislead anyone creating a key: CLAUDE.md's Authentication section still named the pre-rename scopes, and docs/Authentication.md's ops.alice example passed 'read,write', which GatewayScopes.ValidateScopes rejects outright. Same defect family as the Build/Test/Run sample fixed in a5f843c. Recorded as a follow-up: code review finding Server-012 claims it fixed the two CLAUDE.md lists on 2026-05-18, but neither correction was present — a Resolved finding is not re-examined, so the sibling Server-0xx doc resolutions want a spot-check for the same pattern. --- CLAUDE.md | 2 +- docs/Authentication.md | 2 +- docs/plans/2026-08-17-followup-closeout.md | 9 +++++++++ docs/plans/2026-08-17-followup-closeout.md.tasks.json | 2 +- 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4cf30eb..898ab3f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -155,7 +155,7 @@ External analysis sources referenced by design docs: ## Authentication -Gateway gRPC clients authenticate with an API key in metadata: `authorization: Bearer mxgw__`. Keys are stored hashed (with a peppered SHA) in a gateway-owned SQLite DB (default `C:\ProgramData\MxGateway\gateway-auth.db`). Scopes (`session`, `invoke`, `event`, `metadata`, `admin`) gate specific RPCs; missing → `Unauthenticated`, insufficient → `PermissionDenied`. The `apikey` subcommand on the server exe manages keys; see `src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/`. +Gateway gRPC clients authenticate with an API key in metadata: `authorization: Bearer mxgw__`. Keys are stored hashed (with a peppered SHA) in a gateway-owned SQLite DB (default `C:\ProgramData\MxGateway\gateway-auth.db`). Scopes (`session:open`, `session:close`, `invoke:read`, `invoke:write`, `invoke:secure`, `events:read`, `metadata:read`, `admin`) gate specific RPCs; missing → `Unauthenticated`, insufficient → `PermissionDenied`. The `apikey` subcommand on the server exe manages keys; see `src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/`. 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. diff --git a/docs/Authentication.md b/docs/Authentication.md index bc59c3e..02c1206 100644 --- a/docs/Authentication.md +++ b/docs/Authentication.md @@ -260,7 +260,7 @@ Examples: ```bash 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 ops.alice --display-name "Alice (ops)" --scopes invoke:read,invoke: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 diff --git a/docs/plans/2026-08-17-followup-closeout.md b/docs/plans/2026-08-17-followup-closeout.md index 8420480..4847a40 100644 --- a/docs/plans/2026-08-17-followup-closeout.md +++ b/docs/plans/2026-08-17-followup-closeout.md @@ -496,6 +496,15 @@ Follow-ups recorded, not started: - The ack-leg probe stays blocked; unblock paths are in `docs/AlarmProbeFindings.md`. - The dashboard `AlarmsPage` truncation banner is still poll-driven — it could consume the new `snapshot_status` feed frame instead. +- **A closed code-review finding regressed, or was never applied.** Server-012 + (`code-reviews/Server/findings.md:405-412`) is recorded *Resolved 2026-05-18* and claims + it corrected two scope lists to the canonical `*:*` strings — the `CLAUDE.md` + Build/Test/Run `apikey` sample and the `CLAUDE.md` Authentication-section scope list. + Neither correction was present when this branch looked; both were re-fixed here, along + with a third instance the finding never covered (`docs/Authentication.md`'s `ops.alice` + example). The bookkeeping is the follow-up: other `Server-0xx` entries marked Resolved + with documentation-only fixes should be spot-checked for the same pattern, since a + finding that reads Resolved is not otherwise re-examined. Explicitly decided, not an omission: **`../scadaproj/CLAUDE.md` needs no update.** The umbrella index records the *set* of `.proto` files this repo owns, and that set is diff --git a/docs/plans/2026-08-17-followup-closeout.md.tasks.json b/docs/plans/2026-08-17-followup-closeout.md.tasks.json index 3acff2d..91d83d9 100644 --- a/docs/plans/2026-08-17-followup-closeout.md.tasks.json +++ b/docs/plans/2026-08-17-followup-closeout.md.tasks.json @@ -9,7 +9,7 @@ {"id": 6, "subject": "Task 6: ApiKeysPage — DashboardTags in ConstraintText + create-form input", "status": "completed", "commits": ["c037d99", "fab600d"], "review": "Spec compliant; code review Approved after dedupe/help-text fix, re-review Approved"}, {"id": 7, "subject": "Task 7: Wnwrap alarm-probe retry — secured-write path (windev)", "status": "completed", "commits": ["90331b6", "540a118"], "review": "Approved after ack-comment fix; Q2 answered (@COUNT = records-in-reply, exact detection refuted), clear/re-raise GUID answered (new instance), ack leg re-blocked (accepted-but-inert)"}, {"id": 8, "subject": "Task 8: Windev full verification", "status": "completed", "verifiedAt": "90331b6", "result": "slnx 0W/0E; worker x86 523 pass + 1 pre-existing deterministic failure (reproduces on main); gateway 1151/1151 on rerun; live smoke 8/8; check-codegen 1-3 pass, Check 4 blocked by pre-existing Windows .exe pin bug (substance validated out-of-band); final comment-only tip re-check pending after bookkeeping", "residual": "pending-external: cheap windev tip re-build covering the comment/prose-only commits landed after 90331b6"}, - {"id": 9, "subject": "Task 9: Bookkeeping — follow-ups closure + plan record", "status": "completed", "commit": "self (this bookkeeping commit)", "review": "None (trivial classification); closes the prior plan's follow-up block, appends the as-built record, and corrects the CLAUDE.md apikey sample"} + {"id": 9, "subject": "Task 9: Bookkeeping — follow-ups closure + plan record", "status": "completed", "commits": ["a5f843c", "self (follow-up bookkeeping commit, immediately after a5f843c)"], "review": "None (trivial classification); a5f843c closes the prior plan's follow-up block, appends the as-built record, and corrects the CLAUDE.md apikey sample; the follow-up commit closes two adjacent stale scope lists (CLAUDE.md Authentication section, docs/Authentication.md ops.alice example) and records the regressed Server-012 resolution as a follow-up"} ], "lastUpdated": "2026-08-18" }