feat(alarms): structural degraded-status signal for truncated alarm snapshots
The truncation-cliff fix made alarm transitions truncation-safe but silent:
when GetXmlCurrentAlarms2 returns exactly maxAlmCnt records the worker
suppresses absence-implies-Clear inference and says so only in a rate-limited
stderr warning. No client and no operator could tell a complete active set
from a capped one.
Two additive proto3 booleans carry the verdict out:
- QueryActiveAlarmsReplyPayload.snapshot_truncated = 2 (worker IPC reply)
- ActiveAlarmSnapshot.from_truncated_snapshot = 16 (per record)
The per-record field is not an aesthetic choice. QueryActiveAlarms returns a
bare `stream ActiveAlarmSnapshot` with no envelope, header, or trailer, so a
per-record boolean is the only carrier that stays wire-compatible; an envelope
message would change every existing client's stream element type. The reply
payload states it too because a prefix filter can leave zero records and a
truncated fetch with nothing to report still has to say so. The flag means
"this set may be incomplete", never "this record is unreliable" — it is
independent of the subtag-fallback `degraded` field.
Detection is deliberately UNCHANGED: IsTruncatedFetch remains
`fetchedRecordCount >= maxAlarmsPerFetch`. The live probe (docs/AlarmProbeFindings.md,
ce5d8ae) could not verify whether ALARM_RECORDS/@COUNT reports the total active
count or only the records in the reply, so @COUNT is not parsed for detection;
switching to it stays blocked on probe evidence. The probe's comment
annotations in WnWrapAlarmConsumer.cs are preserved.
Reset semantics: not latched. WnWrapAlarmConsumer.FoldFetch replaces the
verdict on every poll under the same lock as the snapshot merge, so the first
sub-cap fetch clears it; GatewayAlarmMonitor.ClearCache drops it with the cache
generation it describes. A caveat that never turns off is one operators learn
to ignore.
Flow: WnWrapAlarmConsumer.LastSnapshotTruncated -> AlarmDispatcher (stamps every
record) / IAlarmCommandHandler (payload) -> MxAccessCommandExecutor reply ->
GatewayAlarmMonitor._snapshotTruncated -> IGatewayAlarmService.SnapshotTruncated
-> DashboardAlarmQueryResult -> AlarmsPage warning banner (render-side only; the
poll loop and DisposeAsync drain are untouched). The public QueryActiveAlarms
RPC forwards worker snapshots unmodified, so the per-record flag needed no
mapper change — a test pins that.
Parity: this describes OUR fetch mechanics — additive gateway metadata — not
MXAccess provider behavior. No event is synthesized and no MXAccess-observable
semantics change, so it is not a parity deviation.
Tests: worker LastSnapshotTruncated set/reset/consecutive-burst (windev-run);
gateway end-to-end truncated reply -> monitor -> public stream, with the
complete-reply control as the load-bearing assertion; AlarmsPage banner
present/absent. Docs: gateway.md alarm surface, docs/DesignDecisions.md entry.
This commit is contained in:
@@ -295,3 +295,57 @@ to defer heavy revocation.
|
||||
- **Staleness bound.** A revoked tag grant takes effect within one token lifetime
|
||||
(≤5 min) for token-auth connections and immediately for a fresh cookie login.
|
||||
```
|
||||
|
||||
## 12. As-built notes (enforcement landed 2026-08)
|
||||
|
||||
### 12.1 §4's "no second seam" no longer held — the ACL gates two
|
||||
|
||||
This design was written against a dashboard whose only route to a session's event
|
||||
feed was the SignalR hub, which is why §4 concludes "gating at join is sufficient —
|
||||
there is no second seam to guard". The 2026-08 in-process feed refactor invalidated
|
||||
that premise: server-rendered pages stopped opening a loopback SignalR connection to
|
||||
`/hubs/events` and now read the mirror directly through
|
||||
`IDashboardSessionEventSubscriber.Subscribe(sessionId)` (`DashboardEventBroadcaster`
|
||||
implements both the publish and subscribe interfaces). `SessionDetailsPage` is that
|
||||
second consumer, and it never touches `SubscribeSession`, so a hub-only gate would
|
||||
have left the page as an ungated path to the same events.
|
||||
|
||||
The shipped enforcement therefore puts the *same* `IDashboardSessionAcl` decision in
|
||||
front of both subscribe calls:
|
||||
|
||||
- `EventsHub.SubscribeSession` — denies with `HubException("Not authorized for this
|
||||
session.")` before the group join **and** before the `EventsHubViewerRegistry`
|
||||
registration, so a denied caller neither receives events nor turns the mirror on.
|
||||
- `SessionDetailsPage` — resolves the circuit principal via
|
||||
`AuthenticationStateProvider` and checks the ACL *before* `Subscribe(SessionId)`.
|
||||
A denial creates no subscription, starts no pump, and registers no viewer; the
|
||||
events panel renders "Not authorized for this session's events." in place of its
|
||||
empty state. The gate wraps only whether the subscription is created — the page's
|
||||
generation/`ReferenceEquals` guards, `MarkDisconnectedAsync`, and the
|
||||
`DisposeAsync`/`DetachEventsAsync` coupling are untouched.
|
||||
|
||||
Both seams remain subscribe-time-only. Session tags are immutable for the session's
|
||||
life (§3), so a joined group or a live in-process subscription cannot go stale, and
|
||||
no per-event check is needed on either path.
|
||||
|
||||
### 12.2 Where the grant is stamped
|
||||
|
||||
`zb:dashboardtag` claims are added at both principal-construction sites:
|
||||
`DashboardAuthenticator.CreatePrincipal` (cookie login, so a circuit carries its
|
||||
grant without a token round-trip) and `HubTokenService.Issue` (hub bearer). Both
|
||||
resolve the grant from the caller's `mxgateway:ldap_group` claims through
|
||||
`DashboardGroupTagMapping` + `Dashboard:GroupToTag` rather than copying tag claims
|
||||
already on the principal — re-resolving at mint is what makes the token's 5-minute
|
||||
lifetime an actual staleness bound on a changed grant, as §4 claims. Tags are
|
||||
stamped for Administrators too; they are simply moot, because the ACL's admin bypass
|
||||
is checked first.
|
||||
|
||||
### 12.3 Deviations worth knowing
|
||||
|
||||
- `CanViewSession` takes a **nullable** `ClaimsPrincipal`. `HubCallerContext.User` is
|
||||
nullable, and null denies — the fail-closed reading.
|
||||
- The admin bypass additionally requires `Identity.IsAuthenticated`, matching
|
||||
`DashboardSessionAdminService.CanManage`. A role claim on an unauthenticated
|
||||
identity does not bypass.
|
||||
- Tag *values* are never logged at either seam; only the identifiers and the
|
||||
allow/deny outcome are observable.
|
||||
|
||||
Reference in New Issue
Block a user