Skip to content

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:

  • throw statements in Transform() 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() reads entity.Address - returns null on first run, changes hash on second run
  • Transform() calls SetAddress() 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:

  1. Run sync with test data → record device state
  2. Run sync again with identical input
  3. Second run must produce zero writes (no add/update/delete)
  4. Device state must be unchanged

Framework rules:

  • address + range declarations: framework allocates IDs, may call SetAddress() internally
  • id: <property> declarations: natural key from entity data, value is constructor param
  • Cross-entity references in access-model.yaml: use EntityName or EntityName[], 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:

  1. Sync a batch where one item is unrepresentable → that item is skipped, all others upload, sync does not throw.
  2. Sync a batch that exceeds device capacity → items that fit upload, overflow items are logged/reported, sync does not throw.
  3. 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.Address is 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: int or <entity>_address: int in access-model.yaml for cross-entity references
  • entity.Address ?? 0 or (int)entity.Address reads in Transform()
  • 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:

  1. Transform returns entity with reference to other entity (not its address)
  2. Framework collects all entities, allocates addresses based on declared ranges
  3. At upload time, framework resolves: User.Schedule.Address is now valid
  4. 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/ulong property 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:

  1. For every domain relationship between access-model entities, confirm a corresponding entity-typed property (Entity or Entity[]) exists in access-model.yaml.
  2. Confirm no access-model property encodes another entity's identity or membership as an integer, id/address scalar, or bit mask.
  3. 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 ResolveAs the person, and MUST also ResolveAs the 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 Transform producing a person-owned root record (inferred root — unreferenced in the access-model graph — or explicit owner: person) without a ResolveAs(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:

  1. Every person-owned root record resolves the person; per-credential root records also resolve their credential.
  2. No shared / referenced entity resolves a person.
  3. 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.yaml declares entity with holiday name and range: [n, m]
  • Corresponding C# partial record returns Enumerable.Empty or [] from CreateCommands()
  • 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() and DeleteCommands() each contain at least one ProtocolCommand<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:

  • ProfileData references in adapter Capabilities/AccessSynchronization.cs
  • JsonSerializer.Deserialize or JsonElement access for profile fields
  • person.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:

  • Transform signature uses AccessModel.PersonAccess[] when person_profiles is declared.
  • Profile values are read from person.<SlotType>Profile properties.
  • No adapter code references ProfileData or 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() or First() on credential collections without a documented single-credential protocol limit
  • foreach (var c in person.Credentials) without OrderBy(...)
  • 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.md marks 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.json exists) a cited constant's value in code differs from the value field of the matching entry in citation-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.json before 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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.Pin inside 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 standalone Pin credentials 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:

  1. A card-bound PIN appears only in its card's credential record, never as a separate entry.
  2. 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.
  3. No code path copies a standalone / person-level PIN onto card records.
  4. 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 Pin credentials there is correct; do not flag its non-consumption of CardCredentialData.Pin as a gap.
  5. The adapter's design-notes document (or capability coverage) states whether the panel supports a PIN-only credential.
  6. 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 user may 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 explicit owner: person) access-model entity whose Transform projects one record per person while taking FirstOrDefault() / 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:

  1. Determine the panel record's credential capacity from the protocol docs — list-valued or fixed single field — before reading its name.
  2. For a list-capacity record, confirm one record per person embeds all supported credentials.
  3. 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).
  4. Confirm no Transform takes first-of a multi-valued credential collection and drops the remainder onto a fixed-capacity record.