Skip to content

Events

Rules for event handling, routing, and audit log integrity.

Part of compliance-rules.

Audit-visibility & taxonomy rules (RULE-020, RULE-024, RULE-026, RULE-027) live in events-audit.md.


RULE-001: Status Polling Does Not Participate in Event Processing

Severity: Critical

Description: Status polling is a state-only path: it applies current device state via StatusBatch and does nothing else. It must not touch the event side in either direction — it must not emit auditable events, and it must not read, seed/baseline, or advance the event/history replay cursor. Both are the same principle: state polling and event processing are separate concerns and the poll stays entirely on the state side. (Some devices provide state but no event history; there edge-detection from the poll is the one narrow, justified exception — see below.)

Core principle: An event is logged once, when its state transition occurs, by the event path — never on a poll cycle and never gated by poll-owned state. The status poll neither produces events nor owns the replay high-watermark.

Detection pattern: In PollStatus, UpdateState, or status polling methods, look for:

  1. Action method calls (Alarm(), Armed(), Tamper(), etc.) without edge-detection logic
  2. Events generated on every poll regardless of state change
  3. Missing state tracking (_lastState, _hasInitialized flags)
  4. Any access to an event/history replay cursor (IEventCursor, IPersistentSettings<*EventIndex>, a SavedEventIndex/high-watermark, SkipReplayed) from a status-poll path — reading it, seeding it from the current-state read, or advancing it. The cursor is owned solely by the history/replay path. This coupling emits no event, so it is invisible to checks 1–3, yet it entangles the two concerns and breaks replay correctness after a restart (skipped or re-played events). Search the adapter's source for EventIndex, EventCursor, SavedEvent, or SkipReplayed and confirm no hit sits in a status-poll file.

The build enforces both halves as PQC201. Clause 4 has no exception: a PollStatus handler handed an IEventCursor<T>, reaching one, or calling SkipReplayed is always reported. Clauses 1–3 are reported unless the handler declares [EdgeDetectedEvents("…")] with the protocol reason — the sanctioned exception becomes a declared one, so a correct edge detector and a missing one stop looking alike. Whether the edge detection is right, and whether the device truly has no history stream, stay with review.

Preferred pattern (status-only):

// Status polling - only StatusBatch, no events
var batch = zone.StatusBatch();
batch.Set(IntrusionDetectorState.Alarm);
await batch.Commit(ct);

Acceptable pattern (edge-detection):

// Edge-detection: event fires only on state CHANGE, not every poll
if (isAlarm != _lastAlarm)
{
    _lastAlarm = isAlarm;
    if (isAlarm)
        await Alarm(timestamp);  // OK: fires once on rising edge
    else
        await Cleared(timestamp); // OK: fires once on falling edge
}

Violation example:

// WRONG: generates event on EVERY poll when alarm is active
if (isAlarm)
    await Alarm(timestamp);  // floods audit log with duplicates

When to use each pattern:

Scenario Pattern Review status
Device has event history (HistoryPoll) StatusBatch only PASS
Device has no event history, only state Edge-detection with state tracking WARNING - requires justification
Initial state sync on connect StatusBatch snapshot via ApplySnapshotState PASS
No edge-detection, events on every poll N/A VIOLATION

Review guidance:

  • Edge-detection from polling is a code smell - always suspicious, sometimes necessary
  • StatusBatch is always preferred when the protocol allows it
  • Edge-detection requires explicit justification in the adapter's design-notes document explaining why StatusBatch is insufficient
  • If adapter has HistoryPoll AND edge-detection events from StatusPoll for the same event types → likely a bug (duplicate events)

Notes:

  • Events from polling have poll-time timestamps, not actual event time - known limitation, acceptable
  • Events from polling lack operator identity - use Guid.Empty or omit
  • Edge-detection is an exception forced by protocol limitations, not a design choice

RULE-003: No EventBuilder - All Events via YAML Declarations

Severity: High

Description: Adapters must NOT build events themselves — neither with EventBuilder nor with the generated PqEvent catalog. Every event the adapter emits must be:

  1. Declared in adapter-registration.yaml under events: (for the relevant device type), or published by a function the type declares
  2. Emitted by calling the generated method on the Thing (panel.HardwareFault(ts, ...), door.Opened(ts))

The source generator builds those methods from PqEvent, with parameters and severity from the taxonomy and NATS subject routing. Building the event by hand bypasses all of it.

Why this matters:

  • Type safety - typo in event name or parameter name caught at compile time, not runtime
  • Audit integration - generated events automatically route to ABAC engine
  • Identity tracking - taxonomy parameters (personId, credentialId) are method parameters
  • Schema validation - event taxonomy enforced by the framework
  • Documentation - YAML is the single source of truth for what an adapter emits

Detection pattern:

  • Any usage of EventBuilder.Event(...) in adapter source
  • Any usage of a PqEvent.* builder in adapter source
  • Any usage of new EventBase(...) or similar manual construction
  • IEventPublisher.Publish with untyped/anonymous payload
  • String-based event names in PublishEvent calls

The build enforces the first two clauses as PQC203: an EventBuilder.Event(...) call or a PqEvent.* reference in hand-written adapter code is reported on every build. Generated code is excluded — the generator builds its methods from both, one layer below the adapter, which is where they belong. The remaining clauses stay with review.

Correct pattern - function method (preferred):

// Function method handles state + event + status atomically
await zone.Alarm(timestamp);
await door.Opened(timestamp);
await reader.AccessGranted(timestamp, personId, credentialId);

Correct pattern - declared event method (event no function owns):

# adapter-registration.yaml
device_types:
  - type_id: "Panel"
    events:
      - pq.event.technical.fault.hardware

// generated on Panel:
//   ValueTask<bool> HardwareFault(DeviceTimestamp timestamp, string faultDescription, string? faultCode = null)
await panel.HardwareFault(timestamp, faultDescription: "Device licence removed");

Violation examples:

// WRONG #1: manual EventBuilder when function method exists
var evt = EventBuilder.Event("pq.event.intrusion.alarm")
    .At(timestamp, zone)
    .Build();
await zone.PublishEvent(evt);
// Should be: await zone.Alarm(timestamp);

// WRONG #2: typed catalog builder instead of the declared method
PublishEvent(PqEvent.Technical.Fault.Hardware.At(timestamp, this)
    .WithParameter("reason", "DeviceLicenseRemoved")   // not a parameter of this event
    .Build());
// Should be: declare under events:, call panel.HardwareFault(timestamp, faultDescription: ...)

// WRONG #3: string-based event publishing
await publisher.Publish("pq.event.access.granted", new { personId, doorId });
// Should be: await reader.AccessGranted(timestamp, personId, credentialId);

Workflow when adding a new event:

  1. Add event identifier to events: block in adapter-registration.yaml (under the emitting device type)
  2. Build — PQA027 fails the build when the id is not in the taxonomy
  3. Call the generated method on the Thing; see PQC203 for the step-by-step example

Exception: None for adapters. EventBuilder and PqEvent are generator building material - adapter code should never reference them directly.


RULE-008: Timestamp Source Must Match Event Source

Severity: Medium

Description: Events from device history must use device-provided timestamps. Events from real-time streams use DeviceTimestamp.UtcNow. Never use wall clock for historical events.

Detection pattern:

  • DeviceTimestamp.UtcNow or DateTime.UtcNow in history/event replay code
  • Device timestamp field ignored when present in protocol data

Correct pattern:

// History event - use device timestamp
var timestamp = DeviceTimestamp.FromDevice(evt.DeviceTime, panel.TimeZone);
await zone.Alarm(timestamp);

// Real-time push - use UtcNow
var timestamp = DeviceTimestamp.UtcNow;
await zone.Alarm(timestamp);


RULE-009: Initial State Must Use Snapshot, Not Events

Severity: Medium

Description: When adapter connects and reads initial state, use StatusBatch snapshot, not individual events. Initial state sync should not flood the audit log with synthetic events.

Detection pattern:

  • Event-generating methods called during OnConnected or initial state sync
  • Loop processing initial state that calls Alarm(), Armed(), etc.

The build enforces this as PQC209: a device-domain audit publication inside an IConnectionAware.OnConnected implementation is reported on every build. Adapter-operational events — connection notices, synchronization lifecycle — are exempt by construction: they carry no [EventAction] marker.

Correct pattern:

// Initial state - snapshot only
if (!_hasInitializedState)
{
    _hasInitializedState = true;
    await ApplySnapshotState(states); // Uses StatusBatch internally
    return;
}


RULE-010: HistoryPoll and StatusPoll Must Be Complementary

Severity: Medium

Description: When adapter has both HistoryPoll (events) and StatusPoll (state), they must serve different purposes:

  • HistoryPoll: generates auditable events with accurate timestamps
  • StatusPoll: synchronizes current state, detects drift, no events

If StatusPoll detects state mismatch vs expected state from events, log diagnostic - don't generate corrective events.

Detection pattern:

  • StatusPoll generating events that duplicate HistoryPoll events
  • Same event type generated from both poll mechanisms

RULE-012: Control-Plane Noise Must Use Explicit Ignore

Severity: Medium

Description: Control-plane noise (heartbeats, keepalives, midnight ticks, session refreshes, software logon/logoff) — events with no security, audit, or operational value — must be explicitly ignored via router.Ignore("reason"), not left to fall through to Unhandled. Without explicit routes, these events flood the audit log as Unknown entries.

This rule does NOT mean "use Ignore for system events". Many system events (firmware updates, config changes, service mode, health/trouble) have audit value and must NOT be ignored. Ignore() is reserved strictly for protocol noise that has no customer-visible meaning. When in doubt, publish device.unknown — a visible unknown is safer than a silent skip.

Detection pattern:

  • Heartbeat/keepalive/session event types in protocol with no route configured
  • Unknown audit events that are actually known protocol noise
  • Unhandled fallback firing for periodic control events

Correct pattern:

router.When<HeartbeatEvent>().Ignore("Heartbeat - no audit value");
router.When<MidnightTickEvent>().Ignore("Midnight tick");
router.When<SessionRefreshEvent>().Ignore();

Violation example:

// WRONG: heartbeat falls into Unhandled
// → published as pq.event.unknown
// → pollutes audit log with hundreds of entries per day
// (no route for heartbeat at all)

Why this matters:

  • Operators see "Unknown event" entries and waste time investigating
  • Real unknown events get lost in the noise
  • Audit log compliance reports show false-positive anomalies

Framework support:

  • router.Ignore(reason) logs at Debug level, returns RouteResult.Success
  • Event does not reach Unhandled handler
  • No audit event published

RULE-013: Unknown Device Addresses Must Be Diagnosable

Severity: Medium

Description: When an event carries a device address (zone ID, door number, reader address) but no Thing matches that address, the framework records UnhandledReason.Unresolved with ThingType and Address. This is critical diagnostic data - it reveals configuration drift, missing devices in YAML, or topology mismatches between vendor system and PQ.

A catch-all fallback route handling the same event type masks this diagnostic, making the mismatch invisible.

Even without an explicit Unhandled handler, the framework surfaces an unresolved address automatically: when a route matched the event type but the Thing was not found, Dispatch publishes pq.event.device.unresolved on the owner by default. An adapter-registered Unhandled handler still adds value (device-accurate timestamp, real vendor event code), but the diagnostic is no longer lost when the handler is absent.

Detection pattern:

A handler cannot be registered without an address — every Handle overload lives on a route that has already bound an address, a person or a credential — so the shapes that can still mask the diagnostic are the suppressing ones:

  • router.When(_ => true).Ignore(...) for an event type that has address-bound routes
  • a predicate broad enough that events of unknown addresses fall into it rather than through
  • Broad Unhandled handler that swallows address-bearing events silently

Correct pattern:

// Address-bound route - unresolved addresses surface as Unresolved diagnostic
router.WithAddress<ZoneDevice, int>(e => e.ZoneId)
    .Handle((zone, evt) => zone.Alarm(evt.Timestamp));

// Suppression names its subject, so an unlisted event still falls through
router.When(e => e.Kind is ZoneEventKind.Heartbeat or ZoneEventKind.Keepalive)
    .Ignore("control-plane noise - no audit value");

Violation example:

// Address-bound route
router.WithAddress<ZoneDevice, int>(e => e.ZoneId)
    .Handle((zone, evt) => zone.Alarm(evt.Timestamp));

// WRONG: blanket ignore masks unresolved zone addresses
// Zone 85 exists on the panel but not in PQ
// → no diagnostic, configuration drift invisible
router.When(_ => true)
    .Ignore("nothing else here is interesting");

The build enforces this as PQC213: an always-true route ending in Ignore, for an event type routed by address anywhere in the adapter, is reported on every build. A predicate that names the events it suppresses is left alone — which is why naming them is the fix.

Why this matters:

  • Device with 100 zones but only 80 configured in PQ → 20 zones generate events with unknown IDs
  • Without diagnostic: events silently dropped or logged as generic info
  • With diagnostic: Unresolved(ThingType=ZoneDevice, Address=85) → operator knows which zones are missing
  • Critical for commissioning and troubleshooting

Exception: Truly address-less event variants (e.g., system-wide alarms, panel-level events without zone context) can have address-less fallback routes - but only if they're a separate event subtype, not the same type as address-bound events.


RULE-025: Startup Event Replay Must Use SkipReplayed

Severity: High

Description: When a device sends startup event replay (e.g. GetAllEvents() delivers historical events on every connect), the adapter must deduplicate using route.SkipReplayed(keySelector, cursor) backed by IEventCursor<TKey>. Manual deduplication via HashSet, List, or IPersistentSettings<*EventIndex> is forbidden.

SkipReplayed provides a persistent high-watermark cursor scoped to the device, survives restarts, requires a single line of configuration, and is covered by framework-level tests. Manual alternatives repeat the same 5–8 boilerplate steps per adapter and are error-prone (unbounded growth, missed lock, wrong eviction policy).

Detection pattern:

  • HashSet<uint> / HashSet<ulong> / List<uint> fields in Protocol, Transport, or routing classes combined with GetAllEvents / SubscribeNewEvent calls
  • IPersistentSettings<*EventIndex> / IPersistentSettings<*StoreIndex> declared as constructor parameters on Things or protocols
  • Methods named TryRememberEvent, IsAlreadySeen, RecordEvent, or similar dedup helpers in protocol code
  • Manual Contains + Add calls on a private ID collection in the event dispatch path

Correct pattern:

public partial class EventRouting(
    ILogger<EventRouting> logger,
    IEventCursor<uint> cursor)
{
    partial void ConfigureRoutes(IEventRoute<SdkEvent> route)
    {
        route.SkipReplayed(e => e.Id, cursor);
        // ... normal routes follow
    }
}

For object routers (replay stream is one subtype of a union):

route.SkipReplayed<SdkAuditEvent, uint>(e => e.Id, cursor);

Violation examples:

// WRONG #1: manual HashSet in Protocol
private readonly HashSet<uint> _seenEventIds = [];

bool TryRememberEvent(SdkEvent evt)
{
    return _seenEventIds.Add(evt.Id);
}
// → unbounded growth, lost on restart, boilerplate per adapter

// WRONG #2: manual IPersistentSettings wiring
public Panel(IPersistentSettings<EventStoreIndex> eventStore) { ... }
// ConfigureEventPersistence(eventStore) called in Connect()
// → 6 manual steps, adapter re-implements what SkipReplayed provides

Why this matters:

  • Manual dedup state is held in memory — lost on crash, requiring replay of already-audited events
  • Bounded eviction (e.g. keep last 1000 IDs) creates a window where old replayed events re-enter the audit log after the window slides
  • The framework cursor is a true high-watermark: once an ID is recorded, all IDs at or below it are rejected permanently, regardless of restart count
  • Consistency: all adapters behave identically, reviewers know exactly where to look

See also: Pattern 17 (docs/adapter-development/build/patterns/17-event-replay-deduplication.md) for full usage guide.


RULE-032: Event Publication Must Be Encapsulated in the Owning Thing

Severity: High

Description: Thing.PublishEvent(...) is the Thing's own concern. It must be called only from within the Thing's own class (as PublishEvent(...) / this.PublishEvent(...)), never on another Thing instance from outside it — not from an event router, command handler, status-polling class, or extension method.

PublishEvent is public on Thing only because the source generator emits function methods that call it across the generated partial boundary; it cannot be narrowed to protected/internal. That visibility is a technical necessity, not an invitation for external callers.

The build enforces this as PQC232: a qualified PublishEvent call whose receiver is a Thing the calling class does not own is reported on every build, local and CI. Review still applies the rule — the diagnostic is a floor, not a substitute — but a violation no longer depends on someone noticing it.

Why this matters:

  • State coordination: a Thing emitting an event often must update its own function status / internal state in the same step (this is exactly what the generated function methods do). An external thing.PublishEvent(...) publishes the audit event but cannot touch the Thing's private state — state and audit drift apart.
  • Uniformity: every event a Thing can emit stays discoverable on the Thing's own surface (its YAML functions + partial methods), not scattered across routers and command handlers.
  • Consistency: reviewers look in one place, and future cross-cutting behavior (severity, throttling, ordering, dedup) can be added once on the Thing instead of audited across every call site.

This is the sibling of RULE-003: RULE-003 says call the generated method, never build the event; RULE-032 says and the PublishEvent call that consumes it lives on the Thing, not on the caller.

Detection pattern:

  • A qualified receiver: <expr>.PublishEvent(...) where <expr> is a parameter, local, or field of a Thing type, appearing in a file that is not that Thing's own class. (Example: channel.PublishEvent(...), door.PublishEvent(...), keypad.PublishEvent(...) inside HistoryEventRouting.)
  • Scan the whole adapter directory, not just Events/ — violations also live in Commands/*.cs, *StatusPolling.cs, and *Extensions.cs.

Compliant — do NOT flag:

  • Unqualified / this. — PublishEvent(...) or this.PublishEvent(...) inside the Thing's own partial class file (e.g. Devices/AcmeDoor.cs). The Thing is publishing its own event.
  • Generated function methods — await door.Opened(ts), channel.AccessGranted(ts, personId). These are the Thing's named surface; they call PublishEvent internally, on the right side of the boundary.
  • Framework extension methods — owner.Unknown(ts, eventType, reason, ...) (ThingEventExtensions). A sanctioned framework surface that wraps PublishEvent behind a coordinated named API (RULE-020/RULE-027). Not a violation.

Worked contrast (both call PublishEvent; only one is compliant):

// COMPLIANT — Panel.Events.g.cs (generated from events: on Panel, the Thing's own class)
public partial class Panel
{
    public ValueTask<bool> HardwareFault(DeviceTimestamp timestamp, string faultDescription, string? faultCode = null) =>
        PublishEvent(PqEvent.Technical.Fault.Hardware.At(timestamp, this) // this.PublishEvent
            .WithParameter("faultDescription", faultDescription)
            .WithParameter("faultCode", faultCode)
            .Build());
}

// VIOLATION — Events/HistoryEventRouting.cs (external router)
private static ValueTask<bool> ChannelEnabled(Channel channel, HistoryEvent evt) =>
    channel.PublishEvent(PqEvent.System.Device.Enabled.At(Timestamp(evt), channel).Build());
//  ^^^^^^^ qualified receiver from outside Channel — bypasses Channel's state coordination

Correct fix (priority order):

A hand-written partial that builds the event itself is not a fix — PQC203 reports it.

  1. Declare a YAML function for the event in adapter-registration.yaml / access-model.yaml when the event also carries function state. The source generator emits the named method on the Thing (like AccessGranted, Alarm); the router then calls channel.Enabled(ts). Preferred where a function fits — it is the same mechanism the compliant routes in the same file already use, and it coordinates state and audit in one call.
  2. List the event under the type's events: in adapter-registration.yaml when no function owns it. The source generator emits a publication method named after the shortest unique tail of the event id, carrying the event's taxonomy parameters (pq.event.system.device.enabled → channel.DeviceEnabled(ts)); the router calls that and passes every value the device reports; a value reported only sometimes goes in as null. A one-line YAML change and no C# at all. See Declared Event Methods.

Either way the PublishEvent call site moves onto the Thing, and the external caller invokes a named semantic method.

Exception: None for adapter code. The only legitimate callers of PublishEvent are the Thing itself and framework infrastructure (ThingEventExtensions, generated function methods).


RULE-050: Poll Handlers Must Exist and Be Wired When the Protocol Exposes Them

Severity: Critical

Description: The rule covers both convention-detected poll handlers — PollStatus (current state) and PollHistory (event/history retrieval). When the vendor protocol exposes the corresponding capability — readable current state (status query commands, state masks) for PollStatus, a readable event/history buffer for PollHistory — and the device does not reliably push the same data on its own, the handler MUST exist and MUST be wired. Two failure modes, both violations:

  1. Missing handler — no handler anywhere in the adapter although the protocol documents the capability. For status: Things stay unknown until the first event arrives; for states that rarely emit events (armed, bypass, trouble) that means indefinitely. For history: device-recorded events never reach the audit log.
  2. Handler not wired — a handler method exists in source but neither wiring path is complete (see below). The framework never calls it, and the adapter compiles clean while polling silently never runs.

The two legitimate wiring paths — the handler must fully satisfy one:

Path A — auto-detected static handler (preferred). The source generator detects the signature and emits the dispatcher:

public static async Task PollStatus(      // or PollHistory — same contract
    PanelThing panel,          // first param = the Thing type that owns the poll
    Protocol protocol,         // remaining params resolved via DI
    CancellationToken ct)      // CancellationToken last
Instance methods, non-Task/ValueTask return types, or a first parameter that is not a Thing type are NOT detected — there is no compiler error and no warning; the framework simply never polls. Detection success is visible as a generated <ThingType>.StatusPoll.g.cs / <ThingType>.HistoryPoll.g.cs dispatcher. The Thing type must also exist as a device type in adapter-registration.yaml — a handler whose first parameter names an unregistered type is silently skipped.

Path B — hand-written interface. The Thing's partial implements IPollableStatus / IPollableHistory itself and StatusPoll: / HistoryPoll: is declared on the device type in adapter-registration.yaml (which registers the Thing with the scheduler on connect). Legitimate only with a documented reason why the generated static path is insufficient (see Pattern 12).

Verification:

  • Handler source exists (**/*StatusPolling.cs, **/*HistoryPolling.cs, or the methods elsewhere) when the protocol documents the capability.
  • Path A: check generated output for this dispatcher, because detection success is visible only there: the adapter's generated output must contain a <ThingType>.StatusPoll.g.cs (resp. .HistoryPoll.g.cs) dispatcher. Static-looking handler + no generated dispatcher + no Path B wiring = Critical.
  • Path B: the implementing type declares the interface and the device type carries the function in adapter-registration.yaml. Either half missing = Critical.
  • Do not cite generated files for anything else; the Path A check answers exactly one question — "was the handler detected?"

Acceptable exception (must be documented): No handler is acceptable only when the device reliably pushes the same data over its event stream, or the protocol exposes no such query at all — and the adapter's design-notes document states which of the two applies under Known Implementation Gaps (or an equivalent design note).


RULE-051: Poll Must Cover All Relevant States of Each Polled Element

Severity: High

Description: A PollStatus handler must read and set every status-bearing function the protocol exposes for each polled Thing type in its domain — not only the one state the author happened to need. Each domain element carries a characteristic state set:

Element Typical states to cover
Partition armed/disarmed (often a single bit), alarm, trouble
Zone / detector alarm, tamper, bypass, fault
Door open/closed, locked state, forced, held
Output active/inactive
Module / expander / keypad comm, tamper, power/battery

The authoritative set is not this table — it is the intersection of the Thing's declared functions in adapter-registration.yaml and what the protocol's status queries expose. For each polled Thing type, every non-framework-managed function the protocol can report must appear in a batch.Set(...).

Why partial polling is actively harmful: Commit scopes to exactly the functions you Set(...); a function you never set is never refreshed by the poll. Setting only some states therefore leaves the rest stuck at their last value — unknown until some other path happens to update them. For a state the protocol reports but the poll skips, that means indefinitely stale status.

Detection pattern:

  • List the Thing types the handler iterates (descendant queries, panel itself).
  • For each, diff the functions declared in adapter-registration.yaml against the batch.Set(...) calls in the handler.
  • Check the protocol layer (Protocol.cs, vendor docs) for status queries whose data the handler never reads — e.g. an armed-partitions mask that is queried nowhere.
  • A Thing type with declared status-bearing functions that the handler skips entirely (e.g. partitions absent while zones and outputs are polled) is a violation of this rule, not a style note.

Acceptable exceptions (must be declared):

  • A state genuinely not exposed by any protocol status query.
  • A state intentionally excluded because the event stream reliably owns it.

Both are declared on the poll handler, naming the state and the reason:

[PollExcludes(typeof(TamperState), "Acme reports door tamper on the event stream only; no status query exposes it")]
public static async Task PollStatus(AcmeDoor door, CancellationToken ct)

The build enforces this as PQC251: the generated status batch offers one Set overload per declared status-bearing function, so the diff against what the poll sets is exact. Coverage is assembled across everything the handler reaches, so a batch built in a helper counts. What the build cannot know is whether the protocol really exposes the state — which is what the declaration records.


RULE-053: Known Card Wire Bit-Length Must Be Propagated

Severity: Medium

Description: A card value reaches PQ as a hex string, which is lossy about width (leading zeros vanish). When the protocol or SDK hands the adapter the card's wire bit count alongside the card data, the adapter must carry it through:

  • Enrollment — set CardEnrollmentData.BitLength on the completion payload.
  • Unknown-card denial — pass card_bits to AccessDeniedUnknown(...).

Leaving the width off is correct only when the source genuinely does not provide it. Never fabricate a width. See the concept page Card Wire Bit-Length for the full contract (including the recommended consume side, CardCredentialData.CodeBits, which is guidance not a gate).

Why this matters:

  • The server stores the enrolled card's canonical width from what the adapter reports; if the adapter drops a bit count it actually had, the width is lost and cannot be recovered from the hex.
  • A later sync then ships that card with no CodeBits, so a fixed-width panel adapter cannot reconstruct the wire encoding — a fidelity gap that traces back to the dropped enrollment width.
  • The unknown-card card_bits lets PQ identify the presented card's format; without it, an otherwise-diagnosable card is harder to place.

Detection pattern:

  • A card-capture path (CardEnrollmentData { ... }) whose source event/SDK exposes a bit-count field (bit_count, bitCount, nBits, bit_length, a documented Wiegand frame width) that the adapter reads or discards, while BitLength is left unset.
  • An AccessDeniedUnknown(...) call that passes card_code but omits card_bits, where the same event carries a bit-count field.
  • Card extraction that converts a bit count into hex/bytes (e.g. (bit_count + 7) / 8) and then throws the bit count away instead of also propagating it.

Correct pattern:

// enroll: the reader transaction exposes a bit count — carry it
var data = new CardEnrollmentData
{
    EnrollmentId = command.EnrollmentId,
    PersonId = command.PersonId,
    CardCode = ExtractCardCode(evt),
    Technology = "vendor.card",
    BitLength = evt.BitCount,   // propagated, not discarded
};

// unknown card: pass the width alongside the code
await reader.AccessDeniedUnknown(timestamp, card_code: code, card_bits: evt.BitCount);

Violation example:

// WRONG: the SDK gave a bit count; the adapter uses it to size the hex, then drops it
var hex = ToHex(evt.BitArray, (evt.BitCount + 7) / 8);
var data = new CardEnrollmentData
{
    CardCode = hex,
    Technology = "vendor.card",
    // BitLength omitted even though evt.BitCount was available -> width lost
};

Acceptable exception:

  • The protocol/SDK does not expose a bit count for the captured card (only a value). Leaving BitLength / card_bits unset is then correct — no width was available to propagate.

Verification:

  1. For each card-capture and unknown-card path, check whether the source event/SDK exposes a bit count.
  2. If it does, verify CardEnrollmentData.BitLength (enroll) or card_bits (unknown-card denial) is populated from it.
  3. If it does not, confirm the width is genuinely unavailable — the omission is then compliant.