Skip to content

Adapter Credential Synchronization Guide

Complete guide for adapter developers implementing credential/access synchronization to physical devices.


Overview

Access synchronization transfers person credentials (cards, PINs, biometrics) and access schedules from PQ server to physical access control devices. The framework handles complexity (diffing, ordering, storage), adapter implements device-specific logic.

What Framework Does

  • Fetches authorization data from PQ API
  • Detects changes using hash comparison
  • Orders operations by dependencies (leaves-first for adds, roots-first for deletes)
  • Persists hash-to-address mappings in LiteDB
  • Executes commands in correct order
  • Handles partial failures gracefully

What Adapter Does

  1. Define device-specific data model in YAML
  2. Implement Transform() method - converts PQ data to device model
  3. Implement protocol commands - actual device communication

Implementation Checklist

[ ] 1. Define access model in adapter-registration.yaml
[ ] 2. Build solution (generates entity classes)
[ ] 3. Create AccessSynchronization class inheriting AccessSynchronizationBase
[ ] 4. Implement Transform() method
[ ] 5. Create protocol command classes
[ ] 6. Implement *Commands.cs partial files for each entity
[ ] 7. Test with real device
[ ] 8. Run unchanged sync twice (second run must not throw `Address already assigned`)

Item 8 is a hard gate for access sync changes.


Step 1: Define Access Model (YAML)

In adapter-registration.yaml, define all entity types needed for your device.

Model Design Principles

Before writing YAML, understand these core principles:

1. Closed Model Principle The access model must be self-contained. All references between entities must be explicit typed references (not raw uint). The only exception is references to physical Things (doors, readers) which use uint as device address.

# CORRECT - explicit reference to entity in model
levels: AccessLevel[]        # framework knows this references AccessLevel

# WRONG - raw ID, framework doesn't know what it references
level_ids: uint[]            # just numbers, no relationship tracking

2. Trackable vs Embedded Decision

Ask these questions to decide:

  • Does the device have a separate API to create/delete this data? → Trackable (needs address)
  • Is this data always sent as part of a parent entity? → Embedded (no address)
  • Can this data be shared between multiple parents? → Trackable
  • Is this just structural grouping of fields? → Embedded

3. Flatten Complex Nested Structures

Device protocols often have deeply nested structures. Flatten them for simpler sync:

# DEVICE HAS:
# Schedule → WeeklySchedule → DaySchedule[7] → TimePeriod[]

# FLATTEN TO:
- type_id: Schedule
  properties:
    address: uint
    periods: SchedulePeriod[]    # flat array

- type_id: SchedulePeriod        # embedded, combines day + time
  properties:
    day_of_week: int             # 0-6
    start_time: int              # minutes
    end_time: int

Reconstruct the nested structure in your protocol command when sending to device.

4. Physical vs Logical References

Reference Type Syntax Example Notes
To entity in model EntityName schedule: Schedule Framework tracks dependency
To physical Thing uint door_id: uint Just an address, Thing managed elsewhere

Entity vs Embedded Structure

Has address Type Separate API Storage Hash includes
Yes Trackable Entity Yes LiteDB mapping Referenced entity addresses only
No Embedded No (part of parent) None Full content recursively

Decision Tree:

Does device have Create/Delete API for this data?
├─ YES → Does it have its own ID/address on device?
│        ├─ YES → TRACKABLE ENTITY (add address property)
│        └─ NO  → TRACKABLE ENTITY (framework assigns address)
└─ NO  → Is it always part of another entity?
         ├─ YES → EMBEDDED STRUCTURE (no address)
         └─ NO  → Reconsider - probably needs to be trackable

Complete Example: Access Control System

Real-world example showing all concepts:

model:
  # ============================================
  # TRACKABLE ENTITIES (have address, separate device API)
  # Listed in dependency order: leaves → roots
  # ============================================

  # Layer 0 - leaf (no dependencies on other entities)
  - type_id: Schedule
    properties:
      address: uint
      periods: SchedulePeriod[]      # embedded, flattened from nested structure

  # Layer 1 - references Schedule
  - type_id: AccessLevel
    properties:
      address: uint
      door_schedules: DoorSchedule[] # embedded, contains entity reference

  # Layer 2 - references AccessLevel
  - type_id: AccessGroup
    properties:
      address: uint
      levels: AccessLevel[]          # entity reference array

  # Layer 3 - root (references AccessGroup, contains embedded credentials)
  - type_id: User
    properties:
      address: string                # some devices use string IDs
      name: string
      pin: byte[]
      access_groups: AccessGroup[]   # entity reference array
      cards: CardData[]              # embedded credentials
      fingers: FingerData[]          # embedded biometrics

  # ============================================
  # EMBEDDED STRUCTURES (no address, part of parent)
  # ============================================

  # Flattened schedule - replaces nested Weekly/Daily/TimePeriod
  - type_id: SchedulePeriod
    properties:
      day_of_week: int               # 0=Sun, 1=Mon, ... 6=Sat
      start_time: int                # minutes from midnight (0-1440)
      end_time: int

  # Links door (physical Thing) to schedule (model entity)
  - type_id: DoorSchedule
    properties:
      door_id: uint                  # physical Thing address (outside model)
      schedule: Schedule             # entity reference (inside model)

  # Credentials - embedded because device API sends them with User
  - type_id: CardData
    properties:
      card_type: int
      data: byte[]

  - type_id: FingerData
    properties:
      finger_index: int              # 0-9
      flag: uint
      template: byte[]               # single blob, not byte[][]

Optional: Person Profiles

If the panel stores per-user flags that are not credentials or access grants, add a person_profiles section to access-model.yaml:

person_profiles:
  - slot_type: User
    fields:
      - name: user_level
        type: int
        default: 0
        range: [0, 3]
        category: alarm
        description: "Alarm user level"
      - name: dual_code
        type: bool
        default: false
        category: alarm
        description: "Requires dual-code behavior"

The generated code maps profile JSON from the server to a typed profile record before Transform() runs. Adapter code reads the profile from the person, not from each device access entry.

Dependency Graph:

User (Layer 3 - root)
 ├──► AccessGroup[] (Layer 2)
 │     │
 │     └──► AccessLevel[] (Layer 1)
 │           │
 │           └──► DoorSchedule[] (embedded)
 │                 ├──► door_id (physical Thing)
 │                 └──► Schedule (Layer 0 - leaf)
 │                       │
 │                       └──► SchedulePeriod[] (embedded)
 ├──► CardData[] (embedded)
 └──► FingerData[] (embedded)

Why credentials are embedded:

  • Device API sends credentials as part of user (e.g., User.Enroll(userWithCredentials))
  • No separate Card.Create() API on device
  • Credential cannot exist without a user on this device type

Why Schedule is trackable:

  • Device has separate Schedule.Add() / Schedule.Delete() API
  • Can be shared by multiple AccessLevels (deduplicated)
  • Has its own ID/address on device

Why DoorSchedule is embedded but contains entity reference:

  • No separate API for DoorSchedule - it's part of AccessLevel
  • But it references Schedule which IS a trackable entity
  • Framework resolves schedule: Schedule to Schedule.Address when computing hash

Supported Property Types

YAML Type C# Type Use Case
string string Names, string IDs
uint uint Numbers, physical Thing addresses
int int Signed numbers
bool bool Flags
byte[] byte[] Binary data (biometrics, card data)
uint[] uint[] Only for physical Thing addresses
EntityName EntityName Single reference to model entity
EntityName[] EntityName[] Array reference to model entities

Important: Use EntityName[] for references to entities in your model, not uint[]. The framework needs explicit references to track dependencies and compute hashes correctly.


Step 2: Build and Review Available Types

After building, these types become available:

Entity Records

// For entity with address
public partial record Card(
    string CardNumber,
    uint FacilityCode)
{
    public uint? Address { get; private set; }

    public void SetAddress(uint address) { ... }

    public byte[] ComputeHash() => ...;
}

// For embedded structure (no Address property/method)
public partial record DoorSchedule(
    uint DoorId,
    uint TimeStart,
    uint TimeEnd)
{
    public byte[] ComputeHash() => ...;
}

Access Model Container

public partial record AccessModel(
    Card[] Cards,
    Person[] Persons,
    AccessLevel[] AccessLevels,
    Schedule[] Schedules);

Base Class (inherit from this)

public abstract class AccessSynchronizationBase
{
    // you must implement this
    protected abstract AccessModel Transform(PersonAccess[] persons);

    // optional reset hook (default no-op)
    protected virtual IEnumerable<IProtocolCommand> ClearDeviceMemory(
        IReadOnlyList<OccupiedSlotGroup> occupiedSlotGroups) => [];

    // call this from your handler to run the sync orchestration
    protected Task<DeviceCommandResult> Synchronize(
        Thing thing,
        Access.Synchronize command,
        IServiceProvider services,
        CancellationToken cancellationToken);
}

Step 3: Create AccessSynchronization Class

// Capabilities/AccessSynchronization.cs
using Pq.Adapters.Framework;
using Pq.Adapters.Framework.AccessUpload;
using Pq.Adapters.Framework.Commands;

namespace Pq.Adapter.YourAdapter;

public class AccessSynchronization(Protocol protocol) : AccessSynchronizationBase
{
    /// <summary>
    /// Protocol for device communication (used by commands).
    /// </summary>
    public Protocol Protocol => protocol;

    protected override AccessModel.AccessModel Transform(PersonAccess[] persons)
    {
        // implement transformation logic
    }
}

The generated base provides the synchronization command orchestration. Add a hand-written synchronization wrapper only when the adapter needs real pre/post behavior such as authoritative device cleanup, slot preallocation, or custom reset semantics.

Reset Hook for Device Memory Cleanup

Access.Synchronize.Reset now runs in this order:

  1. Build occupiedSlotGroups snapshot from MappingStore.GetAll(...) for tracked entity types in access-model.yaml, grouped by EntityType and ordered for delete (roots -> leaves)
  2. Build and execute clear commands from ClearDeviceMemory(occupiedSlotGroups)
  3. ClearMappings() - clears local PQ↔device address mapping store after successful device cleanup
  4. Synchronize(...) - full upload from current PQ state

If your device keeps old credentials in panel memory, override ClearDeviceMemory.

Clear commands should fail when device cleanup fails. Do not catch and ignore protocol errors inside reset cleanup commands; preserving mappings after a failed clear is safer than forgetting which credentials may still exist on the device.

Two valid strategies:

  1. authoritative bulk wipe - protocol has one command that clears whole memory
  2. targeted delete - protocol deletes specific slots/items

Do not combine both in one naive implementation (ClearAllUsers() and then per-slot delete), because it is usually redundant and can fail on the second phase when slots are already gone.

Example for authoritative bulk wipe:

protected override IEnumerable<IProtocolCommand> ClearDeviceMemory(
    IReadOnlyList<OccupiedSlotGroup> occupiedSlotGroups)
{
    yield return new ProtocolCommand<AccessSynchronization>(async (ctx, ct) =>
    {
        await ctx.Protocol.ClearAllUsers(ct);
    });
}

Use occupiedSlotGroups as authoritative input and clear exactly what the framework tracked. Avoid blind sweeps such as for (var i = 1; i <= 999; i++) when the protocol supports targeted delete.

How to know which address belongs to which entity

occupiedSlotGroups contains groups with:

  • EntityType - model type (User, Schedule, TimeRange, ...)
  • Slots - IReadOnlyList<AddressMapping> for that type

Each AddressMapping in Slots contains:

  • DeviceAddress - address stored for that type
  • identity/hash metadata used by the framework

So in a mixed model (for example users in 1..1000 and time ranges in 100..200), you do not infer ownership from numeric range. You route by EntityType and parse DeviceAddress to the expected protocol format.

Type grouping and ordering are already provided by framework; adapter code should not recompute dependency order.

Example for targeted delete by entity type:

protected override IEnumerable<IProtocolCommand> ClearDeviceMemory(
    IReadOnlyList<OccupiedSlotGroup> occupiedSlotGroups)
{
    foreach (var group in occupiedSlotGroups)
    {
        switch (group.EntityType)
        {
            case "User":
                foreach (var mapping in group.Slots)
                {
                    if (int.TryParse(mapping.DeviceAddress, out var userSlot))
                        yield return new ProtocolCommand<AccessSynchronization>(async (ctx, ct) =>
                        {
                            await ctx.Protocol.DeleteUser(userSlot, ct);
                        });
                }
                break;

            case "TimeRange":
                foreach (var mapping in group.Slots)
                {
                    if (int.TryParse(mapping.DeviceAddress, out var rangeSlot))
                        yield return new ProtocolCommand<AccessSynchronization>(async (ctx, ct) =>
                        {
                            await ctx.Protocol.DeleteTimeRange(rangeSlot, ct);
                        });
                }
                break;
        }
    }
}

If your protocol supports batch/range deletes, consume each provided group with the best primitive for that type. Group ordering is already provided by framework (roots -> leaves).

If not overridden, default behavior remains unchanged (empty command sequence, no device-side clear).


Step 4: Implement Transform Method

Transform receives array of persons with their credentials and device access:

Input Data Structure

public record PersonAccess(
    Guid PersonId,           // PQ person ID (for confirmations)
    string Name,             // person name
    CredentialEntry[] Credentials,  // all credentials
    DeviceAccess[] Devices,  // devices this person can access
    bool ApbExempt);

public record CredentialEntry
{
    string Type;         // "Pin", "PersonCard", "Biometric"
    Guid CredentialId;   // PQ credential ID
    object? Data;        // CardCredentialData, PIN string, etc.
}

public record DeviceAccess(
    Thing Device,              // resolved device Thing
    WeeklyTimeRange[]? Schedule);  // null = 24/7 access

Transform Implementation Pattern

protected override AccessModel.AccessModel Transform(PersonAccess[] persons)
{
    var cards = new List<Card>();
    var users = new List<Person>();
    var schedules = new List<Schedule>();

    // deduplicate schedules
    var scheduleMap = new Dictionary<string, Schedule>();

    foreach (var person in persons)
    {
        // 1. process credentials
        foreach (var cred in person.Credentials)
        {
            if (cred.Type == "PersonCard" && cred.Data is CardCredentialData cardData)
            {
                var card = new Card(
                    CardNumber: cardData.CardNumber,
                    FacilityCode: ParseFacilityCode(cardData));
                cards.Add(card);
            }
        }

        // 2. process schedules (deduplicate)
        var personSchedules = new List<Schedule>();
        foreach (var device in person.Devices)
        {
            if (device.Schedule != null)
            {
                var scheduleKey = ComputeScheduleKey(device.Schedule);
                if (!scheduleMap.TryGetValue(scheduleKey, out var schedule))
                {
                    schedule = ConvertSchedule(device.Schedule);
                    scheduleMap[scheduleKey] = schedule;
                    schedules.Add(schedule);
                }
                personSchedules.Add(schedule);
            }
        }

        // 3. create person with references
        var user = new Person(
            Name: person.Name,
            Cards: cards.Where(c => BelongsToPerson(c, person)).ToArray());
        users.Add(user);
    }

    return new AccessModel.AccessModel(
        Cards: cards.ToArray(),
        Persons: users.ToArray(),
        Schedules: schedules.ToArray(),
        // ... other entity arrays
    );
}

When person_profiles is declared, the generated base uses adapter-specific access input instead:

protected override AccessModel.AccessModel Transform(AccessModel.PersonAccess[] persons)
{
    foreach (var person in persons)
    {
        var userLevel = person.UserProfile?.UserLevel ?? 0;
        var dualCode = person.UserProfile?.DualCode ?? false;
        // build device-specific user record
    }
}

Do not parse profile JSON manually. The generated person.<SlotType>Profile property is the adapter-facing API.

Key Transform Rules

  1. Never prefill Address in Transform - all entities start with Address = null; framework/differ assigns addresses
  2. Deduplicate shared entities - schedules may be shared by multiple persons
  3. Build references between entities - Person.Cards references Card entities
  4. Filter by target device - only include data relevant to current device

Forbidden in Transform (Do Not Do This)

  • Do not call SetAddress() inside Transform() for tracked entities.
  • Do not read entity.Address inside Transform() to build other entities.
  • Do not model tracked-entity references as uint / uint[] when EntityName / EntityName[] is possible.

If you break these rules, repeated sync can fail with InvalidOperationException: Address already assigned when the differ tries to set address for UNCHANGED entities.

Reconstructing Nested Structures in Commands

When you flattened a structure in your model, reconstruct it when sending to device:

// Model has flat SchedulePeriod[]
// Device expects nested WeeklySchedule → DaySchedule[7] → TimePeriod[]

internal sealed class CreateScheduleCommand(Schedule schedule) : IProtocolCommand
{
    public async Task Execute(object context, CancellationToken ct)
    {
        var sync = (AccessSynchronization)context;

        // reconstruct nested structure from flat periods
        var weeklySchedule = new DeviceWeeklySchedule();

        for (int day = 0; day < 7; day++)
        {
            var daySchedule = new DeviceDaySchedule();

            var periodsForDay = schedule.Periods
                .Where(p => p.DayOfWeek == day)
                .Select(p => new DeviceTimePeriod
                {
                    StartTime = p.StartTime,
                    EndTime = p.EndTime
                });

            daySchedule.Periods.AddRange(periodsForDay);
            weeklySchedule.DaySchedules.Add(daySchedule);
        }

        await sync.Protocol.CreateScheduleAsync(
            schedule.Address!.Value,
            weeklySchedule,
            ct);
    }
}

Key point: Your model is optimized for sync (flat, simple). Device protocol may need different structure. Transform in your command.


Step 5: Create Protocol Commands

Commands execute actual device communication:

// AccessModel/Commands/CreateCardCommand.cs
using Pq.Adapters.Framework.AccessUpload.Commands;

namespace Pq.Adapter.YourAdapter.AccessModel;

internal sealed class CreateCardCommand(Card card) : IProtocolCommand
{
    public async Task Execute(object context, CancellationToken cancellationToken)
    {
        // context is your AccessSynchronization instance
        var sync = (AccessSynchronization)context;

        // use protocol to send command to device
        await sync.Protocol.SendCardAsync(new DeviceCard
        {
            Address = card.Address!.Value,
            CardNumber = card.CardNumber,
            FacilityCode = card.FacilityCode
        }, cancellationToken);
    }
}

internal sealed class DeleteCardCommand(uint address) : IProtocolCommand
{
    public async Task Execute(object context, CancellationToken cancellationToken)
    {
        var sync = (AccessSynchronization)context;
        await sync.Protocol.DeleteCardAsync(address, cancellationToken);
    }
}

Step 6: Implement Command Generation

Each entity needs partial file implementing command generation:

// AccessModel/Card.Commands.cs
using Pq.Adapters.Framework.AccessUpload.Commands;

namespace Pq.Adapter.YourAdapter.AccessModel;

public partial record Card
{
    /// <summary>
    /// Creates commands to add this entity to device.
    /// </summary>
    public IEnumerable<IProtocolCommand> CreateCommands()
        => [new CreateCardCommand(this)];

    /// <summary>
    /// Creates commands to update this entity on device.
    /// </summary>
    public IEnumerable<IProtocolCommand> UpdateCommands()
        => [new UpdateCardCommand(this)];

    /// <summary>
    /// Creates commands to delete entity at specified address.
    /// </summary>
    public static IEnumerable<IProtocolCommand> DeleteCommands(string address)
        => [new DeleteCardCommand(uint.Parse(address))];
}

Command Generation for Each Entity

Create EntityName.Commands.cs for every entity with address property:

AccessModel/
  Card.Commands.cs
  Person.Commands.cs
  Schedule.Commands.cs
  AccessLevel.Commands.cs

Execution Flow

When Access.Synchronize command is received:

1. Framework fetches PersonAccess[] from PQ API

2. Your Transform() converts to AccessModel

3. Framework computes hashes for all entities

4. Framework loads stored mappings from LiteDB

5. Framework classifies entities:
   - NEW: hash not in storage
   - UNCHANGED: hash matches stored
   - DELETED: stored hash not in desired

6. Framework allocates addresses for NEW entities

7. Framework builds command list:
   - Additions: leaves first (Card before Person)
   - Deletions: roots first (Person before Card)

8. Framework executes commands via your Execute()

9. On success: framework persists hash mapping
   On failure: logged, continues with next

Dependency Ordering

Why Order Matters

Device typically requires referenced entity to exist before referencing it:

  • Schedule must exist before AccessLevel references it
  • AccessLevel must exist before AccessGroup references it
  • AccessGroup must exist before User references it

Layer Concept

Framework automatically determines layers from your entity references:

Layer 0 (leaves):   Schedule        ← no entity references
Layer 1:            AccessLevel     ← references Schedule
Layer 2:            AccessGroup     ← references AccessLevel
Layer 3 (roots):    User            ← references AccessGroup

Embedded structures don't have layers - they're processed with their parent entity.

Visual Example

ADDITIONS (leaves → roots):          DELETIONS (roots → leaves):

1. Schedule.Add()                    1. User.Delete()
        ↓                                    ↓
2. AccessLevel.Add()                 2. AccessGroup.Delete()
        ↓                                    ↓
3. AccessGroup.Add()                 3. AccessLevel.Delete()
        ↓                                    ↓
4. User.Add()                        4. Schedule.Delete()

Execution Order

Operation Order Reason
Additions Leaves → Roots Referenced entity must exist first
Updates Leaves → Roots Same as additions
Deletions Roots → Leaves Remove references before deleting referenced entity

How Framework Determines Order

  1. Parse entity references from YAML (levels: AccessLevel[])
  2. Build dependency graph
  3. Topological sort to determine layers
  4. Execute in layer order

Circular dependencies are not allowed - framework will fail at build time.


Hash Computation

Hash determines if entity changed. Framework computes automatically.

What's Included in Hash

Property Type Included
Primitives (string, uint) Value
Primitive arrays (uint[]) All values
Entity reference Only Address
Entity array reference All Addresses
Embedded structure Full ComputeHash() recursively
Address property Never

Implication

Changing Card content changes Card hash, but NOT Person hash (only Card.Address is in Person hash).


Storage

Framework persists to LiteDB (data/adapters/{deviceId}/access-mappings.db):

AddressMapping {
    Id: "Card:A1B2C3..."       // EntityType:HashHex
    EntityType: "Card"
    Hash: byte[20]             // SHA1
    DeviceAddress: "42"
    SyncedAt: 1703520000       // unix timestamp
}

On Successful Add/Update

Storage.Upsert(new AddressMapping {
    Id = $"{type}:{hashHex}",
    Hash = hash,
    DeviceAddress = address.ToString(),
    SyncedAt = now
});

On Successful Delete

Storage.Delete($"{type}:{hashHex}");

Address Allocation

Framework manages device address space:

// Range allocator (default)
var allocator = new RangeAddressAllocator(min: 1, max: 65535);

// During sync:
// - Stored addresses: allocator.MarkUsed(addr)
// - Deleted addresses: allocator.Release(addr)
// - New entities: addr = allocator.Allocate()

Device-Assigned Addresses

If device assigns addresses itself during upload:

public class DeviceAssignedAllocator : IAddressAllocator<uint>
{
    public uint Allocate() => throw new InvalidOperationException(
        "Device assigns address during upload");
}

// In command execution, capture assigned address and set it:
var response = await protocol.CreateCard(card);
card.SetAddress(response.AssignedAddress);

Do not use this pattern when your adapter sends the slot/address to the device (for example WriteUser(slot, ...)). In that case the device is not assigning addresses; treat it as allocator or adapter-selected addressing and keep Transform() address-free.


Error Handling

Partial Sync

Framework continues after individual failures:

  • Failed entity logged
  • Dependent entities may fail naturally
  • Successful entities persisted
  • Final result contains success/failure counts

Address Space Exhausted

Situation: Device has max 1000 cards, all slots used

Result: AddressSpaceExhaustedException
- Entity marked as FAILED
- Dependent entities propagate failure
- Other entities continue

Resolution: User must delete something first

Command Failure

public async Task Execute(object context, CancellationToken ct)
{
    try
    {
        await protocol.SendCard(...);
    }
    catch (DeviceException ex)
    {
        // throw to signal failure
        // framework logs and continues
        throw new ProtocolCommandException($"Failed to create card: {ex.Message}", ex);
    }
}

Complete Example: Simple Card-Only Adapter

adapter-registration.yaml

adapter_id: "simple-card-adapter"
name: "Simple Card Reader"

model:
  - type_id: Person
    properties:
      address: uint
      name: string
      cards: Card[]

  - type_id: Card
    properties:
      address: uint
      card_number: string

AccessSynchronization.cs

public class AccessSynchronization(Protocol protocol) : AccessSynchronizationBase
{
    public Protocol Protocol => protocol;

    protected override AccessModel.AccessModel Transform(PersonAccess[] persons)
    {
        var cards = new List<Card>();
        var users = new List<Person>();

        foreach (var person in persons)
        {
            var personCards = new List<Card>();

            foreach (var cred in person.Credentials.Where(c => c.Type == "PersonCard"))
            {
                if (cred.Data is CardCredentialData cardData)
                {
                    var card = new Card(CardNumber: cardData.CardNumber);
                    cards.Add(card);
                    personCards.Add(card);
                }
            }

            var user = new Person(
                Name: person.Name,
                Cards: personCards.ToArray());
            users.Add(user);
        }

        return new AccessModel.AccessModel(
            Cards: cards.ToArray(),
            Persons: users.ToArray());
    }
}

Card.Commands.cs

public partial record Card
{
    public IEnumerable<IProtocolCommand> CreateCommands()
        => [new CreateCardCommand(this)];

    public IEnumerable<IProtocolCommand> UpdateCommands()
        => [new UpdateCardCommand(this)];

    public static IEnumerable<IProtocolCommand> DeleteCommands(string address)
        => [new DeleteCardCommand(uint.Parse(address))];
}

CreateCardCommand.cs

internal sealed class CreateCardCommand(Card card) : IProtocolCommand
{
    public async Task Execute(object context, CancellationToken ct)
    {
        var sync = (AccessSynchronization)context;
        await sync.Protocol.WriteCard(card.Address!.Value, card.CardNumber, ct);
    }
}

Troubleshooting

"Hash not stable after SetAddress"

Address property is excluded from hash. If hash changes after SetAddress, you have a bug - Address shouldn't affect hash.

"Address already assigned"

Symptoms:

  • First sync succeeds, second sync with same data fails.
  • Exception includes InvalidOperationException: Address already assigned.
  • Stack trace points to generated SetAddress(...) from differ path.

Root cause:

  • Transform() already called SetAddress() (or used prefilled addresses) and differ calls SetAddress() again for UNCHANGED entities.

Fix:

  • Remove all SetAddress() calls from Transform() (except true device-assigned command responses).
  • Return object references between entities, not numeric address references.
  • Keep address selection in allocator/differ path or in post-device-response path only.

Prevention test:

  • Run the same Access.Synchronize twice with unchanged input.
  • The second run must be no-op and must not throw.

"Entity reference has null Address"

Dependencies not processed in correct order. Check:

  1. YAML dependencies are correct (use EntityName not uint)
  2. Referenced entity is included in your model output
  3. All entities that should have addresses are returned from Transform()

"AddressSpaceExhausted during first sync"

Allocator range too small or incorrectly initialized. Check device capacity.

"Commands execute in wrong order"

Framework orders by dependency. If device has additional ordering requirements beyond data dependencies, you may need to emit multiple commands from single entity.

"Stored mappings not found after restart"

Check LiteDB file path and permissions. Storage path: data/adapters/{deviceId}/access-mappings.db

"Commands execute in wrong order" (dependency detection)

Problem: User commands execute before AccessLevel commands, causing device errors.

Root cause: The source generator detects dependencies by parsing property types. It looks for property types that match entity names (like AccessLevel). If you use uint[] instead of EntityName[], the generator sees just numbers - no dependency is detected.

# WRONG - generator sees "uint[]", no dependency detected
# Result: User uploaded BEFORE AccessLevel exists on panel
- type_id: User
  properties:
    access_levels: uint[]      # just numbers, no relationship

# CORRECT - generator sees "AccessLevel[]", dependency detected
# Result: AccessLevel uploaded FIRST, then User
- type_id: User
  properties:
    access_levels: AccessLevel[]   # explicit reference to entity

Symptoms:

  • Device rejects commands (entity not found, invalid reference)
  • Commands timeout or return errors
  • Access sync partially works (some entities OK, dependent ones fail)
  • Credentials work for some access points but not all configured

How to verify: After build, check generated AccessUploadBatchBuilder.g.cs. The foreach loops should appear in dependency order:

// Expected order (leaves → roots):
foreach (var n in diff.Schedule.New)      // Layer 0
foreach (var n in diff.AccessLevel.New)   // Layer 1 (references Schedule)
foreach (var n in diff.User.New)          // Layer 2 (references AccessLevel)

If User appears before AccessLevel, your dependencies are not detected.

"Framework doesn't track entity changes correctly"

Problem: Using uint[] instead of EntityName[] for references.

# WRONG - framework can't track dependencies
access_group_ids: uint[]

# CORRECT - framework tracks dependencies
access_groups: AccessGroup[]

Fix: Use explicit entity references. The model must be self-contained.

"Deeply nested structure is complex to handle"

Problem: Device protocol has A → B → C → D nesting.

Solution: Flatten in model, reconstruct in command:

# Model - flat
- type_id: Schedule
  properties:
    address: uint
    periods: SchedulePeriod[]   # flat array
// Command - reconstruct nested structure
var nested = ReconstructNestedStructure(schedule.Periods);
await protocol.CreateSchedule(nested);

"Biometric data has byte[][] (array of arrays)"

Problem: Device stores multiple templates per finger, but byte[][] is not supported.

Solution: Flatten to single template per record:

# Each template is separate record
- type_id: FingerData
  properties:
    finger_index: int
    template: byte[]         # single template

Or concatenate templates into single blob if device accepts it.

"Credentials should be separate entities but device API sends them with user"

Problem: You want Card as trackable entity, but device has User.Enroll(userWithCards) not Card.Create().

Solution: Make credentials embedded in User. The framework syncs User entity which includes all credentials. If a card changes, User hash changes, and entire User is re-synced.

"Shared entity (Schedule) duplicated instead of reused"

Problem: Same schedule appears multiple times in model.

Solution: Deduplicate in Transform:

var scheduleMap = new Dictionary<string, Schedule>();

foreach (var access in accesses)
{
    var key = ComputeScheduleKey(access.Schedule); // hash of content
    if (!scheduleMap.TryGetValue(key, out var schedule))
    {
        schedule = ConvertSchedule(access.Schedule);
        scheduleMap[key] = schedule;
    }
    // reuse existing schedule reference
}

"Circular dependency detected"

Problem: Entity A references B, and B references A.

Solution: This is not supported. Redesign your model:

  • Make one direction embedded instead of reference
  • Introduce intermediate entity to break cycle
  • Question if circular dependency is really needed

Key Files Reference

File Purpose
adapter-registration.yaml Access model definition
Capabilities/AccessSynchronization.cs Your Transform implementation
AccessModel/*.Commands.cs Command generation for entities
AccessModel/Commands/*.cs Protocol command implementations

Framework Types Reference

The types below are part of the Pq.Adapters.Framework access-upload API you build against. Reference them by type name; resolve the exact member surface from the assembly you compile against.

Type Purpose
PersonAccess Input data structure (a person's resolved access rights)
DeviceAccess Device + schedule pairing
IProtocolCommand Interface your protocol commands implement
RangeAddressAllocator Framework-managed address allocation
IAddressMappingStore Persistence interface for address mappings