Skip to content

Events — Audit Visibility & Taxonomy

Rules for audit-log visibility, event taxonomy, Ignore() suppression, and unknown/unresolved classification.

Part of compliance-rules.

Core event mechanics rules (routing, polling, timestamps, publish encapsulation) live in events.md.


RULE-020: Every Device Event Must Be Processed

Severity: Critical

Description: Every event from the device's history/event stream MUST be processed. No event may be silently dropped.

The semantic contract of device.unknown: publishing it is the adapter stating to the customer, in the audit log, "I do not know what this event means." That statement must be true. For a code whose meaning is documented — named and described in the vendor spec or the adapter's own enum — the statement is false: the adapter does know the meaning and is misreporting it. Every excuse ("no PQ verb fits", "not mapped yet", "low value") fails this test, because none of them changes what the adapter knows. The only true use is a code the adapter genuinely cannot interpret.

If we know what the event means, it MUST NOT fall through to device.unknown. A vendor code whose meaning is documented — named in the vendor protocol spec or in the adapter's event enum — MUST be given a concrete disposition: a typed PQ route (exact, or generalized along the same branch — see RULE-027), an explicit Ignore("reason") justified by genuine lack of independent audit value, or — when no PQ verb expresses it yet — a gap disposition that carries the understood meaning to review. Leaving a known code in the Unhandled/device.unknown fallback, or deferring its mapping to "later", is a compliance failure. Auditors treat a device.unknown entry for a code we demonstrably understand as an unmapped-event defect.

device.unknown / device.unresolved are reserved for events the adapter genuinely cannot interpret: undocumented vendor codes, sub-codes whose meaning is carried in an opaque nested field we do not decode, or events whose target Thing cannot be resolved. For these, publish the diagnostic with full context (raw payload, event code, source) — never drop silently. When the meaning is genuinely uncertain, publish device.unknown rather than guessing a wrong mapping; but uncertainty about which PQ event to use is not such a case — resolve the target via the decision test below (exact route, same-branch generalization, or a gap for review).

Three valid outcomes for every device event:

  1. Handled - matched a route, routed to a typed PQ event on the Thing
  2. Explicitly ignored - router.When<X>().Ignore("reason") - for events with genuinely no independent audit value: control-plane noise, or host-initiated echoes whose outcome is already audited elsewhere (e.g. a remote-command request/denied echo when the command pipeline reports the result). Permitted for known events too, provided the justification holds.
  3. Unknown/unresolved - logged with full context, published as diagnostic event - only when the meaning is genuinely uninterpretable or the Thing cannot be resolved; never as a parking spot for a known-but-unmapped code.

Decision test — apply to every documented vendor code (named/described in the vendor spec or the adapter's event enum):

  1. A typed PQ verb expresses it exactly? → route it.
  2. No exact leaf, but an ancestor event on the same dotted branch truthfully describes it? → route to the ancestor, mark the ledger row generalized: true with the missing specific meaning (RULE-027 bounded generalization).
  3. No truthful same-branch route, but it has real audit/operational value? → record a gap disposition (intent, target, candidates, citation). Extending the framework's function registry or event taxonomy requires a reviewed decision made from that gap — framework verbs and taxonomy entries are never added automatically. The verb gap is the work item, not a reason to park the code in device.unknown.
  4. Genuinely no independent audit value — control-plane noise, or an echo of a host-initiated action whose outcome is audited elsewhere? → Ignore("reason") with the justification inline.
  5. Meaning genuinely undecodable (opaque nested sub-code) or target unresolvable? → only then device.unknown / device.unresolved, with full diagnostic context.

"We know it's X but haven't mapped it yet" is not an acceptable shipped state. A known-meaning code resting in device.unknown is a defect, full stop — auditors read it as incomplete coverage.

Protocol ACK timing: ACK ordering is a communication rule, not an event-processing rule. Do not flag an event adapter for sending a mandatory protocol ACK before Dispatch() when the ACK is required to keep the device/session progressing. RULE-020 still applies to silent drops, missing unknown diagnostics, and unsafe event filters.

The forbidden outcome:

  • Silently dropped - no log, no event, no trace

Why this matters:

  • Audit trail integrity: missing events break compliance reports
  • Security signals can hide in unrecognized event codes
  • Vendor firmware updates may add new event types - we must notice
  • Debugging field issues requires complete event history
  • ABAC rules may depend on events we don't yet recognize

Detection pattern:

  • router.Unhandled(...) handler that returns without logging or publishing
  • Event filtering at protocol layer (before router) that drops events without logging
  • catch (Exception) { } around event parsing that swallows errors
  • Missing Unhandled handler entirely (the framework still auto-publishes device.unresolved, but unmapped/unknown vendor codes are only logged — they never reach the audit log without a handler)
  • Event handler returning false (declined) for events that should be logged as unknown
  • A vendor event whose enum/spec gives it a clear name and description, yet it reaches router.Unhandleddevice.unknown
  • A ledger or mapping-table row with a populated vendor name/description but no real disposition ("fallback unknown", "needs review") left in a shipped adapter
  • Review or report notes that say a known code's mapping is "deferred", "later", or "phase 2"
  • A missing or incomplete event-dispositions.yaml ledger (see below), or hedged plan rows offering alternative dispositions ("rung 1–3 or 5") / catch-all "anything not explicitly mapped → unknown" rows covering documented codes

Disposition ledger (mechanical enforcement): Every adapter ships event-dispositions.yaml next to its sources: the cited documented_space (the closed vendor event-code enumeration, e.g. ["1-100", "150-200"]) plus exactly one disposition per documented code or contiguous range — route (with the PQ event / function-method target; generalized: true when a same-branch ancestor event stands in for a missing leaf), ignore (with an inline reason meeting the criteria above), or gap (understood meaning, no route the build may decide alone; carries gap_id into taxonomy-gaps.md, kind: taxonomy (no PQ route exists) or kind: model (the vendor address cannot identify a unique Thing in the current model — fixed by extending the model, not the taxonomy), intent (one sentence, what happened semantically), target (the model element concerned), candidates (nearest existing PQ events and why each fails), citation, and requires an explicit human-approved deferral). A deterministic coverage audit diffs documented space ↔ ledger ↔ actual routing code and blocks on: a documented code with no disposition, a route with no trace in the routing sources (an enum member declaring the code is not routing), an ignore without a reason, or an unapproved gap. Under this rule device.unknown is correct only for codes outside documented_space — if the docs define events 1–100 and 150–200, every one of those codes needs a disposition, and only an out-of-space code like 234 may surface as device.unknown.

Correct pattern:

// Default Unhandled handler - logs and publishes via thing.Unknown(), which
// branches to device.unresolved (reason.Unresolved) or device.unknown (reason.Unmapped).
// Do NOT hand-pick the event id — let the helper apply RULE-027's two-axis classification.
router.Unhandled(async (owner, evt, reason, ct) =>
{
    _logger.LogWarning(
        "Unhandled event: reason={Reason} address={Address}",
        reason.Unresolved ? "unresolved" : "unmapped",
        reason.Address ?? "n/a");

    // Ts(evt) is the adapter's device-timestamp converter — DeviceEventBase carries no timestamp,
    // so each adapter exposes its own (e.g. DeviceTimestamp.FromDevice(evt.DeviceTime, owner.TimeZone)).
    // eventType is the vendor-specific composite identifier, e.g. $"{evt.Kind}:{evt.EventId}".
    await owner.Unknown(
        Ts(evt),
        eventType: $"{evt.Kind}:{evt.EventId}",
        reason: reason,
        rawData: evt.ToRawString(),
        cancellationToken: ct);
});

There is no silent no-op default — but adapters SHOULD still register their own handler. The source generator no longer emits a no-op UnhandledDefault. When no Unhandled handler is registered, Dispatch applies a framework default: for an Unresolved reason (route matched the type but the Thing was not found) it publishes pq.event.device.unresolved on the owner via owner.Unknown(...), so resolution failures are never silently dropped. For an Unmapped reason (unknown vendor code, no route matched) it only logs a warning and publishes nothing — surfacing unknown vendor codes as device.unknown remains the adapter's job. Therefore adapters SHOULD still register a router.Unhandled(...) that calls owner.Unknown(...) to (a) surface unmapped/unknown vendor codes as device.unknown (the framework does not auto-publish those) and (b) enrich the audit with a device-accurate timestamp and the real vendor event code — the framework default uses receive-time (DeviceTimestamp.UtcNow) and the C# type name, not the vendor code.

A top-level handler now covers MapGroup children too. router.Unhandled(...) set on the top-level router (or the framework default) propagates to MapGroup child routers: each child inherits the parent's Unhandled handler at creation. A MapGroup template only needs its own .Unhandled(...) if it must override the inherited one. So a child scope without an explicit handler still surfaces unresolved events (via the inherited handler or the framework default) rather than dropping them.

The binding target is degraded location information — the highest visible config element, never the bare adapter root. When precise location is lost, device.unresolved / device.unknown binds as high as the visible configuration requires — typically the panel/controller. Treat this as explicitly degraded information: it records that something happened that we could not place precisely, not a precise device fact. The one hard target constraint is that it must never land on the bare DeviceAdapterBase process root. This is not a requirement that the connection-owning Thing be a panel, and no particular topology is mandated — do not enforce that. In practice the framework binds the diagnostic to the router Owner, i.e. the scope at which resolution failed; a MapGroup child scopes this to its resolved parent (e.g. the panel), which is why MapGroup binding degrades less.

// Explicit ignore - conscious decision with reason, only for non-audit control-plane noise router.When().Ignore("Heartbeat - control plane noise, no audit value");

// Known event with diagnostic on unresolved address router.When() .WithAddress(e => e.ZoneId) .Handle((zone, evt) => zone.Alarm(evt.Timestamp)); // → unresolved zone addresses surface via router.Unhandled with UnhandledReason.Unresolved

**User-replication exception:** Device user-record events that result from access synchronization
(create, change, or delete user codes) may be explicitly ignored when the payload cannot
distinguish adapter replication from a manual keypad operation. The access-synchronization command
result is the customer-visible record for the operation. The disposition ledger identifies the
ignored codes and states this reason. Do not add adapter-local synchronization state or timing
correlation to recover a distinction the protocol does not carry; these codes remain ignored even
when a manual operation can produce the same payload.

**Violation examples:**
```csharp
// WRONG #1: silent drop in Unhandled
// (explicitly registering a no-op also suppresses the framework default device.unresolved publish)
router.Unhandled((owner, evt, reason, ct) => Task.CompletedTask);

// WRONG #2: filtering at protocol layer without logging
internal IEnumerable<DeviceEvent> TransformToEvents(Frame frame)
{
    var evt = ParseEvent(frame);
    if (!_knownTypes.Contains(evt.Type))
        yield break;  // event silently lost
    yield return evt;
}

// WRONG #3: swallowed parse errors
try { yield return ParseEvent(frame); }
catch { /* malformed events silently dropped */ }

// WRONG #4: implicit ignore by missing route
// (no route for SessionRefresh event, no Unhandled handler)
// → events silently disappear, no audit, no diagnostic

// WRONG #5: handler returns false on unknown subtype
router.When<AccessEvent>()
    .Handle((evt) =>
    {
        if (evt.SubType == AccessSubType.Granted) { /* ... */ return true; }
        return false;  // returning false = Declined = silent ack: dispatch STOPS, Unhandled is NOT called.
                       // The unknown subtype is silently dropped. To audit it, publish via owner.Unknown(...)
                       // inside the handler (rung 5), or re-parse it into a type that reaches Unhandled.
    });

Note: Returning false is not a discard channel. Per the dispatch contract, Declined (handler returns false) is treated as a silent ack — AfterDispatch fires and routing stops; the event never reaches Unhandled. A handler that returns false for a subtype it does not recognize silently drops it. Unknown subtypes/codes inside a matched type must be published via owner.Unknown(...) (RULE-027 rung 5) or re-parsed into an unmapped type — never signalled by returning false.

Framework support: - router.Unhandled(handler) - catch-all for unmapped events - UnhandledReason.Unmapped - no route matched - UnhandledReason.Unresolved - route matched but Thing not found (includes ThingType + Address) - When no handler is registered, the framework default publishes pq.event.device.unresolved for reason.Unresolved (so resolution failures are never silently dropped), but only logs a warning for reason.Unmapped. Adapters SHOULD register an Unhandled handler that logs + calls owner.Unknown(...) to surface unmapped/unknown vendor codes as pq.event.device.unknown (RULE-027) and to enrich the unresolved record with a device-accurate timestamp and the real vendor event code (the default uses receive-time + the C# type name).

Verification: - Enable verbose protocol logging - Run adapter against device with rich event mix (alarms, accesses, system events) - Compare device event count vs. audit log count - must match - For each unrecognized event in audit, confirm it appears as pq.event.device.unknown (unknown type/code) or pq.event.device.unresolved (known type, address absent from config) with diagnostic context - Check Ignore() calls - each must have explicit reason string documenting why the event has no customer-visible security/audit value

Distinction from RULE-012: - RULE-012: Use Ignore() for known control-plane noise (conscious skip) - RULE-020: Security/safety/audit-relevant events must surface as typed/unresolved/unknown customer-visible audit, never only logs or Ignore()


RULE-054: Audit Events Must Be Hardware-Sourced — No Fabricated Occurrences

Severity: Critical

Description: An adapter must not invent audit-event occurrences. Every device-domain audit event (state change, actuation, access grant/deny, alarm, tamper, arm/disarm, output on/off, …) published to the customer audit log MUST be backed by an event the hardware actually reported — its event/history stream or a protocol event frame. The adapter reports what the device did; it does not manufacture device history.

The line — translation is allowed, fabrication is not:

  • Allowed — translation / re-attribution. When the hardware reports an event (e.g. Activated) and the adapter knows it was caused by a PQ operator command, re-attributing it to the operator variant (ActivatedRemote with command.OperatorIdentity) is not invention: the occurrence is real and hardware-sourced, only the actor annotation is added. This is exactly the event-supervised ExecuteCommand<TEvent,TState> pattern (intercept the real event, attach who commanded it). Carry the device's timestamp (RULE-008).
  • Forbidden — fabrication. Manufacturing an audit event when the hardware reported none — e.g. synthesizing an "activated" event from a 0x17/status read-back or a poll transition because the command was accepted. Command acceptance (0xEF/ACK) and state confirmation belong to the command result and the status plane — not the audit-event plane. If the device does not log the action, there is no audit event; the change is visible via status only. A fabricated occurrence is a false audit record (it asserts a device event that never happened) and it silently hides the real gap — that the device doesn't log the action.

Exception — adapter-operational events. The adapter MAY author events describing its own operation rather than device-domain facts: connection loss/restore, command errors/timeouts, lifecycle/presence, discovery outcomes. These are the adapter's own truthful observations and legitimately use adapter time.

Detection pattern:

  • A device-domain audit event (thing.Xxx(...)) published from a command handler's success/onSuccess path when the confirmation was a status/state read-back or plain ACK and no matching hardware event exists.
  • An audit event emitted from a status-poll transition (state-mask change) rather than the event/history stream (cross-ref RULE-001: status polling must not emit events).
  • DeviceTimestamp.UtcNow used as the timestamp of a device-domain audit event — a real hardware event carries the device's own timestamp; UtcNow on a device-domain event signals a fabricated occurrence (RULE-008). Adapter-operational events legitimately use adapter time.

Not a violation: Activated → ActivatedRemote (or any operator re-attribution) driven by a real hardware event; adapter-operational events; publishing device.unknown/device.unresolved for a genuinely uninterpretable real event.


RULE-024: Vendor Event Streams Must Preserve Semantics

Severity: Critical

Description: When a vendor protocol exposes multiple notification streams with different semantics, the adapter must model those streams explicitly. Do not collapse heterogeneous callbacks into object and route them as if they had the same audit meaning.

Typical stream classes: - Status/state stream (*StateChanged, poll snapshots): current state for UI/status refresh, not audit by itself - Active event stream (NewEvent, active alarms/faults): currently active panel conditions, often requiring acknowledgement; may be routed when deduplicated against history - History/audit stream (NewHistoryEvent, history records): device audit log; default source for auditable PQ events and command pairing

Why this matters: - Status callbacks can be transient UI refresh signals and may not represent audit-log entries - Active events and historical audit records often have different lifecycle and acknowledgement semantics, and can overlap - Command supervision must pair with audited execution records, not status refresh callbacks - Collapsing streams into object hides these distinctions from reviewers and future maintainers

Detection pattern: - Generated protocol uses ProtocolBase<object> while vendor docs describe distinct event streams - Event routing branches only on CLR SDK callback type without preserving source stream semantics - *StateChanged callbacks emit audit events when a history/audit stream exists for the same domain - NewEvent and NewHistoryEvent are both routed without a shared deduplication key/cursor or documented proof that they cannot overlap - Command predicates match status callbacks instead of history/audit records

Correct pattern: Keep the streams separated and feed each one into the correct pipeline:

  • Status/state stream: update statuses only (StatusBatch, pending reconciliation, UI refresh). Do not dispatch these notifications through the audit EventRouter.
  • Active event stream: feed the adapter's active-event/alarm model and acknowledgement lifecycle. It may also enter event routing if the adapter applies a deduplication key shared with history, or documents that active and history streams cannot overlap.
  • History/audit stream: feed audit event routing and ExecuteCommand predicates.

If both active and history streams can represent the same device event, the adapter must use route.SkipReplayed with IEventCursor<TKey> (see RULE-025) before routing. If no overlap is possible, the adapter docs must state the protocol reason.

Review expectation: - Status stream updates status only (StatusBatch, pending reconciliation, UI refresh) and never enters the audit event router - Active event stream feeds active event/alarm panel behavior and may enter event routing only with dedupe/overlap proof - History/audit stream feeds audit log and ExecuteCommand predicates - Adapters consuming multiple event streams must define the event identity key used for deduplication, or explicitly document why streams cannot overlap - Any fallback that uses status callbacks as audit/command source must be explicitly documented with vendor evidence that no audit/history event exists


RULE-026: Archive Events Are Authoritative — Must Produce Audit Records Regardless of Current State

Severity: Critical

Description: Events from a device archive (circular FIFO, on-connect drain, history read) are authoritative records of past facts. Each archive event represents a real occurrence at a specific point in time and must produce a corresponding audit log entry — even if the current Thing state already reflects that same event.

Archive events are distinguished from live events by their device-provided timestamp: they capture "this happened at T", not "this is the current state". A known event type with a precise typed route arriving from archive must never be routed to Unhandled or logged as pq.event.device.unknown solely because the handler's state-transition method returns false (no current state change).

The violation pattern: Some Thing functions (e.g., module.Disconnected()) perform state-guarded transitions: they update state and return true on first call, but return false on subsequent calls when state is already set. This is correct for live event streams (prevents duplicate audit entries from repeated live signals). For archive events it is wrong: the device archive may contain many valid historical occurrences of the same event type. Only the first one produces a precise audit record; all subsequent ones fall to Unhandled and appear as device.unknown despite having an exact typed route.

Example (Acme bus module): Device archive holds 50 historical Disconnected (EventType=0xFF) records from past RS485 bus outages. - Archive record 1 → module.Disconnected() → state transition → communication.lost ✓ - Archive record 2–50 → module.Disconnected() returns false (already disconnected) → Unhandledpq.event.device.unknown

Each of the 50 records is a real historical event with a distinct timestamp. All 50 must appear in the audit log as communication.lost, not as device.unknown.

Detection pattern: - Archive drain dispatches to the same event router used for live events, with no special handling for idempotent state - First archive event of a type produces a correct audit entry; subsequent identical types produce pq.event.device.unknown - pq.event.device.unknown flood in audit log immediately after adapter startup, with event codes matching known event types - Unhandled handler fires for event types that have an explicit route (not truly unknown)

Correct approaches (in priority order):

  1. Idempotent-safe recording — use Thing event-recording methods that force an audit entry regardless of state, passing the archive device timestamp. The audit record captures "this was recorded at T in device history", not a live state transition. Framework support for this is required; if absent, escalate.

  2. Explicit archive-aware routing — detect archive context during drain and use a separate dispatch path that records events as historical facts without triggering state machine transitions.

  3. Minimum acceptable fallback — if the framework cannot record a state-idempotent event, the Unhandled handler must distinguish known-but-idempotent from truly unknown, and log them with a different diagnostic (e.g., pq.event.device.archive.idempotent) rather than pq.event.device.unknown.

What is NOT acceptable: - Filtering archive events before dispatch to suppress the flood (silently drops valid historical records — violates RULE-020) - Letting event types that have an exact typed route appear as pq.event.device.unknown because state was already current (audit trail falsification) - Treating idempotent state as a reason to discard an event

Distinction from RULE-025 (SkipReplayed): RULE-025 deduplicates by event identity key: the same event (same ID) replayed on reconnect must not produce a second audit entry. RULE-026 is the inverse: distinct occurrences of the same event type (different timestamps, different historical facts) must each produce an audit entry even if they produce no state transition.

Verification: - After adapter startup, count pq.event.device.unknown entries in audit log - For each unknown entry, decode the event code (e.g., acme:01:255) and verify it is genuinely unrecognized, not a known event type - Event types that have an exact typed route must never appear as device.unknown because the state transition was idempotent — any occurrence is a RULE-026 violation - Simulate archive backlog (disconnect device, trigger events, reconnect) and verify all archive entries produce typed audit records with correct timestamps


RULE-027: Event Classification Ladder — Most Precise Audit Record Always

Severity: Critical

Description: Event mapping is the translation of a vendor protocol notification into a PQ event written to the audit log. The audit log serves security auditing and daily operation, so every record must capture the incoming fact in the most precise form objectively available. The adapter degrades precision only when forced by the data — never for convenience, and never further than the data forces.

Precision has two independent axes. Degrade each one on its own; never collapse a failure on one axis into a failure on the other.

Axis Best Degraded
Event-type precision exact PQ event type with correct parameters pq.event.device.unknown (type not safely mapped to PQ yet)
Thing-location precision concrete Thing instance pq.event.device.unresolved (address known, no Thing in config) bound to the panel

device.unresolved = type known, location lost. device.unknown = type lost. They are not interchangeable.

The ladder (apply the highest rung the data allows; degrade only when objectively impossible):

  1. WIN — exact type + correct params + concrete Thing. The precise PQ event type, parameters filled from device data, bound to the concrete Thing instance the packet identifies. This is the target for every event; reach it whenever the data allows.

  2. Exact type, params defaulted or compensated. The PQ event type still describes exactly what happened, but the device data does not carry every parameter. Emit the precise typed event with defaulted or compensated parameters — the device timestamp is itself such a parameter (see RULE-008). A missing, partial, or out-of-range parameter (including a bad timestamp) never justifies degrading the event type. Stay on this rung; do not fall to rung 5.

  3. Exact type, location degraded along the static tree. When static routing cannot pinpoint the concrete Thing but the event genuinely belongs to a known ancestor scope, bind the precise event to the most specific ancestor the configuration proves it belongs to, degrading upward only as far as needed — down to the panel if necessary. This is static-routing degradation, not the unresolved case below.

  4. pq.event.device.unresolved — type known, address absent from config. When the packet does carry routing data but resolves to a Thing/address that does not exist in the current configuration, publish pq.event.device.unresolved on the highest visible config element (typically the panel/controller) — degraded location information, never the bare adapter root. rawData must carry enough to reconstruct the missing route: the would-be unresolvedThingType, unresolvedAddress, the original event type, and the raw payload. This surfaces configuration drift (a device present in the field but missing from PQ) as a diagnosable record rather than a lost event.

  5. pq.event.device.unknown — last resort, type not safely mapped. Use this when the adapter cannot safely emit a precise typed PQ event yet. This is not ideal, but it is customer-visible audit and is safer than Ignore(). It signals that the adapter implementation is incomplete and a new version must be released. rawData must carry enough for a maintainer to add the missing route. Reaching this rung is always a defect signal — never a routine outcome.

device.unknown covers two distinct sub-cases that must not be conflated — both route to the same runtime id, but they carry opposite diagnostic meanings:

  • (a) Truly unknown code — a defect/diagnostic. An undocumented or unrecognized vendor code whose audit/security meaning the adapter cannot determine. There is nothing to propose; the record exists so a maintainer can investigate.
  • (b) Understood-but-unmapped — a taxonomy-gap proposal. A documented vendor code whose security/audit meaning is known, but for which no precise PQ taxonomy route exists yet. The protocol has it; PQ taxonomy does not. Routing it to device.unknown at runtime is correct (it must not vanish), but it MUST ALSO be recorded as a taxonomy-gap proposal in the adapter's taxonomy-gap notes — never silently collapsed into the unknown bucket as if its meaning were lost.

Distinguishing (a) from (b) is mandatory: (a) is "PQ can't know what this is," (b) is "PQ knows what this is but has no route for it yet." A taxonomy-gap proposal is not an automatic invention of a new event id — extending the framework's event taxonomy is a deliberate decision made later by a human, informed by the recorded proposal. Nothing adds taxonomy entries automatically; the gap is only recorded so the decision can be made.

Bounded generalization (type axis, allowed): the taxonomy is NATS-style — specificity grows left to right, so an ancestor event is always a weaker but true statement about its descendants. When no leaf event matches exactly but an ancestor on the same dotted branch truthfully describes what happened, emit the ancestor (pq.event.access.denied.antipassbackpq.event.access.denied) and mark the ledger row generalized: true with the missing specific meaning. This stays on rung 1–2 — it is controlled type-precision degradation, not a defect. Prohibited: jumping branches (access.*security.*), "similar meaning" substitutes, and generalizing past the point where the statement stops being true — those cases are gap dispositions for human review, never routes.

Orthogonal: Ignore — events we deliberately do not audit. Not a rung on the ladder. Some combinations are known and intentionally excluded from the PQ audit log: periodic heartbeats, keepalives, session refreshes, byte/flag combinations the documentation marks reserved, or per-person upload confirmations proven to have no operational/security meaning. Route these with router.When<X>().Ignore("reason") — a conscious, documented decision, never an oversight (see RULE-020 for the silent-drop prohibition). Never use Ignore() for security, safety, access, credential, configuration, service-mode, diagnostic, health, trouble, restore, tamper, alarm, media-read, archive-overflow, or firmware/config-change events. If no precise typed route exists yet, publish device.unknown.

Hard constraints (each is independently a violation):

  • Never collapse location loss into type loss. Address-known-but-Thing-missing is device.unresolved, never device.unknown. The framework distinguishes them via UnhandledReason.Unresolved vs UnhandledReason.Unmapped.
  • Never degrade the type for missing/partial/out-of-range parameters. That is rung 2 — emit the precise typed event with defaulted/compensated params. Rung 5 is only for cases where a precise PQ event cannot be safely selected yet.
  • Never bind device.unresolved / device.unknown to the bare DeviceAdapter root. Bind to the highest visible config element (typically the panel/controller) — understood as degraded location information, not a precise record (see RULE-020). This does not mandate that the connection-owning Thing be a panel and must not be enforced as a topology rule. Separately: a MapGroup child router inherits the top-level Unhandled handler (or, absent any handler, the framework default that auto-publishes device.unresolved), so a child scope without its own Unhandled still surfaces unresolved events. A MapGroup template only needs its own Unhandled to override the inherited behavior.
  • Ignore requires a documented non-audit reason. An undocumented silent skip is a RULE-020 violation, not an Ignore. A documented reason is insufficient if the event has plausible security/safety/audit value.
  • Never drop identity the packet carries. When the vendor event carries credential, cardholder, person, or operator identity — a card number, PIN id, template id, badge id — the route MUST forward it into the event parameters: WithCredential / WithPerson for identifiers PQ can resolve, and the raw value (e.g. card_code) when it cannot. An unknown or unenrolled card still carries its number; emitting AccessDeniedUnknown (or any read event) with a blank credential when the packet contained a card number discards available data and destroys audit attribution — a rung-1 failure, not a permitted degradation. If the device exposes the credential identifier on a read, the adapter must surface it.

Framework support: - thing.Unknown(timestamp, eventType, reason, ...) (ThingEventExtensions) publishes the correct event automatically: device.unresolved (with unresolvedThingType / unresolvedAddress) when reason.Unresolved, otherwise device.unknown. Use it inside the Unhandled handler — do not hand-pick the event id. - UnhandledReason.Unresolved carries ThingType + Address; UnhandledReason.Unmapped means no route matched.

Distinction from neighbours: - RULE-012 governs which known noise to Ignore; RULE-027 places Ignore relative to the precision ladder. - RULE-013 requires unresolved addresses to stay diagnosable; RULE-027 names the resulting event (device.unresolved) and its binding target (the panel). - RULE-020 forbids silent drops and defines the Unhandled handler; RULE-027 defines the precision decision the handler and every route encode.