Access Synchronization¶
Rules for access model Transform, idempotency, entity references, and credential upload/projection/format compliance.
Part of compliance-rules.
RULE-006: Access Sync Transform Must Not Throw¶
Severity: High
Description:
Transform() methods in access model entities must return null for unsupported input, not throw exceptions. Throwing breaks the entire sync batch.
Detection pattern:
throwstatements inTransform()methods- Missing null-checks that could cause NullReferenceException
- No handling for edge cases (empty strings, out-of-range values)
Correct pattern:
public User? Transform(DeviceAccessAuthorization auth)
{
if (auth.CardNumber is null && auth.Pin is null)
return null; // Skip - no credentials
if (auth.CardNumber > MaxCardNumber)
return null; // Skip - unsupported format
return new User { /* ... */ };
}
Violation example:
// WRONG: throws on unsupported input
public User Transform(DeviceAccessAuthorization auth)
{
if (auth.CardNumber is null)
throw new ArgumentException("Card required"); // Breaks entire sync
}
RULE-014: Access Sync Must Be Idempotent¶
Severity: Critical
Description: Access synchronization must be idempotent - running sync twice with unchanged input must not throw, must not create duplicate device records, and must not reassign addresses to existing entities.
Why this matters:
- Sync runs periodically (every change, every reconnect, on demand)
- Non-idempotent sync floods device with duplicate uploads, exhausts memory slots, corrupts mappings
- Hash-based change detection requires stable input → stable output
Detection pattern:
Transform()readsentity.Address- returnsnullon first run, changes hash on second runTransform()callsSetAddress()on framework-tracked entity- Cross-entity references built from address integers instead of object references
- Hash computation includes framework-managed fields (Address)
Correct pattern:
// Pass object references - framework resolves addresses at upload time
public User? Transform(Person person, Schedule schedule)
{
return new User
{
Name = person.Name,
Schedule = schedule, // Object reference - framework-resolved
AccessLevels = person.Levels, // Array of references
};
}
Violation examples:
// WRONG #1: reads Address - null on first run, breaks idempotency
public User? Transform(Person person, IEnumerable<Schedule> schedules)
{
return new User
{
ScheduleAddress = schedules.First().Address ?? 0, // unstable
};
}
// WRONG #2: assigns Address during Transform
public User? Transform(Person person)
{
var user = new User { Name = person.Name };
user.SetAddress(person.Id); // framework owns this!
return user;
}
// WRONG #3: uses address integers for references
public User? Transform(Person person, Schedule schedule)
{
return new User
{
ScheduleId = (int)schedule.Address, // null cast fails on first sync
};
}
Verification:
- Run sync with test data → record device state
- Run sync again with identical input
- Second run must produce zero writes (no add/update/delete)
- Device state must be unchanged
Framework rules:
address+rangedeclarations: framework allocates IDs, may callSetAddress()internallyid: <property>declarations: natural key from entity data, value is constructor param- Cross-entity references in
access-model.yaml: useEntityNameorEntityName[], never address integers
RULE-039: Access Sync Must Be Best-Effort — One Bad Item Must Not Abort the Batch¶
Severity: Critical
Description: Access synchronization must be best-effort: the failure or ineligibility of ONE item (person, card, credential, or entity) must never abort or degrade the synchronization of the remaining items. One invalid card must not stop 4,999 other employees from starting their shift.
Concretely:
- A credential the transformation cannot represent (unparseable value, value out of the panel's representable range, empty PIN, etc.) is skipped — the person's remaining credentials and all other persons still synchronize.
- Panel capacity exhaustion (address range full) is a per-item failure of the items that did not fit — items already placed still upload. The sync must not throw and abandon the whole batch.
- A panel rejecting one write is a per-item failure — remaining commands still execute.
- Silent degradation is equally forbidden. Dropping a capability of an uploaded item (e.g. uploading a user without their door permissions because an internal limit was hit) must at minimum be logged/reported, never silent.
Why this matters:
- A single malformed record in a 5,000-person database must not lock the entire site out at shift start.
- Capacity limits are normal operating conditions, not exceptions — the site keeps running with whoever fits, and the overflow is reported.
- Silently uploading a user without their access rights is worse than skipping them: the operator sees a synced user and assumes access works, while doors quietly reject them.
Detection pattern:
- An uncaught capacity/range-full exception that propagates out of
Transform()or command execution and aborts the whole sync (the observed anti-pattern: capacity exhaustion throws and abandons the batch) - Hitting an internal access-level / slot limit and continuing to upload the item with the capability stripped, with no log or report (the observed anti-pattern: users uploaded without door access)
Transform()that throws on the first unrepresentable credential instead of skipping it (see RULE-006)- A per-item projection loop wrapped so that one iteration's failure breaks the loop for all remaining items
- Dropped capabilities (permissions, credentials, schedules) with no diagnostic trail
Correct pattern:
// skip the item that cannot be represented; keep going
foreach (var person in persons)
{
var card = ParseCard(person);
if (card is null)
{
Log.Warning("Skipping card for person {PersonId}: value not representable", person.PersonId);
continue; // remaining credentials and persons still sync
}
// ...
}
// capacity exhaustion is per-item: place what fits, report the overflow
if (!allocator.TryReserve(out var address))
{
Log.Warning("Capacity reached for {Type}; person {PersonId} not uploaded", typeId, person.PersonId);
continue; // items already placed remain valid; batch is not abandoned
}
// never strip a capability silently — if a limit forces it, log/report it
if (accessLevels.Count > MaxAccessLevels)
{
Log.Warning("Person {PersonId}: {Dropped} access levels exceed device limit and were not uploaded",
person.PersonId, accessLevels.Count - MaxAccessLevels);
accessLevels = accessLevels.Take(MaxAccessLevels).ToList();
}
Violation examples:
// WRONG #1: capacity exhaustion aborts the entire batch
var address = allocator.Reserve(); // throws when range is full -> 4,999 valid users never upload
// WRONG #2: internal access-level limit silently drops door access
var levels = person.AccessLevels.Take(MaxAccessLevels).ToArray();
// user uploaded, but the operator is never told their doors were dropped -> silent lockout
Verification:
- Sync a batch where one item is unrepresentable → that item is skipped, all others upload, sync does not throw.
- Sync a batch that exceeds device capacity → items that fit upload, overflow items are logged/reported, sync does not throw.
- Force an internal capability limit → the uploaded item's dropped capability is visible in logs/report, never silent.
RULE-016: Cross-Entity References Must Use Object References¶
Severity: High
Description:
In access-model.yaml, cross-entity references (User → Schedule, User → AccessLevel[]) must use object references (typed entity references), not raw address integers. Framework allocates addresses AFTER Transform() returns, so reading entity.Address during Transform yields null or stale values.
Why this matters:
- Framework allocation happens after Transform completes
entity.Addressis null on first sync → reference becomes 0 → silent corruption- On subsequent syncs, address values may differ between runs → unstable hash → infinite resync
- Object references let framework resolve actual address at upload time
Detection pattern:
<entity>_id: intor<entity>_address: intinaccess-model.yamlfor cross-entity referencesentity.Address ?? 0or(int)entity.Addressreads inTransform()- Manual ID lookup tables in adapter code
- Hash function includes computed reference IDs
Correct pattern (YAML):
model:
- type_id: Schedule
properties:
name: string
intervals: TimeInterval[]
range: [1, 64]
- type_id: User
properties:
name: string
schedule: Schedule # object reference
access_levels: AccessLevel[] # array of references
range: [1, 1000]
Correct pattern (Transform):
public User? Transform(Person person, Schedule schedule, AccessLevel[] levels)
{
return new User
{
Name = person.Name,
Schedule = schedule, // framework resolves address at upload
AccessLevels = levels,
};
}
Violation examples:
# WRONG: address integer in YAML
model:
- type_id: User
properties:
name: string
schedule_id: int # framework can't resolve, adapter must do it manually
access_level_ids: int[]
// WRONG: reads Address during Transform - returns null on first run
public User? Transform(Person person, Schedule schedule)
{
return new User
{
ScheduleId = (int)(schedule.Address ?? 0), // null → 0 → broken reference
};
}
// WRONG: manual ID lookup
private readonly Dictionary<Guid, int> _scheduleIds = new();
public User? Transform(Person person, Schedule schedule)
{
return new User
{
ScheduleId = _scheduleIds[schedule.Id], // race condition with framework allocation
};
}
Why object references work:
- Transform returns entity with reference to other entity (not its address)
- Framework collects all entities, allocates addresses based on declared ranges
- At upload time, framework resolves:
User.Schedule.Addressis now valid - Adapter's protocol layer reads the resolved address for protocol message construction
Verification:
- Run sync from empty device → all references resolved correctly
- Run sync again → hash matches, no rewrites
- Remove a referenced entity → dependent entities updated cleanly
RULE-040: Cross-Entity References Must Be Entity-Typed, Never Integers or Bit Masks¶
Severity: Critical
Description:
Every relationship between two access-model entities MUST be expressed as an entity-typed property in access-model.yaml — a property whose type is another entity (Schedule, AccessLevel, AccessLevel[], Door[], …). A relationship must never be flattened into a bare integer, an id/address scalar, or a bit mask that encodes the identity or membership of another entity by bit position or value (door_flags: uint, area_mask: int, access_level_id: int).
This is stricter than, and complementary to, RULE-016. RULE-016 governs how a modelled reference is populated (pass the object, don't read a framework-allocated address during Transform() — an idempotency defect). RULE-040 governs whether the relationship is modelled as a typed reference at all. A perfectly stable, idempotent mask still violates RULE-040, because the framework never sees a typed edge.
Why this matters:
- The framework derives the entity dependency graph and topological upload/delete ordering solely from entity-typed properties in the access model. A relationship encoded as an integer or bit mask is invisible to that derivation.
- When every relationship is flattened into scalars, the derived dependency graph is empty — the framework treats the entities as independent. Dependency-aware synchronization (uploading a referenced entity before its referent, propagating changes and deletes along edges, stamping dependency keys onto sync commands) silently degrades to no ordering.
- The failure is silent: sync still runs, hashes stay stable, review looks green. Nothing surfaces the missing edges until a dependency-ordered operation quietly does the wrong thing (e.g. deleting a schedule still referenced by a level, or uploading in an order the panel rejects).
- A bit mask additionally caps the relationship at the machine word width and discards the target entity's identity, so the referenced entities are frequently not modelled as entities at all — leaving nothing for the framework to order against.
Detection pattern:
- An access-model entity holds a
uint/int/ulongproperty whose name or comment describes membership of, or a reference to, another entity —door_flags,area_mask,<entity>_bits,level_mask,schedule_id,<entity>_address. - A relationship that exists in the domain (user → doors, level → schedule, level → doors) has no entity-typed property on either side — the target entity is unmodelled or linked only through a scalar.
Transform()builds a bit mask from device addresses (mask |= 1 << (address - 1)) and stores it on an access-model entity in place of a typed reference collection.- The framework-derived dependency graph / topological order for the adapter is empty (or missing edges) while the domain clearly has inter-entity relationships.
Correct pattern (YAML):
model:
- type_id: Door
properties:
address: uint
range: [1, 32]
- type_id: AccessLevel
properties:
doors: Door[] # typed reference — framework derives AccessLevel → Door edges
schedule: Schedule # typed reference — framework derives AccessLevel → Schedule
range: [1, 15]
- type_id: User
properties:
name: string
access_levels: AccessLevel[] # typed reference, not an access-level bit mask
range: [2, 999]
Violation example (YAML):
# WRONG: relationships flattened into masks/scalars — the derived dependency graph is empty
model:
- type_id: AccessLevel
properties:
door_flags: uint # 32 doors as bits; Door is never modelled, no edge derived
range: [1, 15]
- type_id: User
properties:
area_mask: int # partition membership as bits
access_level_id: int # scalar reference — framework cannot derive User → AccessLevel
range: [2, 999]
Relationship to other rules:
- RULE-016 catches an address integer standing in for a modelled reference (an idempotency defect). RULE-040 catches the relationship not being modelled as a typed reference at all. A mask can pass RULE-016's idempotency verification (stable hash, no rewrites) and still violate RULE-040.
- For relationships between device-registration Things (rather than access-model entities), see MODEL-006 (relationships via deviceRef or parent-child only).
Verification:
- For every domain relationship between access-model entities, confirm a corresponding entity-typed property (
EntityorEntity[]) exists inaccess-model.yaml. - Confirm no access-model property encodes another entity's identity or membership as an integer, id/address scalar, or bit mask.
- Confirm the framework-derived dependency graph contains an edge for each such relationship — the topological order must reflect the real dependencies, not list every entity as independent.
RULE-056: Selective-Sync Ownership Determines Identity Resolution in Transform¶
Severity: High
Description:
Every allocator-addressed access-model entity (address + range, no id) is classified — inferred from the model's reference graph, or set by an explicit owner: override — as either a person-owned root (nothing references it) or a shared derived table (referenced by some entity-typed property, directly or via an embedded type). Transform() must resolve identities to match that classification:
- A person-owned root record MUST
ResolveAsthe person, and MUST alsoResolveAsthe credential when the record is per-credential (one panel record per card / PIN). This(PersonId, CredentialId)identity is the pairing key selective sync uses to update a changed record in place. - A shared-table record MUST NOT resolve a person. Shared tables are additive-only during selective sync; a person resolved on a shared record is a projection error.
Ownership itself is inferred and requires no declaration — see Pattern 1: Selective Sync Ownership.
Why this matters:
- Selective sync (a subset of persons) pairs a desired record to its stored counterpart by resolved
(PersonId, CredentialId). A root record that does not resolve the person has no pairing key: the changed record uploads as a brand-new one and the person's previous card/PIN stays active on the panel next to the new one — a security defect. - A person resolved onto a shared table makes a table that every person references appear to belong to one person, corrupting both the additive shared-table diff and the person-owned pairing.
Runtime behavior:
- An unresolved person on a person-owned root → that record fails (its pairing group cannot be formed); every other group still synchronizes (best-effort, RULE-039).
- A resolved person on a shared table → the framework rejects that record rather than uploading it.
Detection pattern:
- A
Transformproducing a person-owned root record (inferred root — unreferenced in the access-model graph — or explicitowner: person) without aResolveAs(person)on it. - A per-credential root record that resolves the person but not the credential it carries.
- A
ResolveAs(person)reaching a shared / referenced entity (schedule, access level, holiday, door), directly or through a helper.
Correct pattern:
// person-owned root: resolve the person (and the credential for a per-credential record)
var user = new User(/* ... */);
user.ResolveAs(person);
user.ResolveAs(cardCredential); // only when the record is per-credential
// shared table: never resolve a person
var schedule = new Schedule(/* ... */); // no ResolveAs(person)
Violation examples:
// WRONG #1: person-owned root without the person identity -> no pairing key -> stale credential left on panel
var user = new User(Card: card);
// missing user.ResolveAs(person)
// WRONG #2: person resolved onto a shared table -> framework rejects the record
var schedule = new Schedule(Periods: periods);
schedule.ResolveAs(person); // a shared table must not carry a person
Verification:
- Every person-owned root record resolves the person; per-credential root records also resolve their credential.
- No shared / referenced entity resolves a person.
- Change one selected person's credential and re-run selective sync → the record is updated in place at its existing address, with no stale duplicate remaining on the panel.
RULE-035: Holiday Entities Must Not Be Stubs When Protocol Documents Holiday Support¶
Severity: High
Description:
When access-model.yaml declares a holiday entity (any type with holiday semantics, e.g. Holiday, AcmeHoliday) with a non-empty range, the entity's CreateCommands() and DeleteCommands() methods must contain real protocol writes. Returning empty (=> []) is only acceptable when the vendor protocol explicitly does not support holiday slots — and that decision must be documented in the adapter's design-notes document under Known Implementation Gaps.
ACS and alarm system (EZS/IDS) protocols frequently support holiday calendars. Leaving stub implementations causes holidays to silently not reach the device, making time-based access rules bypass holidays — a correctness and security issue that is invisible to customers.
Detection pattern:
access-model.yamldeclares entity with holiday name andrange: [n, m]- Corresponding C# partial record returns
Enumerable.Emptyor[]fromCreateCommands() - No documented exception in the adapter's design-notes document
Correct pattern:
# access-model.yaml — Layer 0, independent of schedule
- type_id: Holiday
properties:
address: uint
day: uint
month: uint
range: [1, 64]
updatable: replace
public sealed partial record Holiday
{
/// <inheritdoc/>
public IEnumerable<IProtocolCommand> CreateCommands()
{
var addr = Address ?? throw new InvalidOperationException("Address not assigned");
yield return new ProtocolCommand<AccessSynchronization>(async (ctx, ct) =>
await ctx.Protocol.WriteHoliday(addr, Day, Month, ct));
}
/// <inheritdoc/>
public IEnumerable<IProtocolCommand> UpdateCommands() => CreateCommands();
/// <summary>Creates delete commands for the holiday at the given address.</summary>
/// <param name="address">Allocated holiday slot address.</param>
public static IEnumerable<IProtocolCommand> DeleteCommands(uint address)
{
yield return new ProtocolCommand<AccessSynchronization>(async (ctx, ct) =>
await ctx.Protocol.DeleteHoliday(address, ct));
}
}
Violation example:
// WRONG: holiday entity declared in YAML with range, but commands are stubs
public IEnumerable<IProtocolCommand> CreateCommands() => [];
public static IEnumerable<IProtocolCommand> DeleteCommands(uint address) => [];
Acceptable exception (must be documented):
// OK only when the adapter's design-notes document states:
// "Known Implementation Gaps — Holidays: protocol command X is not documented;
// upload is intentionally deferred until vendor provides write format."
public IEnumerable<IProtocolCommand> CreateCommands() => [];
Verification:
CreateCommands()andDeleteCommands()each contain at least oneProtocolCommand<T>yield- Protocol method called exists and is non-trivial (not itself a stub or no-op)
- If stubs are present, the adapter's design-notes document records the gap under Known Implementation Gaps
RULE-037: Person Profiles Must Use Generated Typed Input¶
Severity: High
Description:
When access-model.yaml declares person_profiles, adapter code must consume the
generated typed profile property on AccessModel.PersonAccess. Adapter code must not
deserialize ProfileData, inspect profile field dictionaries, or parse profile JSON.
Why this matters:
- The generated mapper applies YAML defaults consistently.
- Required profile fields are validated before
Transform()runs. - Raw JSON parsing duplicates framework behavior and can diverge from YAML schema.
- Profile data is person-scoped for the current adapter transform, not device-scoped.
Detection pattern:
ProfileDatareferences in adapterCapabilities/AccessSynchronization.csJsonSerializer.DeserializeorJsonElementaccess for profile fieldsperson.Devices.Select(device => device.Profile...)- manual dictionaries keyed by profile field name
Correct pattern:
protected override AccessModel.AccessModel Transform(AccessModel.PersonAccess[] persons)
{
foreach (var person in persons)
{
var userLevel = person.UserProfile?.UserLevel ?? 0;
var canSelectGroup = person.UserProfile?.CanSelectGroup ?? false;
// build generated access model entity
}
}
Violation example:
// WRONG: bypasses generated profile mapping and YAML defaults
var raw = person.Profile!.Fields["user_level"];
var userLevel = raw.GetInt32();
Verification:
Transformsignature usesAccessModel.PersonAccess[]whenperson_profilesis declared.- Profile values are read from
person.<SlotType>Profileproperties. - No adapter code references
ProfileDataor raw profile field names.
Credentials¶
Rules for credential upload coverage, deterministic credential projection, and documented credential format transformations.
RULE-015: Multi-Credential Projection Must Be Deterministic¶
Severity: High
Description:
When a person has multiple credentials of a supported type (cards, PINs, mobile credentials, biometrics), Transform() must project all supported credentials and use deterministic ordering.
Detection pattern:
FirstOrDefault()orFirst()on credential collections without a documented single-credential protocol limitforeach (var c in person.Credentials)withoutOrderBy(...)- Pairing credentials by enumeration order without explicit stable sort keys
- LINQ chains over credentials without
OrderBy(c => c.CredentialId)or similar stable key
Exception: Devices that genuinely support only one credential of a type per user and where fanning the extra credentials out into additional panel records is impossible per RULE-055's exception clause — the record is keyed by person identity, or a documented hard protocol constraint forbids additional records. A panel that merely stores one credential per record is not such a case: fan out one record per credential (RULE-055), do not select one and drop the rest. Where the exception genuinely applies, the limitation must be documented in vendor/SDK notes or adapter docs, and the selected credential must still be chosen deterministically.
RULE-021: Credential Upload Coverage Must Match PQ and Protocol Capabilities¶
Severity: Critical
Description: If PQ can provide a credential type and the device protocol/SDK can upload or synchronize that credential type, the adapter must support it. This applies to cards, PINs, mobile credentials, face, fingerprints, biometric templates, QR codes, and vendor-specific credentials.
Detection pattern:
- Protocol/SDK docs mention credential upload APIs but adapter ignores the corresponding PQ credential type
- Adapter creates users/access rights but omits cards, PINs, or biometrics supported by both PQ and protocol
CHECKLIST.mdmarks cardholder sync complete while supported credential types are missing- Transform filters only one credential type when docs show more upload APIs
Correct pattern:
- Build an explicit mapping from PQ credential types to protocol credential records
- Upload every supported credential type deterministically
- Document true protocol limits and missing PQ source data in the adapter's design-notes document /
CHECKLIST.md
Violation example:
Protocol exposes CreateUserIdentificatorAsync(...), PQ provides PersonCard, but adapter uploads only PIN and doors.
RULE-022: Credential Format Transformations Must Be Documented¶
Severity: High
Description: Every credential format transformation must be traceable to vendor docs, SDK docs, meeting notes, or provided source material. Do not guess card number conversions or credential type mappings.
Citation obligation is presence AND correctness. When a citation map (citation-map.json) exists for the adapter, a citation reference alone is not sufficient — the transformation constant's VALUE in code must match the value recorded in the citation map. A citation that points to a real doc entry but disagrees with it in value is worse than no citation: it is a Critical finding. When no citation map exists, fall back to the presence-only check (citation exists, references a real doc/note).
Detection pattern:
- Hex/decimal conversion without citation
- Prefix/suffix insertion without citation
- Wiegand/mobile/biometric type mapping without citation
- Parity stripping, endian swaps, padding, truncation, hashing, or template conversion without citation
- Adapter docs say "no conversion" but code converts values
- (when
citation-map.jsonexists) a cited constant's value in code differs from thevaluefield of the matching entry incitation-map.json
Correct pattern:
- Preserve the original credential value when docs say no conversion
- Cite the exact doc/note for required conversions and protocol-specific type mappings
- Prefer "not documented" over inferred behavior
- When a citation map exists, cross-check the cited constant's value against
citation-map.jsonbefore accepting the citation as valid
Violation example:
Code converts card hex to decimal because the SDK property is string, but docs only prove the property type and do not say decimal is required.
Violation example (value mismatch):
Code defines private const int CardPrefixLength = 4; citing citation-map.json constant CARD_PREFIX_LEN, but the citation map records "value": "3". The code's value does not match the cited source — Critical finding, not a pass.
RULE-041: PIN Credentials Must Be Projected by Role — Card-Bound vs Standalone¶
Severity: High
Description: PQ distinguishes two kinds of PIN, and an adapter must project each into the panel according to its role. The two roles are:
- Card-bound PIN — a PIN attached to one specific card, acting as a second factor for THAT card only. It is non-unique and must never be usable as a sole identifier.
- Standalone PIN — a person's own PIN credential, intended to be usable as a sole identifier at a keypad (the panel makes an access decision on the PIN alone).
The required projection:
- A card-bound PIN is always uploaded together with its card, into the same panel credential record — the card record's PIN / second-factor field (a field checked only with its card and never valid alone). It is never uploaded as an independent credential record. If the panel has no such second-factor field — its only PIN storage is a sole-identifier user code that is also valid on its own at a keypad (typical of intrusion panels) — then a card-bound PIN is not representable and MUST be skipped (silent skip of an unsupported type, like item 3). Writing it into the sole-identifier code slot is forbidden: it would turn a non-unique second factor into a sole identifier. The card itself still uploads normally; only the bound PIN drops.
- A standalone PIN is uploaded as its own panel credential record on which the panel can make an access decision (a record authorized by PIN with no card presentation) — only if the panel natively supports a PIN-only credential.
- An adapter whose panel does not support a PIN-only credential must never stamp the standalone PIN onto card records. It skips the standalone PIN — the same way any adapter skips a credential type its panel cannot represent (silent skip of an unsupported type is the accepted convention). Surfacing the skipped PIN as unrepresentable / not-synchronized through a sync progress / diagnostic channel is recommended so the operator learns the PIN did not reach the panel, but it is opt-in where such a channel exists and is not itself a hard violation. The hard, High-severity violation is the stamping (item 1/3) and mis-role projection (item 2), not the absence of a diagnostic.
- Whether a bare PIN, a card, or card+PIN grants access is a property of the reader / panel mode, not of PQ. The adapter only projects the credential data faithfully; it must not try to enforce the second-factor challenge itself.
Why this matters:
- Stamping a person's standalone PIN onto every card record turns a non-unique convenience code into a spurious second factor (or, on some panels, a sole identifier) attached to hardware it was never meant to guard — a security-relevant misprojection.
- Dropping a standalone PIN on a panel that cannot represent it is correct behavior; the only cost is operator awareness. Surfacing it (recommended, opt-in) turns a silent no-op into a visible "not synchronized" signal, but its absence is not a security defect the way stamping is.
- A card-bound PIN uploaded as its own record can collide with, or be mistaken for, a real standalone identifier because it is non-unique.
- The grant decision (bare PIN vs card vs card+PIN) belongs to reader/panel configuration; an adapter that re-implements the challenge diverges from the panel's own mode and produces inconsistent behavior.
Detection pattern:
- A standalone / person-level PIN written into every card record (
card.Pin = person.Pininside a loop over cards). - A card-bound PIN emitted as a separate credential record instead of being set on its owning card's record.
- A card-bound PIN (
CardCredentialData.Pin) written into a panel's sole-identifier user-code slot (a code also valid alone at a keypad) — same violation class as stamping, since it makes a non-unique second factor usable alone. On such panels (typical intrusion panels) the card-bound PIN must be skipped, not stored; reading only standalonePincredentials there is correct, not a coverage gap. - A standalone PIN skipped with no diagnostic when the panel lacks PIN-only support — a recommendation gap, not a hard failure; flag as advisory only where a sync-progress/diagnostic channel is available.
- Adapter code that gates door access on the PIN itself (comparing/validating the second factor) rather than leaving that to the reader/panel mode.
- The adapter's design-notes document / capability coverage does not state whether the panel supports a PIN-only credential, so the correct standalone-PIN path cannot be determined.
Correct pattern:
// card-bound PIN rides in its card's record — never a separate entry
foreach (var card in person.Cards.OrderBy(c => c.CardId))
{
yield return new PanelCredential
{
CardNumber = card.Number,
Pin = card.BoundPin, // second factor for THIS card only
};
}
// standalone PIN: own record ONLY when the panel supports PIN-only credentials
if (person.StandalonePin is { } pin)
{
if (SupportsPinOnlyCredential) // documented panel capability
{
yield return new PanelCredential { Pin = pin }; // panel decides on PIN alone
}
else
{
// not representable on this panel — surface it, do not stamp, do not drop silently
progress.ReportUnrepresentable(person.PersonId, "standalone PIN",
"panel has no PIN-only credential");
}
}
Violation examples:
// WRONG #1: standalone PIN stamped onto every card record
foreach (var card in person.Cards)
{
card.Pin = person.StandalonePin; // non-unique code smeared across all cards
}
// ADVISORY (not a hard violation): standalone PIN dropped with no diagnostic
// when panel lacks PIN-only support — correct to skip, but surfacing it is recommended
if (SupportsPinOnlyCredential)
yield return new PanelCredential { Pin = person.StandalonePin };
// else: skipping is fine; a "not synchronized" diagnostic (opt-in) would be better
// WRONG #2: card-bound PIN promoted to its own credential record
yield return new PanelCredential { Pin = card.BoundPin }; // non-unique second factor as a standalone entry
Verification:
- A card-bound PIN appears only in its card's credential record, never as a separate entry.
- A standalone PIN is projected as its own record only when the adapter documents native PIN-only support; otherwise it is skipped (never stamped onto cards). Surfacing the skip as a "not synchronized" diagnostic is recommended and opt-in — its absence is advisory, not a hard failure.
- No code path copies a standalone / person-level PIN onto card records.
- On a panel whose only PIN field is a sole-identifier user code (no card-bound second-factor field, e.g. typical intrusion panels), a card-bound PIN is skipped — the card uploads, the bound PIN drops — and is never written into the code slot. An adapter that reads only standalone
Pincredentials there is correct; do not flag its non-consumption ofCardCredentialData.Pinas a gap. - The adapter's design-notes document (or capability coverage) states whether the panel supports a PIN-only credential.
- The adapter does not itself validate or enforce the second-factor challenge — it projects credential data and leaves the grant decision to reader/panel mode.
RULE-055: Record Shape Must Follow Panel Record Credential Capacity¶
Severity: High
Description: The shape of a synced person/credential record is dictated by the credential capacity of the panel record, not by what the record happens to be called. Determine capacity first, then choose the shape:
- List-capacity record → person-root. When the panel's person-related record carries credential lists — multiple identifiers of a type per record (e.g. an array of cards, several enrolled fingerprints) — emit one record per person embedding all of that person's credentials.
- Fixed single-credential record → credential-root. When the panel record carries fixed single-credential fields (one card slot, one code) it cannot hold a person's whole credential set. Fan out one record per credential using the stable pairing strategy of Pattern 1 (paired / card-only / pin-only), respecting RULE-041's PIN-role semantics.
Modeling person-root over a fixed-capacity record — selecting one credential of a supported type and silently dropping the person's remaining credentials of that type — is a violation. The record's protocol name (e.g. user) does not determine the shape; its capacity does.
Why this matters:
- A person with three cards on a one-card-slot panel needs three panel records. Collapsing them to one record silently strips two of the person's working credentials — two badges that stop opening doors, invisibly.
- The protocol name is a false signal: a record called
usermay hold one card slot (credential-root) or an array of cards (person-root). Reading the name instead of the capacity produces the wrong shape. - This is a data-loss defect that hides behind a green sync: the record uploads, the hash is stable, review looks clean, yet the person's other credentials never reach the panel.
Detection pattern:
- An allocator-addressed (
address+range), person-owned (inferred root — unreferenced in the access-model graph — or explicitowner: person) access-model entity whoseTransformprojects one record per person while takingFirstOrDefault()/ first-of a credential collection of a type the panel record cannot hold as a list. - A fixed single-credential panel record modelled person-root, so a person's second and later credentials of a supported type are dropped.
- Shape chosen from the record's protocol name (
user,cardholder) rather than from its documented credential capacity.
Correct pattern:
// list-capacity record → person-root: one record embeds all the person's cards
// (panel "AcmeUser" record carries a card ARRAY)
foreach (var person in persons.OrderBy(p => p.PersonId))
{
yield return new AcmeUser
{
Cards = person.Cards.OrderBy(c => c.CardId).Select(ToPanelCard).ToArray(),
};
}
// fixed single-credential record → credential-root: fan out one record per credential
// (panel "AcmeSlot" record has ONE card field)
foreach (var person in persons.OrderBy(p => p.PersonId))
{
foreach (var card in person.Cards.OrderBy(c => c.CardId))
{
yield return new AcmeSlot { Card = ToPanelCard(card) }; // one record per card
}
}
Violation example:
// WRONG: fixed single-credential "AcmeSlot" record modelled person-root.
// The person's first card wins; every other card is silently dropped.
foreach (var person in persons)
{
var card = person.Cards.FirstOrDefault(); // one-of, remaining cards lost
yield return new AcmeSlot { Card = ToPanelCard(card) };
}
A person with N hardware-compatible credentials expects all N to work, so consuming N panel records for those N credentials is the correct and expected behavior of a credential-root model, not waste to optimize away. Dropping credentials is always the worse outcome. Reviewers must not treat slot consumption as an argument against fan-out.
Exception (the only legitimate reasons not to fan out):
- The record is keyed by person identity — a natural key per person (
id: <person-property>), so a person maps to exactly one record and extra records for the same person cannot exist by construction. - A documented hard protocol constraint makes additional records for the same person impossible (cited in vendor/SDK notes or the adapter's design-notes document).
Slot-count pressure alone is not an exception — a nearly full address range is a capacity condition handled per-item (RULE-039), not a licence to collapse a person's credentials into one record.
Verification:
- Determine the panel record's credential capacity from the protocol docs — list-valued or fixed single field — before reading its name.
- For a list-capacity record, confirm one record per person embeds all supported credentials.
- For a fixed single-credential record, confirm the adapter fans out one record per credential (per RULE-015 stable pairing), or documents a RULE-055 exception (person-keyed record, or cited hard protocol constraint).
- Confirm no
Transformtakes first-of a multi-valued credential collection and drops the remainder onto a fixed-capacity record.