PQC211 — Command handlers publish from the confirming event, not from the handler body¶
A command handler publishes a device event directly, instead of from the callback that receives the device's confirmation.
A handler that has just sent a command knows one thing: the device accepted the packet. It does not know the door opened, the partition armed, or the output switched. That is what the device's own confirming event says, and ExecuteCommand exists to wait for it.
// reported
public static async Task<DeviceCommandResult> Open(AcmeDoor door, Access.Open command, Protocol protocol, CancellationToken ct)
{
var accepted = await protocol.OpenDoor(door.Address, ct);
if (accepted)
await door.UnsecuredRemote(DeviceTimestamp.UtcNow, null);
// ^^^^^^^^^^^^^^^^^^^^^^^ ^^^^ command time, and nobody to attribute it to
return DeviceCommandResult.Succeeded();
}
Why the build refuses this¶
The operator disappears from the audit trail. Pairing the command with the device's event is what upgrades the record to its *Remote variant carrying command.OperatorIdentity. Publish from the handler and the audit says "door unsecured" where it should say "unsecured remotely by John Smith". Worse: the device's own event still arrives later and lands as a second, unattributed record.
The timestamp is wrong. The audit record gets the moment the adapter sent a packet, not the moment the device acted — and those differ by however long the panel took, which for a delayed action (arm with an exit delay) is minutes.
It reports something that may not have happened. Most panels ACK any well-formed packet. Publishing on acceptance asserts an occurrence that the device may never carry out.
How to fix it¶
Move the publication into the callback.
public static Task<DeviceCommandResult> Open(AcmeDoor door, Access.Open command, Protocol protocol, CancellationToken ct) =>
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, command.OperatorIdentity),
ct: ct);
The device's timestamp and the operator identity both arrive where they belong, and the result reflects execution rather than acceptance.
If the device genuinely never confirms — an ACK-only command with no audited event and no observable state — then there is no audit event to publish at all. Return the result and leave the audit plane alone; do not manufacture the occurrence.
What is not reported¶
Publishing from any callback. A lambda or local function inside the handler is where the framework hands you the confirmation. That is the compliant shape and the check looks specifically for calls outside one.
Adapter-operational events. Command errors, timeouts and connection notices describe the adapter, not the device, and are unmarked on purpose.
A handler that only sends. Nothing published, nothing to report.
If you disagree with a report¶
Do not suppress it. A wrong report is a bug in the check — report it with the code that triggered it.