Skip to content

Event Mapping Guide

How to map vendor-specific device events to the PQ event taxonomy.


Overview

Every adapter translates vendor events into the PQ unified event taxonomy (pq-events.yaml). The process produces one machine-readable artifact in the adapter project directory:

  • event-dispositions.yaml — the disposition ledger: the cited documented code space plus exactly one disposition (route / ignore / gap) per documented vendor code. A deterministic coverage audit gates the adapter on it (RULE-020).

Vendors name the same logical events creatively ("card accepted", "presence", "valid entry" are all pq.event.access.granted). The mapping decision is always semantic: what does the event mean, not what does the vendor call it.


Zero Fabrication Policy

Vendor documentation is the only authoritative source for event codes, sub-codes, struct fields, and behavior.

  1. Every value must be traceable to documentation. If it is not documented, it does not exist.
  2. Documentation missing or ambiguous → STOP. Mark the item "not documented" and flag it for human review. Never guess, interpolate from neighboring codes, or infer from patterns.
  3. Documentation contradicts implementation assumptions → STOP. Flag both sources; a human resolves it.
  4. Never derive constants from observed device behavior alone. Observed behavior may be firmware-specific or a bug — document the observation, do not turn it into a constant.
  5. Collect ALL sub-codes of every event type, not just the common ones.

Classify Each Vendor Event

Classification Criteria Examples
audit Historical occurrence, compliance-relevant, triggers ABAC rules Access granted/denied, door forced, alarm triggered
status Current state report, no historical significance by itself Device online/offline, door position
control Protocol-level communication, no business meaning ACK, keepalive, handshake
dual Both audit AND status — use function methods that handle both automatically Door opened (audit event + door state change)

Status data never enters the audit event router (RULE-001, RULE-024); control messages are handled in the protocol layer or explicitly Ignore()d (RULE-012).


The Disposition Ledger

event-dispositions.yaml declares the cited closed vendor code space and one disposition per code:

adapter: Vendor.Model
source: "VENDOR DOC.md Appendix 1 pp.17-21"
documented_space: ["1-100", "150-200"]
dispositions:
  - code: 66
    disposition: route
    target: pq.event.intrusion.armed
  - code: 67
    disposition: route
    target: pq.event.access.denied      # exact leaf missing; same-branch ancestor used
    generalized: true
    notes: "vendor distinguishes 'denied - schedule window closed'; no PQ leaf for it yet"
  - code: 68
    disposition: ignore
    reason: "RTC-set echo of adapter command; outcome audited via command pipeline"
  - code: 300
    disposition: gap
    gap_id: TG-01
    kind: taxonomy                       # taxonomy | model
    intent: "siren activated by external input"
    target: "sounder output"
    candidates: "pq.event.safety.sounder.*  none covers external trigger source"
    citation: "VENDOR DOC.md §4.2 p.31"
    approved: true                       # set only from an explicit human decision
  • route — a typed PQ route exists; target is the PQ event or function method.
  • ignore — known event with genuinely no independent audit value (control-plane noise, or an echo of a host-initiated action audited elsewhere). Never for security/safety/access/credential/ config/service/diagnostic/health events (RULE-012).
  • gap — the meaning is understood but the build cannot decide the route alone. kind: taxonomy = no PQ route exists; kind: model = the vendor address cannot identify a unique Thing in the current device model (the fix is the model, not the taxonomy). Every gap carries intent, target, candidates, and citation so a human can decide from the row alone — add a framework verb, pick a semantically close event, or approve deferral. Extending pq-events.yaml / functions.yaml is always a human decision; adapters never add taxonomy entries.

pq.event.device.unknown is reserved for codes outside the documented space. Publishing unknown is the adapter telling the customer "I do not know what this event means" — for a documented code that statement is false, whatever the excuse. A documented event parked in device.unknown without a recorded, human-approved gap is a compliance failure (RULE-020).


Routing-Target Ladder

For every route disposition, pick the first mechanism that fits:

  1. Function verb already on the Thing. The Thing's assigned framework function has the action: call it — reader.AccessGranted(ts, …), door.Opened(ts), zone.Alarm(ts). Function methods publish the event and update state together; this is the normal case.
  2. Framework verb exists, Thing lacks the function. The action exists in the framework function registry on a function the device type does not carry: assign the function to the device type in the adapter registration YAML.
  3. Adapter-local action set. Vendor-specific verbs that belong to this adapter only: declare a custom_functions entry in the adapter registration YAML (same action/events schema as framework functions; every event id must exist in pq-events.yaml) and call the generated method.
  4. Standalone panel-level fact. No action set fits: list the pq event under the device type's events: in the adapter registration YAML and publish from a Thing partial via the generated PqEvent.* builder. Never call EventBuilder.Event("string") in routing code (RULE-003, RULE-032), and never list an event in events: that a function on the same type already declares — the function declaration suffices.

Generalization fallback — allowed, bounded. The taxonomy is NATS-style: specificity grows left to right, so an ancestor event is a weaker but true statement about its descendants. When no leaf matches exactly but an ancestor on the same dotted branch truthfully describes what happened, route to the ancestor (…access.denied.antipassback…access.denied) and mark the ledger row generalized: true with the missing specific meaning in notes. Never jump branches (access.*security.*), never generalize past the point of truth — those are gaps for human review.

Nothing fits → gap with the full field set above.


Best Practices

Never Drop Events

Every vendor event is either routed (audit), published as status, handled internally (control), explicitly Ignore()d with a reason, or published via thing.Unknown() — which produces pq.event.device.unknown (type unknown) or pq.event.device.unresolved (type known, address absent from config; the two are never interchangeable — RULE-027). Silent drops break compliance, ABAC rules, and forensics.

Never Downgrade the Event Type for a Missing Parameter

Event-type precision and parameter precision are independent axes (RULE-027 rung 2). A parameter you cannot fill — a missing timestamp, an unresolved person, a partial field — never justifies mapping to a less specific event type. Pick the type from what the event means; fill parameters from what the data carries, defaulting or compensating the rest.

The most common trap is actor identity. When IdentityResolver.ResolvePerson(...) returns null (the vendor user has no access-sync mapping), follow the identity ladder and keep the precise event:

  1. personId resolved → pass personId.
  2. not resolved, vendor sent a user/position/id → pass it as externalPersonRef (a string? on the function method).
  3. neither → the precise event is still published, just without actor detail.
var personId = _accessSync.ResolvePerson(evt.UserId.ToString());

await reader.AccessDeniedAntipassback(
    Timestamp(evt),
    externalPersonRef: personId.HasValue ? null : evt.UserId.ToString(),
    personId: personId);
// renders: "Access denied to unknown person (120) at Door 5: antipassback violation"

Optional parameters — externalPersonRef, credential fields, direction, even the timestamp — exist to preserve what you do know; an absent one is never a reason to coarsen the type. Note the distinction: the generalization fallback above degrades the type because no exact PQ event exists; a missing parameter never does.

Preserve Vendor Timestamps

Always use the device timestamp from the event, never the adapter's wall clock. Events may arrive delayed (buffered, reconnect, etc.).

Handle ALL Sub-codes

Every sub-code within an event type must have a disposition. Unknown sub-codes within a mapped event type must still surface (with the raw sub-code in event data) — never signal them by returning false from a handler (RULE-020).

Some vendors have multiple event types that map to the same PQ event (e.g., different card format lengths all represent access events). Share handler logic where possible.

Separate Siblings That Describe Different Facts

Neighbouring leaves under one branch are not interchangeable, however similar the vendor wording is. Routing to the wrong sibling produces an audit record that reads as a different occurrence — the reader is misled, and unlike a generalization there is no ledger mark to warn them. Each pair below sits on one branch and answers a different question:

Vendor fact PQ event Not this
A supply or output circuit draws more current than it may pq.event.energy.power.overload (Warn); …overload.restored (Info) when the circuit returns to normal …power.fuse.failed — an overload is a current condition on a live circuit, a blown fuse is a failed component
The device configuration is set back to defaults pq.event.system.reset.settings (Critical) …reset.factory — a factory reset returns the whole device to its factory state, not only the settings
A stored configuration is written back onto the device pq.event.system.configuration.restored (Warn) …configuration.changed — the resulting configuration is not the one the operator last edited, which is why the restore carries its own record

Both power branches carry a restore leaf, so a panel that supervises output current reports the overload and its clearing through the overload pair and keeps the fuse pair for the component fault. When the vendor code genuinely cannot be told apart from the sibling, the row is a gap with both leaves listed in candidates — never a guess between them.

Document Vendor Quirks

Events that fire in pairs, codes whose meaning depends on context, events overlapping with status updates — document them in the ledger row's notes and the adapter's design-notes.


See Also