Pattern 1: Access Synchronization¶
When to Use¶
Your device supports offline access control (stores credentials, schedules, access levels locally).
access-model.yaml Structure¶
Define entities in dependency order (leaves first):
model:
# Layer 0 - leaves (no dependencies)
- type_id: Schedule
updatable: inplace
properties:
address: uint
periods: SchedulePeriod[]
range: [1, 255]
# Layer 0 - embedded in Schedule
- type_id: SchedulePeriod
properties:
day_mask: uint
start_time: uint
end_time: uint
# Layer 1 - references Schedule
- type_id: AccessLevel
updatable: inplace
properties:
address: uint
door_schedules: DoorSchedule[]
range: [1, 32000]
# Layer 1 - embedded in AccessLevel
- type_id: DoorSchedule
properties:
door_id: uint
schedule: Schedule # object reference, resolved at command time
# Layer 2 - root (references AccessLevel)
- type_id: Cardholder
properties:
card_number: uint
access_levels: AccessLevel[] # explicit entity references
valid_from: uint
valid_until: uint
Key rules:
- Tracked entities have
addressproperty (stored in mapping DB) - Embedded entities have no
address(nested in parents) - Define
rangefor address allocation - Cross-entity references MUST use object references (
EntityName/EntityName[]), not address integers (see pitfall below)
Person Profiles¶
Use person_profiles when the device requires per-person settings that are not
credentials and are not access grants. Typical examples are alarm user level, menu
type, group-selection permission, dual-code mode, bypass permissions, or similar
panel-specific user flags.
Declare profile fields in access-model.yaml next to model:
model:
- type_id: User
properties:
address: uint
name: string
pin: string
user_level: int
can_select_group: bool
range: [1, 999]
person_profiles:
- slot_type: User
fields:
- name: user_level
type: int
default: 0
range: [0, 3]
category: alarm
description: "Alarm user level"
- name: can_select_group
type: bool
default: false
category: alarm
description: "Allows group selection"
Profile fields use the same property shape as device properties where applicable:
type, required, default, range, pattern, category, description, and
sensitive.
The source generator creates one typed profile record per slot_type, named from the
adapter and slot type, for example VendorPanelUserProfile. YAML defaults become
property initializers. Required profile fields are validated by the generated mapper
before Transform() runs.
When profiles are declared, the generated Transform signature uses generated access
input:
protected override AccessModel.AccessModel Transform(AccessModel.PersonAccess[] persons)
{
foreach (var person in persons)
{
var profile = person.UserProfile;
var user = new AccessModel.User(
Name: person.Name,
UserLevel: profile?.UserLevel ?? 0,
CanSelectGroup: profile?.CanSelectGroup ?? false);
}
}
Do not parse raw profile JSON in adapter code. The generated typed profile on
person is the supported adapter API. Profile data is per person for the current
adapter transform; do not try to select a different profile from individual devices.
Selective Sync Ownership: Person-Owned Roots vs Shared Tables¶
Selective sync synchronizes only a subset of persons. To do that safely it must know, for each allocator-addressed entity (address + range, no id), whether a record belongs to one specific person or is a shared table that many persons reference. The framework infers this from the model's reference graph — you write nothing:
- An entity that is the target of any entity-typed property anywhere in the model — directly (
Schedule,AccessLevel?,Door[]) or through an embedded type (DoorSchedule.schedule: Schedule) — is a shared derived table. - An entity that nothing references is a person-owned root.
Plain-language test:
- Person-owned root — "the record exists because of a specific person and disappears with them" (a cardholder / user slot).
- Shared — "a derived table persons merely reference" (a schedule, access level, holiday, door).
What each kind does during selective sync¶
- Person-owned roots pair by
(PersonId, CredentialId).Transform()mustResolveAsthe person on every root record — and the credential as well when the record is per-credential (one panel record per card / PIN). That resolved identity is the pairing key selective sync uses to update a changed record in place, instead of leaving the person's previous card/PIN active on the panel next to the new one. - Shared tables are additive-only during selective sync and must never resolve a person. Resolving a person on a shared-table record is a projection error; the framework rejects such a record rather than uploading it.
This resolution obligation is enforced as RULE-056.
The optional owner override¶
owner: person | shared in access-model.yaml is an optional override of the inference, for exceptional models only — a model with more than one legitimate person-owned root, or a genuinely unreferenced shared table. Normal models declare nothing; the author never has to reason about ownership.
The generator warns when it infers more than one root, because the usual cause is a real relationship flattened into an integer or bit mask (see RULE-040), which orphans a shared table into a false root. Fix the flattened relationship first; reach for owner: only when the multiple roots are genuinely intended (declaring owner: person on each silences the warning). Any explicit owner: declaration is exceptional by definition and is always audited at review.
Natural-id entities (id: <property>) must not declare owner.
First-ever selective sync¶
The very first selective sync on a device runs as a full sync until the device store is initialized. Until the framework has the complete panel picture it cannot safely diff a subset, so the first run synchronizes everything; once the store is initialized, later runs honor the selected subset.
Address Ownership Contract (Critical)¶
Address is framework-owned state for diffing. Treat it as write-once sync metadata.
Transform()MUST construct entities withAddress == null.- Never call
SetAddress()insideTransform(). - Never read
entity.AddressinsideTransform()to build references. - Never encode tracked-entity references as raw address integers when object references are available.
SetAddress()in adapter code is valid only for true device-assigned flows (device response returns the address).- Predefined built-in device records may be address-pinned only when the model handles them explicitly so the differ will not reassign or duplicate them.
If this contract is broken, repeated sync can fail with InvalidOperationException: Address already assigned when differ processes UNCHANGED entities.
Implementation¶
Create Capabilities/AccessSynchronization.cs by inheriting the generated AccessSynchronizationBase. The generated base supplies synchronization orchestration and command dispatch; the adapter normally implements only Transform(...) and protocol command objects.
For full reset flow (pq.command.access.synchronize.reset), the generated base now exposes a reset hook:
protected virtual IEnumerable<IProtocolCommand> ClearDeviceMemory(
IReadOnlyList<OccupiedSlotGroup> occupiedSlotGroups) => [];
Reset(...) now executes:
- snapshot mappings from
MappingStore.GetAll(...)for tracked entity types inaccess-model.yaml, grouped byEntityTypeand ordered for delete (roots -> leaves) - execute commands from
ClearDeviceMemory(occupiedSlotGroups) ClearMappings()Synchronize(...)
occupiedSlotGroups is a frozen snapshot from before mappings are cleared. If there are no mappings yet, the collection is empty.
Override this method when the device supports bulk credential wipe and you need reset to fully align device memory with PQ state.
The framework clears local mappings only after the device clear commands complete successfully. Adapter clear commands should therefore report real clear failures by throwing. Do not catch and swallow protocol errors inside ClearDeviceMemory() commands; a failed reset should preserve mappings so the next reset/sync still knows what was previously uploaded.
To distinguish mixed address spaces, route by group EntityType (not numeric ranges). Each group carries Slots (IReadOnlyList<AddressMapping>) for one type, and groups are already emitted in delete-oriented order (roots -> leaves), so adapter code does not need to compute type ordering.
Choose one clear strategy per adapter reset path: either authoritative bulk wipe or targeted delete from occupiedSlotGroups. Avoid naive combination of both unless the protocol explicitly guarantees idempotent delete-after-wipe behavior.
Multi-Credential Projection And Determinism (Critical)¶
When a person has multiple credentials of supported types, Transform() must project all supported credentials, not only the first one.
Rules¶
- Never use
FirstOrDefault()as the only source for a credential type that can appear multiple times. - Iterate persons in deterministic order (for example by
PersonId). - Iterate each supported credential type in deterministic order (for example by
CredentialId, then value-specific secondary key). - If one device record carries multiple credential fields, use a deterministic index-based projection strategy.
- Process leftover credentials deterministically according to the device model.
- Never rely on incidental source enumeration order.
Deterministic ordering (critical)¶
Transform() output must be stable for identical input to avoid incremental-sync churn.
Anti-pattern: unsorted person or credential iteration that can reshuffle equivalent mappings between sync runs.
Record Shape Selection¶
Before projecting, decide the record shape from the panel record's credential capacity, not from what the record is called (RULE-055):
- Person-root — the panel person record carries credential lists (multiple identifiers of a type per record, e.g. an array of cards): emit one record per person embedding all of that person's credentials.
- Credential-root — the panel record carries fixed single-credential fields (one card slot, one code): fan out one record per credential using the stable pairing below.
A record named user may be either — read its capacity. Modeling person-root over a fixed-capacity record (taking one credential and dropping the person's remaining credentials of that type) is a violation.
Stable Pairing Example (cards[] + pins[])¶
For sorted arrays cards[] and pins[]:
- Create paired records for
i < min(cards.Length, pins.Length):cards[i] + pins[i] - Create card-only records for remaining cards
- Create pin-only records for remaining pins
This is one model-specific example. Apply the same deterministic projection principle to other credential sets (including biometrics) based on the device record shape.
Identity Mapping¶
Each produced record must resolve identities that were actually projected into it:
- paired record:
ResolveAs(person) + ResolveAs(cardCredential) + ResolveAs(pinCredential) - card-only record:
ResolveAs(person) + ResolveAs(cardCredential) - pin-only record:
ResolveAs(person) + ResolveAs(pinCredential)
Do not attach unrelated credentials to a projected record.
namespace Pq.Adapter.Vendor.Product.Capabilities;
/// <summary>
/// Transforms PQ access model to device-specific entities.
/// </summary>
internal sealed class AccessSynchronization(Protocol protocol) : AccessSynchronizationBase
{
public Protocol Protocol => protocol;
/// <summary>
/// Transform PQ PersonAccess to device-specific access model.
/// </summary>
protected override AccessModel Transform(PersonAccess[] persons)
{
var schedules = new List<Schedule>();
var accessLevels = new List<AccessLevel>();
var cardholders = new List<Cardholder>();
foreach (var person in persons)
{
var personAccessLevels = new List<AccessLevel>();
// extract schedules from person's device access
foreach (var deviceAccess in person.Devices)
{
var schedule = CreateSchedule(deviceAccess.TimeRestrictions);
schedules.Add(schedule);
var accessLevel = CreateAccessLevel(deviceAccess.DoorIds, schedule);
accessLevels.Add(accessLevel);
personAccessLevels.Add(accessLevel);
}
// create cardholder with credentials
var cardholder = CreateCardholder(person, personAccessLevels);
cardholders.Add(cardholder);
}
return new AccessModel(
Schedules: schedules.Distinct(),
AccessLevels: accessLevels.Distinct(),
Cardholders: cardholders
);
}
private Schedule CreateSchedule(TimeRestriction[] restrictions)
{
var periods = restrictions.Select(r => new SchedulePeriod
{
DayMask = ConvertDayMask(r.Days),
StartTime = (uint)r.Start.TotalMinutes,
EndTime = (uint)r.End.TotalMinutes
}).ToArray();
return new Schedule { Periods = periods };
}
private AccessLevel CreateAccessLevel(uint[] doorIds, Schedule schedule)
{
var doorSchedules = doorIds.Select(doorId => new DoorSchedule
{
DoorId = doorId,
Schedule = schedule // object reference, address resolved at command time
}).ToArray();
return new AccessLevel { DoorSchedules = doorSchedules };
}
private Cardholder CreateCardholder(PersonAccess person, List<AccessLevel> levels)
{
var card = person.Credentials.FirstOrDefault(c => c.Type == "card");
return new Cardholder
{
CardNumber = card != null ? ParseCardNumber(card.Data) : 0,
AccessLevels = levels.Distinct().ToArray(),
ValidFrom = (uint)person.ValidFrom.ToUnixTimeSeconds(),
ValidUntil = (uint)person.ValidUntil.ToUnixTimeSeconds()
};
}
}
Protocol Commands¶
Create Capabilities/ProtocolCommand.cs:
namespace Pq.Adapter.Vendor.Product.Capabilities;
/// <summary>
/// Wraps device SDK calls as IProtocolCommand for access upload.
/// </summary>
internal sealed class ProtocolCommand(Func<AccessSynchronization, CancellationToken, ValueTask> execute)
: IProtocolCommand
{
public async Task Execute(object context, CancellationToken cancellationToken)
{
if (context is not AccessSynchronization sync)
throw new ArgumentException("Expected AccessSynchronization context");
await execute(sync, cancellationToken);
}
}
Entity Command Methods¶
Implement IUploadProtocolCommands on generated entities:
// in Capabilities/Access/Schedule.cs
internal sealed partial record Schedule
{
public IEnumerable<IProtocolCommand> CreateCommands()
{
yield return new ProtocolCommand(async (sync, ct) =>
{
await sync.Protocol.AddSchedule(Address!.Value, Periods, ct);
});
}
public IEnumerable<IProtocolCommand> UpdateCommands() => CreateCommands();
public static IEnumerable<IProtocolCommand> DeleteCommands(uint address)
{
yield return new ProtocolCommand(async (sync, ct) =>
{
await sync.Protocol.DeleteSchedule(address, ct);
});
}
}
Holidays¶
ACS and alarm system protocols frequently support holiday calendars. Holidays are independent leaf entities (no dependencies) allocated from a device slot range — typically [1, 64] or [1, 255]. Schedules often reference holidays via a bitmask that overrides the normal weekly window when the day matches a holiday.
When the vendor protocol documents holiday slot write/delete commands, the entity must be fully implemented — stub CreateCommands() => [] is not acceptable (RULE-035).
access-model.yaml¶
# Layer 0 — holiday (leaf, no dependencies)
- type_id: Holiday
properties:
address: uint
day: uint
month: uint
range: [1, 64]
updatable: replace
# Layer 1 — schedule references holidays via mask
- type_id: Schedule
properties:
address: uint
periods: SchedulePeriod[]
holiday_mask: uint # bitmask of holiday types that override this schedule
range: [1, 64]
updatable: replace
If the device uses typed holiday groups (like an Acme
type_mask) rather than positional bitmasks, add atype_mask: uintproperty and wire it throughCreateCommands().
Holiday.cs¶
// in Capabilities/Access/Holiday.cs
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));
}
}
Transform¶
Holidays are projected from DeviceAccessAuthorization.Holidays (provided by the framework). Each holiday maps to one entity instance:
protected override AccessModel.AccessModel Transform(AccessModel.PersonAccess[] persons)
{
var model = new AccessModel.AccessModel();
foreach (var holiday in Holidays)
{
model.AddHoliday(new Holiday
{
Day = (uint)holiday.Date.Day,
Month = (uint)holiday.Date.Month,
});
}
// ... cards, schedules, etc.
return model;
}
When holidays are not supported by the protocol¶
If the vendor protocol has no documented holiday slot write command, leave CreateCommands() => [] and document the gap explicitly in the adapter's design-notes:
## Known Implementation Gaps
- **Holidays:** Protocol does not define a holiday write command; holiday rows in Block 7
are always written as empty. Customers must pre-program holidays on the device directly.
What Framework Handles¶
Framework automatically handles:
- Dependency ordering (Schedule → AccessLevel → Cardholder)
- Address allocation and persistence
- Differential sync (detect add/update/delete)
- Hash-based change detection
- Error handling and retry
Best-Effort Synchronization (Critical)¶
Access sync must be best-effort: the failure or ineligibility of one item (a person, card, credential, or entity) must never abort or degrade the sync of the remaining items. One invalid card must not stop 4,999 other employees from starting their shift in the morning (RULE-039).
Skip what you cannot represent, keep going. A credential the transform cannot represent — unparseable value, value out of the device's representable range, empty PIN — is skipped. The person's other credentials and every other person still synchronize.
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, not fatal. When the address range fills up, the items that already fit stay uploaded; the overflow items are the ones that failed. Never let a "range full" condition throw out of Transform() or command execution and abandon the whole batch.
if (!allocator.TryReserve(out var address))
{
Log.Warning("Capacity reached for {Type}; person {PersonId} not uploaded", typeId, person.PersonId);
continue; // batch is not abandoned; the site runs with whoever fits
}
A device rejecting one write is a per-item failure. Remaining commands still execute.
Never degrade silently. If an internal limit forces you to drop a capability of an item you do upload — for example uploading a user without some of their door permissions because an access-level cap was hit — that drop must be logged or reported. A user that appears synced but is quietly missing their access rights is worse than one that was skipped: doors reject them and nobody knows why.
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();
}
Don't:
- Let a capacity/range-full exception propagate and abort the entire sync batch.
- Hit an internal access-level limit and silently upload users without their door access.
- Throw from
Transform()on the first unrepresentable credential (see RULE-006) instead of skipping it.
Pitfall: Cross-Entity References in Transform¶
Problem: Transform() builds the access model BEFORE the framework allocates addresses.
Entity .Address is null during Transform -- it gets assigned by the differ after Transform returns.
Wrong -- using address in embedded type:
// in Transform():
var schedule = GetOrCreateSchedule(ranges, scheduleMap);
doorSchedules.Add(new DoorSchedule(DoorId: doorId, ScheduleAddress: schedule.Address ?? 0));
// BUG: Address is always null here -> 0 -> wrong schedule reference
Correct -- using object reference:
// in Transform():
var schedule = GetOrCreateSchedule(ranges, scheduleMap);
doorSchedules.Add(new DoorSchedule(DoorId: doorId, Schedule: schedule));
// in CreateCommands() -- address is available here:
config.schedule_id = (short)(doorSchedule.Schedule.Address ?? 0);
"Always active" / empty references: use a static empty singleton instead of null:
public partial record Schedule
{
public static readonly Schedule Empty = new(Periods: [], HolidayMask: 0);
public bool IsEmpty => Periods.Length == 0;
}
Empty is not added to the entity map, so it never gets uploaded to the device.
Its Address stays null, which resolves to 0 (typically "always active" in device firmware).
Reserved Address Positions¶
Some devices keep certain address slots permanently occupied by firmware entries (admin accounts, remote-access slots, built-in schedules). The framework must not allocate into those slots and must not delete them during Reset.
Override GetReservedAddresses on your AccessSynchronization class to declare them:
protected override IEnumerable<uint> GetReservedAddresses(string typeId)
=> typeId == "User" ? [1u, 2u] : [];
For runtime reservations (slots derived from device configuration read at startup), return the live values:
protected override IEnumerable<uint> GetReservedAddresses(string typeId)
=> typeId == "User"
? [(uint)protocol.Panel.PositionAdmin,
(uint)protocol.Panel.PositionTechnician,
(uint)protocol.Panel.PositionRemoteAccess]
: [];
Framework behavior:
- Reserved slots are marked in the allocator before
Diff()runs, so new entities are never assigned to them. - Reserved slots never appear in
ClearDeviceMemorybecause the framework never writesTrackedCommandrecords for them — they cannot be in the mapping store. - If the reserved set changes between deploys (adapter version 2 adds or removes a slot), the framework detects the change via a stored hash on the next sync and automatically triggers a Reset for that entity type. A structured warning log is emitted before the Reset and an info log after it completes. No adapter code is required to handle this case.
What not to do:
// Wrong: skip logic in Transform or commands
if (address == 1 || address == 2)
continue; // the framework owns this — don't encode it here
Manual skip logic is fragile (must be maintained in multiple places, missed during Reset) and conflicts with the framework's allocation authority. Use GetReservedAddresses exclusively.
Scope: reserved positions apply only to entity types with range: [min, max] in access-model.yaml. Natural-id entities (id: property_name) are out of scope.
See Also¶
- Pattern 7: Address Resolution - Address allocation strategies
- Pattern 10: Error Handling - Handling sync failures