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.

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 use EventBuilder directly. Every event the adapter emits must be:

  1. Declared in adapter-registration.yaml under events: (for the relevant device type)
  2. Emitted via the generated PqEvent.<Name> strongly-typed class

The source generator produces typed builders, audit integration, identity tracking, and NATS subject routing. Manual EventBuilder usage bypasses all of it.

Why this matters:

  • Type safety - typo in event name caught at compile time, not runtime
  • Audit integration - generated events automatically route to ABAC engine
  • Identity tracking - WithPerson/WithCredential fluent API integrated
  • 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 new EventBase(...) or similar manual construction
  • IEventPublisher.Publish with untyped/anonymous payload
  • String-based event names in PublishEvent calls

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 - generated PqEvent (for adapter-specific events):

# adapter-registration.yaml
device_types:
  - type_id: VendorPanel
    events:
      - "pq.event.vendor.firmware.update.started"
      - "pq.event.vendor.firmware.update.completed"

// Generated typed class - compile-time safety.
// NOTE: this call lives INSIDE the panel's own class (this.PublishEvent) — per RULE-032,
// PublishEvent is never called on a Thing from outside it. From a router, call a named
// method on the Thing instead.
public partial class VendorPanel
{
    public ValueTask<bool> FirmwareUpdateStarted(DeviceTimestamp timestamp, string newVersion) =>
        PublishEvent(
            PqEvent.Vendor.Firmware.Update.Started
                .At(timestamp, this)
                .WithParameter("version", newVersion)
                .Build());
}

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: manual EventBuilder for custom event (not in YAML)
var evt = EventBuilder.Event("pq.event.vendor.custom.thing")
    .At(timestamp, panel)
    .Build();
await panel.PublishEvent(evt);
// Should be: declare in adapter-registration.yaml, use PqEvent.Vendor.Custom.Thing

// WRONG #3: string-based event publishing
await publisher.Publish("pq.event.access.granted", new { personId, doorId });
// Should be: use generated PqEvent.Access.Granted typed class

Workflow when adding a new event:

  1. Add event identifier to events: block in adapter-registration.yaml (under the emitting device type)
  2. Verify the event exists in the framework's event taxonomy
  3. Build → source generator produces PqEvent.<Name> typed class
  4. Use the typed class in adapter code

Exception: None for adapters. EventBuilder is framework-internal infrastructure - adapter code should never reference it 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.

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:

  • router.When(_ => true) or router.When(e => true).Handle(...) for an event type that has address-bound routes
  • Broad Unhandled handler that swallows address-bearing events silently
  • Address-bound .WithAddress<T>(...) route followed by .When(...) fallback for same event type without address binding

Correct pattern:

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

// Only add fallback for truly address-less variants (system events, etc.)
router.When<SystemEvent>()
    .Handle((evt) => HandleSystemEvent(evt));

Violation example:

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

// WRONG: catch-all masks unresolved zone addresses
// This fallback catches events for unknown zone IDs
// → no diagnostic, configuration drift invisible
router.When<ZoneEvent>(e => true)
    .Handle((evt) => _logger.LogInformation("Got zone event"));

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/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 use the typed PqEvent, not raw EventBuilder; 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 methodsawait 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 methodsowner.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 — Devices/AcmeDoor.cs (the Thing's own class)
public partial class AcmeDoor
{
    public ValueTask<bool> ForcedOpen(DeviceTimestamp ts) =>
        PublishEvent(PqEvent.Access.Door.Forced.At(ts, this).Build()); // this.PublishEvent
}

// 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):

  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.enabledchannel.DeviceEnabled(ts)); the router calls that. A one-line YAML change and no C# at all. See Declared Event Methods.
  3. Hand-written partial method on the Thing's own class when the declaration cannot express the call — an extra parameter, a parameter supplied conditionally, a constant the protocol implies. Add Devices/Channel.cs with public partial class Channel { public ValueTask<bool> Enabled(DeviceTimestamp ts) => PublishEvent(PqEvent.System.Device.Enabled.At(ts, this).Build()); }; the router calls channel.Enabled(ts). A hand-written name wins over the generated one, so the richer version stands. A partial that only forwards to the builder and adds nothing is redundant — declare the event instead.

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 documented):

  • A state genuinely not exposed by any protocol status query — document which states are event-only and why, in the adapter's design-notes document or a comment in the polling class.
  • A state intentionally excluded because the event stream reliably owns it — omit its Set(...) from the poll and add a comment naming the excluded function and the reason.

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.