Skip to content

Commands

Rules for command handlers, execution patterns, and operator identity tracking.

Part of compliance-rules.

Cross-reference (RULE-032, events): a command handler must never call thing.PublishEvent(...) on a Thing to emit a follow-up audit event. Publication is the Thing's concern — call a named method on the Thing (emitted by a YAML function, emitted from the type's events: list, or written on the Thing's partial). See events.md → RULE-032.


RULE-007: Command Handlers Must Report Accurate Results

Severity: Critical

Description: Command handlers must return accurate DeviceCommandResult for command acceptance by the device/control system. This is not the same as final physical execution. For delayed actions (for example arm with exit delay), success can mean "accepted and scheduled" while final execution is observed later through events.

Do not return Succeeded for send timeout, explicit reject/NACK, partial send, or unknown acceptance outcome.

Do not discard a device-reported command outcome. When the protocol answers with a structured result — a result-code byte/table, a non-OK terminator, a documented rejection reason — the handler must read it and reflect it: map a non-OK/ERROR/reject terminator to Failed (never to Succeeded or true), and carry the device's documented reason into the failure result and the trace. Collapsing a documented rejection code to a generic failure, or mapping any non-OK terminator to success, throws away the one fact that tells the operator why the command was refused.

Detection pattern:

  • return DeviceCommandResult.Succeeded() without verifying device response
  • Missing timeout handling
  • Catching exceptions and returning success
  • Waiting for final state event and treating lack of immediate final event as command rejection when the protocol supports delayed execution
  • A non-OK/ERROR terminator or a structured result-code mapped to success/true (e.g. SendAuthorized returning true on ERROR)
  • A documented result-code table collapsed to one generic Failed(...) without decoding or logging the device's rejection reason

Correct pattern:

var response = await protocol.SendCommand(cmd, ct);
if (response.IsAck)
    return DeviceCommandResult.Succeeded();

// decode the documented result code so the operator sees WHY it was refused
var reason = ResultCodes.Describe(response.ResultCode);   // decode the documented result code into its reason
logger.LogWarning("Command {Cmd} rejected: {Code:X2} ({Reason})", cmd, response.ResultCode, reason);
return DeviceCommandResult.Failed(reason);

Violation example:

// WRONG: assumes success without verification
await protocol.SendCommand(cmd, ct);
return DeviceCommandResult.Succeeded(); // Did it actually succeed?

// WRONG: non-OK terminator reported as success
return response.Terminator == "OK" || response.Terminator == "ERROR"; // ERROR masked as success

// WRONG: documented rejection code collapsed to a generic failure
return DeviceCommandResult.Failed("command failed"); // which of the documented reasons? lost


RULE-011: Command Handlers Must Use ExecuteCommand Pattern

Severity: Critical

Description: Command handlers should use thing.ExecuteCommand() when the adapter can represent a command as a supervised state transition. This provides execution supervision, operator identity propagation, and event pairing. The command result reflects actual execution — confirmed by the device's audited execution event or an authoritative device state read-back — never by ACK alone. A device ACK (a formally well-formed reply carrying an "accepted" result code) means the packet was accepted for processing, not that the action ran, so it is necessary but not sufficient; a documented device rejection code still maps to Failed (see RULE-007). ExecuteCommand supervises the confirming event/state and attaches command.OperatorIdentity to the resulting audit event. For an action the device confirms only after a delay (arm with an exit delay, or a remote output whose result is observed only seconds later), the handler returns an accepted/pending result immediately and the confirming event/state reconciles it later — pending-then-confirmed, not "succeeded on ACK".

Some device commands are ACK-only operations where no reliable final event or status confirmation exists for the remote command. In those cases, ExecuteCommand is not required and can be misleading: the handler may simply send the protocol command and return success only for device/control-system acceptance.

Why this matters:

  • Device records actual execution in its audit/event log (e.g., "door unlocked" event)
  • Some actions are delayed after acceptance (e.g., arm starts an exit-delay timer and only emits "armed" minutes later)
  • HistoryPoll will pick up that event with device timestamp
  • Without ExecuteCommand, the event has no operator identity - audit shows "door unlocked" but not "unlocked by John"
  • ExecuteCommand intercepts the later execution event and attaches the operator identity from the original command

What ExecuteCommand provides:

  1. Operator identity propagation - command.OperatorIdentity → event (PRIMARY)
  2. Event pairing - matches audited device event with the command that triggered it
  3. Delayed execution supervision - command can return accepted while final state is still pending
  4. Device timestamp - uses timestamp from device event, not command time
  5. Pending status for UI feedback
  6. Automatic revert/onFault if the expected execution event does not arrive

Variants:

Variant Use when
ExecuteCommand<TEvent, TState> (event-supervised) Device sends or exposes a later audited event when action actually happens - USE THIS for state-changing commands
ExecuteCommand<TState> (state-confirmed overload, with an isConfirmed state-read predicate) Strict last-resort fallback — permitted ONLY when no audited execution event exists for this command at all, so the action is observable solely as a state change. Never combine it with event-supervision as a race ("first evidence wins"): a state read-back that concludes the command cancels the event intercept, so the audited event — which still arrives — routes as a local, unattributed action instead of being paired to the command and upgraded to its *Remote variant with the operator identity. A state read-back concluding a command for which an audited event exists is a Critical finding, in every form — fallback, race, or timeout rescue. The result is confirmed by a documented device state read-back: poll the state until it reflects the command; set the final status on confirmation. State-confirmation cannot attach operator identity. A state read-back is not a command-paired event, so if the device ever does log the action as its own audited event, that event is not paired to the command and surfaces in the audit trail attributed to an unknown/unattributed actor ("access granted to unknown person" instead of "by \<operator>"). That is why this overload is forbidden whenever an audited execution event exists: choose it because the event genuinely does not exist — never because it is simpler, and never because an existing event is slow, delayed, or delivered late on a periodic poll (fix a slow-but-existing event by keeping event-supervision and aligning the timeout to the delivery cadence, not by forfeiting attribution here). Do not fabricate a device-domain audit event from the state confirmation (RULE-054); when no audit event exists there is only status. This is NOT the "generic status/snapshot predicate" anti-pattern below: it applies only when the protocol has no audited event for the command whatsoever.
ExecuteCommand<TState> (immediate) No confirming event expected (rare)
Plain send + ACK result No meaningful pending/final state can be supervised, and the protocol only confirms command acceptance

Detection pattern:

  • Command handler emits event with Guid.Empty or missing operator identity
  • door.Unsecured(DeviceTimestamp.UtcNow) emitted directly from the command handler instead of from onSuccess
  • No predicate to match audited execution event
  • Predicate matches generic status/snapshot callbacks (OutputStateChanged, SubsystemStateChanged, etc.) instead of an audit/event-log entry when the protocol exposes audited events
  • command.OperatorIdentity not propagated to onSuccess callback
  • Manual WaitForEvent used to block DeviceCommandResult until final execution event, especially with short fixed timeout, unless protocol guarantees immediate final event and no delayed execution
  • Plain protocol send for a state-changing command without documentation that the command is ACK-only and cannot be meaningfully supervised
  • A state read-back used to conclude an event-supervised command — as fallback, as a race against the event, or as a timeout rescue — when an audited execution event exists for the action

Correct pattern (event-supervised momentary door open / strike release):

public static Task<DeviceCommandResult> Open(
    DoorDevice door,
    Access.Open command,
    Protocol protocol,
    CancellationToken ct)
{
    return door.ExecuteCommand(
        pendingState: DoorState.Unsecured,
        send: () => protocol.OpenDoor(door.Address, ct),
        protocol: protocol,
        predicate: evt => evt.Type == EventType.StrikeReleased && evt.DoorId == door.Address,
        onSuccess: async evt => await door.UnsecuredRemote(
            evt.Timestamp,                    // Device timestamp, not UtcNow
            command.OperatorIdentity),        // WHO triggered this
        ct: ct);
}

Violation example:

// WRONG: mixes command acceptance with final execution, loses delayed supervision, uses command time
public static async Task<DeviceCommandResult> Open(DoorDevice door, Access.Open command, Protocol protocol, CancellationToken ct)
{
    var success = await protocol.OpenDoor(door.Address, ct);
    if (success)
        await door.Unsecured(DeviceTimestamp.UtcNow);  // Missing: command.OperatorIdentity
    return success ? DeviceCommandResult.Succeeded() : DeviceCommandResult.Failed();
}
// Audit shows: "Door unsecured at 10:05:03" (command time)
// Should show: "Door unsecured remotely at 10:05:04 by John Smith" (device time + operator)

Door taxonomy guard: use Access.Open + UnsecuredRemote for momentary strike release / door-open commands. Use Access.Unlock + UnlockedRemote only when the vendor protocol explicitly controls a deadbolt or lock actuator unlock state.

Event pairing flow:

1. Command arrives with OperatorIdentity (e.g., "John Smith")
2. ExecuteCommand registers event intercept (predicate)
3. Protocol sends command to device/control system
4. Device/control system ACKs acceptance; handler returns `accepted/pending` (ACK confirms receipt, not execution)
5. Device later executes the action and writes/emits an audited event (or the device state reads back as executed) — this is the success signal
6. HistoryPoll picks up the audited event OR it arrives via push
7. Intercept matches event, onSuccess fires with device event
8. onSuccess emits PQ event with device timestamp + operator identity

Important distinction: ExecuteCommand<TEvent,TState> reports success only for confirmed execution — the audited execution event (or authoritative state read-back), never the ACK. Because most panels ACK any well-formed packet without having executed it, reporting Succeeded on ACK would report success for commands that may never run. For a delayed action the handler does not block the operator waiting for that confirmation: DeviceCommandResult returns accepted/pending the moment acceptance is known, and the confirming event/state reconciles it later — attaching operator identity and the device timestamp through onSuccess, or firing revert/onFault if the expected confirmation never arrives. Accepted/pending (execution scheduled, not yet observed) is distinct from Succeeded (execution confirmed) and from "succeeded on ACK" (never valid).

Audit event requirement: Event-supervision is mandatory whenever an audited execution event exists; state-confirmation is the fallback only in that event's genuine absence. The predicate must prefer the protocol's audited event/log entry for the executed action. Do not pair commands to ordinary status/snapshot notifications when an audited event exists. A status-change callback may be used only as a documented fallback when the protocol exposes no audited event for that action at all — meaning the event does not exist, not that it is slow, delayed, late, or harder to await. When the audited event exists but arrives late (for example only on a periodic poll cycle), keep event-supervision and make it survive/await the event — align the timeout to the delivery cadence, expedite delivery — rather than switching to state-confirmation and forfeiting operator attribution. The adapter docs must state why no audited event exists.

Why event evidence is non-negotiable: the intercepted event is what pairs the device's audited action to the command — that pairing is what upgrades the audit record to its *Remote variant (intrusion.armed.remote, access.door.unsecured.remote, …) carrying the operator identity and the device timestamp. Any confirmation path that bypasses the event (state read-back, ACK) severs that pairing: the event still lands in the audit trail, but as an unattributed local action. Losing attribution to make the command result arrive faster is never an acceptable trade.

Idempotent commands (framework gate — nothing for the handler to do): a command whose target state already holds produces no device transition and therefore no audited event — ever. The framework applies an unconditional pre-send idempotence gate inside event-supervised ExecuteCommand: if the Thing's current status already equals the target, it fails fast with a clear reason ("already in target state; no action taken") instead of sending and waiting out the timeout. command.failed is the correct outcome — the command accomplished nothing — made immediate rather than a 45–90 s hang. The gate is uniform across every supervised command (there is no state-vs-momentary distinction — cached status lags reality equally for all states) and requires nothing from the handler. This gate is the only place a device state read may influence a supervised command's result; a Force-flagged command bypasses it.

The existence of a similarly named vendor event is not enough to require event supervision. Require evidence that the event is a reliable confirmation for this remote command. If the event exists but is not emitted for remote commands, is emitted only for local/manual actions, or is not guaranteed, document the command as ACK-only.

Exception: Commands with no state change, no later execution event, or ACK-only semantics where no meaningful final state can be supervised (time sync, config read, diagnostics, some bypass/remove-bypass commands). The exception must be stated in the review report with evidence; otherwise missing ExecuteCommand is a critical finding.


RULE-038: Door Semantics Must Follow the Door Behavior Contract

Severity: Critical

Description: Door and reader handling must satisfy concepts/door-behavior-contract.md. The three door axes — strike (lock.secured/lock.unsecured), deadbolt (lock.deadbolt), position (position.open) — are independent: no vendor contact event may map to a strike action and no strike event to a position action. Pulse commands ("open door", "strike", "relay pulse") map to pq.command.access.open + Unsecured*; hold-open maps to access.open.permanent and its cancel ("close door") to access.close + Secured*; entry-denial commands (deadbolt, lockout, vendor "disable door") map to access.lock + Locked* and their undo to access.unlock + Unlocked* — each classification cited from vendor docs, never inferred from the command name alone. access.lock is registered only when the vendor verifiably denies a valid card while locked; access.disable/.enable never appear on a door (non-door device commands only). REX, operator, and credential releases use their three distinct Door actions (UnsecuredByRex / UnsecuredRemote / Unsecured); collapsing them destroys audit attribution.

Check:

  • For every vendor door event routed: which axis does the doc say it reports? The mapped Door action must be on the same axis.
  • For every door command: a citation classifying it pulse vs hold-open vs lock; access.disable/.enable not registered on any door device type.
  • REX never routed to Unsecured or UnsecuredRemote; operator commands never confirmed by plain Unsecured.
  • The reader-to-door link mechanism is explicit (parent-child / deviceRef / documented resolver); an implicit vendor binding is documented in the adapter's known-quirks / design-notes document, not silently assumed.
  • Access grant/deny decision source (panel-offline vs host-online) is stated; the online path does not double-publish access events.

Exception: Devices with no door hardware (pure intrusion/CCTV adapters) — rule not applicable. A protocol that genuinely conflates axes (single event for strike+position) is documented as a limitation with citation, and the adapter maps to the safer interpretation (position, so forced-entry detection still works).