0702b31029
v2-ci / unit-tests (push) Waiting to run
Item 3 of #765. A keyed (API-key) session whose key had no Galaxy ack identity got Good and then a revert, because whether the key has an identity lives in the Secrets store, which must not be read under the SDK lock. GalaxyAckIdentityDirectory (Driver.Galaxy) keeps a node-wide set of the key ids that have a galaxy-ack-user secret. It lists this node's Secrets store (metadata only, nothing decrypted) and is read lock-free. GalaxyDriver.GetAcknowledgeRefusal (IAlarmAcknowledgePreflight, published through NativeAlarmAckPreflightView as in #760) now refuses a keyed session whose key the directory reports Absent, when the driver has no fallback ack account. A refusal also asks for a refresh. Fails open: before the first refresh, once the snapshot is older than StaleAfter, and from a signalled identity-secret change until the next refresh, the lookup answers Unknown and the ack takes the old Good-then-revert path. Refresh: GalaxyAckIdentityRefreshService runs on driver nodes, every Drivers:Galaxy:AckIdentityCache:RefreshIntervalSeconds (default 30; StaleAfterSeconds 0 = three intervals; validated at start). It also refreshes early on secret changes. The Secrets library has no change event, so SecretsRegistration decorates ISecretCacheInvalidator, which is called by AdminUI /api-keys, /admin/secrets and the replication appliers. API-key changes need no event: a revoked key is already denied before the pre-check. Tests: Galaxy.Tests GalaxyAckIdentityDirectoryTests (16) and Host.Tests GalaxyAckIdentityCacheWiringTests (9). Seven separate mutations each turn at least one test red: pre-check ignores the directory, factory drops it, no invalidator decoration, no staleness check, no change-pending check, no refresh on refusal, loop never wakes early. Docs: Galaxy.md, AlarmTracking.md, Configuration.md, .claude/rules/alarms.md. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
73 lines
6.8 KiB
Markdown
73 lines
6.8 KiB
Markdown
---
|
|
paths:
|
|
- "src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/**"
|
|
- "src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/**"
|
|
- "src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/**"
|
|
- "docs/ScriptedAlarms.md"
|
|
- "docs/AlarmTracking.md"
|
|
---
|
|
|
|
# Scripted alarm ack/shelve and Part 9 condition method behaviour
|
|
|
|
> Moved verbatim from the root `CLAUDE.md` (2026-10-13) so it loads only when working in this area.
|
|
> Root `CLAUDE.md` § "Subsystem guides" indexes it. Edit it here, not in the root file.
|
|
|
|
## Scripted Alarm Ack/Shelve
|
|
|
|
Inbound operator acknowledge/shelve for scripted alarms is fully implemented. Two surfaces converge on the `alarm-commands` DPS topic: (1) OPC UA Part 9 condition methods (Acknowledge / Confirm / AddComment / OneShotShelve / TimedShelve / Unshelve) wired in `OtOpcUaNodeManager`, gated on the `AlarmAck` role — conferred by the session's API-key principal's `NodeAcl` grants (or the `anonymous` principal's), carried by `RoleCarryingUserIdentity`; LDAP is AdminUI-only; (2) AdminUI `/alerts` per-row buttons routed through the `AdminOperationsActor` singleton (gated by `DriverOperator` policy). `ScriptedAlarmHostActor` dispatches from the topic to the engine. Client.CLI supports `ack`, `confirm`, `shelve` commands. See `docs/ScriptedAlarms.md` §"Inbound operator ack/shelve" and `docs/AlarmTracking.md`.
|
|
|
|
⚠️ **One condition event per accepted method call is OURS, not the SDK's (Gitea #587).** The SDK's own
|
|
report is gated on `AutoReportStateChanges` (left `false`), so before #587 a client Acknowledge emitted **no**
|
|
condition event — the old "SDK auto-fires its own event (E2)" comments were wrong. `WireMethodTransitionReporting`
|
|
wraps every method's SDK handler (Ack/Confirm/AddComment/Enable/Disable/OneShotShelve/TimedShelve/Unshelve) and,
|
|
on Good, reports once via `ReportConditionEvent` — **under `Lock`**, so the engine's/driver's re-projection
|
|
(routed by a fire-and-forget Tell *before* the SDK applies state) waits and then hits the delta-gate with no
|
|
delta. Things to keep: the SDK's shelve handlers never apply shelving, so the wrapper does; only a real
|
|
before/after change is reported (a re-ack is silent and restores the Confirmed/Retain the SDK flips);
|
|
`MaxTimeShelved` is set to `int.MaxValue` ms at materialise because `Create()` left it 0 and every TimedShelve
|
|
failed. ⚠️ **Native alarms support Acknowledge and AddComment only** — Confirm, the shelve methods and
|
|
Enable/Disable return `BadNotSupported` (no upstream path, no pair replication, and `NativeAlarmProjector`'s
|
|
Confirmed/Unshelved constants own those fields); a native ack re-asserts `ConfirmedState = true` because the
|
|
SDK's ack unconfirms any confirm-capable condition and ScadaBridge maps that field. Pinned over the wire by
|
|
`ConditionMethodEventTests` (exactly-once after a sentinel, folder + `i=2253`).
|
|
|
|
**A native ack the driver refuses or fails is not left Acknowledged (#760).** Two layers: (1) a refusal the
|
|
driver can decide without I/O (`IAlarmAcknowledgePreflight` — Galaxy, no fallback ack account: an anonymous session, or a keyed
|
|
session whose key `GalaxyAckIdentityDirectory` reports Absent, #765) fails the Part 9 call `BadUserAccessDenied` via `NativeAlarmAckPreflight`, read lock-free from the
|
|
`NativeAlarmAckPreflightView` snapshot `DriverHostActor` publishes; never do I/O (e.g. a Secrets lookup) in a
|
|
pre-check — it runs under the SDK `Lock`; that is why the keyed case reads a cached, periodically refreshed key set
|
|
(`Drivers:Galaxy:AckIdentityCache`), which must answer Unknown (= let it through) whenever it is not certain — no
|
|
refresh yet, stale, or an identity-secret change signalled through the decorated `ISecretCacheInvalidator` and not
|
|
yet re-listed. Only an identity written straight into the store by the `secret` CLI (no in-process signal) can be
|
|
refused until the next refresh, which the refusal itself requests. (2) anything else (a keyed session the cache cannot vouch for, MXAccess
|
|
non-zero `hresult`, driver still connecting/reconnecting) comes back as `DriverInstanceActor.AlarmAckFailed` — or, for a
|
|
disabled/unspawned/stubbed driver, is decided in the host itself (#765) — and `DriverHostActor` re-projects the condition unacknowledged — only while the ack is still pending in
|
|
`NativeAlarmProjector` (any driver transition since then wins). A host that came up **Stale** cannot route any
|
|
native ack, so it marks the whole `NativeAlarmAckPreflightView` unavailable and every native ack is refused
|
|
synchronously until `BecomeSteady` (#765). A test that routes an ack and expects it to STAND must start the owning
|
|
driver (`AckingAlarmDriver` in Runtime.Tests).
|
|
|
|
Behaviours that were wrong or missing and are now fixed — keep them: the SDK's `OnTimedUnshelve` is a
|
|
**no-op** (the engine's `AutoUnshelve` owns timed-shelve expiry, and `UnshelveTime` reports the real
|
|
remaining time from `AlarmConditionSnapshot.UnshelveAtUtc`); a **commented** re-ack is stored as an
|
|
`AddComment` (durable, replicated) and reports one event, an uncommented re-ack stays silent; a **native
|
|
ack is refused `BadInvalidState` off-Primary** via `NativeAlarmAckPrimaryGate` (fed by
|
|
`IDriverPrimaryRoleSource`; `DriverHostActor` publishes its Primary verdict at host start and on member
|
|
changes, so the boot window before the first redundancy snapshot is closed — the same fix covers the MQTT
|
|
Sparkplug primary-host claim). A native re-ack is still re-sent to the driver (the retry path, by design).
|
|
|
|
**Condition `Severity` is carried verbatim, clamped to 1..1000** — `ToPart9Severity`; the node manager used to re-bucket onto
|
|
the five `EventSeverity` points (so an MTConnect Fault read 900, scripted Critical 1000 read 900, native Low 200 read 300).
|
|
ScadaBridge only clamps, so exact values pass through. ⚠️ **Never select a two-state id with `AddSelectClause(type, "X/Id")`** —
|
|
the string becomes ONE QualifiedName named `X/Id`, which no event carries; the Client.CLI/Client.UI filter did this since
|
|
`a2883b82` and every alarm read inactive+unacked. Use `new SimpleAttributeOperand(type, [X, Id])` (ScadaBridge already does).
|
|
|
|
**Condition children are instance-scoped (#629).** Every child of a materialised condition carries
|
|
`{ConditionId}/{browse path}` in the condition's namespace (e.g. `…/temp_hi/ShelvingState/UnshelveTime`), assigned by
|
|
the `OtOpcUaNodeManager.New` override — the SDK default kept each child on the TYPE's ns=0 id, so a direct Read hit
|
|
the type (or `BadNodeIdUnknown`), translate failed, and children sat outside the ACL (the slash join now puts them
|
|
under their condition's scope). ⚠️ The shelve methods resolve by the `AlarmConditionType_ShelvingState_*` ids only
|
|
because `New` adopts those (a fixed set) as their `MethodDeclarationId`; calls by the `ShelvedStateMachineType_*` id
|
|
are no longer supported (pinned by a test). Condition teardown must forget the whole `PredefinedNodes` subtree
|
|
(`ForgetPredefinedSubtree`) or a removed condition's children keep answering.
|