Compare commits

...

49 Commits

Author SHA1 Message Date
Joseph Doherty f4b974eec1 chore(plan): followups-and-tickets as-built record; prior follow-ups closed
ci / windows-x86 (push) Successful in 1m30s
ci / nightly-windev (push) Has been skipped
ci / java (push) Successful in 3m38s
ci / portable (push) Successful in 11m12s
2026-08-18 07:29:15 -04:00
Joseph Doherty fb68bdb699 test(dashboard): settings render test proves label-value pairing
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m30s
ci / java (push) Successful in 3m3s
ci / portable (push) Successful in 12m36s
2026-08-18 07:25:57 -04:00
Joseph Doherty 8ae0c2f3d3 fix(codegen): PS5.1-safe quoting for native-exe args in generate-proto probes
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m26s
ci / java (push) Successful in 3m29s
ci / portable (push) Successful in 11m43s
2026-08-18 07:25:42 -04:00
Joseph Doherty 753d070535 style(worker-tests): field ordering in FakeRuntimeSession
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m31s
ci / java (push) Successful in 3m32s
ci / portable (push) Successful in 23m6s
refreshStaActivityOnCapture was declared between DispatchAsync and the property that
wraps it, rather than with the other instance fields. Moved up to the field block per the
member ordering in docs/style-guides/CSharpStyleGuide.md. Declaration move only — no
behavior, no other edits.
2026-08-18 07:24:39 -04:00
Joseph Doherty db7b1db947 docs(dashboard): GroupToRole/GroupToTag keys are short names — full-DN keys never match the pre-stripped groups
Follow-up to the Server-040 comment restoration (d3ac527). The restored
comment in DashboardGroupRoleMapping records that the shared
ZB.MOM.WW.Auth.Ldap provider hands the mapper groups already stripped to
short RDN names, so a full-DN GroupToRole *key* can never match. Three
doc sites still told operators the opposite, and the tag mapper carried
the same consequence with no comment at all.

- docs/GatewayConfiguration.md: GroupToRole and GroupToTag rows both said
  keys may be 'short CN or full DN — leading-RDN match'. Corrected to
  short group names only, with the reason (pre-stripped groups) and a
  note that the mapper's full-string-then-RDN fallback only matters to
  non-library callers of the IGroupRoleMapper<string> seam.
- docs/GatewayDashboardDesign.md: the login-flow bullet said the user's
  memberOf '(or short CN)' is matched against GroupToRole; it now
  describes what actually happens — groups arrive short and are matched
  against short-name keys.
- DashboardGroupTagMapping: short comment at the lookup pointing at the
  DashboardGroupRoleMapping comment, since it reuses ExtractFirstRdnValue
  and inherits the same full-DN-key-unsupported consequence.

Both doc rows are pre-existing on main — they predate this branch and
were not introduced by the Server-040 work; only their incorrectness was
surfaced by it. Comment/doc-only; no logic changes.
2026-08-18 07:23:59 -04:00
Joseph Doherty 6b5c737b04 docs(toolchain): windev protoc-gen-go-grpc updated to pinned 1.6.2
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m24s
ci / java (push) Successful in 2m54s
ci / portable (push) Failing after 6m48s
2026-08-18 07:23:37 -04:00
Joseph Doherty 1605f542cc docs(probe): align RPC-doc hedging with the findings doc; attribute rig-state inventory 2026-08-18 07:23:33 -04:00
Joseph Doherty 5417222f44 chore(plan): tasks 1-8 completed with review outcomes; task 9 in progress
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m23s
ci / java (push) Successful in 2m38s
ci / portable (push) Successful in 15m28s
2026-08-18 07:13:46 -04:00
Joseph Doherty aaeb86b132 fix(worker-tests): decouple FakeRuntimeSession dispatch safety-net from the inspection window
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m13s
ci / java (push) Successful in 2m26s
ci / portable (push) Failing after 4m56s
The BlockDispatch branch waited 5 real seconds and then proceeded regardless — it does not
branch on the wait's result. The long-in-flight test's inspection loop is bounded by
elapsed time and a frame floor, so on a loaded box (the documented 4-5x slowdown class) it
can plausibly outrun that 5 s. When it does, the reply is emitted mid-window,
AssertNotWorkerFault waves it past, and the reply leg then waits for a reply already gone
by — failing at the 20 s cancellation with no message, on exactly the loaded-box run the
widened windows exist to survive.

The wait is a pure safety net: nothing asserts on it firing, and every test that blocks
dispatch releases it explicitly (ReleaseDispatch, or a WorkerShutdown envelope, both of
which Set the event) — none reaches the timeout on a healthy run. Named it
BlockedDispatchSafetyNet and raised it to 30 s, above any window a test opens and above the
20 s cancellation those tests arm, so a wedged test always fails on its own token with its
own message. Dispose still releases the wait, so teardown never waits on it either.

Inline rationale in the test now states the decoupling and what a close pairing would cost,
rather than asserting the window stays inside a 5 s ceiling. Nothing else changed.
2026-08-18 07:08:47 -04:00
Joseph Doherty bc22792a36 docs(probe): close superseded unblock bullet, hedge the summary, evidence gobject_id, ack-caveat in RPC docs 2026-08-18 07:03:20 -04:00
Joseph Doherty 462850afb7 fix(worker-tests): doc-comment placement + watchdog-window headroom in long-in-flight test
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Failing after 17s
ci / java (push) Successful in 2m38s
ci / portable (push) Successful in 8m34s
Three review findings on 7da52b6:

1. The AssertNotFault doc block landed between the predicate-overload ReadUntilAsync's
   doc block and the helper, so the compiler attached the merged block to the helper and
   ReadUntilAsync lost its docs entirely. Helper and its docs moved above ReadUntilAsync,
   whose docs are back where they belong.

2. The compressed watchdog windows (50 ms grace, 100 ms ceiling) reintroduced the
   load-sensitivity the fix removed, one layer down.
   ReportWatchdogFaultIfNeededAsync measures staleness AFTER the heartbeat frame is
   written and flushed over the real named pipe, so a beat whose pipe I/O outlasts the
   ceiling faults a healthy session no matter how fresh the captured activity was. At
   100 ms that is a plausible stall on a loaded box, and this is the one test asserting
   the watchdog NEVER fires. Widened to a 200 ms grace and a 1 s ceiling — still two
   orders of magnitude under the 75 s production default.

   The inspection loop is now bounded by a 2 s window (twice the ceiling, so a fake whose
   activity stopped advancing still accumulates past it and faults) with a 30-frame floor,
   rather than a fixed 30 frames that no longer outran the wider ceiling. The floor keeps
   a window that saw almost no beats from passing as a clean one. Two seconds stays well
   inside FakeRuntimeSession's 5 s dispatch-block ceiling, so the command is still in
   flight for the whole window.

3. AssertNotFault renamed AssertNotWorkerFault, matching the WorkerFault body case it
   tests.

Scenario intent unchanged: long in-flight command, pump refreshing, zero fault frames,
reply delivered. Test-only.
2026-08-18 07:01:16 -04:00
Joseph Doherty d1ae43d0d3 docs(probe): ack-leg third attempt — unavailable by configuration, GUID leg still assumed 2026-08-18 06:56:58 -04:00
Joseph Doherty f57a6ae5ff fix(dashboard): alarms feed doc row + monitor-contract comment + mid-truncation attach test
The In-process page feeds table still described the alarms page's subscription as
provider status only, contradicting the three passages updated in 7b6dfba.

The loop's catch-all comment (and the cadence bullet that repeated it) claimed the
monitor completes a subscriber's stream on restart. It does not: ClearCache pushes
snapshot_status(false) through the still-open channel, and a subscriber is only
completed-with-error on a failed TryWrite, or removed by its own disposal.

The SnapshotStatus arm's priming claim had no test behind it — every push test
attached to an untruncated feed and pushed the edge itself. ScriptedAlarmFeed now
replays an optional open sequence, and a new test attaches to a feed already
primed provider_status then snapshot_status(truncated) and asserts the banner
comes up with no edge pushed after render.
2026-08-18 06:49:20 -04:00
Joseph Doherty d3ac52758c docs(reviews): re-verify doc-only Server-0xx resolutions; re-fix regressions
Audited every Server-0xx finding whose Resolution described a
documentation-only or comment-only change, and spot-checked the doc
sub-claims of otherwise test-backed resolutions. 20 entries annotated
in place (append-only; no historical resolution text rewritten).

Two corrections had not survived and are re-applied:

Server-040: the MapGroupsToRoles lookup-precedence comment moved intact
into DashboardGroupRoleMapping (792e3f9) and was then deleted wholesale
by fca978d, a sweep meant only to strip (Server-NNN) tracking markers.
That also removed a later, substantive paragraph recording that the
shared ZB.MOM.WW.Auth.Ldap provider pre-strips groups to short RDN
names, so a full-DN GroupToRole key is unsupported. Both paragraphs
restored, minus the tracking IDs.

Server-009: the WAL / busy_timeout note vanished when the Storage
section of docs/Authentication.md was rewritten to delegate
connection-factory detail to ZB.MOM.WW.Auth.ApiKeys. The behavior is
still live in the library (confirmed against 0.2.1), so the fix is
prose-only.

Server-011/014/022/023 are annotated as moot rather than regressed:
the IAlarmRpcDispatcher trio was deleted in dc9c0c9 and no stale
'not yet wired' / 'PR A.6/A.7' prose survives in Server source.
Server-038's documented v1 ACL gap was later closed by
IDashboardSessionAcl, so its remarks are current.

Comment/doc-only; no logic changes.
2026-08-18 06:48:45 -04:00
Joseph Doherty 7da52b65b7 fix(worker-tests): model the STA pump at heartbeat capture — WorkerPipeSessionTests long-in-flight repro
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m11s
ci / java (push) Successful in 2m14s
ci / portable (push) Successful in 8m33s
RunAsync_LongInFlightCommandThatKeepsPumping_DoesNotFaultAndDeliversReply failed
deterministically on the Windows box with a StaHung fault whose command_method was
empty and whose staleness was 233 ms — i.e. a fault raised with no command in flight,
before the scenario under test began. FakeRuntimeSession stamps LastStaActivityUtc once,
at construction, and the test only started refreshing it after the blocked dispatch
signalled. Everything between those two points — handshake, STA init, the first
heartbeat — captured a snapshot already stale past the compressed 50 ms grace, with no
correlation id for the watchdog to suppress on, so the watchdog correctly reported the
fake as hung.

The harness, not the product, was wrong: StaRuntime.ThreadMain calls MarkActivity() on
every WaitForWorkOrMessages iteration, so a live worker is never captured stale, idle or
busy. Model that where it belongs — FakeRuntimeSession.RefreshStaActivityOnCapture (opt
in, default off) stamps activity at each CaptureHeartbeat and leaves the rest of the
snapshot alone — and arm it before RunAsync so the first beat is covered. The test-owned
refresh loop goes away with it; a thread-pool loop racing a compressed grace could not
have held the invariant anyway.

Scenario intent is unchanged and slightly stronger: the command still blocks in dispatch
across 30 heartbeats (~600 ms, many multiples of the 100 ms stuck ceiling), no frame may
be a fault, and the reply must still arrive. The reply leg is now fault-checked too
(previously it skipped frames blindly), and the pump keeps running across the release, as
it does in production while the reply is marshalled off the STA. Fault assertions now
report the category and diagnostic message instead of a bare body-case mismatch.

Test-only change; no product code, frame protocol, or STA rule touched.
2026-08-18 06:46:08 -04:00
Joseph Doherty 7b6dfba654 feat(dashboard): alarms page consumes snapshot_status feed frame for the truncation banner
The truncated-snapshot caveat moved from poll-only to push-driven. The page
already held an in-process alarm-feed subscription for the provider badge; it
now also handles the feed's snapshot_status frame, so a capped provider fetch is
caveated when the monitor decides it rather than up to three seconds later.

The poll's assignment stays as the reconcile baseline — both sources read the
same monitor verdict, and the frame is consumed, never synthesized page-side.
StreamAsync primes every subscriber with a snapshot_status frame at open, so a
page attaching mid-truncation needs no priming logic of its own; the loop is
renamed StatusFeedLoopAsync because it now feeds two indicators, not one.
2026-08-18 06:43:23 -04:00
Joseph Doherty 2b1efb5e50 docs(auth): make Authentication.md examples runnable as written 2026-08-18 06:40:11 -04:00
Joseph Doherty a390fe16e2 feat(dashboard): settings page shows RecentFaultLimit and RecentSessionLimit 2026-08-18 06:39:32 -04:00
Joseph Doherty c94c4d43c4 fix(codegen): normalize .exe off plugin version banners so Check 4 runs on Windows 2026-08-18 06:39:00 -04:00
Joseph Doherty df45cb4a37 fix(java-client): checkGeneratedClean via ProviderFactory.exec — Project.exec is gone in Gradle 9 2026-08-18 06:38:35 -04:00
Joseph Doherty fd941c249e docs(plan): followups-and-tickets — 10 tasks closing every recorded follow-up
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m21s
ci / java (push) Successful in 2m11s
ci / portable (push) Successful in 8m53s
2026-08-18 06:36:47 -04:00
Joseph Doherty 45058d57a5 Merge feat/followup-closeout: feed-level snapshot_status truncation frame on StreamAlarms rolled out to all five clients, ShowTagValues gating all three dashboard seams, both-direction codegen Check 3, GroupToTag/UntaggedSessionVisibility settings rows, dashboard_tags on ApiKeysPage, wnwrap probe answers (@COUNT records-in-reply, re-raise GUID replacement, ack leg re-blocked)
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m20s
ci / java (push) Successful in 2m15s
ci / portable (push) Successful in 8m8s
2026-08-18 06:26:30 -04:00
Joseph Doherty f2a422b02f docs(auth): canonical scope strings in the two remaining stale lists
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m18s
ci / java (push) Successful in 2m58s
ci / portable (push) Successful in 8m49s
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.
2026-08-18 06:13:52 -04:00
Joseph Doherty a5f843cbfb chore(plan): close the record — prior follow-ups annotated, closeout as-built noted
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.
2026-08-18 06:12:31 -04:00
Joseph Doherty c3c603f169 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.
2026-08-18 06:09:06 -04:00
Joseph Doherty a95e510015 chore(plan): Task 8 verification-green at 90331b6 — residuals recorded 2026-08-18 05:56:45 -04:00
Joseph Doherty 47d74032e6 chore(plan): Task 7 complete — probe retry answered Q2 and the re-raise leg, ack leg re-blocked with evidence 2026-08-18 05:48:10 -04:00
Joseph Doherty 540a1188aa docs(alarms): stop describing the wnwrap ack as working, and say the re-raise replaces the record
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.
2026-08-18 05:46:35 -04:00
Joseph Doherty 90331b622c docs(alarms): second wnwrap probe attempt — secured-write verb unblocked the rig, answering @COUNT and the re-raise GUID leg
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m23s
ci / java (push) Successful in 2m25s
ci / portable (push) Successful in 24m49s
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.
2026-08-18 05:40:11 -04:00
Joseph Doherty ad30adca2f chore(plan): Task 2 complete — five-client snapshot_status rollout approved 2026-08-18 05:33:25 -04:00
Joseph Doherty 4d4f443c27 fix(clients): review nits — alphabetize the Java CLI alarm imports, cover the Rust snapshot-status render
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.
2026-08-18 05:33:00 -04:00
Joseph Doherty c7483615cf chore(clients): roll out feed-level snapshot_status to all five clients
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.
2026-08-18 05:26:06 -04:00
Joseph Doherty d13144a9d7 chore(plan): Task 3 complete — ShowTagValues coverage approved across all three seams 2026-08-18 05:21:45 -04:00
Joseph Doherty 155c9d619f chore(plan): Task 6 complete — dashboard_tags display and create input approved 2026-08-18 05:21:21 -04:00
Joseph Doherty 51b0a8f55f chore(plan): Task 1 complete — feed-level snapshot_status approved 2026-08-18 05:21:11 -04:00
Joseph Doherty e50ec493cb docs(config): ShowTagValues /browse clause — redaction applies to successful reads only 2026-08-18 05:20:25 -04:00
Joseph Doherty 312e072070 docs(proto): spell out the monitor-restart clearing emission on AlarmSnapshotStatus 2026-08-18 05:19:31 -04:00
Joseph Doherty fab600d3b0 fix(dashboard): de-duplicate dashboard_tags typed into the API-key create form
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.
2026-08-18 05:19:02 -04:00
Joseph Doherty 1ea6f60ea2 fix(dashboard): keep the '-' placeholder on failed /browse reads instead of '[redacted]' 2026-08-18 05:18:47 -04:00
Joseph Doherty bf7b44c2d7 chore(plan): Task 5 complete — settings rows approved with comparer fix 2026-08-18 05:15:37 -04:00
Joseph Doherty ff16c69d59 fix(config): propagate GroupToTag's comparer into the effective-config projection 2026-08-18 05:14:58 -04:00
Joseph Doherty c037d9960d feat(dashboard): ApiKeysPage lists and accepts dashboard_tags constraints
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.
2026-08-17 07:17:43 -04:00
Joseph Doherty fccf75324b feat(alarms): feed-level snapshot_status truncation frame on StreamAlarms 2026-08-17 07:16:51 -04:00
Joseph Doherty 094f2ffee4 feat(dashboard): settings page shows GroupToTag and UntaggedSessionVisibility 2026-08-17 07:15:24 -04:00
Joseph Doherty eff17d177c fix(dashboard): ShowTagValues now gates the alarms hub and /browse live values 2026-08-17 07:14:40 -04:00
Joseph Doherty 222b01f488 chore(plan): Task 4 complete — Check 3 reverse sweep approved 2026-08-17 07:07:09 -04:00
Joseph Doherty 711793ffdc ci(codegen): Check 3 sweeps both directions — canonical protos missing a vendored copy now fail 2026-08-17 07:04:49 -04:00
Joseph Doherty 338bc20a0a docs(plan): follow-up closeout — feed-level truncation signal, ShowTagValues coverage completion, codegen reverse sweep, dashboard display gaps, probe retry 2026-08-17 07:01:48 -04:00
Joseph Doherty ab3ff1612c Merge feat/deferred-closeout: SEC-25 per-session dashboard event ACL on both seams, alarm-truncation degraded-status signal through proto and all five clients, frame-writer lock-parking closed, dead ReadEventsAsync chain removed, alarm probes recorded blocked with evidence
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m19s
ci / java (push) Successful in 2m8s
ci / portable (push) Successful in 7m56s
2026-08-17 05:34:24 -04:00
64 changed files with 5941 additions and 859 deletions
+2 -2
View File
@@ -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`):
@@ -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_<key-id>_<secret>`. 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_<key-id>_<secret>`. 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.
@@ -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
+2 -1
View File
@@ -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<GatewayOptions>` 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. |
@@ -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` · `—`
+10
View File
@@ -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.
@@ -1546,8 +1546,9 @@ public static class MxGatewayClientCli
/// <summary>
/// Renders one <see cref="AlarmFeedMessage"/> for the human-readable
/// (non-JSON) stream-alarms output, distinguishing the <c>payload</c> 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.
/// </summary>
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}",
};
}
+9
View File
@@ -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
+7 -1
View File
@@ -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"
}
+15 -4
View File
@@ -6,7 +6,10 @@ $ErrorActionPreference = 'Stop'
# those header stamps, so a regeneration on an off-pin machine would churn the tree and make
# check-codegen Check 4 false-fail (or mask real drift under churn). Assert the exact versions
# so a regen is deterministic. protoc itself is warn-only (source_code_info is normalized out of
# the committed bindings), matching publish-client-proto-inputs.ps1.
# the committed bindings), matching publish-client-proto-inputs.ps1. On Windows a plugin reports
# its argv[0] name, so the banner carries a trailing `.exe` (e.g. "protoc-gen-go.exe v1.36.11");
# Get-NormalizedToolVersion strips that suffix before the pin compare so Check 4 runs the same on
# Windows as everywhere else.
$PinnedProtocGenGoVersion = 'protoc-gen-go v1.36.11'
$PinnedProtocGenGoGrpcVersion = 'protoc-gen-go-grpc 1.6.2'
$PinnedProtocVersion = 'libprotoc 34.1'
@@ -16,6 +19,14 @@ $protoRoot = Join-Path $repoRoot 'src\ZB.MOM.WW.MxGateway.Contracts\Protos'
$outputRoot = Join-Path $PSScriptRoot 'internal\generated'
$modulePath = 'gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated'
function Get-NormalizedToolVersion {
# On Windows a plugin reports its argv[0] name, so the banner carries an `.exe`
# suffix ("protoc-gen-go.exe v1.36.11"). Strip it so the pin compare is
# host-independent; the version part must still match exactly.
param([string]$RawBanner)
return ($RawBanner -replace '\.exe(?=\s)', '')
}
function Resolve-Tool {
# Resolve a codegen tool from PATH first (portable), then the documented Windows fallbacks,
# instead of the previous hard-coded per-machine paths. See docs/ToolchainLinks.md.
@@ -51,17 +62,17 @@ $protocGenGoGrpc = Resolve-Tool -Names @('protoc-gen-go-grpc', 'protoc-gen-go-gr
# Assert the pinned plugin versions before generating so Check 4 cannot false-fail (or mask drift)
# on an off-pin machine. protoc is warn-only.
$protocGenGoVersion = (& $protocGenGo --version 2>&1 | Out-String).Trim()
$protocGenGoVersion = Get-NormalizedToolVersion (& $protocGenGo --version 2>&1 | Out-String).Trim()
if ($protocGenGoVersion -ne $PinnedProtocGenGoVersion) {
throw "protoc-gen-go reports '$protocGenGoVersion', but regeneration is pinned to '$PinnedProtocGenGoVersion'. " +
"Install the pin: go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.11"
}
$protocGenGoGrpcVersion = (& $protocGenGoGrpc --version 2>&1 | Out-String).Trim()
$protocGenGoGrpcVersion = Get-NormalizedToolVersion (& $protocGenGoGrpc --version 2>&1 | Out-String).Trim()
if ($protocGenGoGrpcVersion -ne $PinnedProtocGenGoGrpcVersion) {
throw "protoc-gen-go-grpc reports '$protocGenGoGrpcVersion', but regeneration is pinned to '$PinnedProtocGenGoGrpcVersion'. " +
"Install the pin: go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.2"
}
$protocVersion = (& $protoc --version 2>&1 | Out-String).Trim()
$protocVersion = Get-NormalizedToolVersion (& $protoc --version 2>&1 | Out-String).Trim()
if ($protocVersion -ne $PinnedProtocVersion) {
Write-Warning "protoc reports '$protocVersion', pin is '$PinnedProtocVersion'. Descriptor comments are normalized out of the committed Go bindings, so patch drift is tolerated; keep CI on the pin."
}
@@ -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:
@@ -7353,6 +7357,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 +7436,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 +7471,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 +7486,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 +7556,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 +7637,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 +7649,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 +7662,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 +7740,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 +7752,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 +7765,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 +7988,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 +8000,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 +8013,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 +8197,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 +8209,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 +8222,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 +8256,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 +8268,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 +8281,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 +8307,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 +8319,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 +8332,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 +8351,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 +8363,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 +8376,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 +8395,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 +8407,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 +8420,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 +8439,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 +8451,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 +8464,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 +8483,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 +8495,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 +8508,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 +8527,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 +8539,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 +8552,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 +8571,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 +8583,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 +8596,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 +8615,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 +8627,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 +8640,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 +8660,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 +8672,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 +8685,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 +9195,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 +9447,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 +9546,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 +9611,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 +9659,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 +9685,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 +9837,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 +9851,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 +9867,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,
},
+10
View File
@@ -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
File diff suppressed because it is too large Load Diff
@@ -47,8 +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.AlarmProviderStatus;
import mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage;
import mxaccess_gateway.v1.MxaccessGateway.AlarmProviderStatus;
import mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus;
import mxaccess_gateway.v1.MxaccessGateway.BulkReadResult;
import mxaccess_gateway.v1.MxaccessGateway.BulkWriteResult;
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest;
@@ -2282,7 +2283,8 @@ public final class MxGatewayCli implements Callable<Integer> {
/**
* 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<Integer> {
"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";
};
}
@@ -72,16 +72,18 @@ tasks.register('checkGeneratedClean') {
group = 'verification'
description = 'Fails if the committed generated Java tree differs from a fresh regeneration.'
dependsOn 'generateProto'
def generatedDir = 'clients/java/src/main/generated'
def repoRoot = rootProject.projectDir.parentFile.parentFile
// Project.exec was removed in Gradle 9; ProviderFactory.exec runs the git-status probe
// lazily (captured at configuration time, evaluated in doLast) and works on both the
// installed Gradle and Gradle 9.
def gitStatus = providers.exec {
workingDir(repoRoot)
commandLine('git', 'status', '--porcelain', '--', generatedDir)
ignoreExitValue = true
}
doLast {
def generatedDir = 'clients/java/src/main/generated'
def stdout = new ByteArrayOutputStream()
def result = exec {
workingDir = rootProject.projectDir.parentFile.parentFile
commandLine 'git', 'status', '--porcelain', '--', generatedDir
standardOutput = stdout
ignoreExitValue = true
}
def dirty = stdout.toString().trim()
def dirty = gitStatus.standardOutput.asText.get().trim()
if (!dirty.isEmpty()) {
throw new GradleException(
"Generated Java is stale:\n${dirty}\n" +
+10
View File
@@ -122,6 +122,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).
`stream_alarms` also carries that completeness verdict at feed level, as a
message whose `WhichOneof("payload")` is `snapshot_status` and whose
`snapshot_status.truncated` 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
`query_active_alarms`.
Canceling a Python task cancels the client-side gRPC call or stream wait. It
does not abort an in-flight MXAccess COM call inside the worker process.
+5 -1
View File
@@ -36,7 +36,11 @@ function Resolve-Python {
function Assert-GrpcioToolsVersion {
param([string]$Python)
$version = (& $Python -c 'import grpc_tools; from importlib.metadata import version; print(version("grpcio-tools"))').Trim()
# Windows PowerShell 5.1 strips embedded double quotes when passing an argument to a native
# exe (pwsh 7 does not), so a Python literal quoted with " " here would arrive at Python as
# print(version(grpcio-tools)) -> NameError. Use single quotes for the Python string literal
# inside this double-quoted PowerShell string so the quoting survives on both hosts.
$version = (& $Python -c "import grpc_tools; from importlib.metadata import version; print(version('grpcio-tools'))").Trim()
if ($version -ne $PinnedGrpcioToolsVersion) {
throw "grpcio-tools $version is installed, but regeneration is pinned to $PinnedGrpcioToolsVersion. " +
"Install the pin (python -m pip install 'grpcio-tools==$PinnedGrpcioToolsVersion') before regenerating, " +
File diff suppressed because one or more lines are too long
+16 -5
View File
@@ -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.
@@ -130,6 +132,15 @@ 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).
`AlarmFeedStream` also carries that completeness verdict at feed level, as an
`alarm_feed_message::Payload::SnapshotStatus` frame whose `truncated` field 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 `query_active_alarms`.
The session helpers keep MXAccess handles visible:
```rust
+28 -1
View File
@@ -2231,7 +2231,7 @@ fn event_value_to_json(value: &ProtoMxValue) -> 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,
}
}
@@ -2961,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);
}
}
+28 -3
View File
@@ -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).
@@ -1018,6 +1022,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 +1036,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
+20 -20
View File
@@ -259,7 +259,7 @@ closed. New findings filed against this pass: Server-051..053.
**Recommendation:** Add a startup hosted service that finds and kills stale worker processes (by executable path / a well-known argument or environment marker) before the server accepts sessions, or update the design docs if reattachment/cleanup is deliberately deferred.
**Resolution:** Resolved 2026-05-18. Confirmed against source: no code path enumerated or killed leftover workers. Added `IRunningProcessInspector` / `SystemRunningProcessInspector` (a testable seam over `Process.GetProcessesByName`/`Kill`), `OrphanWorkerTerminator` (kills processes matched by the configured worker executable path, or by image name when the x64 gateway cannot introspect the x86 worker's `MainModule`, skipping the current process and tolerating per-process kill failures), and `OrphanWorkerCleanupHostedService` (best-effort `IHostedService`). The hosted service is registered in `AddWorkerProcessLauncher` ahead of `AddGatewaySessions` so cleanup runs before the server accepts sessions. `gateway.md` updated to describe the implemented behavior. Regression tests: `OrphanWorkerTerminatorTests` (`KillsWorkerProcessesMatchingConfiguredExecutablePath`, `KillsImageNameMatchWhenExecutablePathUnreadable`, `DoesNotKillUnrelatedProcessSharingImageName`, `DoesNotKillCurrentProcess`, `ContinuesWhenOneKillThrows`).
**Resolution:** Resolved 2026-05-18. Confirmed against source: no code path enumerated or killed leftover workers. Added `IRunningProcessInspector` / `SystemRunningProcessInspector` (a testable seam over `Process.GetProcessesByName`/`Kill`), `OrphanWorkerTerminator` (kills processes matched by the configured worker executable path, or by image name when the x64 gateway cannot introspect the x86 worker's `MainModule`, skipping the current process and tolerating per-process kill failures), and `OrphanWorkerCleanupHostedService` (best-effort `IHostedService`). The hosted service is registered in `AddWorkerProcessLauncher` ahead of `AddGatewaySessions` so cleanup runs before the server accepts sessions. `gateway.md` updated to describe the implemented behavior. Regression tests: `OrphanWorkerTerminatorTests` (`KillsWorkerProcessesMatchingConfiguredExecutablePath`, `KillsImageNameMatchWhenExecutablePathUnreadable`, `DoesNotKillUnrelatedProcessSharingImageName`, `DoesNotKillCurrentProcess`, `ContinuesWhenOneKillThrows`). Re-verified present 2026-08-18 (feat/followups-tickets) — doc sub-claim only; the `gateway.md` orphan-cleanup prose (`OrphanWorkerCleanupHostedService` running `OrphanWorkerTerminator` once on startup, before the server accepts sessions) is still there.
### Server-003
@@ -364,7 +364,7 @@ closed. New findings filed against this pass: Server-051..053.
**Recommendation:** Set `Pooling`, a non-zero `DefaultTimeout`/`busy_timeout`, and enable WAL (`PRAGMA journal_mode=WAL`) once at startup so concurrent readers/writers degrade gracefully.
**Resolution:** Resolved 2026-05-18. Confirmed against source: the connection string set only `DataSource` and `Mode`. `AuthSqliteConnectionFactory.CreateConnection` now also sets `Pooling = true` and a non-zero `DefaultTimeout`. A new `OpenConnectionAsync(CancellationToken)` opens the connection and applies `PRAGMA journal_mode=WAL` and `PRAGMA busy_timeout` (5 s); WAL is a persistent database-level setting so re-applying it per connection is a cheap no-op, while `busy_timeout` is per-connection state. All nine auth-store call sites (`SqliteApiKeyAdminStore`, `SqliteApiKeyAuditStore`, `SqliteApiKeyStore`, `SqliteAuthStoreMigrator`) were switched from `CreateConnection()` + `OpenAsync()` to `OpenConnectionAsync()`. `docs/Authentication.md` updated to describe the WAL/busy-timeout behavior. Regression test: `SqliteAuthStoreTests.OpenConnectionAsync_EnablesWalJournalModeAndBusyTimeout`.
**Resolution:** Resolved 2026-05-18. Confirmed against source: the connection string set only `DataSource` and `Mode`. `AuthSqliteConnectionFactory.CreateConnection` now also sets `Pooling = true` and a non-zero `DefaultTimeout`. A new `OpenConnectionAsync(CancellationToken)` opens the connection and applies `PRAGMA journal_mode=WAL` and `PRAGMA busy_timeout` (5 s); WAL is a persistent database-level setting so re-applying it per connection is a cheap no-op, while `busy_timeout` is per-connection state. All nine auth-store call sites (`SqliteApiKeyAdminStore`, `SqliteApiKeyAuditStore`, `SqliteApiKeyStore`, `SqliteAuthStoreMigrator`) were switched from `CreateConnection()` + `OpenAsync()` to `OpenConnectionAsync()`. `docs/Authentication.md` updated to describe the WAL/busy-timeout behavior. Regression test: `SqliteAuthStoreTests.OpenConnectionAsync_EnablesWalJournalModeAndBusyTimeout`. Regressed or never applied; re-fixed 2026-08-18 in `docs/Authentication.md` (feat/followups-tickets) — the doc sub-claim did not survive: the Storage section was later rewritten to delegate schema/connection-factory detail to `ZB.MOM.WW.Auth.ApiKeys` and the WAL/busy-timeout paragraph went with it, leaving no prose for the behavior anywhere. The behavior itself is intact (the library's `AuthSqliteConnectionFactory.OpenConnectionAsync` still issues both pragmas — confirmed against the 0.2.1 package), so the fix is prose-only: a paragraph restating the pooled-connection + `journal_mode=WAL` + 5 s `busy_timeout` contract and why it is load-bearing for the per-request last-used stamp and the per-denial audit append.
### Server-010
@@ -379,7 +379,7 @@ closed. New findings filed against this pass: Server-051..053.
**Recommendation:** Either hide/disable the Rotate action for revoked keys in `ApiKeysPage.razor`, require an explicit confirmation, or have `RotateAsync` preserve `revoked_utc` and add a separate explicit "reactivate" operation.
**Resolution:** Resolved 2026-05-18. Confirmed against source: `ApiKeysPage.razor` rendered the Rotate button unconditionally while Revoke was already gated on `key.RevokedUtc is null`. Took the lowest-risk recommended option — the dashboard now renders the Rotate (and Revoke) actions only for keys whose status is `Active`; a revoked key shows a "No actions" placeholder, so an operator cannot un-revoke a deliberately disabled key as a side effect of a rotation. `RotateAsync`'s store-level behavior is unchanged (rotation by `key_id` still clears `revoked_utc`, which the CLI relies on); `docs/Authentication.md` updated to document both the store behavior and the dashboard restriction. No automated test added: the change is pure conditional Razor rendering and the test project has no bUnit component-rendering harness; the underlying `DashboardApiKeyManagementService` is already unit-tested.
**Resolution:** Resolved 2026-05-18. Confirmed against source: `ApiKeysPage.razor` rendered the Rotate button unconditionally while Revoke was already gated on `key.RevokedUtc is null`. Took the lowest-risk recommended option — the dashboard now renders the Rotate (and Revoke) actions only for keys whose status is `Active`; a revoked key shows a "No actions" placeholder, so an operator cannot un-revoke a deliberately disabled key as a side effect of a rotation. `RotateAsync`'s store-level behavior is unchanged (rotation by `key_id` still clears `revoked_utc`, which the CLI relies on); `docs/Authentication.md` updated to document both the store behavior and the dashboard restriction. No automated test added: the change is pure conditional Razor rendering and the test project has no bUnit component-rendering harness; the underlying `DashboardApiKeyManagementService` is already unit-tested. Re-verified present 2026-08-18 (feat/followups-tickets) — `ApiKeysPage.razor` still gates both Rotate and Revoke on `key.RevokedUtc is null`, with the "Rotate clears revoked_utc" comment intact.
### Server-011
@@ -394,7 +394,7 @@ closed. New findings filed against this pass: Server-051..053.
**Recommendation:** Add the needed `using` directives, drop the inline fully-qualified names, and convert to a primary constructor for consistency.
**Resolution:** Resolved 2026-05-18. Confirmed against source. Converted `WorkerAlarmRpcDispatcher` to a primary constructor with the standard `?? throw new ArgumentNullException(...)` field-initializer guard; dropped the inline `System.Guid` / `System.ArgumentNullException` qualifications (using implicit `using System;`); removed redundant `using System.Collections.Generic;` / `System.Threading` / `System.Threading.Tasks;` directives (covered by `ImplicitUsings`); replaced the two `if (... is null) throw new System.ArgumentNullException(...)` checks with `ArgumentNullException.ThrowIfNull`. The stale class-level `<summary>`/`<remarks>` ("Replaces NotWiredAlarmRpcDispatcher once ... wired in", "partially wired", "returns an Unimplemented diagnostic") were corrected to describe the actual GUID-vs-`Provider!Group.Tag` handling — overlapping with Server-014. No behavior change, so no new test; existing `WorkerAlarmRpcDispatcherTests` continue to pass and the project builds warning-free under `TreatWarningsAsErrors`.
**Resolution:** Resolved 2026-05-18. Confirmed against source. Converted `WorkerAlarmRpcDispatcher` to a primary constructor with the standard `?? throw new ArgumentNullException(...)` field-initializer guard; dropped the inline `System.Guid` / `System.ArgumentNullException` qualifications (using implicit `using System;`); removed redundant `using System.Collections.Generic;` / `System.Threading` / `System.Threading.Tasks;` directives (covered by `ImplicitUsings`); replaced the two `if (... is null) throw new System.ArgumentNullException(...)` checks with `ArgumentNullException.ThrowIfNull`. The stale class-level `<summary>`/`<remarks>` ("Replaces NotWiredAlarmRpcDispatcher once ... wired in", "partially wired", "returns an Unimplemented diagnostic") were corrected to describe the actual GUID-vs-`Provider!Group.Tag` handling — overlapping with Server-014. No behavior change, so no new test; existing `WorkerAlarmRpcDispatcherTests` continue to pass and the project builds warning-free under `TreatWarningsAsErrors`. Re-verified 2026-08-18 (feat/followups-tickets): not regressed but no longer verifiable in place — `WorkerAlarmRpcDispatcher.cs` was **deleted** (not renamed) by the `dc9c0c9` project-rename/alarm-rework commit, which replaced the whole `IAlarmRpcDispatcher` trio with `GatewayAlarmMonitor` / `IGatewayAlarmService`. A repo-wide grep for the prose this finding removed ("not yet wired", "PR A.6/A.7", "worker-pending", "dev-rig") returns nothing in Server source, so the correction is moot rather than lost. Nothing to re-fix.
### Server-012
@@ -409,7 +409,7 @@ closed. New findings filed against this pass: Server-051..053.
**Recommendation:** Update CLAUDE.md's scope list and the `apikey` example to the canonical `*:*` scope strings, per CLAUDE.md's own rule that docs change with the code.
**Resolution:** Resolved 2026-05-18. Confirmed against `GatewayScopes` (`session:open`, `session:close`, `invoke:read`, `invoke:write`, `invoke:secure`, `events:read`, `metadata:read`, `admin`). CLAUDE.md's Build/Test/Run `apikey create` example and the Authentication-section scope list were both updated to the canonical `*:*` strings. (Note: since finding Server-004 was resolved, the old example would now be actively rejected at create time rather than silently creating an unusable key, making the doc correction load-bearing.) Pure documentation change; no test.
**Resolution:** Resolved 2026-05-18. Confirmed against `GatewayScopes` (`session:open`, `session:close`, `invoke:read`, `invoke:write`, `invoke:secure`, `events:read`, `metadata:read`, `admin`). CLAUDE.md's Build/Test/Run `apikey create` example and the Authentication-section scope list were both updated to the canonical `*:*` strings. (Note: since finding Server-004 was resolved, the old example would now be actively rejected at create time rather than silently creating an unusable key, making the doc correction load-bearing.) Pure documentation change; no test. Regression re-fixed 2026-08-18 on feat/followup-closeout (a5f843c, f2a422b); scope lists re-verified present. (Both CLAUDE.md sites confirmed on feat/followups-tickets: the Build/Test/Run `apikey create-key` example carries the full canonical `session:open,session:close,invoke:read,invoke:write,invoke:secure,events:read,metadata:read,admin` set, and the Authentication-section list names the same eight strings.)
### Server-013
@@ -439,7 +439,7 @@ closed. New findings filed against this pass: Server-051..053.
**Recommendation:** Update the `AcknowledgeAlarm`/`QueryActiveAlarms` remarks to reflect that `WorkerAlarmRpcDispatcher` is the wired default, and describe its actual GUID-vs-`Provider!Group.Tag` handling.
**Resolution:** Resolved 2026-05-18. Confirmed against source: `SessionServiceCollectionExtensions` registers `WorkerAlarmRpcDispatcher` as `IAlarmRpcDispatcher`, so the "not yet wired" / "empty stream until PR A.2" / "PR A.6/A.7 follow-up" prose in the `AcknowledgeAlarm` and `QueryActiveAlarms` `<remarks>` and inline comments was stale. Rewrote both `<remarks>` blocks and both inline comments to state that DI binds the production `WorkerAlarmRpcDispatcher`, that it routes over the worker pipe IPC, and that `AcknowledgeAlarm` handles a canonical-GUID reference (→ `AcknowledgeAlarmCommand`) and a `Provider!Group.Tag` reference (→ `AcknowledgeAlarmByNameCommand`), with `NotWiredAlarmRpcDispatcher` being only the null fallback. The matching stale `WorkerAlarmRpcDispatcher` class-level XML doc was corrected as part of Server-011. Pure documentation/comment change; no test.
**Resolution:** Resolved 2026-05-18. Confirmed against source: `SessionServiceCollectionExtensions` registers `WorkerAlarmRpcDispatcher` as `IAlarmRpcDispatcher`, so the "not yet wired" / "empty stream until PR A.2" / "PR A.6/A.7 follow-up" prose in the `AcknowledgeAlarm` and `QueryActiveAlarms` `<remarks>` and inline comments was stale. Rewrote both `<remarks>` blocks and both inline comments to state that DI binds the production `WorkerAlarmRpcDispatcher`, that it routes over the worker pipe IPC, and that `AcknowledgeAlarm` handles a canonical-GUID reference (→ `AcknowledgeAlarmCommand`) and a `Provider!Group.Tag` reference (→ `AcknowledgeAlarmByNameCommand`), with `NotWiredAlarmRpcDispatcher` being only the null fallback. The matching stale `WorkerAlarmRpcDispatcher` class-level XML doc was corrected as part of Server-011. Pure documentation/comment change; no test. Re-verified 2026-08-18 (feat/followups-tickets): not regressed but superseded — `MxAccessGatewayService.AcknowledgeAlarm` / `QueryActiveAlarms` still exist but were rewritten onto `IGatewayAlarmService` (the `IAlarmRpcDispatcher` seam is gone), and both now carry a bare `<inheritdoc />` rather than the rewritten `<remarks>`. The stale prose this finding removed is absent repo-wide, so there is nothing to re-fix; the rewritten remarks describing a now-deleted dispatcher would be wrong if restored.
### Server-015
@@ -454,7 +454,7 @@ closed. New findings filed against this pass: Server-051..053.
**Recommendation:** Make `CloseAsync` mutate `_state` through the existing `TransitionTo(...)` helper (or acquire `_syncRoot` around the reads/writes) so all `_state` access uses the same lock. Either extend `TransitionTo` to accept the `Closing` and `Closed` transitions (it already handles `Faulted`/`Closed` precedence) or refactor `CloseAsync` to call a private `TrySetClosing()` / `MarkClosed()` that locks `_syncRoot`. Add a regression test that forces a `TransitionTo(Ready)` after `CloseAsync` has set `Closing` and asserts the session does not flip back to `Ready`.
**Resolution:** 2026-05-20 — Unified the close path on `_syncRoot`. `GatewaySession.CloseAsync` (`src/MxGateway.Server/Sessions/GatewaySession.cs`) now mutates `_state` only through two private `_syncRoot`-locked helpers — `TryBeginClose` (writes `Closing`, returns the prior `_closeStarted`) and `MarkClosed` (writes `Closed`) — so every `_state` read/write in the session uses the same lock; `_closeLock` keeps its role of serializing concurrent close attempts. `TransitionTo` was tightened to refuse a transition out of `Closing` to anything other than `Closed`/`Faulted` so a late lifecycle callback cannot walk a closing session back to `Ready`. `docs/Sessions.md` updated to describe the unified lock discipline and the extended terminal precedence. Regression tests in `src/MxGateway.Tests/Gateway/Sessions/GatewaySessionTests.cs`: `TransitionTo_AfterCloseStarted_DoesNotOverwriteClosing` (the named scenario — `BlockingShutdownWorkerClient` parks the close inside `worker.ShutdownAsync` so the test can call `TransitionTo(Ready)` between the `Closing` and `Closed` writes and assert the state stays `Closing`) and `MarkFaulted_AfterCloseCompletes_DoesNotResurrectSession`.
**Resolution:** 2026-05-20 — Unified the close path on `_syncRoot`. `GatewaySession.CloseAsync` (`src/MxGateway.Server/Sessions/GatewaySession.cs`) now mutates `_state` only through two private `_syncRoot`-locked helpers — `TryBeginClose` (writes `Closing`, returns the prior `_closeStarted`) and `MarkClosed` (writes `Closed`) — so every `_state` read/write in the session uses the same lock; `_closeLock` keeps its role of serializing concurrent close attempts. `TransitionTo` was tightened to refuse a transition out of `Closing` to anything other than `Closed`/`Faulted` so a late lifecycle callback cannot walk a closing session back to `Ready`. `docs/Sessions.md` updated to describe the unified lock discipline and the extended terminal precedence. Re-verified present 2026-08-18 (feat/followups-tickets) — doc sub-claim only; `docs/Sessions.md` still states that both close-related writes go through `_syncRoot` via `TryBeginClose`/`MarkClosed` and that `_closeLock` only serializes concurrent close attempts. Regression tests in `src/MxGateway.Tests/Gateway/Sessions/GatewaySessionTests.cs`: `TransitionTo_AfterCloseStarted_DoesNotOverwriteClosing` (the named scenario — `BlockingShutdownWorkerClient` parks the close inside `worker.ShutdownAsync` so the test can call `TransitionTo(Ready)` between the `Closing` and `Closed` writes and assert the state stays `Closing`) and `MarkFaulted_AfterCloseCompletes_DoesNotResurrectSession`.
### Server-016
@@ -484,7 +484,7 @@ closed. New findings filed against this pass: Server-051..053.
**Recommendation:** Add explicit arms to `ResolveRequiredScope`: map `AcknowledgeAlarmRequest` to `GatewayScopes.InvokeWrite` (parity with other write actions; ack changes alarm state) and `QueryActiveAlarmsRequest` to `GatewayScopes.MetadataRead` or `GatewayScopes.InvokeRead`. Update `docs/Authorization.md` to list both. Extend `GatewayGrpcScopeResolverTests` with the new mappings and an assertion that every request type defined by `mxaccess_gateway.proto` is named in the resolver (the test can enumerate the assembly's request types so a future RPC cannot quietly add itself only via the admin fallback).
**Resolution:** 2026-05-20 — Added explicit `AcknowledgeAlarmRequest => GatewayScopes.InvokeWrite` and `QueryActiveAlarmsRequest => GatewayScopes.EventsRead` arms to `GatewayGrpcScopeResolver.ResolveRequiredScope` (`src/MxGateway.Server/Security/Authorization/GatewayGrpcScopeResolver.cs:21-22`). `InvokeWrite` matches the existing `MxCommandKind.Write*` mapping because ack mutates alarm state; `EventsRead` matches `StreamEventsRequest` and `MxCommandKind.DrainEvents` because querying active alarms reads the same alarm/event surface. Extended `GatewayGrpcScopeResolverTests` with two new `InlineData` rows covering both request types (`src/MxGateway.Tests/Security/Authorization/GatewayGrpcScopeResolverTests.cs:16-17`) and added four interceptor-level cases in `GatewayGrpcAuthorizationInterceptorTests` (`UnaryServerHandler_AcknowledgeAlarmMissingScope_ReturnsPermissionDenied`, `UnaryServerHandler_AcknowledgeAlarmWithScope_RunsHandler`, `ServerStreamingServerHandler_QueryActiveAlarmsMissingScope_ReturnsPermissionDenied`, `ServerStreamingServerHandler_QueryActiveAlarmsWithScope_RunsHandler`) proving each new RPC denies callers lacking the chosen scope and runs the handler when the scope is held. Updated `docs/Authorization.md` (resolver snippet and Scope Catalog table) to list both RPCs against their scopes. `dotnet test ... --filter FullyQualifiedName~GatewayGrpcAuthorizationInterceptorTests` → 14 passed, 0 failed; resolver tests 28 passed, 0 failed.
**Resolution:** 2026-05-20 — Added explicit `AcknowledgeAlarmRequest => GatewayScopes.InvokeWrite` and `QueryActiveAlarmsRequest => GatewayScopes.EventsRead` arms to `GatewayGrpcScopeResolver.ResolveRequiredScope` (`src/MxGateway.Server/Security/Authorization/GatewayGrpcScopeResolver.cs:21-22`). `InvokeWrite` matches the existing `MxCommandKind.Write*` mapping because ack mutates alarm state; `EventsRead` matches `StreamEventsRequest` and `MxCommandKind.DrainEvents` because querying active alarms reads the same alarm/event surface. Extended `GatewayGrpcScopeResolverTests` with two new `InlineData` rows covering both request types (`src/MxGateway.Tests/Security/Authorization/GatewayGrpcScopeResolverTests.cs:16-17`) and added four interceptor-level cases in `GatewayGrpcAuthorizationInterceptorTests` (`UnaryServerHandler_AcknowledgeAlarmMissingScope_ReturnsPermissionDenied`, `UnaryServerHandler_AcknowledgeAlarmWithScope_RunsHandler`, `ServerStreamingServerHandler_QueryActiveAlarmsMissingScope_ReturnsPermissionDenied`, `ServerStreamingServerHandler_QueryActiveAlarmsWithScope_RunsHandler`) proving each new RPC denies callers lacking the chosen scope and runs the handler when the scope is held. Updated `docs/Authorization.md` (resolver snippet and Scope Catalog table) to list both RPCs against their scopes. Re-verified present 2026-08-18 (feat/followups-tickets) — doc sub-claim only; the snippet still shows `AcknowledgeAlarmRequest => GatewayScopes.InvokeWrite` / `QueryActiveAlarmsRequest => GatewayScopes.EventsRead` and both RPCs still appear in the Scope Catalog rows. `dotnet test ... --filter FullyQualifiedName~GatewayGrpcAuthorizationInterceptorTests` → 14 passed, 0 failed; resolver tests 28 passed, 0 failed.
### Server-018
@@ -559,7 +559,7 @@ closed. New findings filed against this pass: Server-051..053.
**Recommendation:** Rewrite the `IAlarmRpcDispatcher` `<remarks>` block to match the language now used on `WorkerAlarmRpcDispatcher` and on the gRPC service: DI binds `WorkerAlarmRpcDispatcher` by default; `NotWiredAlarmRpcDispatcher` is only the null fallback for tests/DI omission. Drop the "PR A.6 / A.7" prefix from the `<summary>` — the interface is now the public alarm-RPC seam.
**Resolution:** 2026-05-20 — Rewrote `IAlarmRpcDispatcher`'s `<summary>` and `<remarks>` (`src/MxGateway.Server/Sessions/IAlarmRpcDispatcher.cs`) to match the language now used on `WorkerAlarmRpcDispatcher` and on `MxAccessGatewayService.AcknowledgeAlarm` / `QueryActiveAlarms`: dropped the stale "PR A.6 / A.7" prefix from the summary, and replaced the "this PR ships a not-yet-wired default that returns a clear worker-pending diagnostic" clause with the correct statement that DI binds the production `WorkerAlarmRpcDispatcher` by default and `NotWiredAlarmRpcDispatcher` is only the null fallback for DI omission / standalone tests. Pure documentation change; no test.
**Resolution:** 2026-05-20 — Rewrote `IAlarmRpcDispatcher`'s `<summary>` and `<remarks>` (`src/MxGateway.Server/Sessions/IAlarmRpcDispatcher.cs`) to match the language now used on `WorkerAlarmRpcDispatcher` and on `MxAccessGatewayService.AcknowledgeAlarm` / `QueryActiveAlarms`: dropped the stale "PR A.6 / A.7" prefix from the summary, and replaced the "this PR ships a not-yet-wired default that returns a clear worker-pending diagnostic" clause with the correct statement that DI binds the production `WorkerAlarmRpcDispatcher` by default and `NotWiredAlarmRpcDispatcher` is only the null fallback for DI omission / standalone tests. Pure documentation change; no test. Re-verified 2026-08-18 (feat/followups-tickets): not regressed but no longer verifiable in place — `IAlarmRpcDispatcher.cs` was deleted with the rest of the dispatcher trio in `dc9c0c9`. No stale "PR A.6/A.7" or "not-yet-wired" prose survives anywhere in Server source. Nothing to re-fix.
### Server-023
@@ -574,7 +574,7 @@ closed. New findings filed against this pass: Server-051..053.
**Recommendation:** Replace the `<summary>` and `<remarks>` on `NotWiredAlarmRpcDispatcher` with text that matches the language now used on the interface and `WorkerAlarmRpcDispatcher` — "null fallback `IAlarmRpcDispatcher` used when no dispatcher is registered (DI omission / standalone tests); production wires `WorkerAlarmRpcDispatcher`." Either drop the `AcknowledgeAsync` diagnostic string's dev-rig framing entirely or shorten it to "alarm dispatcher is not registered." `#pragma warning disable CS1998` on `QueryActiveAlarmsAsync` is correct here (empty stream is intentional for the null fallback) and should stay.
**Resolution:** 2026-05-20 — Rewrote `NotWiredAlarmRpcDispatcher` summary/remarks as the null-fallback dispatcher and shortened the `AcknowledgeAsync` diagnostic to "Alarm dispatcher is not registered."; updated the two tests that asserted the old "worker"-prefixed diagnostic.
**Resolution:** 2026-05-20 — Rewrote `NotWiredAlarmRpcDispatcher` summary/remarks as the null-fallback dispatcher and shortened the `AcknowledgeAsync` diagnostic to "Alarm dispatcher is not registered."; updated the two tests that asserted the old "worker"-prefixed diagnostic. Re-verified 2026-08-18 (feat/followups-tickets): not regressed but no longer verifiable in place — `NotWiredAlarmRpcDispatcher.cs` and its tests were deleted with the dispatcher trio in `dc9c0c9`. Nothing to re-fix.
### Server-024
@@ -637,7 +637,7 @@ closed. New findings filed against this pass: Server-051..053.
**Recommendation:** Update the `ResolveCommandScope` snippet to include the four bulk-write arms. Update the Constraint Enforcement prose to enumerate the bulk read/write commands that are actually filtered, and reference the per-entry index-ordered merge that `BulkConstraintPlan.MergeDeniedInto` performs. Adding `ReadBulk` to the `InvokeRead` row of the Scope Catalog would also be useful — the table currently lists `Register`/`AddItem`/`Advise` against `InvokeRead` but not `ReadBulk`.
**Resolution:** 2026-05-20 — Updated the `ResolveCommandScope` snippet in `docs/Authorization.md` to enumerate the four bulk-write arms (`WriteBulk`/`Write2Bulk` against `InvokeWrite`, `WriteSecuredBulk`/`WriteSecured2Bulk` against `InvokeSecure`); expanded the Constraint Enforcement prose to list `ReadBulk` and all four bulk-write commands and to call out `BulkConstraintPlan.MergeDeniedInto`'s index-ordered merge; added `ReadBulk` to the `InvokeRead` row of the Scope Catalog.
**Resolution:** 2026-05-20 — Updated the `ResolveCommandScope` snippet in `docs/Authorization.md` to enumerate the four bulk-write arms (`WriteBulk`/`Write2Bulk` against `InvokeWrite`, `WriteSecuredBulk`/`WriteSecured2Bulk` against `InvokeSecure`); expanded the Constraint Enforcement prose to list `ReadBulk` and all four bulk-write commands and to call out `BulkConstraintPlan.MergeDeniedInto`'s index-ordered merge; added `ReadBulk` to the `InvokeRead` row of the Scope Catalog. Re-verified present 2026-08-18 (feat/followups-tickets) — all three corrections still stand in `docs/Authorization.md`: the snippet carries the four bulk-write arms, the Constraint Enforcement prose names `ReadBulk` plus the four bulk-write commands and `BulkConstraintPlan.MergeDeniedInto`, and `ReadBulk` is in the `InvokeRead` catalog row.
### Server-028
@@ -670,7 +670,7 @@ closed. New findings filed against this pass: Server-051..053.
**Recommendation:** Either (a) extend the advertised list with `bulk-read-command` and `bulk-write-commands` (`WriteBulk` / `Write2Bulk` / `WriteSecuredBulk` / `WriteSecured2Bulk` collectively), or (b) document in `gateway.md` and `docs/Contracts.md` that `Capabilities` is informational only and not the contract version. Option (a) is the simplest forward-compatible fix and keeps the capability token shape clients are already familiar with.
**Resolution:** 2026-05-20 — Extended the `OpenSession` capabilities list with `bulk-read-commands` and `bulk-write-commands` alongside the existing `bulk-subscribe-commands` token, so clients that gate on capability strings have an explicit signal for the bulk-read and bulk-write families.
**Resolution:** 2026-05-20 — Extended the `OpenSession` capabilities list with `bulk-read-commands` and `bulk-write-commands` alongside the existing `bulk-subscribe-commands` token, so clients that gate on capability strings have an explicit signal for the bulk-read and bulk-write families. Re-verified present 2026-08-18 (feat/followups-tickets) — `MxAccessGatewayService.OpenSession` still adds all three tokens.
### Server-030
@@ -839,7 +839,7 @@ Add a regression test that advises N items without an active `StreamEvents` cons
**Recommendation:** Before the EventsHub is exercised by Admin-only sessions or session-scoped Viewer roles, gate `SubscribeSession` on a session-access check — either via a per-session role check in the hub method itself, or by storing a per-user allowed-session-id set in the connection's `Context.Items` at connect time and rejecting subscribes outside that set. The current dashboard surfaces only a per-page Session Details view that the page can prove it's authorized for, but as soon as a Viewer role exists the gap matters.
**Resolution:** 2026-05-24 — Documented the v1 acceptance per the prompt's "practical fix for v1" direction. Added a detailed `<remarks>` block to `EventsHub.SubscribeSession` (`src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/EventsHub.cs`) stating that (a) in v1 the hub-level `HubClientsPolicy` only requires one of the dashboard roles (Admin or Viewer) and both may subscribe to any session id, (b) this is acceptable today because the dashboard's per-session views show non-secret session metadata any authenticated user can already see and value logging is gated by the same redaction policy, and (c) the per-session ACL that gates the gRPC `StreamEvents` RPC is intentionally not yet mirrored here. Added an explicit `TODO(per-session-acl)` describing the future enforcement seam — once a role/scope is introduced that scopes a Viewer to a specific session or tenant, add a session-access check at this method (inline on `Context.User` claims/`Context.Items`, or via a dedicated authorization policy applied to the hub method). No code-behavior change in this pass; the per-session ACL data model design is out of scope for the resolution window. No new regression test (the change is documentation-only).
**Resolution:** 2026-05-24 — Documented the v1 acceptance per the prompt's "practical fix for v1" direction. Added a detailed `<remarks>` block to `EventsHub.SubscribeSession` (`src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/EventsHub.cs`) stating that (a) in v1 the hub-level `HubClientsPolicy` only requires one of the dashboard roles (Admin or Viewer) and both may subscribe to any session id, (b) this is acceptable today because the dashboard's per-session views show non-secret session metadata any authenticated user can already see and value logging is gated by the same redaction policy, and (c) the per-session ACL that gates the gRPC `StreamEvents` RPC is intentionally not yet mirrored here. Added an explicit `TODO(per-session-acl)` describing the future enforcement seam — once a role/scope is introduced that scopes a Viewer to a specific session or tenant, add a session-access check at this method (inline on `Context.User` claims/`Context.Items`, or via a dedicated authorization policy applied to the hub method). No code-behavior change in this pass; the per-session ACL data model design is out of scope for the resolution window. No new regression test (the change is documentation-only). Re-verified 2026-08-18 (feat/followups-tickets): the documented v1 gap was subsequently **closed**, not regressed — `EventsHub` now takes `IDashboardSessionAcl` and `SubscribeSession` throws `HubException("Not authorized for this session.")` when `CanViewSession` denies (SEC-25 / TST-15). The `TODO(per-session-acl)` is correctly gone and the `<remarks>` block now describes the shipped tag-scoped gate (admin bypass first, tag intersection, `Dashboard:UntaggedSessionVisibility`, phantom-id denial for non-Admins). Nothing to re-fix; the finding's own recommendation is what shipped.
### Server-039
@@ -869,7 +869,7 @@ Add a regression test that advises N items without an active `StreamEvents` cons
**Recommendation:** Add a one-line comment above the loop explaining the precedence: full DN/CN literal first, leading-RDN fallback second. Mention the case-insensitive map comparer (`OrdinalIgnoreCase`) so the next reader doesn't ask why `"GwAdmin"` matches `"gwadmin"`.
**Resolution:** 2026-05-24 — Added a precedence comment block above the lookup in `MapGroupsToRoles` (`src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticator.cs:156-163`) explaining that the full literal group string is tried first and the leading-RDN value (e.g. `GwAdmin` extracted from `ou=GwAdmin,ou=groups,...`) is the fallback, and back-referencing `DashboardOptions.GroupToRole` as the source of the `OrdinalIgnoreCase` comparer so a maintainer sees why `"GwAdmin"` matches `"gwadmin"`. No code change — existing `DashboardAuthenticatorTests.MapGroupsToRoles_ResolvesByShortNameAndDistinguishedName` already pins both the full-match and RDN-fallback paths and the case-insensitive lookup; pure documentation-only resolution, no new test.
**Resolution:** 2026-05-24 — Added a precedence comment block above the lookup in `MapGroupsToRoles` (`src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticator.cs:156-163`) explaining that the full literal group string is tried first and the leading-RDN value (e.g. `GwAdmin` extracted from `ou=GwAdmin,ou=groups,...`) is the fallback, and back-referencing `DashboardOptions.GroupToRole` as the source of the `OrdinalIgnoreCase` comparer so a maintainer sees why `"GwAdmin"` matches `"gwadmin"`. No code change — existing `DashboardAuthenticatorTests.MapGroupsToRoles_ResolvesByShortNameAndDistinguishedName` already pins both the full-match and RDN-fallback paths and the case-insensitive lookup; pure documentation-only resolution, no new test. Regressed or never applied; re-fixed 2026-08-18 in `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardGroupRoleMapping.cs` (feat/followups-tickets). Chain of events: the lookup moved out of `DashboardAuthenticator` into the shared `DashboardGroupRoleMapping` helper in `792e3f9` (the `IGroupRoleMapper<string>` seam) **with the comment intact**, then `fca978d` ("docs(src): add missing XML docs and strip tracking-ID comments") deleted the entire block rather than just the `(Server-040)` marker inside it. That also took out a second, substantive paragraph added later — the note that the shared `ZB.MOM.WW.Auth.Ldap` provider already strips groups to short RDN names, making the RDN fallback a no-op on the live login path and making a full-DN `GroupToRole` **key** unsupported. Both paragraphs are restored, minus the tracking IDs (that part of the sweep's intent is respected).
### Server-041
@@ -914,7 +914,7 @@ Add a regression test that advises N items without an active `StreamEvents` cons
**Recommendation:** Add a `<remarks>` block to `HubTokenService` noting "Registered as a singleton in `AddGatewayDashboard`; the underlying `ITimeLimitedDataProtector` is thread-safe and shared across hub-token issuance and validation." Optionally add a comment near the DI registration explaining the lifetime contract.
**Resolution:** 2026-05-24 — Added a `<remarks>` block to `HubTokenService` (`src/ZB.MOM.WW.MxGateway.Server/Dashboard/HubTokenService.cs`) documenting that the service is registered as a singleton in `DashboardServiceCollectionExtensions.AddGatewayDashboard` and is shared by two consumer scopes — `DashboardHubConnectionFactory` (scoped, per-circuit; calls `Issue` from the cookie-authenticated dashboard) and `HubTokenAuthenticationHandler` (transient, per-request; calls `Validate` from the SignalR negotiate / connection path). Notes that the underlying `ITimeLimitedDataProtector` is thread-safe so concurrent mint/validate from any number of callers is safe, and explicitly asks future maintainers to preserve the singleton lifetime to keep the protector instance stable. Pure documentation change; no test.
**Resolution:** 2026-05-24 — Added a `<remarks>` block to `HubTokenService` (`src/ZB.MOM.WW.MxGateway.Server/Dashboard/HubTokenService.cs`) documenting that the service is registered as a singleton in `DashboardServiceCollectionExtensions.AddGatewayDashboard` and is shared by two consumer scopes — `DashboardHubConnectionFactory` (scoped, per-circuit; calls `Issue` from the cookie-authenticated dashboard) and `HubTokenAuthenticationHandler` (transient, per-request; calls `Validate` from the SignalR negotiate / connection path). Notes that the underlying `ITimeLimitedDataProtector` is thread-safe so concurrent mint/validate from any number of callers is safe, and explicitly asks future maintainers to preserve the singleton lifetime to keep the protector instance stable. Pure documentation change; no test. Re-verified present 2026-08-18 (feat/followups-tickets) — the `<remarks>` block still names the `AddGatewayDashboard` singleton registration, the two consumer scopes, the thread-safe `ITimeLimitedDataProtector`, and the preserve-the-lifetime request. One consumer was renamed since (the issuing side is now the `/hubs/token` endpoint rather than `DashboardHubConnectionFactory`) and the doc tracked that change correctly.
## Re-review 2026-05-24 (commit 42b0037)
@@ -983,7 +983,7 @@ The user-visible difference: rotating/revoking/deleting a key vs closing/killing
**Recommendation:** Align `ApiKeysPage.ConfirmPendingAsync` with the sessions pages: hold `PendingAction`, set `IsBusy = true`, run the action, then clear `PendingAction` in the `finally`. The current ApiKeysPage shape was inherited from before the dialog existed (when the confirmation was a `confirm()` JS call); the dialog component change can flatten the difference now. As a smaller alternative, document the divergence on the component's XML doc — but the shared component should ideally be used consistently.
**Resolution:** 2026-05-24 — Took the recommended alignment. `ApiKeysPage.ConfirmPendingAsync` (`src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/ApiKeysPage.razor`) now holds `PendingAction` for the duration of the awaited action (so the shared `ConfirmDialog` renders its `IsBusy` in-flight state on the dialog itself, matching the sessions pages) and clears it in `finally` regardless of outcome. The action is captured up front so a clear in `finally` works even when the action throws. `RunManagementActionAsync` continues to drive `IsBusy = true` inside its own `try/finally`, so the dialog now correctly disables Confirm/Cancel while the awaited service call runs. Pure UX-consistency change; no new automated test (no bUnit harness in the test project — same precedent as Server-010).
**Resolution:** 2026-05-24 — Took the recommended alignment. `ApiKeysPage.ConfirmPendingAsync` (`src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/ApiKeysPage.razor`) now holds `PendingAction` for the duration of the awaited action (so the shared `ConfirmDialog` renders its `IsBusy` in-flight state on the dialog itself, matching the sessions pages) and clears it in `finally` regardless of outcome. The action is captured up front so a clear in `finally` works even when the action throws. `RunManagementActionAsync` continues to drive `IsBusy = true` inside its own `try/finally`, so the dialog now correctly disables Confirm/Cancel while the awaited service call runs. Pure UX-consistency change; no new automated test (no bUnit harness in the test project — same precedent as Server-010). Re-verified present 2026-08-18 (feat/followups-tickets) — `ApiKeysPage.ConfirmPendingAsync` still captures the action up front, awaits inside `try`, and clears `PendingAction` in `finally`; the explanatory comment survives.
### Server-048
@@ -1017,7 +1017,7 @@ The user-visible difference: rotating/revoking/deleting a key vs closing/killing
**Recommendation:** Add `<summary>` blocks to `IDashboardSessionAdminService.CanManage` (states the Admin-role gate), `CloseSessionAsync` and `KillWorkerAsync` (state that missing sessions return `DashboardSessionAdminResult.Fail(...)` rather than throwing, and that the audit log captures actor + remote IP). Add `<param>` and `<returns>` for the request/response shape. The same sweep can pick up the longstanding gap on `IDashboardApiKeyManagementService` if the team wants — but the new file is the load-bearing one.
**Resolution:** 2026-05-24 — Added `<summary>` + `<remarks>` blocks to every member of `IDashboardSessionAdminService` (`src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardSessionAdminService.cs`): an interface-level `<remarks>` describing the Admin-role gate, audit log shape, and `DashboardSessionAdminResult.Fail` semantics; per-member docs on `CanManage`, `CloseSessionAsync`, and `KillWorkerAsync` calling out the missing-session-returns-Fail contract and the `dashboard-admin-kill` reason constant that reaches the worker-kill audit log and `mxgateway.workers.killed` counter tag. `DashboardSessionAdminService` (`src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSessionAdminService.cs`) picked up a class-level `<summary>` + `<remarks>` describing the per-page audit-log seam, plus `<inheritdoc />` on each public method. Pure documentation change; no test (the behavioral contracts the docs describe are already exercised by the existing `DashboardSessionAdminServiceTests` cases).
**Resolution:** 2026-05-24 — Added `<summary>` + `<remarks>` blocks to every member of `IDashboardSessionAdminService` (`src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardSessionAdminService.cs`): an interface-level `<remarks>` describing the Admin-role gate, audit log shape, and `DashboardSessionAdminResult.Fail` semantics; per-member docs on `CanManage`, `CloseSessionAsync`, and `KillWorkerAsync` calling out the missing-session-returns-Fail contract and the `dashboard-admin-kill` reason constant that reaches the worker-kill audit log and `mxgateway.workers.killed` counter tag. `DashboardSessionAdminService` (`src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSessionAdminService.cs`) picked up a class-level `<summary>` + `<remarks>` describing the per-page audit-log seam, plus `<inheritdoc />` on each public method. Pure documentation change; no test (the behavioral contracts the docs describe are already exercised by the existing `DashboardSessionAdminServiceTests` cases). Re-verified present 2026-08-18 (feat/followups-tickets) — the interface-level `<remarks>` and all three per-member blocks are intact, including the `dashboard-admin-kill` reason constant and the missing-session-returns-`Fail` contract.
### Server-050
@@ -1078,7 +1078,7 @@ catch (Exception ex) { _logger.LogWarning(ex, "...continuing with configuration-
**Recommendation:** For (1), align the `IAlarmWatchListResolver` doc with whatever Server-051 settles on. For (2), either restrict the exclude to GR-discovered rows (apply `RemoveAll` before appending the `IncludeAttributes` entries) or update the option XML doc and `GatewayConfiguration.md` to say excludes are applied to the merged GR-plus-include list and therefore also suppress matching explicit includes.
**Resolution:** Resolved 2026-06-15. (1) No longer over-promises: the Server-051 fix makes the implementation propagate `OperationCanceledException`, so the `IAlarmWatchListResolver.ResolveAsync` `<returns>` doc is now accurate and was left unchanged. (2) Kept the "excludes win" code behaviour (excludes applied to the merged GR-plus-include list) and corrected the prose to match: `AlarmDiscoveryOptions.ExcludeAttributes` XML doc and `docs/GatewayConfiguration.md:247` now state the exclude runs after the GR rows and explicit `IncludeAttributes` are combined, so an exclude matching an explicit include suppresses it too. The "excludes win" precedence is pinned by `AlarmWatchListResolverTests.ResolveAsync_ExcludeAlsoSuppressesMatchingExplicitInclude`.
**Resolution:** Resolved 2026-06-15. (1) No longer over-promises: the Server-051 fix makes the implementation propagate `OperationCanceledException`, so the `IAlarmWatchListResolver.ResolveAsync` `<returns>` doc is now accurate and was left unchanged. (2) Kept the "excludes win" code behaviour (excludes applied to the merged GR-plus-include list) and corrected the prose to match: `AlarmDiscoveryOptions.ExcludeAttributes` XML doc and `docs/GatewayConfiguration.md:247` now state the exclude runs after the GR rows and explicit `IncludeAttributes` are combined, so an exclude matching an explicit include suppresses it too. The "excludes win" precedence is pinned by `AlarmWatchListResolverTests.ResolveAsync_ExcludeAlsoSuppressesMatchingExplicitInclude`. Re-verified present 2026-08-18 (feat/followups-tickets) — both doc corrections stand: `IAlarmWatchListResolver.ResolveAsync`'s `<returns>` still documents cancellation propagation (and the implementation still honors it), and the "excludes win over explicit includes" wording is present on `AlarmDiscoveryOptions.ExcludeAttributes` (now in `Configuration/AlarmFallbackOptions.cs`) and in the `docs/GatewayConfiguration.md` option table.
### Server-053
@@ -1113,7 +1113,7 @@ Additionally, `GatewayAlarmMonitor.ApplyProviderModeChangeAsync` increments the
**Recommendation:** Update both `DesignDecisions.md` sections and the revisit list to describe the shipped behavior (gated by `AllowMultipleEventSubscribers`, `DetachGraceSeconds`, replay options), and amend the CLAUDE.md convention bullet.
**Resolution:** 2026-06-16: updated `docs/DesignDecisions.md` (Session Reconnect section rewritten to describe the shipped detach-grace + replay-on-reconnect behavior with config references; Event Subscribers section rewritten to describe the config-gated multi-subscriber fan-out, mode-dependent `FailFast` semantics, and internal vs external subscriber distinction; Later Revisit Items list removes the two shipped items and records them as shipped with config cross-references) and the `CLAUDE.md` conventions bullet to describe the shipped config-gated multi-subscriber + reconnect-replay behavior while preserving the one-worker-per-session invariant.
**Resolution:** 2026-06-16: updated `docs/DesignDecisions.md` (Session Reconnect section rewritten to describe the shipped detach-grace + replay-on-reconnect behavior with config references; Event Subscribers section rewritten to describe the config-gated multi-subscriber fan-out, mode-dependent `FailFast` semantics, and internal vs external subscriber distinction; Later Revisit Items list removes the two shipped items and records them as shipped with config cross-references) and the `CLAUDE.md` conventions bullet to describe the shipped config-gated multi-subscriber + reconnect-replay behavior while preserving the one-worker-per-session invariant. Re-verified present 2026-08-18 (feat/followups-tickets) — `docs/DesignDecisions.md` still records the "no reconnectable sessions" constraint as superseded and documents `DetachGraceSeconds` / `AllowMultipleEventSubscribers` / `ReplayBufferCapacity`, the Later Revisit Items list still cross-references them as shipped, and the `CLAUDE.md` bullet still carries the config-gated wording.
### Server-055
@@ -1158,7 +1158,7 @@ Additionally, `GatewayAlarmMonitor.ApplyProviderModeChangeAsync` increments the
**Recommendation:** Either (a) extend `NormalizeOutboundCommand` and the `MapCommand` tracking path to normalize each `AddItemBulk.TagAddresses` entry (and `AddBufferedItem.ItemDefinition`) the same `IsArray`-gated way, keeping the constraint check, the worker bind, and the stored `SessionItemRegistration.TagAddress` consistent; or (b) if bulk-add normalization is intentionally out of scope for this feature, state that explicitly in `gateway.md` and the client READMEs (alongside the existing `ReadBulk` carve-out) so clients know bulk-added array handles must carry the `[]` suffix themselves to be writable.
**Resolution:** 2026-06-18 — Took option (a). Root cause confirmed: `NormalizeOutboundCommand` had no `AddItemBulk`/`AddBufferedItem` case, so the worker bound bare (non-write-capable) array handles for those paths while single-add was already fixed. Added `AddItemBulk` (normalizes each `TagAddresses` entry in place) and `AddBufferedItem` (normalizes `ItemDefinition`) cases to `NormalizeOutboundCommand`; added the matching `AddBufferedItem` normalization to the `TrackCommandReply`/`MapCommand` tracking path (its registration keys off the command's `ItemDefinition`). `AddItemBulk` tracking needs no change — the worker echoes the already-suffixed address back in each `SubscribeResult.TagAddress`, which `TrackBulkItems` stores. Authz is unchanged and consistent: `FilterTagBulkAsync` checks the bare address through `ConstraintEnforcer.ResolveTarget`'s `[]` fallback, mirroring single-add. Updated `gateway.md` and all five client READMEs (dotnet/go/python/rust/java) so the add-family normalization no longer reads as AddItem-only; the `ReadBulk` carve-out stays. Regression tests: `GatewayArrayWriteWiringTests.AddItemBulk_BareArrayAddress_NormalizedOnWireAndInRegistration`, `.AddBufferedItem_BareArrayAddress_NormalizedOnWireAndInRegistration`.
**Resolution:** 2026-06-18 — Took option (a). Root cause confirmed: `NormalizeOutboundCommand` had no `AddItemBulk`/`AddBufferedItem` case, so the worker bound bare (non-write-capable) array handles for those paths while single-add was already fixed. Added `AddItemBulk` (normalizes each `TagAddresses` entry in place) and `AddBufferedItem` (normalizes `ItemDefinition`) cases to `NormalizeOutboundCommand`; added the matching `AddBufferedItem` normalization to the `TrackCommandReply`/`MapCommand` tracking path (its registration keys off the command's `ItemDefinition`). `AddItemBulk` tracking needs no change — the worker echoes the already-suffixed address back in each `SubscribeResult.TagAddress`, which `TrackBulkItems` stores. Authz is unchanged and consistent: `FilterTagBulkAsync` checks the bare address through `ConstraintEnforcer.ResolveTarget`'s `[]` fallback, mirroring single-add. Updated `gateway.md` and all five client READMEs (dotnet/go/python/rust/java) so the add-family normalization no longer reads as AddItem-only; the `ReadBulk` carve-out stays. Regression tests: `GatewayArrayWriteWiringTests.AddItemBulk_BareArrayAddress_NormalizedOnWireAndInRegistration`, `.AddBufferedItem_BareArrayAddress_NormalizedOnWireAndInRegistration`. Re-verified present 2026-08-18 (feat/followups-tickets) — doc sub-claim only; `gateway.md` still describes normalization across the whole add family (`AddItem`/`AddItem2`/`AddItemBulk`/`AddBufferedItem`) and all five client READMEs still name the bulk and buffered members (Python in its snake_case form: `add_item_bulk` / `add_buffered_item`).
### Server-058
+11
View File
@@ -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.
+331 -32
View File
@@ -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; 2026-08-18 (third attempt) identifies the reason as the test attribute's `MxSecurityOperate` classification, which no non-interactive ack surface on the rig can satisfy |
| `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" and diagnosed in "Third 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,319 @@ 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 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
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. (Answered by the Third attempt
below — enforced by the alarm attribute's `MxSecurityOperate` security classification, an
engine-level write-security setting, not a separate `alarmmgr`-side ack policy.)
- 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.
## Third attempt (2026-08-18): why the acknowledge is refused
The second attempt left two candidate explanations for `AlarmAckByName` returning `rc=0`
and changing nothing: the rig enforces an acknowledgement security requirement the wnwrap
consumer cannot meet, or wnwrap's ack is simply broken here. This attempt was read-only —
no writes, no alarms raised, no configuration touched — and settles the first question:
the requirement is **enforced**, and the alarm attribute's security classification is what
enforces it.
### Method
Read-only inspection of the `ZB` Galaxy Repository over `sqlcmd -S localhost -d ZB -E`,
plus the already-built `mxa` CLI (`C:\Users\dohertj2\Desktop\wwtools\mxaccesscli\src\MxAccess.Cli\bin\x86\Release\net48\mxa.exe`)
for runtime reads. Nothing was written and nothing was installed. The `lmxopcua\gr` schema
notes referenced elsewhere in this repo are **not present on this box** (`Test-Path` is
`False`), so the schema was located by querying `sys.tables` / `sys.columns` directly.
### The test attribute is classified `MxSecurityOperate`
UDA security classification lives in `dynamic_attribute.security_classification`, keyed by
`gobject_id`. For the `$TestMachine` template that is `1055`:
```
SELECT gobject_id, tag_name, hierarchical_name FROM gobject WHERE gobject_id=1055;
1055|$TestMachine|$TestMachine
```
```
attribute_name | security_classification | mx_attribute_category
ProtectedValue | 2 | 10
ProtectedValue1 | 3 | 10
TestAlarm001 | 1 | 10
TestAlarm002 | 1 | 10
TestAlarm003 | 1 | 10
TestChangingInt | 1 | 10
```
The enum is pinned by two independent sources rather than assumed. `ProtectedValue` and
`ProtectedValue1` are the mxaccess analysis project's documented *secured-write* and
*verified-write* fixtures (`C:\Users\dohertj2\Desktop\mxaccess\docs\galaxy-test-fixtures.md`),
and `docs/NMX-COM-Contracts.md` in the same project records "Galaxy security classification
(`2` for `SecuredWrite`, `3` for VerifiedWrite)" — so `2` and `3` land on exactly the two
attributes that are supposed to carry them. `galaxy-test-fixtures.md` also records the
provisioning verb used for every UDA in that inventory: `--security MxSecurityOperate`.
`TestAlarm001` therefore reads `1` = **`MxSecurityOperate`**.
That is the missing piece from 2026-08-17. `Operate` is not free access: it requires an
authenticated galaxy identity holding Operate permission on the object's security group.
An unauthenticated `Write` is refused with `SecurityError` `1008`, and the same write after
`AuthenticateUser` succeeds — which is precisely the pair of results both prior attempts
recorded. Galaxy security is live on this rig, not disabled.
`wwAlarmConsumerClass.AlarmAckByName` carries an operator *name* string and a comment. It
carries no authenticated user id, no credential, and no token — there is no parameter on
either the 6-arg or the 8-arg overload that could convey one. A consumer calling it cannot
satisfy an `Operate` classification, and `rc=0` followed by no state change is what an ack
dropped downstream of an accepted call looks like.
The one step this stops short of is a direct experiment: the rig's security configuration
is out of scope for a read-only probe, so "an `Operate` alarm cannot be acknowledged
without an authenticated identity" is inferred from the classification plus the observed
`1008`/`rc=0` pattern rather than observed by relaxing the classification and watching the
ack start working. The human step below is exactly that experiment.
### There is no writeable `.Ack` attribute — the second attempt targeted the only one there is
A plausible reading of the second attempt was that it wrote the wrong sub-attribute:
ArchestrA alarm extensions are commonly described as exposing a writeable `.Ack` alongside
the read-only `.Acked`. On this galaxy they do not. Every `Ack`-named attribute the alarm
primitives define:
```
attribute_name | security_classification | mx_attribute_category
Acked | -1 | 2
AckMsg | 0 | 6
Bad.Acked | -1 | 2
Bad.AckMsg | 0 | 6
TimeAlarmAcked | -1 | 2
AlarmAckCnt | -1 | 2
AlarmAckErrorsCnt | -1 | 2
AlarmMostUrgentAcked | -1 | 2
AlarmUnAckedCnt | -1 | 2
```
No `Ack`. `Acked` carries `security_classification = -1` — no classification at all, which
is what a non-writeable attribute carries, as distinct from the `1` on the writeable
`TestAlarm001`. That is the configuration-side counterpart of the `detail=1007`
operational refusal the second attempt got when it wrote `.Acked`: not a permission
refusal, but an attribute that has no write path to refuse. The second attempt had already
found the only MXAccess-side candidate, and it is read-only by definition.
### No non-interactive acknowledge surface is installed
The `wwtools` collection on the box (`aalogcli`, `aot`, `graccesscli`, `grdb`, `histdb`,
`mbproxy`, `mxaccesscli`, `secrets`) is the most likely home for a scriptable ack. There
isn't one. `mxa --help` lists `diag`, `info`, `read`, `read-batch`, `subscribe`,
`subscribe-batch`, `write`, `write-batch` — a tag data-plane only, with no alarm surface.
`graccesscli` is a Galaxy Repository configuration tool (`object uda add`, `instance
deploy`), which acts at configure/deploy time and not on live alarm state. Reading the
galaxy's own authentication mode at runtime is also unavailable: `Galaxy.AuthenticationMode`
does not resolve over MXAccess (`Category=4 Detail=6`), and the value is not in
`dynamic_attribute` — only the attribute *definition* names `AuthenticationMode` and
`_AuthenticationModeEnum` exist in `attribute_definition`.
So every remaining acknowledge surface on this rig is interactive: the IDE's alarm client,
InTouch, or an ArchestrA graphic bound to the alarm. Driving those is out of scope.
### Status of the acknowledge leg
**Unavailable by configuration, and the GUID question stays assumed.** The two are separate
statements and both matter:
- The wnwrap ack path is unavailable on this rig as configured, for an identified reason
rather than an unknown one. This is a real finding for the gateway: `AcknowledgeByName`
will behave the same way — accepted, inert — on any customer galaxy whose alarmed
attributes carry a non-free-access security classification (inferred from the mechanism —
no `AlarmAckByName` overload can carry a credential — not confirmed by relaxing the
classification and re-testing; see "What a human would need to do"). It is worth noting in
the alarm client's documentation that a silent `rc=0` is not proof of acknowledgement.
- Whether wnwrap re-mints the record GUID on `UNACK_ALM → ACK_ALM` is still unobserved, and
after three attempts it stays assumed. As the second attempt already noted, this remains
a documentation gap rather than a correctness one: a re-minted GUID produces a spurious
Clear plus a spurious Raise, the same shape the observed re-raise behaviour produces, and
`ComputeTransitions` already handles that correctly as two instances.
#### What a human would need to do
Either of these answers it; the second is cheaper and also confirms or refutes the
classification hypothesis above, which the read-only probe could only infer.
1. Acknowledge `TestMachine_001.TestAlarm001` from an interactive System Platform client
(IDE alarm client, InTouch, or an ArchestrA graphic) while a wnwrap probe polls
`GetXmlCurrentAlarms2` against `\\DESKTOP-6JL3KKO\Galaxy!TestArea`, and record whether
`STATE` reaches `ACK_ALM` and whether `GUID` survives the transition. Raise the alarm
first with `AuthenticateUser` + `WriteSecured` as the second attempt did.
2. Reclassify `TestAlarm001` on the `$TestMachine` template to free access and redeploy —
`graccesscli object uda ... --security MxSecurityFreeAccess` against `$TestMachine`,
then `instance deploy TestMachine_001` — and re-run the second attempt's ack probe
unchanged. If `AlarmAckByName` then moves `STATE` to `ACK_ALM`, the classification is
confirmed as the blocker and the GUID question is answered in the same run. Restore the
classification to `MxSecurityOperate` afterwards, since the secured-write fixtures in
`WorkerLiveMxAccessSmokeTests` depend on the alarm UDAs being secured.
### Rig state left behind
The third attempt changed nothing. `TestMachine_001.TestAlarm001` and its `.Acked` subtag
both read `false` at the end of the session, matching the state the second attempt left.
The second attempt's inventory, unchanged since: 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.
+14 -3
View File
@@ -175,6 +175,17 @@ optional serialized constraints, and the `created_utc`, `last_used_utc`,
belong to `ZB.MOM.WW.Auth.ApiKeys`, this document does not restate their column
readers or SQL; consult the library for that detail.
One connection-level behavior is worth stating here because it is what keeps the
store usable under concurrency rather than an implementation detail of the schema:
the library's `AuthSqliteConnectionFactory.OpenConnectionAsync` opens pooled
connections with a non-zero command timeout and applies `PRAGMA journal_mode=WAL`
and `PRAGMA busy_timeout` (5 s). Last-used stamping runs on every authenticated
request and the audit store appends on every denial, so without WAL and a busy
timeout those concurrent writers would surface `SQLITE_BUSY` as a hard failure on
the request path instead of degrading gracefully. WAL is a persistent
database-level setting, so re-applying it per connection is a cheap no-op;
`busy_timeout` is per-connection state and must be set each time.
### Audit trail
The library emits its own API-key audit entries (from the admin verbs — create,
@@ -260,9 +271,9 @@ 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 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 ops.alice --display-name "Alice (ops)" --scopes session:open,invoke:read,invoke:write
mxgateway apikey create-key --key-id area1.reader --display-name "Area 1 reader" --scopes session:open,invoke:read,metadata:read --read-subtree "Area1/*" --browse-subtree "Area1/*"
mxgateway apikey create-key --key-id ops.temp --display-name "Temp contractor" --scopes session:open,invoke:read --expires 90d
mxgateway apikey create-key --key-id team-a.svc --display-name "Team A service" --scopes session:open,invoke:read --dashboard-tags team-a
mxgateway apikey create-key --key-id ops.audit --display-name "Audit window" --scopes metadata:read --expires 2027-01-01T00:00:00Z
mxgateway apikey list-keys --json
+11 -2
View File
@@ -195,8 +195,17 @@ 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 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
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
+2 -1
View File
@@ -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
+26 -8
View File
@@ -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
@@ -226,19 +226,37 @@ 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
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.
+3 -3
View File
@@ -192,9 +192,9 @@ 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: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: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 must be **short** LDAP group names — the CN / first-RDN value, e.g. `GwAdmin`, not `ou=GwAdmin,ou=groups,dc=zb,dc=local` — matched case-insensitively. The shared `ZB.MOM.WW.Auth.Ldap` provider delivers a user's groups already stripped to short names, so a full-DN key can never match and the group silently maps to nothing. (The mapper does try the full literal string before falling back to the leading-RDN value, but on the live login path both branches see the same short name; the fallback only matters to non-library callers of the `IGroupRoleMapper<string>` seam.) 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 LDAP group names (CN / first-RDN value), matched case-insensitively; a full-DN key never matches the pre-stripped groups the LDAP provider returns. Values are tag lists. A dashboard user's granted tag set is the union over the groups they belong to; an unmapped group contributes nothing. **Visibility only:** tags scope which sessions' event streams a Viewer may observe on the dashboard — they never grant or deny data access, which stays with the API key's scopes and constraints. Independent of `GroupToRole`: a group may appear in either map, both, or neither. Empty (the default) means Viewers hold no tags, so under the default `UntaggedSessionVisibility` they observe no session's events. |
| `MxGateway:Dashboard:UntaggedSessionVisibility` | `AdminOnly` | Who may observe a session that carries no tags (its owning API key declared none). `AdminOnly` (default, fail-closed) restricts untagged sessions to dashboard Administrators. `AllViewers` shows them to every Viewer — opt-in for a single-tenant deployment that wants the pre-tag behaviour. Administrators always see every session regardless of tags. |
| `MxGateway:Dashboard:DisableLogin` | `false` | Dev/test only. When `true`, replaces the cookie authentication handler with `DashboardAutoLoginAuthenticationHandler`, which auto-authenticates every dashboard request — including requests from remote browsers, not just loopback — as `AutoLoginUser` holding both `Administrator` and `Viewer` roles. No login form, LDAP bind, or cookie is involved. A loud one-time startup warning is logged. Differs from `AllowAnonymousLocalhost`: `DisableLogin` mints a real authenticated principal (so role-gated write affordances appear), whereas `AllowAnonymousLocalhost` satisfies the authorization requirement on loopback only without minting a principal (write affordances stay hidden). Never enable in production. **Production hard-stop (SEC-04):** when the host runs in the `Production` environment and `DisableLogin` is `true`, startup validation fails and the process aborts — the flag is only accepted outside Production, where the one-time startup warning still fires. |
| `MxGateway:Dashboard:AutoLoginUser` | `(null)` | Username stamped on the synthetic principal when `DisableLogin` is `true`. Default `(null)` — a null or blank value falls back to `multi-role`. Has no effect when `DisableLogin` is `false`. |
+41 -18
View File
@@ -186,7 +186,7 @@ hubs stay for the audience that genuinely needs a wire.
|---|---|---|
| every page deriving from `DashboardPageBase` | `IDashboardSnapshotFeed.WatchAsync` | `DashboardSnapshotFeed` (singleton) multicasting one `IDashboardSnapshotService.WatchSnapshotsAsync` enumeration |
| `SessionDetailsPage` | `IDashboardSessionEventSubscriber.Subscribe(sessionId)` | `DashboardEventBroadcaster` — the same singleton the session mirror publishes to, registered behind both interfaces |
| `AlarmsPage` | `IGatewayAlarmService.StreamAsync` | the central alarm monitor, **provider status only**; the alarm rows still come from the 3 s `QueryAlarmsAsync` poll |
| `AlarmsPage` | `IGatewayAlarmService.StreamAsync` | the central alarm monitor, **gateway-status frames only**`provider_status` for the badge and `snapshot_status` for the truncated-snapshot banner; the alarm rows still come from the 3 s `QueryAlarmsAsync` poll |
The snapshot feed multicasts rather than handing each page its own enumeration:
`WatchSnapshotsAsync` is not multicast on its own — each enumeration owns a timer
@@ -245,8 +245,9 @@ cancelling, a detach-driven exit leaves the pill to the incoming subscription; w
the pill therefore reports is the case it exists for — the channel completing under
a page that is still watching.
`AlarmsPage` owns two loops of its own (the 3 s alarm poll and the provider-status
badge) and bounds their drain at 5 seconds on dispose, for the same reason
`AlarmsPage` owns two loops of its own (the 3 s alarm poll and the status feed that
drives the provider badge and the truncated-snapshot banner) and bounds their drain
at 5 seconds on dispose, for the same reason
`DashboardPageBase` bounds its watch drain: both loops render through the renderer's
dispatcher, and disposal can run on it. The two are drained concurrently, so the
bound on disposal is 5 seconds in total rather than per loop — a wedged dispatcher
@@ -273,7 +274,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` / `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
@@ -285,11 +286,18 @@ Both seams consume the same producing services, so they share these cadences:
- alarm publisher emits on each transition observed by the central monitor;
- event publisher emits per event fanned by the session's `SessionEventDistributor`
to its internal dashboard-mirror subscriber (independent of any gRPC `StreamEvents`);
- the alarms page's provider-status badge resubscribes one second after its
- the alarms page's status feed resubscribes one second after its
`IGatewayAlarmService.StreamAsync` enumeration ends — the monitor completes a
subscriber's stream when it falls behind and again when it restarts, both
recoverable by resubscribing — and holds its last value in between. The page's
alarm rows are independent of that stream and refresh on the 3 s poll.
subscriber's stream only when that subscriber has fallen behind, which resubscribing
recovers (a monitor restart keeps the channel and pushes cleared status frames through
it) — and the badge and banner hold their last values in between. That feed carries both gateway-status frames: `provider_status` drives the
badge, and `snapshot_status` drives the truncated-snapshot banner, so the caveat
appears on the monitor's verdict change rather than up to three seconds later. Every
subscriber is primed with a `snapshot_status` frame at open, so a page attaching
mid-truncation needs no priming logic of its own. The page's alarm rows are
independent of that stream and refresh on the 3 s poll, which also re-asserts the
truncation verdict as its reconcile baseline — both sources read the same monitor
verdict, and neither is synthesized page-side.
### Idle gating and snapshot cost
@@ -517,12 +525,17 @@ 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 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
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
@@ -535,8 +548,11 @@ alarm-history store, so the page reflects only the live active set. The page is
read-only; it does not acknowledge alarms. A provider-status badge tracks the
central monitor's health from `IGatewayAlarmService.StreamAsync` in process — the
alarm service is already a multi-subscriber fan-out, so the badge needs no SignalR
client, no loopback socket, and no hub token — while the alarm rows themselves
still come from the three-second poll. If `MxGateway:Alarms:Enabled` is
client, no loopback socket, and no hub token — and the same subscription carries the
`snapshot_status` frame behind the truncated-snapshot banner ("Alarm snapshot may be
incomplete"), so a capped provider fetch is caveated the moment the monitor decides
it. The alarm rows themselves still come from the three-second poll, which also
re-asserts the truncation verdict as the reconcile baseline. If `MxGateway:Alarms:Enabled` is
false the central monitor never starts, and the page says so instead of showing
an empty list with no explanation.
@@ -649,6 +665,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.
@@ -669,8 +690,10 @@ Implemented behavior:
- a static `/login` HTML form posts username/password to the gateway;
- `DashboardAuthenticator` binds against `MxGateway:Ldap` (service-account bind,
user search, candidate bind) using `Novell.Directory.Ldap.NETStandard`;
- the user's `memberOf` (or short CN) is matched against
`MxGateway:Dashboard:GroupToRole`; the resolved role(s) are emitted as
- the user's groups arrive from the LDAP provider already stripped to short
names (the CN / first-RDN value of each `memberOf` entry) and are matched
against the short-name keys of `MxGateway:Dashboard:GroupToRole` — a full-DN
key there never matches; the resolved role(s) are emitted as
`ClaimTypes.Role` claims, alongside the per-group `mxgateway:ldap_group`
claims;
- a successful login signs in the `MxGateway.Dashboard` cookie scheme
+5
View File
@@ -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
+38 -5
View File
@@ -92,9 +92,11 @@ An accepted gRPC command payload can still be too large for the worker pipe: the
`AcknowledgeAlarm` is a unary, **session-less** RPC that acknowledges a single alarm. The handler validates `alarm_full_reference` inline (it does not run through `MxAccessGrpcRequestValidator`) and delegates to `IGatewayAlarmService.AcknowledgeAsync`. The always-on `GatewayAlarmMonitor` routes the ack over its own gateway-managed worker session — clients no longer open a session to acknowledge an alarm. A reference that parses as a canonical GUID forwards to `AcknowledgeAlarmCommand`; a `Provider!Group.Tag` reference forwards to `AcknowledgeAlarmByNameCommand`.
An `OK` response means the alarm provider accepted the acknowledgement, not that it applied it: the gateway forwards the ack and reports what the provider returned, in keeping with the parity rule. On galaxies whose alarmed attributes carry a non-free-access security classification the by-name ack is accepted and inert — the underlying `AlarmAckByName` conveys an operator name rather than an authenticated identity, observed on the probe rig with the mechanism inferred rather than confirmed — so a client that needs to confirm an acknowledgement should watch for the resulting transition on `StreamAlarms` instead of treating `OK` as proof. See [Alarm Probe Findings](./AlarmProbeFindings.md).
### `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`
@@ -114,10 +116,41 @@ 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
`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:
+8 -5
View File
@@ -69,8 +69,8 @@ a .NET Framework or COM interop build needs classic Visual Studio MSBuild.
| Tool | Version | Path |
| --- | --- | --- |
| Go | 1.26.2 windows/amd64 | `C:\Program Files\Go\bin\go.exe` |
| protoc-gen-go | latest installed by `go install` | `C:\Users\dohertj2\go\bin\protoc-gen-go.exe` |
| protoc-gen-go-grpc | latest installed by `go install` | `C:\Users\dohertj2\go\bin\protoc-gen-go-grpc.exe` |
| protoc-gen-go | v1.36.11 (pinned) | `C:\Users\dohertj2\go\bin\protoc-gen-go.exe` |
| protoc-gen-go-grpc | 1.6.2 (pinned) | `C:\Users\dohertj2\go\bin\protoc-gen-go-grpc.exe` |
Environment:
@@ -80,11 +80,14 @@ GOPATH=C:\Users\dohertj2\go
Go plugin bin=C:\Users\dohertj2\go\bin
```
Installed plugin commands:
Installed plugin commands. Install the pinned versions, not `@latest`: the committed Go
bindings stamp the plugin version in their headers, so `clients/go/generate-proto.ps1`
throws when a plugin reports anything else. That script is the source of truth for the
pin — change it there first, then update this table.
```powershell
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.11
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.2
```
## Rust
+38 -16
View File
@@ -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`.
---
+532
View File
@@ -0,0 +1,532 @@
# 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 <sha/branch>`.
Commit with explicit **pathspecs on the commit itself**:
`git commit -m "..." -- <paths>` — 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<string, string[]>` 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
`<ul><li><code>group</code> → tag1, tag2</li></ul>` 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 12)
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` 12 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.
---
## 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 — **all closed 2026-08-18 on `feat/followups-tickets`**
(plan `docs/plans/2026-08-18-followups-and-tickets.md`; per-bullet closing commits below):
- `WorkerPipeSessionTests.RunAsync_LongInFlightCommandThatKeepsPumping_DoesNotFaultAndDeliversReply`
fails deterministically on windev **and on `main`** — pre-existing, needs its own
investigation. *Closed (`7da52b6`, `462850a`, `aaeb86b`): test-harness defect — the fake
runtime session stamped STA activity only at construction, so the watchdog correctly
faulted `StaHung` pre-dispatch. Test-only fix; windev worker suite fully green (524/11
skipped) for the first time.*
- `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. *Closed (`c94c4d4`, plus
`8ae0c2f` for a second Windows-only blocker found during verification: PS 5.1 strips
embedded double quotes from the Python probe's `-c` argument). Check 4 verified 4/4 on
windev under both PowerShell 5.1 and pwsh 7.*
- Windev has `protoc-gen-go-grpc` 1.6.1 against the repo's pinned 1.6.2. *Closed: 1.6.2
installed on windev; `docs/ToolchainLinks.md` corrected from `@latest` to the pinned
install commands (`6b5c737`).*
- `clients/java`'s `checkGeneratedClean` is dead under Gradle 9 (`Project.exec` was
removed); it needs `ExecOperations` injection to work again. *Closed (`df45cb4`) via
`ProviderFactory.exec`; verified on Gradle 9.5.1 (macOS) and 9.4.1 (windev), including
a configuration-cache ordering proof.*
- `SettingsPage` renders every `EffectiveDashboardConfiguration` member except
`RecentFaultLimit` / `RecentSessionLimit` (pre-existing, predates this branch).
*Closed (`a390fe1`, test tightened in `fb68bdb`).*
- The ack-leg probe stays blocked; unblock paths are in `docs/AlarmProbeFindings.md`.
*Closed as answered-why (`d1ae43d`, `bc22792`, `1605f54`): the ack is unavailable by
configuration — the test attributes carry `MxSecurityOperate` and no `AlarmAckByName`
overload can carry a credential (inferred, caveated). The GUID-across-ack question
itself stays assumed; the remaining paths need a human at an interactive client.*
- The dashboard `AlarmsPage` truncation banner is still poll-driven — it could consume
the new `snapshot_status` feed frame instead. *Closed (`7b6dfba`, `f57a6ae`): the
page's status feed loop now consumes `snapshot_status`; the poll stays as reconcile
baseline.*
- **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. *Closed (`d3ac527`): 20
doc-only resolutions audited; two more regressions found and re-fixed (Server-040,
Server-009); four moot (target files deleted); annotations recorded in findings.md.*
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.
@@ -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": "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": "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"},
{"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", "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"
}
@@ -0,0 +1,526 @@
# Follow-ups and Tickets Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development
> to execute this plan task-by-task in this session.
**Goal:** Close every follow-up recorded in `docs/plans/2026-08-17-followup-closeout.md`
(as-built notes, "Follow-ups recorded, not started") plus the two ticket-worthy windev
findings — leaving no recorded open item anywhere.
**Architecture:** No new components. Ten independent closures: two toolchain/script fixes
(Check 4 Windows pin, Gradle 9 `checkGeneratedClean`), two dashboard display gaps
(Settings recent-limit rows, push-driven Alarms truncation banner), two documentation
audits (Server-0xx resolution regression sweep, `Authentication.md` runnable-examples
pass), one worker-test investigation (the deterministic `WorkerPipeSessionTests` failure
on windev), one bounded rig probe (ack-leg unblock paths), a windev verification pass
(including the `protoc-gen-go-grpc` 1.6.2 pin install), and bookkeeping.
**Tech Stack:** PowerShell (codegen scripts), Gradle/Groovy (Java client), Blazor
server-side Razor + xUnit/bUnit-style render tests (dashboard), .NET Framework 4.8 x86
xUnit (worker tests, windev-only), wnwrap probe harness (windev rig), Markdown.
**Branch:** `feat/followups-tickets` off `main` (`45058d5`).
---
## Ground rules for every implementer subagent
- **Git discipline (Mac tree):** NEVER `git stash`, `git reset`, `git clean`, or
`git checkout <sha/branch>`. Commit with **pathspecs on the commit**:
`git commit -m "..." -- <paths>`. If `index.lock` blocks you, wait 5 s and retry.
- **Build/test lock:** before any `dotnet build`/`dotnet test`/`cargo`/`gradle`/`go test`
on the Mac, acquire the lock:
`mkdir /private/tmp/claude-501/-Users-dohertj2-Desktop-MxAccessGateway/f36938ae-bbca-4245-b5c9-fac512d69e22/scratchpad/buildlock`
(retry with backoff until it succeeds); `rmdir` it on ALL exit paths, including failure.
- **Quality gates:** `TreatWarningsAsErrors=true`, `Nullable=enable`. Follow
`docs/style-guides/CSharpStyleGuide.md` (file-scoped namespaces, `sealed` by default,
`Async` suffix). Update affected docs in the same commit as source.
- **MXAccess parity:** never synthesize events; the dashboard/gateway forwards only what
the worker/monitor emits. The `snapshot_status` frame is a gateway-status frame (like
`provider_status`), NOT a synthesized MXAccess event.
- **Never log or echo secrets, API keys, credentials, or tag values.**
- **Scope contract:** the task's `Files:` block is the `files_to_edit` contract. If the
task needs other files, that is a plan defect — surface it in your report, don't
silently expand scope. (Exception: Tasks 7 and 8 are investigations; their Files list
is starting points, and they must report every file they end up touching.)
- **Windev access (Tasks 7, 8, 9 only):** `ssh windev` lands in PowerShell. The CI clone
is `C:\build\mxaccessgw-ci`. Quirks: the first `slnx` build after a pull often fails on
stale Contracts obj artifacts (CS2001/CS0016) — clear the Contracts `obj`/`bin` and
rebuild, it is not a regression; the gateway suite is load-sensitive — rerun a filtered
subset before believing a flake. To get branch code there:
`git -C C:\build\mxaccessgw-ci fetch origin && git -C C:\build\mxaccessgw-ci checkout feat/followups-tickets && git -C C:\build\mxaccessgw-ci pull`
which requires the Mac side to have pushed the branch first (the controller pushes;
ask if the branch tip you need is not on origin).
---
### Task 1: check-codegen Check 4 — tolerate the Windows `.exe` version banner
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 2, Task 3, Task 4, Task 5, Task 7
**Files:**
- Modify: `clients/go/generate-proto.ps1` (pin compare at `:52-67`, pin constants at `:10-11`)
- Modify: any doc that states Check 4 is unrunnable on Windows (grep `docs/` and
`code-reviews/` for "Check 4"; `docs/GatewayTesting.md` is the likely holder)
**Problem.** On Windows the plugins report their invoked name with an `.exe` suffix
(`protoc-gen-go.exe v1.36.11`), so the exact-string compare against
`'protoc-gen-go v1.36.11'` throws and Check 4 (which shells this script) is unrunnable on
any Windows host — including windev, the box where regeneration actually happens.
**Step 1: Normalize the banner before comparing.** Add a small helper and use it for both
plugin compares (protoc stays warn-only and gets the same normalization for a fair warn):
```powershell
function Get-NormalizedToolVersion {
# On Windows a plugin reports its argv[0] name, so the banner carries an `.exe`
# suffix ("protoc-gen-go.exe v1.36.11"). Strip it so the pin compare is
# host-independent; the version part must still match exactly.
param([string]$RawBanner)
return ($RawBanner -replace '\.exe(?=\s)', '')
}
```
Apply to `$protocGenGoVersion`, `$protocGenGoGrpcVersion`, `$protocVersion` at the point
each is read (`:54`, `:59`, `:64`), keeping the pinned constants unchanged. Update the
comment block at `:4-9` to note the normalization.
**Step 2: Verify on macOS.** Acquire the build lock, then run
`pwsh scripts/check-codegen.ps1` — all 4 checks must pass (banner has no `.exe` here, so
this proves no regression). Also assert the helper logic directly:
`pwsh -c "& { <paste helper> ; Get-NormalizedToolVersion 'protoc-gen-go.exe v1.36.11' }"`
must print `protoc-gen-go v1.36.11`, and a name-only match check for
`protoc-gen-go-grpc.exe 1.6.2``protoc-gen-go-grpc 1.6.2`.
**Step 3: Fix the stale prose.** Wherever docs state Check 4 cannot run on Windows,
rewrite to: Check 4 runs on Windows; the plugin pins must be installed
(`go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.11`,
`go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.2`).
Do NOT edit `docs/plans/2026-08-17-followup-closeout.md` — Task 10 owns plan bookkeeping.
**Step 4: Commit** with pathspecs on the touched files:
`fix(codegen): normalize .exe off plugin version banners so Check 4 runs on Windows`.
Windows-side proof is deferred to Task 9 (windev runs check-codegen 14 after installing
the 1.6.2 pin).
---
### Task 2: Gradle 9 — revive `checkGeneratedClean` without `Project.exec`
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 1, Task 3, Task 4, Task 5, Task 7
**Files:**
- Modify: `clients/java/zb-mom-ww-mxgateway-client/build.gradle` (`checkGeneratedClean`, `:71-92`)
- Modify: `docs/GatewayTesting.md` only if it describes the task's mechanism (grep
`checkGeneratedClean`)
**Problem.** The task's `doLast` calls the script-level `exec {}` (i.e. `Project.exec`),
removed in Gradle 9 — the task is dead on any Gradle 9 host.
**Step 1: Replace with `ProviderFactory.exec`** (available since Gradle 7.5, so it works
on both the current toolchain and Gradle 9, and is configuration-cache safe). Capture the
provider reference at configuration time, call `.get()` in `doLast` so the git status
runs at execution time:
```groovy
tasks.register('checkGeneratedClean') {
group = 'verification'
description = 'Fails if the committed generated Java tree differs from a fresh regeneration.'
dependsOn 'generateProto'
def generatedDir = 'clients/java/src/main/generated'
def repoRoot = rootProject.projectDir.parentFile.parentFile
// Project.exec was removed in Gradle 9; ProviderFactory.exec runs the probe lazily
// at doLast time and works on Gradle 7.5+.
def gitStatus = providers.exec {
workingDir = repoRoot
commandLine 'git', 'status', '--porcelain', '--', generatedDir
ignoreExitValue = true
}
doLast {
def dirty = gitStatus.standardOutput.asText.get().trim()
if (!dirty.isEmpty()) {
throw new GradleException(
"Generated Java is stale:\n${dirty}\n" +
"Regenerate and commit the Java client after a .proto change " +
"(gradle :zb-mom-ww-mxgateway-client:generateProto).")
}
}
}
```
Preserve the explanatory comment block above the task (`:61-70`) — amend it, don't delete.
**Step 2: Verify.** Acquire the build lock. From `clients/java`:
`gradle :zb-mom-ww-mxgateway-client:checkGeneratedClean` must pass (tree is clean), and
`gradle --version` must be recorded in your report. Then prove the failure path: touch a
scratch edit inside `clients/java/src/main/generated/` (append a comment line to one
generated file), rerun the task, confirm it fails with the stale message, then **revert
that file with `git checkout -- <that one file>`** (this narrow file-level checkout is the
one permitted use; the file is committed generated output).
**Step 3: Commit:**
`fix(java-client): checkGeneratedClean via ProviderFactory.exec — Project.exec is gone in Gradle 9`.
---
### Task 3: SettingsPage — RecentFaultLimit / RecentSessionLimit rows
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 1, Task 2, Task 4, Task 5, Task 7
**Files:**
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SettingsPage.razor`
(dashboard rows around `:98-102`)
- Test: `src/ZB.MOM.WW.MxGateway.Tests/` — extend the existing settings render coverage
(`SettingsPageTagVisibilityRenderTests` or its sibling settings render test class; find
with `grep -rl "SettingsPage" src/ZB.MOM.WW.MxGateway.Tests`)
- Modify: `docs/GatewayDashboardDesign.md` only if it enumerates settings rows (grep
`RecentFaultLimit` / "Snapshot interval" there first)
**Problem.** `EffectiveDashboardConfiguration` carries `RecentFaultLimit` and
`RecentSessionLimit` (already projected by `GatewayConfigurationProvider.cs:62-63`), but
`SettingsPage` renders every member except these two.
**Step 1: Write the failing test.** Extend the settings render test: rendered page
contains `Recent fault limit` with the configured value and `Recent session limit` with
the configured value (use non-default values in the arranged options so the assertion
proves plumbing, not defaults).
**Step 2: Run it, expect FAIL** (`dotnet test --filter "FullyQualifiedName~SettingsPage"`
under the build lock).
**Step 3: Add the two rows** next to the other Dashboard rows (after "Snapshot interval",
matching the existing `<tr><th scope="row">…</th><td>…</td></tr>` idiom):
```razor
<tr><th scope="row">Recent fault limit</th><td>@Snapshot.Configuration.Dashboard.RecentFaultLimit</td></tr>
<tr><th scope="row">Recent session limit</th><td>@Snapshot.Configuration.Dashboard.RecentSessionLimit</td></tr>
```
**Step 4: Run the filtered test, expect PASS.**
**Step 5: Commit:**
`feat(dashboard): settings page shows RecentFaultLimit and RecentSessionLimit`.
---
### Task 4: Authentication.md — runnable-as-written examples pass
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 1, Task 2, Task 3, Task 5, Task 7
**Files:**
- Modify: `docs/Authentication.md`
**Problem.** The doc's CLI/scope examples were flagged during the previous branch as not
runnable as written — notably samples that grant only invoke/event scopes with no
`session:open`, which since Server-004's validation would produce keys that cannot open a
session (or, for unknown scope strings, be rejected at create time). The canonical scope
catalog is: `session:open`, `session:close`, `invoke:read`, `invoke:write`,
`invoke:secure`, `events:read`, `metadata:read`, `admin`; the verb is `apikey create-key`
with `--key-id` required.
**Step 1: Sweep every example** (` ```-fenced blocks and inline commands) in the doc. For
each, either (a) make it runnable as written — canonical verb, required flags, only
canonical scopes, scope sets that support what the surrounding prose says the key is for
(a key described as opening sessions needs `session:open`) — or (b) if it is deliberately
a fragment, mark it explicitly (e.g. "illustrative — not a complete command"). Prefer (a);
use (b) only where completeness would obscure the point being made.
**Step 2: Cross-check** each corrected scope list against
`src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/GatewayScopes.cs` (read-only) —
do not invent scope strings.
**Step 3: Commit:** `docs(auth): make Authentication.md examples runnable as written`.
---
### Task 5: AlarmsPage — push-driven truncation banner from the snapshot_status frame
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 1, Task 2, Task 3, Task 4, Task 7
**Files:**
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/AlarmsPage.razor`
(in-process feed loop around `:199-230`, `_snapshotTruncated` at `:170`/`:401`)
- Test: `src/ZB.MOM.WW.MxGateway.Tests/` — the AlarmsPage/dashboard alarm test class
(find with `grep -rl "AlarmsPage" src/ZB.MOM.WW.MxGateway.Tests`); if no AlarmsPage
render/behavior test exists, add coverage at the level the existing dashboard tests use
- Modify: `docs/GatewayDashboardDesign.md` (truncation-banner description, if it says the
banner is poll-driven)
**Problem.** The truncation banner (`_snapshotTruncated`, rendered at `:39`) updates only
from the 3-second poll (`:401`), even though the page already holds an in-process
subscription to the alarm feed (the loop at `:211-213` that feeds the provider-status
badge). The feed now carries an edge-triggered `snapshot_status` frame
(`AlarmFeedMessage.PayloadCase.SnapshotStatus`, shipped `fccf753`) — consume it so the
banner flips on the edge instead of up to 3 s late.
**Spec:**
- In the existing in-process feed loop, add a case for
`AlarmFeedMessage.PayloadCase.SnapshotStatus`: set `_snapshotTruncated =
message.SnapshotStatus.Truncated` and re-render (same invoke/StateHasChanged pattern the
provider-status case uses).
- Keep the poll's assignment at `:401` — the poll is the reconcile baseline and both
sources derive from the same `GatewayAlarmMonitor` verdict, so they cannot disagree
except transiently. Do not remove or restructure the poll.
- Remember `StreamAsync` primes every subscriber with an unconditional `snapshot_status`
baseline frame after `provider_status` — so on attach the page gets the current verdict
push-side too. No extra priming logic needed in the page.
- Do NOT touch `GatewayAlarmMonitor` or the proto — gateway emission is done and shipped.
**Steps:** failing test first (a `SnapshotStatus` frame delivered through the in-process
subscription flips the banner state without a poll tick; a `false` frame clears it), then
implement, then filtered dashboard/alarm tests green under the build lock, then commit:
`feat(dashboard): alarms page consumes snapshot_status feed frame for the truncation banner`.
---
### Task 6: Server-0xx resolution audit — doc-only "Resolved" entries re-verified
**Classification:** standard
**Estimated implement time:** ~5 min (audit) + fixes as found
**Parallelizable with:** none (runs after Task 4 lands — both may touch `docs/Authentication.md`)
**Files:**
- Modify: `code-reviews/Server/findings.md` (annotations only — do not rewrite history)
- Modify: any file where a claimed correction is found absent (expected candidates:
`CLAUDE.md`, `docs/*.md`, XML doc comments named in findings — report each)
**Problem.** Server-012 was recorded *Resolved 2026-05-18* claiming two scope-list
corrections that were absent from the tree when `feat/followup-closeout` looked — a
resolution that regressed or was never applied. Findings that read Resolved are never
re-examined, so every *documentation/comment-only* resolution needs the same spot-check.
**Procedure:**
1. Enumerate every `Server-0xx` entry in `code-reviews/Server/findings.md` whose
Resolution describes documentation-only or comment-only changes (no test named, prose
like "Pure documentation change" — at minimum Server-011, Server-012, Server-013/014
remarks rewrites; sweep all entries, don't assume).
2. For each, verify the specific claimed text exists in today's tree (grep the exact
phrases/identifiers the resolution names).
3. Where present: append one line to that finding's Resolution:
`Re-verified present 2026-08-18 (feat/followups-tickets).`
4. Where absent: re-apply the correction in the target file, and append:
`Regressed or never applied; re-fixed 2026-08-18 in <file> (feat/followups-tickets).`
(Server-012 itself was already re-fixed on the previous branch — annotate it as such,
citing commits `a5f843c`/`f2a422b`, rather than re-fixing.)
5. Report a table: finding id → verified/regressed → action.
**Commit:** `docs(reviews): re-verify doc-only Server-0xx resolutions; re-fix regressions`.
---
### Task 7: WorkerPipeSessionTests deterministic failure — investigate and fix (windev)
**Classification:** high-risk
**Estimated implement time:** investigation timeboxed ~10 min; fix ≤5 min
**Parallelizable with:** Task 15 (only windev user in wave 1)
**Files (starting points — investigation task, report everything touched):**
- Test: `src/ZB.MOM.WW.MxGateway.Worker.Tests/Ipc/WorkerPipeSessionTests.cs`
(`RunAsync_LongInFlightCommandThatKeepsPumping_DoesNotFaultAndDeliversReply`)
- Suspect: `src/ZB.MOM.WW.MxGateway.Worker/` pipe-session / frame-protocol sources the
test exercises (follow the test's references)
**Problem.** The test fails deterministically on windev, and reproduces on `main`
pre-existing, not introduced by any recent branch. Everything else in the worker suite
passes (523/523 otherwise).
**Procedure (all building/testing on windev over ssh; edits in the Mac tree, pushed by
the controller, pulled on the CI clone — coordinate via your report if you need a push):**
1. Repro on the CI clone at this branch:
`dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86 --filter "FullyQualifiedName~RunAsync_LongInFlightCommandThatKeepsPumping"`.
Capture the full failure output (assertion text, timeout, stack).
2. Read the test and the code under test. Classify: (a) test defect (bad timing
assumption, races in the harness), (b) product defect in the pipe session under a
long in-flight command, or (c) environment-specific (windev timing/load).
**Timebox: if the cause is not isolated after ~10 minutes of investigation, stop and
report your best hypothesis with evidence — do not churn.**
3. Fix minimally per the classification. A product fix in the frame/pipe layer is
high-risk territory: preserve the frame protocol (`docs/WorkerFrameProtocol.md`), the
STA pumping rules, and MXAccess parity. A test fix must keep the scenario's intent —
a long in-flight command that keeps pumping must not fault the session and must
deliver its reply; don't weaken it into a sleep-and-hope.
4. Verify: the fixed test passes 3 consecutive runs on windev; then the full worker suite
(`-p:Platform=x86`) is green. If you touched product code, also build the full `slnx`
on windev and run the gateway suite filtered to any shared-surface tests.
5. Docs in the same commit if behavior/rules changed.
**Commit:** `fix(worker): <cause> — WorkerPipeSessionTests long-in-flight repro` (adjust
`fix(worker-tests)` if the defect is in the test).
---
### Task 8: Ack-leg probe — bounded unblock attempt (windev rig)
**Classification:** standard
**Estimated implement time:** timeboxed ~15 min of probing
**Parallelizable with:** Task 6 (runs after Task 7 — serialize windev use)
**Files (starting points — investigation task):**
- Modify: `docs/AlarmProbeFindings.md` (append a third-attempt section, whatever the outcome)
- Reference (read-only): the probe harness locations named in that doc's earlier attempts;
the mxaccess analysis project at `C:\Users\dohertj2\Desktop\mxaccess` (windev)
**Problem.** The acknowledge leg remains unobserved: every wnwrap ack surface is
accepted-but-inert (`rc=0`, state stays `UNACK_ALM`), and `.Acked` is write-rejected
(OperationalError 1007). Two recorded unblock paths remain
(`docs/AlarmProbeFindings.md`, "Remaining unblock paths for the acknowledge leg").
**Procedure — attempt path 2 first (it is inspectable), path 1 only if non-interactive:**
1. **Path 2 — ack-security configuration:** inspect whether the rig's galaxy/`alarmmgr`
is configured with an alarm-acknowledgement security requirement the wnwrap consumer
(operator *name* string, no authenticated identity) cannot meet. Look in galaxy
configuration (the `ZB` SQL Galaxy Repository — read-only queries only), area/object
security settings for the `TestMachine_00x` objects, and any alarm-security docs in
the mxaccess analysis project. **Read-only: do not change galaxy security config.**
2. **Path 1 — platform-side ack:** only if a *scriptable, non-interactive* path exists on
the rig as-installed (e.g. an existing harness or automation entry point). Do NOT
install software, do NOT drive GUI automation, do NOT change rig state beyond the
established raise/clear pattern on `TestMachine_001.TestAlarm001`. If only interactive
IDE/InTouch paths exist, record that and stop.
3. Whatever the outcome, append the third-attempt section: what was inspected, evidence,
and the leg's final status (observed / unavailable-by-configuration / still assumed
with the paths requiring a human). If the ack was actually observed, record whether
`STATE` reached `ACK_ALM` and whether the GUID survived — that answers the original
question and should update the findings table at the top of the doc.
4. **Stop condition:** at ~15 minutes of probing without a decisive result, write up what
was learned and conclude "still assumed" — the doc itself notes this is a
documentation gap, not a correctness one.
**Commit:** `docs(probe): ack-leg third attempt — <outcome>`.
---
### Task 9: Windev toolchain pin + full verification
**Classification:** verification (no review chain)
**Estimated implement time:** ~15 min wall (mostly build/test wait)
**Parallelizable with:** none (after all code tasks land and are pushed)
**Files:** none in-repo except possibly `docs/ToolchainLinks.md` (update the windev
`protoc-gen-go-grpc` entry if it records 1.6.1).
**Procedure (all over `ssh windev`, CI clone `C:\build\mxaccessgw-ci` at this branch tip):**
1. `go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.2` — then
`protoc-gen-go-grpc --version` must report 1.6.2. Update `docs/ToolchainLinks.md` if
it pins the old version (commit from the Mac tree).
2. Full `slnx` build — 0 warnings / 0 errors (clear Contracts obj/bin on CS2001/CS0016).
3. Worker tests `-p:Platform=x86` — expect the Task 7 outcome (fully green if fixed;
otherwise the documented known-failure only).
4. Gateway tests — full suite; rerun filtered on load flakes before believing a failure.
5. `powershell scripts/check-codegen.ps1`**all 4 checks**, proving Task 1 on the
platform that had the bug.
6. `gradle --version` + `gradle :zb-mom-ww-mxgateway-client:checkGeneratedClean` from
`clients\java` if Gradle is installed there — record version and result either way.
7. Live MXAccess smoke: `MXGATEWAY_RUN_LIVE_MXACCESS_TESTS=1`, filter
`WorkerLiveMxAccessSmokeTests` — 8/8.
8. Report every result verbatim (counts, not "green").
---
### Task 10: Bookkeeping — close the follow-ups record
**Classification:** trivial
**Estimated implement time:** ~3 min
**Parallelizable with:** none (last)
**Files:**
- Modify: `docs/plans/2026-08-17-followup-closeout.md` (follow-ups block `:483-507`):
annotate each bullet closed with this branch's closing commit (or precisely narrowed,
e.g. ack leg if still assumed)
- Modify: `docs/plans/2026-08-18-followups-and-tickets.md` (this file): append as-built
notes — per-task commits, review outcomes, verification results, anything learned
- Modify: `docs/plans/2026-08-18-followups-and-tickets.md.tasks.json`: final statuses
**Commit:** `chore(plan): followups-and-tickets as-built record; prior follow-ups closed`.
---
## Execution notes for the controller
- **Model/review chains** (subagent-driven-development skill): Tasks 14 small → Sonnet
implementer + Haiku code review (diffs <100 LOC). Task 5 standard → Opus implementer,
spec (Haiku) ∥ code (Sonnet). Task 6 standard → Opus implementer, spec ∥ code. Task 7
high-risk → Opus implementer, serial spec (Haiku) → code (Sonnet). Task 8 standard →
Opus implementer, spec ∥ code. Task 9 verification → Opus, no review. Task 10 trivial →
Sonnet, no review.
- **Waves:** Wave 1 = Tasks 1, 2, 3, 4, 5, 7 (disjoint files; Task 7 alone on windev).
Wave 2 = Task 6 (after 4) and Task 8 (after 7). Wave 3 = push branch, Task 9.
Wave 4 = Task 10, final integration review, then hold for the user's merge decision.
- **Push cadence:** controller pushes the branch before any windev task needs its code
there (Task 7 investigates on-branch; Tasks 89 need the wave-1/2 tips).
---
## As-built record (2026-08-18)
All 10 tasks completed on `feat/followups-tickets`; every classification-driven review
chain resolved **Approved** (Tasks 5, 7, and 8 after fix rounds; Task 7's high-risk chain
took two rounds, ending with the mid-window auto-release race made structurally
unreachable — 30 s `BlockedDispatchSafetyNet` > every test's 20 s cancellation). The
final integration review returned *Ready with reservations*; every reservation was fixed
on-branch: I-2 (`6b5c737``ToolchainLinks.md` recorded `@latest` for plugins the
regeneration script hard-pins), I-3 (`db7b1db` — three doc sites claimed full-DN
`GroupToRole`/`GroupToTag` keys work; the Server-040 restoration proves they never match
the pre-stripped groups; rows were pre-existing on `main`), M-2/M-5 (`1605f54`), M-3
(`fb68bdb`), M-4 (`753d070`), and M-1 was discharged by evidence (a
`--configuration-cache --rerun-tasks` discriminator proved `providers.exec` evaluates at
execution time after `generateProto`; no change needed).
Verification (Task 9, windev CI clone at `5417222`, post-fix commits re-verified
individually by their owners at their pushed tips): full `slnx` 0W/0E; worker x86 **524
passed / 11 skipped / 0 failed** — the suite's first fully green windev run; gateway
1155/1155 with no reruns; `check-codegen.ps1` **4/4 on Windows** under pwsh 7, and after
`8ae0c2f` also end-to-end under Windows PowerShell 5.1; Gradle 9.4.1
`checkGeneratedClean` green on windev (and 9.5.1 on macOS); live MXAccess smoke 8/8;
toolchain pins now real on windev (`protoc-gen-go v1.36.11`, `protoc-gen-go-grpc 1.6.2`).
Notable findings made along the way:
- **Check 4 had a second Windows-only blocker** behind the `.exe` banner: Windows
PowerShell 5.1 strips embedded double quotes when marshaling native-exe args, breaking
the Python `grpcio-tools` version probe (`clients/python/generate-proto.ps1:39`).
Fixed (`8ae0c2f`) by swapping quote nesting; no other native-exe invocation in the
codegen scripts carries the pattern.
- **The ack leg is blocked by galaxy security configuration**, not a missing verb: the
probe fixtures carry `MxSecurityOperate`, and no `AlarmAckByName` overload can convey
an authenticated identity (inferred from mechanism, honestly caveated). `gateway.md`
and `docs/Grpc.md` now carry the "acceptance is not application" caveat on the
acknowledge RPC.
- **The `fca978d` tracking-marker sweep deleted substantive prose** along with markers in
at least one place (Server-040's precedence comment plus an operator-facing RDN
paragraph that was never part of the finding). A targeted re-read of that commit's
larger comment deletions is ticket-worthy (203 files swept; only the audited file was
examined).
- **`generateProto`'s up-to-date check does not notice an out-of-band deletion** of a
single generated file (protobuf-gradle-plugin behavior, orthogonal to this branch) — a
manually deleted generated file stays missing until a `.proto` change or
`--rerun-tasks` invalidates the task.
- Windev process notes: the CI clone sat on a detached HEAD, so bare `git pull` silently
no-ops (use `git pull origin <branch>`); concurrent MSBuilds on the clone can kill each
other's child nodes (MSB4166) — `-m:1` avoids it; ssh can transiently refuse with "Too
many authentication failures" under agent concurrency (back off and retry).
Follow-ups recorded, not started (deliberately small):
- The `fca978d` sweep re-read (above) — the one genuinely ticket-worthy item.
- `DashboardGroupTagMapping`'s class-level `<summary>` still describes the lookup as
"full DN first, leading-RDN fallback" — accurate mechanics, but it could point at the
short-name-keys consequence the inline comment now records.
- wwtools `mxa read` human-readable formatter throws `RuntimeBinderException` on a failed
read (`ReadCommand.cs:137`); `--llm-json` works. Different repo, noted here so it isn't
lost.
Explicitly decided, not an omission: **`../scadaproj/CLAUDE.md` needs no update** — no
`.proto`, contract, command, or architecture fact the umbrella index records changed on
this branch.
@@ -0,0 +1,16 @@
{
"planPath": "docs/plans/2026-08-18-followups-and-tickets.md",
"tasks": [
{"id": 1, "subject": "Task 1: check-codegen Check 4 — Windows .exe banner normalization", "status": "completed", "commit": "c94c4d4", "review": "Approved (Haiku code review, no issues); macOS check-codegen 4/4; Windows-side proof deferred to Task 9"},
{"id": 2, "subject": "Task 2: Gradle 9 checkGeneratedClean via ProviderFactory.exec", "status": "completed", "commit": "df45cb4", "review": "Approved; verified on Gradle 9.5.1 (local toolchain already Gradle 9 — task was dead here), clean-pass and forced-stale-fail both proven"},
{"id": 3, "subject": "Task 3: SettingsPage RecentFaultLimit/RecentSessionLimit rows", "status": "completed", "commit": "a390fe1", "review": "Approved; fail-first render test with non-default values, 3/3 filtered"},
{"id": 4, "subject": "Task 4: Authentication.md runnable-as-written examples pass", "status": "completed", "commit": "2b1efb5", "review": "Approved; three examples gained session:open, ops.audit correctly left session-less (metadata:read gates session-less Galaxy RPCs)"},
{"id": 5, "subject": "Task 5: AlarmsPage push-driven truncation banner (snapshot_status)", "status": "completed", "commits": ["7b6dfba", "f57a6ae"], "review": "Spec compliant (rename judged in-scope); code review Approved after fixes: stale doc table row, monitor-contract comment (restart does NOT complete subscribers), mid-truncation attach priming test; 5/5 + 370/370 filtered"},
{"id": 6, "subject": "Task 6: Server-0xx doc-only resolution audit", "status": "completed", "commit": "d3ac527", "review": "Spec compliant; code review Approved (controller verified DashboardGroupRoleMapping change comment-only). 20 entries audited; 2 regressions re-fixed (Server-040 swept comment block incl. RDN pre-strip paragraph; Server-009 WAL/busy-timeout prose); 4 moot (target deleted); Server-038 closed-not-regressed. Systemic finding: fca978d tracking-marker sweep deleted substantive prose in at least one place"},
{"id": 7, "subject": "Task 7: WorkerPipeSessionTests deterministic failure — investigate + fix (windev)", "status": "completed", "commits": ["7da52b6", "462850a", "aaeb86b"], "review": "High-risk chain: spec compliant; code review Approved after two fix rounds (doc-comment attachment; watchdog-window headroom 200ms/1s + 2s window/30-frame floor; 30s BlockedDispatchSafetyNet > 20s CTS makes the mid-window auto-release race structurally unreachable). Root cause: test-harness defect — FakeRuntimeSession stamped LastStaActivityUtc only at construction, watchdog correctly faulted StaHung pre-dispatch. Test-only fix; windev worker suite 524 passed/11 skipped (was 523+1 fail)"},
{"id": 8, "subject": "Task 8: Ack-leg probe bounded unblock attempt (windev rig)", "status": "completed", "commits": ["d1ae43d", "bc22792"], "review": "Spec compliant; code review Approved after fixes (superseded bullet closed, summary hedged, gobject_id evidenced, ack-caveat added to gateway.md + Grpc.md as authorized scope extension). Outcome: ack unavailable-by-configuration (MxSecurityOperate classification, no credential-bearing AlarmAckByName overload — inferred, honestly caveated); no .Ack attribute exists; no non-interactive platform-side surface; GUID-across-ack row stays Open with reason updated"},
{"id": 9, "subject": "Task 9: Windev toolchain pin + full verification", "status": "completed", "verifiedAt": "5417222", "commit": "6b5c737", "result": "protoc-gen-go-grpc 1.6.2 installed; slnx 0W/0E; worker x86 524/11/0; gateway 1155/1155; check-codegen 4/4 (pwsh 7; and under PS 5.1 after 8ae0c2f fixed the quote-stripping probe bug it surfaced); Gradle 9.4.1 checkGeneratedClean green; live smoke 8/8; ToolchainLinks @latest rows corrected to pins (integration-review I-2)"},
{"id": 10, "subject": "Task 10: Bookkeeping — close the follow-ups record", "status": "completed", "review": "None (trivial); closes the 2026-08-17 plan's follow-ups block with per-bullet commits, appends the as-built record (integration-review resolution: I-2 6b5c737, I-3 db7b1db, M-2/M-5 1605f54, M-3 fb68bdb, M-4 753d070, M-1 discharged by evidence; plus 8ae0c2f PS5.1 fix), final statuses here"}
],
"lastUpdated": "2026-08-18"
}
+25 -4
View File
@@ -227,6 +227,15 @@ ack. If the attribute has no writable ack-comment subtag configured, the RPC
returns `FailedPrecondition`. In alarm-manager mode, `AlarmAckByName` is
used as before.
**Acceptance is not application:** the ack is forwarded to the provider, and a
successful return means the provider accepted the call, not that the
acknowledgement was applied. On galaxies whose alarmed attributes carry a
non-free-access security classification, `AlarmAckByName` returns `rc=0` and the
alarm stays `UNACK_ALM` — it carries an operator name, not an authenticated
identity. (Observed on the probe rig; the mechanism is inferred.) Confirm an ack
by the resulting transition, never by the return code. See
`docs/AlarmProbeFindings.md`.
**Degraded state visibility:** every subtag-mode transition carries
`degraded = true` and `source_provider = ALARM_PROVIDER_MODE_SUBTAG` on the
`OnAlarmTransitionEvent` and `ActiveAlarmSnapshot` proto fields. The
@@ -249,14 +258,26 @@ 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
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
+15 -2
View File
@@ -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)")
@@ -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),
@@ -28509,9 +28513,13 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
}
/// <summary>
/// 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.
/// </summary>
[global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")]
public sealed partial class AlarmFeedMessage : pb::IMessage<AlarmFeedMessage>
@@ -28561,6 +28569,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 +28659,23 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
}
}
/// <summary>Field number for the "snapshot_status" field.</summary>
public const int SnapshotStatusFieldNumber = 5;
/// <summary>
/// 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.
/// </summary>
[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_;
/// <summary>Enum of possible cases for the "payload" oneof.</summary>
public enum PayloadOneofCase {
@@ -28656,6 +28684,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 +28719,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 +28732,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 +28768,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 +28798,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 +28824,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 +28861,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 +28919,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 +28978,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 +29318,221 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
}
/// <summary>
/// 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.
/// </summary>
[global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")]
public sealed partial class AlarmSnapshotStatus : pb::IMessage<AlarmSnapshotStatus>
#if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
, pb::IBufferMessage
#endif
{
private static readonly pb::MessageParser<AlarmSnapshotStatus> _parser = new pb::MessageParser<AlarmSnapshotStatus>(() => new AlarmSnapshotStatus());
private pb::UnknownFieldSet _unknownFields;
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public static pb::MessageParser<AlarmSnapshotStatus> 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);
}
/// <summary>Field number for the "truncated" field.</summary>
public const int TruncatedFieldNumber = 1;
private bool truncated_;
/// <summary>
/// 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.
/// </summary>
[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<MxStatusProxy>
#if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
@@ -29267,7 +29548,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 +29979,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 +30889,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 +31683,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 +31944,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 +32191,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 +32380,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 +32569,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 +32758,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 +32947,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 +33136,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 +33323,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 +33510,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 +33697,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]
@@ -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).
@@ -1018,6 +1022,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 +1036,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
@@ -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<ActiveAlarmSnapshot> snapshots, bool snapshotTruncated)
{
Dictionary<string, ActiveAlarmSnapshot> 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 };
@@ -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.
/// </para>
/// <para>
/// This is the polled read of the verdict. The same verdict is pushed to
/// the live feed as the <c>snapshot_status</c>
/// (<c>AlarmSnapshotStatus</c>) case of <see cref="AlarmFeedMessage"/> —
/// once at <see cref="StreamAsync"/> open and again on every change — so
/// a streaming consumer need not poll this property.
/// </para>
/// </summary>
bool SnapshotTruncated { get; }
/// <summary>
/// Attaches to the central alarm feed. The returned stream yields one
/// <see cref="AlarmFeedMessage"/> per currently-active alarm, then a
/// single <c>snapshot_complete</c> sentinel, then a <c>transition</c>
/// for every subsequent change.
/// Attaches to the central alarm feed. The returned stream opens with a
/// <c>provider_status</c> and a <c>snapshot_status</c>
/// <see cref="AlarmFeedMessage"/> — 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
/// <c>snapshot_complete</c> sentinel, then a <c>transition</c> for every
/// subsequent change (and a further status message on every provider-mode
/// or truncation-verdict change).
/// </summary>
/// <param name="alarmFilterPrefix">Optional alarm-reference prefix scoping the feed.</param>
/// <param name="cancellationToken">Token that ends the subscription.</param>
@@ -7,4 +7,6 @@ public sealed record EffectiveDashboardConfiguration(
int RecentFaultLimit,
int RecentSessionLimit,
bool ShowTagValues,
IReadOnlyDictionary<string, string> GroupToRole);
IReadOnlyDictionary<string, string> GroupToRole,
IReadOnlyDictionary<string, IReadOnlyList<string>> GroupToTag,
UntaggedSessionVisibility UntaggedSessionVisibility);
@@ -62,7 +62,16 @@ public sealed class GatewayConfigurationProvider(IOptions<GatewayOptions> 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<string>; 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<string>)pair.Value,
value.Dashboard.GroupToTag.Comparer),
UntaggedSessionVisibility: value.Dashboard.UntaggedSessionVisibility),
Protocol: new EffectiveProtocolConfiguration(
value.Protocol.WorkerProtocolVersion,
value.Protocol.MaxGrpcMessageBytes));
@@ -191,13 +191,13 @@
private Task? _pollTask;
private DashboardAlarmProviderStatus _providerStatus = DashboardAlarmProviderStatus.Healthy;
private Task? _providerStatusTask;
private Task? _statusFeedTask;
/// <inheritdoc />
protected override void OnInitialized()
{
_pollTask = PollLoopAsync();
_providerStatusTask = ProviderStatusLoopAsync();
_statusFeedTask = StatusFeedLoopAsync();
}
private string? ProviderStatusTitle()
@@ -210,8 +210,13 @@
// The badge tracks the central monitor directly rather than looping back through
// /hubs/alarms: the alarm service is an in-process multi-subscriber fan-out, so a
// server-rendered page needs no SignalR client, no loopback socket and no auth token.
// Alarm rows still come from the 3-second poll below — this loop only feeds the badge.
private async Task ProviderStatusLoopAsync()
// This loop feeds the two gateway-status indicators — the provider badge and the
// truncation banner — from the feed's own status frames, so both move as soon as the
// monitor's verdict changes instead of on the next 3-second tick. Alarm rows still come
// from the poll below, which also re-asserts the truncation verdict as its reconcile
// baseline: both sources read the same monitor verdict, so they cannot disagree for
// longer than one tick, and neither one is synthesized here.
private async Task StatusFeedLoopAsync()
{
while (!_cts.IsCancellationRequested)
{
@@ -221,16 +226,30 @@
.StreamAsync(alarmFilterPrefix: null, _cts.Token)
.ConfigureAwait(false))
{
if (message.PayloadCase != AlarmFeedMessage.PayloadOneofCase.ProviderStatus)
switch (message.PayloadCase)
{
continue;
}
case AlarmFeedMessage.PayloadOneofCase.ProviderStatus:
await InvokeAsync(() =>
{
_providerStatus = DashboardAlarmProviderStatus.FromFeed(message);
StateHasChanged();
}).ConfigureAwait(false);
break;
await InvokeAsync(() =>
{
_providerStatus = DashboardAlarmProviderStatus.FromFeed(message);
StateHasChanged();
}).ConfigureAwait(false);
// Every subscriber is primed with this frame at open, so a page that
// attaches mid-truncation gets the caveat without waiting for an edge —
// no page-side priming needed.
case AlarmFeedMessage.PayloadOneofCase.SnapshotStatus:
await InvokeAsync(() =>
{
_snapshotTruncated = message.SnapshotStatus.Truncated;
StateHasChanged();
}).ConfigureAwait(false);
break;
default:
break;
}
}
}
catch (OperationCanceledException)
@@ -239,9 +258,13 @@
}
catch
{
// The monitor completes a subscriber's stream when it falls behind, and
// again when the monitor restarts. Both are recoverable by resubscribing;
// the badge holds its last value in the meantime.
// The monitor drops a subscriber whose queue it cannot write to, completing
// that stream with an error; short of cancellation or disposal that is the
// only way this enumeration ends. A monitor restart is NOT one of them — it
// keeps the channel and pushes the cleared status frames through it — so this
// catch is the fell-behind case, recoverable by resubscribing. The badge and
// banner hold their last values meanwhile, and the resubscribe is primed with
// the current ones.
}
try
@@ -321,7 +344,7 @@
};
}
// Fault handling sits inside the loop, matching ProviderStatusLoopAsync: a query or render
// Fault handling sits inside the loop, matching StatusFeedLoopAsync: a query or render
// fault on one tick is transient (a provider blip, a momentarily unavailable session), so it
// is surfaced on the page and retried on the next tick rather than ending polling for the
// life of the page. Cancellation is the only exit. The loop method itself therefore cannot
@@ -398,6 +421,10 @@
{
DashboardAlarmQueryResult result = await LiveData.QueryAlarmsAsync(_cts.Token);
_queryError = result.Error;
// Kept alongside the feed's snapshot_status frame rather than replaced by it: this is
// the reconcile baseline. Both read the same monitor verdict, so the poll can only
// confirm what the frame already showed — but it also re-establishes the banner for a
// page whose feed subscription is mid-resubscribe after the monitor dropped it.
_snapshotTruncated = result.SnapshotTruncated;
_workerPid = result.WorkerProcessId;
_lastRefresh = DateTimeOffset.UtcNow;
@@ -416,7 +443,7 @@
// Drained together, not one after the other: the wedged dispatcher this bound exists
// for blocks both loops at once, so sequential drains would time out twice and make
// the real bound 10 seconds. DrainAsync tolerates a null task.
await Task.WhenAll(DrainAsync(_pollTask), DrainAsync(_providerStatusTask))
await Task.WhenAll(DrainAsync(_pollTask), DrainAsync(_statusFeedTask))
.ConfigureAwait(false);
_cts.Dispose();
@@ -115,6 +115,20 @@ else
<label for="browseSubtrees" class="form-label small">Browse subtrees</label>
<textarea id="browseSubtrees" class="form-control form-control-sm" rows="2" @bind="CreateModel.BrowseSubtrees" @bind:event="oninput"></textarea>
</div>
<div class="mb-2">
<label for="dashboardTags" class="form-label small">Dashboard tags</label>
<textarea id="dashboardTags" class="form-control form-control-sm" rows="2"
aria-describedby="dashboardTagsHelp"
@bind="CreateModel.DashboardTags" @bind:event="oninput"></textarea>
<div id="dashboardTagsHelp" class="form-text small">
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
<code>Dashboard:GroupToTag</code>. Scopes dashboard event visibility only —
never what the key may read, write, or browse. Empty leaves the key's sessions
untagged, whose visibility follows <code>Dashboard:UntaggedSessionVisibility</code>.
</div>
</div>
<div class="mb-3">
<label for="maxWriteClassification" class="form-label small">Max write classification</label>
<input id="maxWriteClassification" class="form-control form-control-sm" @bind="CreateModel.MaxWriteClassification" @bind:event="oninput" />
@@ -238,7 +252,14 @@ else
GatewayScopes.Admin
];
private ApiKeyCreateModel CreateModel { get; } = new();
/// <summary>
/// Backing state for the create dialog. Internal rather than private so
/// <c>ApiKeysPageDashboardTagsTests</c> can drive the model-to-request mapping
/// directly — the assembly's established test seam (see <c>InternalsVisibleTo</c>
/// in <c>Properties/AssemblyInfo.cs</c>), because a create form behind a click is
/// unreachable from static rendering.
/// </summary>
internal ApiKeyCreateModel CreateModel { get; } = new();
private bool CanManageApiKeys { get; set; }
@@ -399,7 +420,8 @@ else
LastGeneratedApiKey = result.ApiKey;
}
private void OpenCreateDialog()
/// <summary>Opens the create dialog. Internal so a render test can reach the form's markup.</summary>
internal void OpenCreateDialog()
{
IsCreateDialogOpen = true;
}
@@ -412,7 +434,11 @@ else
}
}
private bool TryBuildCreateRequest(
/// <summary>Maps <see cref="CreateModel"/> onto a create request, or reports why it cannot.</summary>
/// <param name="request">The built request when this returns true.</param>
/// <param name="validationMessage">The reason the model is unusable when this returns false.</param>
/// <returns>True when the model produced a request.</returns>
internal bool TryBuildCreateRequest(
[System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out DashboardApiKeyManagementRequest? request,
out string? validationMessage)
{
@@ -449,7 +475,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 = ParseDashboardTags(CreateModel.DashboardTags),
});
return true;
}
@@ -514,6 +545,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}");
@@ -540,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<string> ParseDashboardTags(string? value)
{
return [.. ParseList(value).Distinct(StringComparer.OrdinalIgnoreCase)];
}
private static IReadOnlyList<string> ParseList(string? value)
{
return (value ?? string.Empty)
@@ -548,7 +596,7 @@ else
.ToArray();
}
private sealed class ApiKeyCreateModel
internal sealed class ApiKeyCreateModel
{
public string KeyId { get; set; } = string.Empty;
@@ -568,6 +616,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 +633,7 @@ else
WriteTagGlobs = string.Empty;
BrowseSubtrees = string.Empty;
MaxWriteClassification = string.Empty;
DashboardTags = string.Empty;
ReadAlarmOnly = false;
ReadHistorizedOnly = false;
}
@@ -65,6 +65,27 @@ else
}
</td>
</tr>
<tr>
<th scope="row">Dashboard tag mapping</th>
@* 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. *@
<td>
@if (Snapshot.Configuration.Dashboard.GroupToTag.Count == 0)
{
<span class="text-muted">(none configured)</span>
}
else
{
<ul class="mb-0">
@foreach (KeyValuePair<string, IReadOnlyList<string>> pair in Snapshot.Configuration.Dashboard.GroupToTag)
{
<li><code>@pair.Key</code> → @string.Join(", ", pair.Value)</li>
}
</ul>
}
</td>
</tr>
<tr><th scope="row">Worker executable</th><td><code>@Snapshot.Configuration.Worker.ExecutablePath</code></td></tr>
<tr><th scope="row">Worker architecture</th><td>@Snapshot.Configuration.Worker.RequiredArchitecture</td></tr>
<tr><th scope="row">Startup timeout</th><td>@Snapshot.Configuration.Worker.StartupTimeoutSeconds seconds</td></tr>
@@ -77,7 +98,10 @@ else
<tr><th scope="row">Dashboard enabled</th><td>@Snapshot.Configuration.Dashboard.Enabled</td></tr>
<tr><th scope="row">Anonymous localhost</th><td>@Snapshot.Configuration.Dashboard.AllowAnonymousLocalhost</td></tr>
<tr><th scope="row">Snapshot interval</th><td>@Snapshot.Configuration.Dashboard.SnapshotIntervalMilliseconds ms</td></tr>
<tr><th scope="row">Recent fault limit</th><td>@Snapshot.Configuration.Dashboard.RecentFaultLimit</td></tr>
<tr><th scope="row">Recent session limit</th><td>@Snapshot.Configuration.Dashboard.RecentSessionLimit</td></tr>
<tr><th scope="row">Show tag values</th><td>@Snapshot.Configuration.Dashboard.ShowTagValues</td></tr>
<tr><th scope="row">Untagged session visibility</th><td>@Snapshot.Configuration.Dashboard.UntaggedSessionVisibility</td></tr>
<tr><th scope="row">Worker protocol</th><td>@Snapshot.Configuration.Protocol.WorkerProtocolVersion</td></tr>
</tbody>
</table>
@@ -31,6 +31,23 @@ internal static class DashboardGroupRoleMapping
{
string normalizedGroup = group.Trim();
// Lookup precedence: the full literal group string is tried first; only if
// that misses do we fall back to the leading RDN value (e.g. "GwAdmin"
// extracted from "ou=GwAdmin,ou=groups,..."). The map's comparer is
// OrdinalIgnoreCase (see DashboardOptions.GroupToRole), so "GwAdmin" and
// "gwadmin" both match.
//
// With the shared ZB.MOM.WW.Auth.Ldap provider, groups arrive here already
// stripped to short RDN names (the library calls FirstRdnValue before
// returning them). So through the live login path the full-string branch
// only ever sees short names and the RDN fallback is effectively a no-op —
// they collapse to the same key. The fallback is retained because this
// mapping is also reachable directly via the IGroupRoleMapper<string> seam
// (DashboardGroupRoleMapper), where a caller could still pass a full DN.
// CONSEQUENCE: configuring a full-DN GroupToRole *key* (e.g.
// "ou=GwAdmin,ou=groups,...") is UNSUPPORTED with the shared library — the
// incoming group is a short name, so it will never equal a full-DN key.
// Keep GroupToRole keys as short group names.
if (groupToRole.TryGetValue(normalizedGroup, out string? mapped)
|| groupToRole.TryGetValue(ExtractFirstRdnValue(normalizedGroup), out mapped))
{
@@ -35,6 +35,13 @@ internal static class DashboardGroupTagMapping
{
string normalizedGroup = group.Trim();
// Same lookup semantics as DashboardGroupRoleMapping.MapGroupsToRoles —
// full literal group string first, leading-RDN value as the fallback, over
// an OrdinalIgnoreCase map. See the comment there for the consequence that
// applies verbatim here: the shared ZB.MOM.WW.Auth.Ldap provider delivers
// groups already stripped to short RDN names, so a full-DN GroupToTag *key*
// is UNSUPPORTED — it can never equal the short name that arrives. Keep
// GroupToTag keys as short group names.
if (!groupToTag.TryGetValue(normalizedGroup, out string[]? granted)
&& !groupToTag.TryGetValue(
DashboardGroupRoleMapping.ExtractFirstRdnValue(normalizedGroup),
@@ -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 <see cref="_gate"/> so the
/// single backing worker only ever sees one in-flight command.
/// </summary>
/// <remarks>
/// This service is also where <c>MxGateway:Dashboard:ShowTagValues</c> is
/// applied to the Browse panel: with the flag false (the default) the
/// formatted value never leaves this boundary — the projected
/// <see cref="DashboardTagValue"/> carries
/// <see cref="DashboardTagValue.RedactedValueText"/> 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.
/// </remarks>
public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsyncDisposable
{
private const string BackendName = "Galaxy";
@@ -38,6 +50,18 @@ public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsync
private readonly ILogger<DashboardLiveDataService> _logger;
private readonly SemaphoreSlim _gate = new(1, 1);
/// <summary>
/// <c>MxGateway:Dashboard:ShowTagValues</c>. False (the default)
/// substitutes <see cref="DashboardTagValue.RedactedValueText"/> for every
/// 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.
/// </summary>
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 +77,19 @@ public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsync
/// <summary>Initializes the live-data service.</summary>
/// <param name="sessionManager">Gateway session manager.</param>
/// <param name="alarmService">Gateway central alarm service.</param>
/// <param name="options">Gateway options supplying <c>Dashboard:ShowTagValues</c>.</param>
/// <param name="logger">Diagnostic logger.</param>
public DashboardLiveDataService(
ISessionManager sessionManager,
IGatewayAlarmService alarmService,
IOptions<GatewayOptions> options,
ILogger<DashboardLiveDataService> 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;
}
/// <inheritdoc />
@@ -96,8 +124,20 @@ 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.
//
// 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.Ok
? value
: value with { ValueText = DashboardTagValue.RedactedValueText })
.ToArray();
return new DashboardLiveReadResult(values, null, session.SessionId, session.WorkerProcessId);
}
@@ -17,6 +17,17 @@ public sealed record DashboardTagValue(
DateTimeOffset? SourceTimestamp,
string? Error)
{
/// <summary>
/// Placeholder rendered in place of <see cref="ValueText"/> when
/// <c>MxGateway:Dashboard:ShowTagValues</c> is false (the default). The
/// substitution happens once, in <c>DashboardLiveDataService</c>, 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.
/// </summary>
public const string RedactedValueText = "[redacted]";
/// <summary>
/// Classic OPC-DA "Good" quality. MXAccess surfaces 192 for a healthy
/// advised value; anything lower is uncertain or bad.
@@ -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.
/// </summary>
/// <remarks>
/// When <c>MxGateway:Dashboard:ShowTagValues</c> is false (the default), the
/// alarm value fields are stripped from a redacted copy before the message
/// reaches any browser client — the same rule
/// <see cref="DashboardEventBroadcaster"/> 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
/// <c>StreamAlarms</c> subscribers and the alarms page as well, and none of
/// those audiences is subject to this dashboard-display flag.
/// </remarks>
/// <param name="alarmService">The gateway's central alarm feed.</param>
/// <param name="hubContext">Hub context used to broadcast to the alarms group.</param>
/// <param name="options">Gateway options supplying <c>Dashboard:ShowTagValues</c>.</param>
/// <param name="logger">Logger for best-effort broadcast failures.</param>
public sealed class AlarmsHubPublisher(
IGatewayAlarmService alarmService,
IHubContext<AlarmsHub> hubContext,
IOptions<GatewayOptions> options,
ILogger<AlarmsHubPublisher> logger) : BackgroundService
{
private readonly bool _showTagValues = options.Value.Dashboard.ShowTagValues;
/// <inheritdoc />
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(
}
}
}
/// <summary>
/// Produces a copy of <paramref name="source"/> 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.
/// </summary>
/// <param name="source">The message as the alarm feed produced it.</param>
/// <returns>
/// 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.
/// </returns>
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;
}
}
}
@@ -16,7 +16,8 @@ namespace ZB.MOM.WW.MxGateway.Tests.Alarms;
/// <summary>
/// Carries the worker's truncated-fetch verdict across the gateway: worker
/// reply payload → <see cref="GatewayAlarmMonitor"/> → the public
/// <c>QueryActiveAlarms</c> stream.
/// <c>QueryActiveAlarms</c> stream (per record) and the live
/// <c>StreamAlarms</c> feed (the <c>snapshot_status</c> frame).
/// </summary>
/// <remarks>
/// <para>
@@ -145,6 +146,302 @@ public sealed class AlarmTruncationSignalTests
await monitor.StopAsync(CancellationToken.None);
}
/// <summary>
/// The feed-level edge. A reconcile that flips the verdict to truncated
/// pushes exactly one <c>snapshot_status</c> 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.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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<AlarmFeedMessage> 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);
}
/// <summary>
/// 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.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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<AlarmFeedMessage> 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);
}
/// <summary>
/// Late-joiner priming while the verdict is truncated. The frame must
/// arrive after <c>provider_status</c> and before the cached
/// <c>active_alarm</c> frames: a consumer that applies the snapshot as it
/// streams needs the completeness caveat in hand before the records it
/// qualifies, not after.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>
/// 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.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>
/// 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.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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<AlarmFeedMessage> 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<AlarmFeedMessage> 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<AlarmFeedMessage> 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<AlarmFeedMessage[]> ReadPreambleAsync(GatewayAlarmMonitor monitor)
{
List<AlarmFeedMessage> 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<WorkerEvent> _events = Channel.CreateUnbounded<WorkerEvent>();
private readonly TaskCompletionSource _reconciled =
new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly object _sync = new();
private bool _snapshotTruncated;
private IReadOnlyList<ActiveAlarmSnapshot> _snapshots = [];
/// <summary>Gets or sets the truncation verdict the scripted reply carries.</summary>
public bool SnapshotTruncated { get; init; }
/// <summary>
/// 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.
/// </summary>
public bool SnapshotTruncated
{
get { lock (_sync) { return _snapshotTruncated; } }
set { lock (_sync) { _snapshotTruncated = value; } }
}
/// <summary>Gets or sets the snapshots the scripted reply carries.</summary>
public IReadOnlyList<ActiveAlarmSnapshot> Snapshots { get; init; } = [];
public IReadOnlyList<ActiveAlarmSnapshot> Snapshots
{
get { lock (_sync) { return _snapshots; } }
set { lock (_sync) { _snapshots = value; } }
}
/// <summary>Completes once the monitor has issued its first QueryActiveAlarms.</summary>
/// <param name="timeout">The maximum time to wait.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task WaitForReconcileAsync(TimeSpan timeout) => _reconciled.Task.WaitAsync(timeout);
/// <summary>Pushes a worker event into the session's distributor pump.</summary>
/// <param name="mxEvent">The event to push.</param>
public void EmitEvent(MxEvent mxEvent) =>
_events.Writer.TryWrite(new WorkerEvent { Event = mxEvent });
/// <summary>
/// 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.
/// </summary>
public void EndWorkerEventStream() => _events.Writer.TryComplete();
/// <inheritdoc />
public Task<GatewaySession> 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();
}
@@ -0,0 +1,164 @@
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server.Configuration;
namespace ZB.MOM.WW.MxGateway.Tests.Configuration;
/// <summary>
/// Covers the projection <see cref="GatewayConfigurationProvider"/> makes from bound
/// <see cref="GatewayOptions"/> onto the effective-configuration record the dashboard renders.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed class GatewayConfigurationProviderTests
{
/// <summary>The group → tag map reaches the projection with its groups, tags and ordering intact.</summary>
[Fact]
public void GetEffectiveConfiguration_CopiesGroupToTagMapping()
{
GatewayOptions options = new()
{
Dashboard = new DashboardOptions
{
GroupToTag = new Dictionary<string, string[]>(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"]);
}
/// <summary>
/// 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.
/// </summary>
[Fact]
public void GetEffectiveConfiguration_KeepsGroupToTagLookupCaseInsensitive()
{
GatewayOptions options = new()
{
Dashboard = new DashboardOptions
{
GroupToTag = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
["GwOps"] = ["team-a"],
},
},
};
EffectiveDashboardConfiguration dashboard = Project(options).Dashboard;
Assert.True(dashboard.GroupToTag.ContainsKey("gwops"));
}
/// <summary>
/// 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.
/// </summary>
[Fact]
public void GetEffectiveConfiguration_WhenNoTagsConfigured_ProjectsEmptyMapping()
{
EffectiveDashboardConfiguration dashboard = Project(new GatewayOptions()).Dashboard;
Assert.Empty(dashboard.GroupToTag);
Assert.Empty(dashboard.GroupToRole);
}
/// <summary>The untagged-session visibility policy is projected, defaulting to the fail-closed value.</summary>
[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);
}
/// <summary>The pre-existing dashboard members keep coming from the options they always did.</summary>
[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<string, string>(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"]);
}
/// <summary>
/// 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.
/// </summary>
[Fact]
public void GetEffectiveConfiguration_RedactsOnlyTheSecretBearingMembers()
{
GatewayOptions options = new()
{
Ldap = new LdapOptions { ServiceAccountPassword = "bind-password" },
Dashboard = new DashboardOptions
{
GroupToTag = new Dictionary<string, string[]>(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();
}
@@ -1,7 +1,11 @@
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Threading.Channels;
using Microsoft.AspNetCore.Components.Web.HtmlRendering;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
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;
@@ -36,6 +40,8 @@ public sealed class AlarmsPageTruncationBannerTests
{
private const string BannerMarker = "Alarm snapshot may be incomplete";
private static readonly TimeSpan RenderWaitTimeout = TimeSpan.FromSeconds(10);
/// <summary>A capped provider fetch puts the completeness caveat on the page.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
@@ -61,6 +67,158 @@ public sealed class AlarmsPageTruncationBannerTests
Assert.Contains("Active Alarms", html, StringComparison.Ordinal);
}
/// <summary>
/// The push path. A <c>snapshot_status</c> frame arriving on the page's
/// in-process alarm-feed subscription raises the caveat on its own, with
/// no poll tick behind it — the poll is a 3-second reconcile baseline, and
/// an operator should not stare at an un-caveated alarm list for up to
/// three seconds after the gateway has already decided the set is capped.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AlarmsPage_WhenFeedPushesTruncated_RaisesTheBannerWithoutAPollTick()
{
ScriptedAlarmFeed feed = new();
await using ServiceProvider provider = BuildPushServices(feed);
await using HtmlRenderer renderer = new(
provider,
provider.GetRequiredService<ILoggerFactory>());
HtmlRootComponent page = await renderer.Dispatcher.InvokeAsync(
() => renderer.RenderComponentAsync<AlarmsPage>());
// The one poll answer this page will ever get said "complete", so everything
// the banner does from here is the feed's doing.
Assert.DoesNotContain(BannerMarker, await HtmlAsync(renderer, page), StringComparison.Ordinal);
await feed.PushAsync(SnapshotStatusFrame(truncated: true));
await WaitForHtmlAsync(
renderer,
page,
html => html.Contains(BannerMarker, StringComparison.Ordinal),
"banner to appear after a truncated snapshot_status frame");
}
/// <summary>
/// The clearing edge, page-side. Absence authority comes back when the
/// gateway says so; a banner that only ever went up would caveat the alarm
/// list for the life of the circuit.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AlarmsPage_WhenFeedPushesComplete_ClearsTheBannerWithoutAPollTick()
{
ScriptedAlarmFeed feed = new();
await using ServiceProvider provider = BuildPushServices(feed);
await using HtmlRenderer renderer = new(
provider,
provider.GetRequiredService<ILoggerFactory>());
HtmlRootComponent page = await renderer.Dispatcher.InvokeAsync(
() => renderer.RenderComponentAsync<AlarmsPage>());
await feed.PushAsync(SnapshotStatusFrame(truncated: true));
await WaitForHtmlAsync(
renderer,
page,
html => html.Contains(BannerMarker, StringComparison.Ordinal),
"banner to appear before the clearing frame is pushed");
await feed.PushAsync(SnapshotStatusFrame(truncated: false));
await WaitForHtmlAsync(
renderer,
page,
html => !html.Contains(BannerMarker, StringComparison.Ordinal),
"banner to clear after a complete snapshot_status frame");
}
/// <summary>
/// Attach while the verdict is already truncated. The page carries no
/// priming logic of its own — it relies on <c>StreamAsync</c> opening every
/// subscription with a <c>snapshot_status</c> baseline — so the caveat has
/// to come up off the open sequence alone, with no edge pushed afterwards.
/// A page that only handled the edge would show an un-caveated alarm list
/// to every operator who opened it after the truncation began.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AlarmsPage_AttachingToAnAlreadyTruncatedFeed_RaisesTheBannerFromThePriming()
{
// The monitor's open sequence: provider status, then the unconditional
// completeness baseline. Nothing is pushed after this.
ScriptedAlarmFeed feed = new()
{
Priming =
[
new AlarmFeedMessage { ProviderStatus = new AlarmProviderStatus() },
SnapshotStatusFrame(truncated: true),
],
};
await using ServiceProvider provider = BuildPushServices(feed);
await using HtmlRenderer renderer = new(
provider,
provider.GetRequiredService<ILoggerFactory>());
HtmlRootComponent page = await renderer.Dispatcher.InvokeAsync(
() => renderer.RenderComponentAsync<AlarmsPage>());
await WaitForHtmlAsync(
renderer,
page,
html => html.Contains(BannerMarker, StringComparison.Ordinal),
"banner to appear from the feed's open-time snapshot_status baseline");
}
private static AlarmFeedMessage SnapshotStatusFrame(bool truncated)
{
return new AlarmFeedMessage
{
SnapshotStatus = new AlarmSnapshotStatus { Truncated = truncated },
};
}
private static ServiceProvider BuildPushServices(ScriptedAlarmFeed feed)
{
ServiceCollection services = new();
services.AddLogging();
services.AddSingleton<IDashboardLiveDataService, SinglePollLiveDataService>();
services.AddSingleton<IGatewayAlarmService>(feed);
services.AddSingleton<IOptions<GatewayOptions>>(
Options.Create(new GatewayOptions { Alarms = new AlarmsOptions { Enabled = true } }));
return services.BuildServiceProvider();
}
private static Task<string> HtmlAsync(HtmlRenderer renderer, HtmlRootComponent page)
{
// Serialization has to happen on the renderer's dispatcher, and it reads the
// component's current render tree — so it reflects renders the page's feed
// loop queued after the initial quiescent render.
return renderer.Dispatcher.InvokeAsync(page.ToHtmlString);
}
private static async Task WaitForHtmlAsync(
HtmlRenderer renderer,
HtmlRootComponent page,
Func<string, bool> predicate,
string expectation)
{
Stopwatch elapsed = Stopwatch.StartNew();
while (elapsed.Elapsed < RenderWaitTimeout)
{
if (predicate(await HtmlAsync(renderer, page)))
{
return;
}
await Task.Delay(TimeSpan.FromMilliseconds(20));
}
Assert.Fail($"Timed out after {RenderWaitTimeout.TotalSeconds:N0}s waiting for the {expectation}.");
}
private static async Task<string> RenderAsync(bool snapshotTruncated)
{
ServiceCollection services = new();
@@ -102,4 +260,107 @@ public sealed class AlarmsPageTruncationBannerTests
WorkerProcessId: null,
SnapshotTruncated: snapshotTruncated));
}
// Answers exactly one poll — the inline first pass — and parks every later tick
// until the page's disposal cancels it. The parking is what makes the push tests
// measure the push: a second tick would re-assert the poll's own verdict, and
// could either mask a banner the feed raised or raise one the feed did not.
private sealed class SinglePollLiveDataService : IDashboardLiveDataService
{
private int _polls;
/// <inheritdoc />
public Task<DashboardLiveReadResult> ReadAsync(
IReadOnlyCollection<string> tagAddresses,
CancellationToken cancellationToken) =>
Task.FromResult(DashboardLiveReadResult.Empty);
/// <inheritdoc />
public async Task<DashboardAlarmQueryResult> QueryAlarmsAsync(CancellationToken cancellationToken)
{
if (Interlocked.Increment(ref _polls) > 1)
{
await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false);
}
return new DashboardAlarmQueryResult(
Alarms: [],
Error: null,
WorkerProcessId: null,
SnapshotTruncated: false);
}
}
// A hand-driven stand-in for the alarm feed: the test writes the frames the real
// monitor would push. Unbounded and never completed, so a frame written before the
// page's loop attaches is still delivered, and the loop never has to resubscribe.
private sealed class ScriptedAlarmFeed : IGatewayAlarmService
{
private readonly Channel<AlarmFeedMessage> _frames =
Channel.CreateUnbounded<AlarmFeedMessage>(new UnboundedChannelOptions
{
SingleReader = false,
SingleWriter = false,
});
/// <inheritdoc />
public GatewayAlarmMonitorState State => GatewayAlarmMonitorState.Monitoring;
/// <inheritdoc />
public string? LastError => null;
/// <inheritdoc />
public int? WorkerProcessId => null;
/// <inheritdoc />
public IReadOnlyList<ActiveAlarmSnapshot> CurrentAlarms => [];
/// <inheritdoc />
public bool SnapshotTruncated { get; set; }
/// <summary>
/// Frames replayed at the head of every subscription, standing in for the
/// monitor's open sequence (provider status, then the unconditional
/// completeness baseline). Empty means the subscriber sees only what the
/// test pushes.
/// </summary>
public IReadOnlyList<AlarmFeedMessage> Priming { get; init; } = [];
/// <summary>Pushes one frame onto the feed the page is subscribed to.</summary>
/// <param name="message">The feed frame to deliver.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public ValueTask PushAsync(AlarmFeedMessage message) => _frames.Writer.WriteAsync(message);
/// <inheritdoc />
public async IAsyncEnumerable<AlarmFeedMessage> StreamAsync(
string? alarmFilterPrefix,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
foreach (AlarmFeedMessage primed in Priming)
{
cancellationToken.ThrowIfCancellationRequested();
yield return primed;
}
await foreach (AlarmFeedMessage message in _frames.Reader
.ReadAllAsync(cancellationToken)
.ConfigureAwait(false))
{
yield return message;
}
}
/// <inheritdoc />
public Task<AcknowledgeAlarmReply> AcknowledgeAsync(
AcknowledgeAlarmRequest request,
CancellationToken cancellationToken)
{
return Task.FromResult(new AcknowledgeAlarmReply
{
CorrelationId = request.ClientCorrelationId,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
DiagnosticMessage = string.Empty,
});
}
}
}
@@ -0,0 +1,358 @@
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;
/// <summary>
/// Covers <c>dashboard_tags</c> on the dashboard API Keys page: the constraints
/// column must name it, and the create form must be able to set it.
/// </summary>
/// <remarks>
/// <para>
/// The column mattered first. <c>ApiKeyConstraints.IsEmpty</c> counts
/// <c>DashboardTags</c>, so a key whose only per-key policy is a dashboard tag is
/// <em>not</em> unconstrained — but the page's summary enumerated only the eight
/// positional members, produced an empty string, and rendered it as <c>-</c>. 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.
/// </para>
/// <para>
/// Rendered through the framework's static rendering infrastructure, the idiom
/// <c>AlarmsPageTruncationBannerTests</c> and <c>SessionDetailsPageEventAclTests</c>
/// use — the assertions are about emitted markup, so no component-testing package is
/// warranted.
/// </para>
/// </remarks>
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";
/// <summary>
/// A key whose only constraint is a dashboard tag names the tags in its constraints
/// cell. Before the fix this cell read <c>-</c>.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApiKeysPage_WhenKeyOnlyHasDashboardTags_NamesThemInTheConstraintsColumn()
{
string html = await RenderAsync(openCreateDialog: false);
Assert.Contains("dashboard_tags=[team-a, team-b]", html, StringComparison.Ordinal);
}
/// <summary>
/// The control for the assertion above: a key with genuinely no constraints must still
/// read <c>unconstrained</c>, 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.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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"));
}
/// <summary>
/// The create form offers a dashboard-tags field, so a tagged key can be minted from the
/// dashboard rather than only from the <c>apikey</c> CLI.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>
/// The field is wired through to the request, not merely displayed: the CLI's comma
/// separation is honoured and each tag is trimmed.
/// </summary>
[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);
}
/// <summary>
/// Two spellings of one tag persist as one grant, matching how
/// <c>ApiKeyAdminCommandLineParser.ParseDashboardTags</c> collapses them and how the
/// enforcement site compares them.
/// </summary>
/// <remarks>
/// 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
/// <c>dashboard_tags=[team-a, TEAM-A]</c> and report one grant as two, which is the
/// reading an audit surface can least afford.
/// </remarks>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// <c>Reset</c> clears the new field, so the next key minted in the same dialog does not
/// inherit the previous key's tag grant.
/// </summary>
[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);
}
/// <summary>An empty field leaves the key untagged rather than inventing a tag.</summary>
[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<string> RenderAsync(bool openCreateDialog)
{
ServiceCollection services = new();
services.AddLogging();
services.AddSingleton<IDashboardSnapshotService>(new StubSnapshotService());
services.AddSingleton<IDashboardSnapshotFeed>(new IdleSnapshotFeed());
services.AddSingleton<IDashboardApiKeyManagementService>(new ManagingApiKeyService());
services.AddSingleton<AuthenticationStateProvider>(new StubAuthenticationStateProvider());
await using ServiceProvider provider = services.BuildServiceProvider();
await using InstanceMountingHtmlRenderer renderer = new(
provider,
provider.GetRequiredService<ILoggerFactory>());
return await renderer.Dispatcher.InvokeAsync(async () =>
{
ApiKeysPage page = renderer.CreateComponent<ApiKeysPage>();
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)
{
/// <summary>Instantiates a component with its <c>[Inject]</c> properties resolved.</summary>
/// <typeparam name="TComponent">Component type to create.</typeparam>
/// <returns>The component instance, not yet attached to the renderer.</returns>
public TComponent CreateComponent<TComponent>()
where TComponent : IComponent =>
(TComponent)InstantiateComponent(typeof(TComponent));
}
#pragma warning restore BL0006
private sealed class StubSnapshotService : IDashboardSnapshotService
{
/// <inheritdoc />
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<string>(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<string>(StringComparer.Ordinal),
Constraints: ApiKeyConstraints.Empty,
CreatedUtc: DateTimeOffset.UnixEpoch,
LastUsedUtc: null,
RevokedUtc: null),
],
Configuration: null!,
Galaxy: null!);
/// <inheritdoc />
public IAsyncEnumerable<DashboardSnapshot> 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
{
/// <inheritdoc />
public async IAsyncEnumerable<DashboardSnapshot> 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
{
/// <inheritdoc />
public bool CanManage(ClaimsPrincipal user) => true;
/// <inheritdoc />
public Task<DashboardApiKeyManagementResult> CreateAsync(
ClaimsPrincipal user,
DashboardApiKeyManagementRequest request,
CancellationToken cancellationToken) =>
Task.FromResult(DashboardApiKeyManagementResult.Fail("not exercised"));
/// <inheritdoc />
public Task<DashboardApiKeyManagementResult> RevokeAsync(
ClaimsPrincipal user,
string keyId,
CancellationToken cancellationToken) =>
Task.FromResult(DashboardApiKeyManagementResult.Fail("not exercised"));
/// <inheritdoc />
public Task<DashboardApiKeyManagementResult> RotateAsync(
ClaimsPrincipal user,
string keyId,
CancellationToken cancellationToken) =>
Task.FromResult(DashboardApiKeyManagementResult.Fail("not exercised"));
/// <inheritdoc />
public Task<DashboardApiKeyManagementResult> DeleteAsync(
ClaimsPrincipal user,
string keyId,
CancellationToken cancellationToken) =>
Task.FromResult(DashboardApiKeyManagementResult.Fail("not exercised"));
}
private sealed class StubAuthenticationStateProvider : AuthenticationStateProvider
{
/// <inheritdoc />
public override Task<AuthenticationState> 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))));
}
}
@@ -0,0 +1,158 @@
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;
/// <summary>
/// Renders <see cref="SettingsPage"/> and asserts the two SEC-25 dashboard-ACL options —
/// <c>Dashboard:GroupToTag</c> and <c>Dashboard:UntaggedSessionVisibility</c> — reach the page.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// Static rendering via the framework's <see cref="HtmlRenderer"/>, the idiom used by
/// <c>SecretsNavRenderTests</c> and <c>AlarmsPageTruncationBannerTests</c> — the assertion is
/// about markup the server emits, so no component-testing dependency is warranted.
/// </para>
/// </remarks>
public sealed class SettingsPageTagVisibilityRenderTests
{
private const string EmptyMarker = "(none configured)";
/// <summary>A configured mapping renders its group and every tag, plus the visibility policy.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SettingsPage_WhenTagsConfigured_RendersGroupsTagsAndVisibility()
{
string html = await RenderAsync(new DashboardOptions
{
GroupToTag = new Dictionary<string, string[]>(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);
}
/// <summary>
/// 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.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>
/// <c>RecentFaultLimit</c> and <c>RecentSessionLimit</c> reach the page, each value landing in
/// the row its own label names — not merely present somewhere in the document. Non-default
/// values prove the projection is plumbed through, not just that the defaults happen to render.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SettingsPage_RendersRecentFaultAndSessionLimits()
{
string html = await RenderAsync(new DashboardOptions
{
RecentFaultLimit = 123,
RecentSessionLimit = 456,
});
// Combined label+value fragments, matching the razor's exact markup (no whitespace between
// tags): a bare Contains on "123"/"456" would pass even if the values landed in the wrong
// row, or any other row on the page.
Assert.Contains("""<th scope="row">Recent fault limit</th><td>123</td>""", html, StringComparison.Ordinal);
Assert.Contains("""<th scope="row">Recent session limit</th><td>456</td>""", html, StringComparison.Ordinal);
}
private static async Task<string> RenderAsync(DashboardOptions dashboard)
{
EffectiveGatewayConfiguration configuration =
new GatewayConfigurationProvider(Options.Create(new GatewayOptions { Dashboard = dashboard }))
.GetEffectiveConfiguration();
ServiceCollection services = new();
services.AddLogging();
services.AddSingleton<IDashboardSnapshotService>(new StubSnapshotService(configuration));
services.AddSingleton<IDashboardSnapshotFeed>(new IdleSnapshotFeed());
await using ServiceProvider provider = services.BuildServiceProvider();
await using HtmlRenderer renderer = new(
provider,
provider.GetRequiredService<ILoggerFactory>());
return await renderer.Dispatcher.InvokeAsync(async () =>
{
HtmlRootComponent output = await renderer.RenderComponentAsync<SettingsPage>();
return output.ToHtmlString();
});
}
// Seeds the page's first (and only) render. Everything except Configuration is inert here.
private sealed class StubSnapshotService(EffectiveGatewayConfiguration configuration)
: IDashboardSnapshotService
{
/// <inheritdoc />
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!);
/// <inheritdoc />
public IAsyncEnumerable<DashboardSnapshot> 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
{
/// <inheritdoc />
public async IAsyncEnumerable<DashboardSnapshot> WatchAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false);
yield break;
}
}
}
@@ -0,0 +1,351 @@
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;
/// <summary>
/// Verifies that <see cref="AlarmsHubPublisher"/> honours
/// <c>MxGateway:Dashboard:ShowTagValues</c> (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
/// <see cref="AlarmFeedMessage"/> — shared with the gRPC <c>StreamAlarms</c>
/// subscribers and the alarms page — is never mutated.
/// </summary>
public sealed class AlarmsHubPublisherTests
{
private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5);
/// <summary>Both value-bearing arms lose their values when the flag is off; metadata survives.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>
/// Redaction applies to a clone: the source message fans out to gRPC
/// <c>StreamAlarms</c> subscribers and the alarms page, so it must keep its
/// values.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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]);
}
/// <summary>Values pass through unredacted — and uncloned — when the flag is on.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>
/// 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.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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",
},
};
AlarmFeedMessage snapshotStatus = new()
{
SnapshotStatus = new AlarmSnapshotStatus { Truncated = true },
};
CapturingHubContext hubContext = await RunPublisherAsync(
showTagValues: false,
snapshotComplete,
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);
}
/// <summary>
/// Starts the publisher over a scripted feed, waits until every scripted
/// message has been broadcast, and stops it.
/// </summary>
/// <param name="showTagValues">The <c>Dashboard:ShowTagValues</c> value under test.</param>
/// <param name="messages">The messages the fake alarm feed yields, in order.</param>
/// <returns>The hub context holding everything the publisher sent.</returns>
private static async Task<CapturingHubContext> 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<AlarmsHubPublisher>.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<bool> predicate)
{
using CancellationTokenSource cancellationTokenSource = new(TestTimeout);
while (!predicate())
{
await Task.Delay(TimeSpan.FromMilliseconds(5), cancellationTokenSource.Token);
}
}
/// <summary>Builds a value-bearing <c>transition</c> feed message.</summary>
/// <returns>The message.</returns>
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 },
},
};
}
/// <summary>Builds a value-bearing <c>active_alarm</c> feed message.</summary>
/// <returns>The message.</returns>
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 },
},
};
}
/// <summary>
/// Yields a scripted message list once and then stays open until cancelled,
/// so the publisher's reconnect loop never re-subscribes mid-test.
/// </summary>
/// <param name="messages">The messages to yield, in order.</param>
private sealed class ScriptedAlarmService(IReadOnlyList<AlarmFeedMessage> messages) : IGatewayAlarmService
{
/// <inheritdoc />
public GatewayAlarmMonitorState State => GatewayAlarmMonitorState.Monitoring;
/// <inheritdoc />
public string? LastError => null;
/// <inheritdoc />
public int? WorkerProcessId => null;
/// <inheritdoc />
public IReadOnlyList<ActiveAlarmSnapshot> CurrentAlarms => [];
/// <inheritdoc />
public bool SnapshotTruncated => false;
/// <inheritdoc />
public async IAsyncEnumerable<AlarmFeedMessage> 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)
{
}
}
/// <inheritdoc />
public Task<AcknowledgeAlarmReply> AcknowledgeAsync(
AcknowledgeAlarmRequest request,
CancellationToken cancellationToken) => throw new NotSupportedException();
}
private sealed class CapturingHubContext : IHubContext<AlarmsHub>
{
private readonly CapturingHubClients _clients = new();
/// <summary>Gets the hub clients.</summary>
public IHubClients Clients => _clients;
/// <summary>Gets the group manager.</summary>
public IGroupManager Groups { get; } = new NoopGroupManager();
/// <summary>Gets every message the publisher broadcast, in order.</summary>
public IReadOnlyList<AlarmFeedMessage> Sent => _clients.GroupProxy.Sent;
}
private sealed class CapturingHubClients : IHubClients
{
/// <summary>Gets the capturing client proxy shared by this fake.</summary>
public CapturingClientProxy GroupProxy { get; } = new();
public IClientProxy All => GroupProxy;
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => GroupProxy;
public IClientProxy Client(string connectionId) => GroupProxy;
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => GroupProxy;
public IClientProxy Group(string groupName) => GroupProxy;
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => GroupProxy;
public IClientProxy Groups(IReadOnlyList<string> groupNames) => GroupProxy;
public IClientProxy User(string userId) => GroupProxy;
public IClientProxy Users(IReadOnlyList<string> userIds) => GroupProxy;
}
private sealed class CapturingClientProxy : IClientProxy
{
private readonly List<AlarmFeedMessage> _sent = [];
/// <summary>Gets every alarm message sent through this proxy, in order.</summary>
public IReadOnlyList<AlarmFeedMessage> Sent
{
get
{
lock (_sent)
{
return [.. _sent];
}
}
}
/// <summary>Records the broadcast message and completes synchronously.</summary>
/// <param name="method">The SignalR method name.</param>
/// <param name="args">The method arguments.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>A completed task.</returns>
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;
}
}
@@ -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,100 @@ public sealed class DashboardLiveDataServiceTests
Assert.Equal(filler[0], worker.SubscribedTags[^1]);
}
private static DashboardLiveDataService CreateService(ISessionManager sessionManager)
/// <summary>
/// Verifies the <c>/browse</c> live-value seam honours
/// <c>MxGateway:Dashboard:ShowTagValues</c> (TST-16): with the flag off — the
/// default — the value text the page renders is the redaction placeholder,
/// never the formatted tag value.
/// </summary>
[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);
}
/// <summary>Verifies the formatted value is served when the flag is on.</summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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)
{
GatewayOptions gatewayOptions = new()
{
Dashboard = new DashboardOptions { ShowTagValues = showTagValues },
};
return new DashboardLiveDataService(
sessionManager,
new FakeGatewayAlarmService(),
Options.Create(gatewayOptions),
NullLogger<DashboardLiveDataService>.Instance);
}
@@ -322,6 +413,15 @@ public sealed class DashboardLiveDataServiceTests
/// <summary>Gets or sets a value indicating whether unsubscribe commands throw.</summary>
public bool FailUnsubscribe { get; set; }
/// <summary>
/// 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.
/// </summary>
public MxValue? ReadValue { get; set; }
/// <summary>Gets the tag addresses whose bulk read comes back unsuccessful.</summary>
public HashSet<string> FailReadFor { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Gets the item handle bound for a previously subscribed tag.</summary>
/// <param name="tagAddress">Tag address to look up.</param>
/// <returns>The bound item handle.</returns>
@@ -434,14 +534,35 @@ public sealed class DashboardLiveDataServiceTests
BulkReadReply readReply = new();
foreach (string tagAddress in tagAddresses)
{
readReply.Results.Add(new BulkReadResult
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,
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;
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.IO.Pipes;
using System.Runtime.InteropServices;
@@ -1451,10 +1452,17 @@ public sealed class WorkerPipeSessionTests
/// refresh <c>LastActivityUtc</c> on every wait iteration, so a healthy
/// <c>ReadBulk</c> holding the STA far longer than
/// <c>HeartbeatStuckCeiling</c> (75 s in production) keeps its activity
/// timestamp fresh. This test compresses the clock — a 100 ms ceiling
/// with a command in flight across a window many multiples longer — and
/// models the pump refresh by continuously advancing the snapshot's
/// <c>LastStaActivityUtc</c> while the command blocks. Contrast
/// timestamp fresh. This test compresses the clock — a 1 s ceiling with
/// a command in flight across a window twice as long — and models the
/// pump refresh with
/// <see cref="FakeRuntimeSession.RefreshStaActivityOnCapture"/>, which
/// stamps activity at every heartbeat capture exactly as the pump's
/// per-iteration <c>MarkActivity()</c> does. The refresh has to be in
/// effect from construction, not from the moment the command blocks: the
/// idle window covering handshake and startup carries no correlation id
/// for the watchdog to suppress on, so a fake whose activity timestamp is
/// frozen at construction is reported <c>StaHung</c> before the scenario
/// under test even starts. Contrast
/// <see cref="RunAsync_WhenStaActivityIsStaleBeyondCeilingWithCommandInFlight_WritesWatchdogFault"/>,
/// where a frozen timestamp beyond the ceiling correctly faults; here
/// the refreshed timestamp must keep the fault suppressed and let the
@@ -1469,15 +1477,28 @@ public sealed class WorkerPipeSessionTests
FakeRuntimeSession runtime = new()
{
BlockDispatch = true,
// The pump refreshes STA activity on every wait iteration, so every
// heartbeat capture on a healthy worker sees fresh activity — while
// a command holds the STA and while it is idle alike. Armed before
// RunAsync so the very first beat, sent as soon as the session is
// Ready, is already covered.
RefreshStaActivityOnCapture = true,
};
WorkerPipeSession session = CreatePipeSession(
pipePair.WorkerStream,
runtime,
new WorkerPipeSessionOptions
{
// Compressed relative to production (75 s ceiling), but no further than the real
// pipe underneath can carry. ReportWatchdogFaultIfNeededAsync measures staleness
// AFTER the heartbeat frame has been written and flushed, so any beat whose pipe
// I/O outlasts the ceiling faults a healthy session. At a 100 ms ceiling that is a
// plausible stall on a loaded box; at 1 s it is not — and this is the one test
// asserting the watchdog NEVER fires, so it has to hold under load.
HeartbeatInterval = TimeSpan.FromMilliseconds(20),
HeartbeatGrace = TimeSpan.FromMilliseconds(50),
HeartbeatStuckCeiling = TimeSpan.FromMilliseconds(100),
HeartbeatGrace = TimeSpan.FromMilliseconds(200),
HeartbeatStuckCeiling = TimeSpan.FromSeconds(1),
});
Task runTask = session.RunAsync(cancellation.Token);
await CompleteGatewayHandshakeAsync(pipePair, cancellation.Token);
@@ -1490,49 +1511,57 @@ public sealed class WorkerPipeSessionTests
runtime.DispatchStarted.Wait(TimeSpan.FromSeconds(5)),
"The long command must reach the runtime and begin dispatch.");
// Model the pump refreshing STA activity on each wait iteration: keep
// the snapshot's LastStaActivityUtc current while the command is in
// flight.
using CancellationTokenSource pumpRefresh = new();
Task refreshLoop = Task.Run(
async () =>
{
while (!pumpRefresh.IsCancellationRequested)
{
runtime.SetSnapshot(new WorkerRuntimeHeartbeatSnapshot(
DateTimeOffset.UtcNow,
pendingCommandCount: 1,
outboundEventQueueDepth: 0,
lastEventSequence: 0,
currentCommandCorrelationId: "long-bulk-read"));
await Task.Delay(TimeSpan.FromMilliseconds(20)).ConfigureAwait(false);
}
});
// Publish the in-flight shape the heartbeat then reports for the whole
// blocked window; only LastStaActivityUtc moves after this, refreshed by
// the modelled pump at each capture.
runtime.SetSnapshot(new WorkerRuntimeHeartbeatSnapshot(
DateTimeOffset.UtcNow,
pendingCommandCount: 1,
outboundEventQueueDepth: 0,
lastEventSequence: 0,
currentCommandCorrelationId: "long-bulk-read"));
// Inspect a bounded number of frames over a window many multiples of the
// 100 ms ceiling (at least 30 heartbeats at 20 ms ~ 600 ms). None may be
// a WorkerFault while activity is continuously refreshed.
const int framesToInspect = 30;
for (int index = 0; index < framesToInspect; index++)
// Inspect frames across a window twice the stuck ceiling — long enough that a fake whose
// activity timestamp stopped advancing would accumulate staleness past the ceiling and
// fault — and require the beats to have actually flowed while it ran, so an inspection
// that saw a couple of frames and timed out cannot pass for a clean window. None may be a
// WorkerFault while activity is continuously refreshed. Nothing here is racing
// FakeRuntimeSession's blocked-dispatch backstop: that wait is a safety net sized far above
// any window a test opens (and above this test's own cancellation), so the command stays in
// flight for however long a loaded box stretches the loop. Were the two close together, a
// slow run would take the reply mid-window and then fail waiting for a reply already gone
// by — a cancellation at teardown, naming nothing.
TimeSpan inspectionWindow = TimeSpan.FromSeconds(2);
const int minimumFramesInspected = 30;
Stopwatch inspection = Stopwatch.StartNew();
int frameIndex = 0;
while (inspection.Elapsed < inspectionWindow || frameIndex < minimumFramesInspected)
{
WorkerEnvelope envelope = await pipePair.GatewayReader
.ReadAsync(cancellation.Token);
Assert.NotEqual(
WorkerEnvelope.BodyOneofCase.WorkerFault,
envelope.BodyCase);
AssertNotWorkerFault(envelope, frameIndex++);
}
// Stop refreshing and release the command; its reply must be delivered
// because the session never faulted (state stayed Ready).
pumpRefresh.Cancel();
await refreshLoop;
// Release the command with the pump still running — as it is in
// production while the reply is marshalled off the STA. The reply must
// be delivered (the session never faulted, so its state stayed Ready),
// and no frame on the way to it may be a fault either.
runtime.ReleaseDispatch();
WorkerEnvelope reply = await ReadUntilAsync(
pipePair.GatewayReader,
WorkerEnvelope.BodyOneofCase.WorkerCommandReply,
envelope => envelope.CorrelationId == "long-bulk-read",
cancellation.Token);
WorkerEnvelope reply;
while (true)
{
WorkerEnvelope envelope = await pipePair.GatewayReader
.ReadAsync(cancellation.Token);
AssertNotWorkerFault(envelope, frameIndex++);
if (envelope.BodyCase == WorkerEnvelope.BodyOneofCase.WorkerCommandReply
&& envelope.CorrelationId == "long-bulk-read")
{
reply = envelope;
break;
}
}
Assert.Equal(
ProtocolStatusCode.Ok,
reply.WorkerCommandReply.Reply.ProtocolStatus.Code);
@@ -2211,6 +2240,26 @@ public sealed class WorkerPipeSessionTests
cancellationToken);
}
/// <summary>
/// Fails when the frame is a <c>WorkerFault</c>, naming the category and diagnostic message.
/// A bare body-case comparison reports only "expected not WorkerFault", which says nothing
/// about which watchdog or protocol path produced it — the one fact needed to tell a
/// regression from a harness that mis-models the runtime.
/// </summary>
/// <param name="envelope">Frame read from the gateway end.</param>
/// <param name="frameIndex">Ordinal of the frame within the inspected run.</param>
private static void AssertNotWorkerFault(WorkerEnvelope envelope, int frameIndex)
{
if (envelope.BodyCase != WorkerEnvelope.BodyOneofCase.WorkerFault)
{
return;
}
Assert.Fail(
$"Frame {frameIndex} is a WorkerFault ({envelope.WorkerFault.Category}): "
+ envelope.WorkerFault.DiagnosticMessage);
}
/// <summary>Reads frames until one matches the expected body type and predicate.</summary>
/// <param name="reader">Frame reader.</param>
/// <param name="expectedBody">Expected body case.</param>
@@ -20,6 +20,17 @@ namespace ZB.MOM.WW.MxGateway.Worker.Tests.TestSupport;
/// </summary>
internal sealed class FakeRuntimeSession : IWorkerRuntimeSession
{
/// <summary>
/// Backstop on the <see cref="BlockDispatch"/> wait so a test that never releases leaves no
/// thread parked forever. It is a safety net, never a scenario's timing budget: nothing
/// asserts on it firing, and a test whose blocked window outruns it silently gets its reply
/// mid-window, which then fails as an opaque cancellation somewhere later. Kept far above
/// any test's window — and above the 20 s cancellation those tests arm — so the test's own
/// token always fails first, with its own message. <see cref="Dispose"/> releases the wait
/// regardless, so teardown never depends on this elapsing.
/// </summary>
private static readonly TimeSpan BlockedDispatchSafetyNet = TimeSpan.FromSeconds(30);
private readonly ManualResetEventSlim releaseDispatch = new(false);
private readonly object gate = new();
private readonly Queue<WorkerEvent> events = new();
@@ -31,6 +42,7 @@ internal sealed class FakeRuntimeSession : IWorkerRuntimeSession
// disposed SemaphoreSlim would turn that shutdown into an ObjectDisposedException.
private readonly SemaphoreSlim eventSignal = new(0, 1);
private TimeSpan? lastWaitForEventsTimeout;
private bool refreshStaActivityOnCapture;
private WorkerRuntimeHeartbeatSnapshot snapshot = new(
DateTimeOffset.UtcNow,
pendingCommandCount: 0,
@@ -91,7 +103,7 @@ internal sealed class FakeRuntimeSession : IWorkerRuntimeSession
if (BlockDispatch)
{
releaseDispatch.Wait(TimeSpan.FromSeconds(5));
releaseDispatch.Wait(BlockedDispatchSafetyNet);
}
SetSnapshot(new WorkerRuntimeHeartbeatSnapshot(
@@ -127,11 +139,52 @@ internal sealed class FakeRuntimeSession : IWorkerRuntimeSession
});
}
/// <summary>
/// When set, <see cref="CaptureHeartbeat"/> stamps the snapshot's
/// <c>LastStaActivityUtc</c> with the capture time and leaves every other field as the last
/// <see cref="SetSnapshot"/> left it. Models a live STA whose pump calls
/// <c>MarkActivity()</c> on each wait iteration (<c>StaRuntime.ThreadMain</c>), so a healthy
/// worker is never captured stale — which a watchdog test needs to hold for the <em>whole</em>
/// session, including the handshake window before any command exists for the watchdog to
/// suppress on. A test-owned refresh loop cannot hold it: it is a thread-pool continuation
/// racing a compressed grace, and the gap between this fake being constructed and that loop's
/// first tick is already enough to look hung.
/// </summary>
public bool RefreshStaActivityOnCapture
{
get
{
lock (gate)
{
return refreshStaActivityOnCapture;
}
}
set
{
lock (gate)
{
refreshStaActivityOnCapture = value;
}
}
}
/// <inheritdoc />
public WorkerRuntimeHeartbeatSnapshot CaptureHeartbeat()
{
lock (gate)
{
if (refreshStaActivityOnCapture)
{
snapshot = new WorkerRuntimeHeartbeatSnapshot(
DateTimeOffset.UtcNow,
snapshot.PendingCommandCount,
snapshot.OutboundEventQueueDepth,
snapshot.LastEventSequence,
snapshot.CurrentCommandCorrelationId,
snapshot.StaCallInProgress);
}
return snapshot;
}
}
@@ -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)
{
@@ -343,13 +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). The legacy 6-arg IwwAlarmConsumer.AlarmAckByName works
// and reaches the alarm-history path correctly. 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;
@@ -429,10 +446,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<MxAlarmTransitionEvent> transitions =
@@ -491,12 +510,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 <c>ALARM_RECORDS/@COUNT</c> 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 <c>docs/AlarmProbeFindings.md</c>), so the count is deliberately
/// not trusted here. Exposed as <c>internal static</c> so the rule is
/// unit-testable without the wnwrapConsumer COM object.
/// root carries an <c>ALARM_RECORDS/@COUNT</c> 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 <c>docs/AlarmProbeFindings.md</c>). Exposed as
/// <c>internal static</c> so the rule is unit-testable without the
/// wnwrapConsumer COM object.
/// </summary>
/// <param name="fetchedRecordCount">ALARM records the reply carried.</param>
/// <param name="maxAlarmsPerFetch">The cap that was passed to the fetch.</param>
@@ -660,10 +681,17 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
/// <em>instance</em> 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
/// (<c>docs/AlarmClientDiscovery.md</c>); 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
/// (<c>docs/AlarmClientDiscovery.md</c>, reconfirmed 2026-08-18).
/// Re-raise-after-clear was observed on 2026-08-18 and does
/// <em>not</em> 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 (<c>AlarmAckByName</c> returns 0 and nothing moves), so no
/// record can be driven into an acknowledged state — see
/// <c>docs/AlarmProbeFindings.md</c>.
/// </para>
/// </remarks>