PQC201 — Status poll must not participate in event processing¶
A
PollStatushandler publishes an audit event without declaring why, or touches the event replay cursor.
The status poll is a state-only path. It reads what is true now and writes it through StatusBatch. It crosses into event processing in two ways, and the build reports both.
Publishing events from a poll¶
An event is logged once, when its transition happens, by the event path. A poll sees the same state on every cycle, so publishing from it either duplicates what the history stream already reported, or invents an occurrence stamped with poll time and attributed to nobody.
// reported
public static async Task PollStatus(AcmeZone zone, CancellationToken ct)
{
if (await protocol.IsInAlarm(zone.Address, ct))
await zone.Alarm(DeviceTimestamp.UtcNow); // fires again every poll cycle
}
Some devices leave no alternative. A panel that exposes zone state and keeps no history cannot deliver that alarm any other way, and edge detection from the poll is then the sanctioned pattern. What the build refuses is an undeclared one — because a correct edge detector and a missing one look identical, and only the author knows which this is.
Declare it on the handler, with the protocol reason:
[EdgeDetectedEvents("Acme panels expose zone state only; there is no history stream for zone alarms")]
public static async Task PollStatus(AcmeZone zone, CancellationToken ct)
{
var isAlarm = await protocol.IsInAlarm(zone.Address, ct);
if (isAlarm != _lastAlarm) // fires on the edge, not on the level
{
_lastAlarm = isAlarm;
if (isAlarm)
await zone.Alarm(DeviceTimestamp.UtcNow);
}
}
Events derived this way carry poll-time timestamps and no operator identity. That is the cost, and it is why the declaration asks for a reason rather than just a flag.
Touching the replay cursor¶
The replay cursor (IEventCursor<TKey>, consumed by route.SkipReplayed) is the high-watermark of the history path. It records which device events have already been audited so a restart does not replay them.
The status poll belongs to the state path. It reads what is true now and writes it through StatusBatch. It has no business knowing what has been audited.
// reported
public static async Task PollStatus(AcmeZone zone, IEventCursor<long> cursor, CancellationToken ct)
// ^^^^^^^^^^^^^^^^^^^^^^^^^ the poll now owns replay
The cursor has no exception at all — [EdgeDetectedEvents] permits publication, never this.
The coupling emits nothing, so nothing looks wrong. No duplicate events, no flood, no error — the poll simply reads or nudges a number. The damage appears at the next restart, as events that never arrive or events audited twice, and by then nothing points back here.
Seeding is the common shape and the worst one. A poll that sets the cursor from its current-state read tells the history path "everything up to now is already audited". Nothing was audited. Every event the device is holding is discarded on the next connect.
How to fix it¶
Leave the cursor to the history path. Take it where replay is actually gated:
and let the poll do only what it is for:
public static async Task PollStatus(AcmeZone zone, IProtocol protocol, CancellationToken ct)
{
var state = await protocol.ReadZone(zone.Address, ct);
await zone.StatusBatch().Set(state).Commit(ct);
}
What is not reported¶
Publishing from a handler that declares [EdgeDetectedEvents]. That is the sanctioned pattern; the declaration is what makes it visible.
Whether the edge detection is correct. The build sees that you declared the exception, not that your state tracking works. That stays with review — as does whether the device really has no history stream to read instead.
Anything outside a PollStatus handler. Events published from routing, from history, from anywhere else, are the normal path.
A cursor or a publication reached through a helper the poll calls. The cursor clause does not follow calls out of the handler. The rule still applies.
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.