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.
- Every value must be traceable to documentation. If it is not documented, it does not exist.
- 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.
- Documentation contradicts implementation assumptions → STOP. Flag both sources; a human resolves it.
- 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.
- 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;targetis 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 carriesintent,target,candidates, andcitationso a human can decide from the row alone — add a framework verb, pick a semantically close event, or approve deferral. Extendingpq-events.yaml/functions.yamlis 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:
- 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. - 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.
- Adapter-local action set. Vendor-specific verbs that belong to this adapter only: declare a
custom_functionsentry in the adapter registration YAML (same action/events schema as framework functions; every event id must exist inpq-events.yaml) and call the generated method. - 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 generatedPqEvent.*builder. Never callEventBuilder.Event("string")in routing code (RULE-003, RULE-032), and never list an event inevents: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:
personIdresolved → passpersonId.- not resolved, vendor sent a user/position/id → pass it as
externalPersonRef(astring?on the function method). - 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).
Group Related Vendor Types¶
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¶
- Event Types — classifying audit vs status vs control
- Event Routing — declarative routing with EventRouter
- Address Resolution — resolving vendor addresses to Things
- Event Publishing — PqEvent builders and function shortcuts