Skip to main content

SYNC_CONTRACT — lock-integration-service

Bundle: SERVICE_OVERVIEW · DOMAIN_MODEL · APPLICATION_LOGIC · DATA_MODEL · SECURITY_MODEL

Cross-cutting: docs/architecture/ADR-0003 — Electron Offline-First, docs/04 — Event-Driven Architecture, sync-service /sync/v1 protocol.

The Electron desktop is the only consumer that mirrors a subset of this service's state. Mobile apps and tenant booking sites consume read APIs synchronously and do not maintain a mirror. This contract specifies what is replicated, in which direction, and the conflict policy per aggregate as required by SERVICE_TEMPLATE — SYNC_CONTRACT.

1. Direction summary

AggregateCloud → Desktop (pull)Desktop → Cloud (push)Conflict policy on push
KeyCredential (active set for the property's near horizon)YesYes (only provisional: true rows minted offline)server_authoritative on issuance state
KeyCredentialAttemptNo (desktop receives via vendor adapters or vendor webhooks routed back via cloud)Yes (locally observed attempts during offline window)append_only
LockDevice registry for the propertyYesNo (desktop reads only; pairing wizard hits cloud REST directly)n/a
MasterKey for the staff currently bound to this deviceYesYes (only provisional: true minted offline)server_authoritative on issuance state
KeyKindPolicy (per property)YesNon/a
OfflineIssuance (the desktop's own cert + CRL of revoked)Yes (own cert metadata + global CRL)No (cert minting is cloud REST, not sync)n/a
EncoderSession (own sessions only)NoYesappend_only
VendorAdapter, VendorCredential, WebhookInbound, lock_auditNo — never replicated to desktopNon/a

2. Pull payload (cloud → desktop)

The desktop calls POST /sync/v1/pull (handled by sync-service) with a per-aggregate cursor. The handlers registered by this service return:

{
"since": { "lock.key_credentials": "2026-04-30T05:00:00Z", ... },
"now": "2026-04-30T11:00:00Z",
"lock": {
"keyCredentials": [
{
"id": "key_01HX...",
"tenantId": "tnt_01...",
"propertyId": "ppt_01...",
"reservationId": "rsv_01...",
"guestId": "gst_01...",
"kind": "rfid_card",
"rooms": ["rmu_01..."],
"scope": {},
"validFrom": "2026-04-30T14:00:00Z",
"validUntil": "2026-05-02T11:00:00Z",
"state": "active",
"vendor": "salto",
"provisional": false,
"version": 3,
"updatedAt": "2026-04-30T10:13:42Z"
}
],
"masterKeys": [ /* MasterKey shape minus key_credential lineage */ ],
"lockDevices": [ /* LockDevice without vendor_device_ref */ ],
"keyKindPolicy": { /* one row per property */ },
"offlineCertificates": {
"own": [ /* the desktop's OfflineIssuance cert metadata */ ],
"crl": [ { "serial": "lock-oic-...", "revokedAt": "...", "reason": "rotation" } ]
}
},
"cursors": { "lock.key_credentials": "2026-04-30T11:00:00Z", ... }
}

vendorRef is never present in any pull payload. vendor_device_ref is never present. Past-horizon credentials (validUntil < now() - 24h) are not replicated.

3. Pull horizon

To bound desktop SQLite size, the desktop only mirrors credentials whose validity window overlaps [now - 24h, now + 7d] plus all state = active master keys for staff currently bound to the device. The horizon is per property.

4. Push payload (desktop → cloud)

The desktop's local outbox accumulates melmastoon.lock.*.local.v1 events (see EVENT_SCHEMAS §7) and pushes them via POST /sync/v1/push. Per-batch shape:

{
"deviceId": "dev_01HX...",
"deviceSig": "<ed25519 over canonical JSON>",
"batchSeq": 421,
"events": [
{
"topic": "melmastoon.lock.credential.issued.local.v1",
"payload": {
"localId": "prov_keycred_01HX...",
"tenantId": "tnt_01...",
"propertyId": "ppt_01...",
"reservationId": "rsv_01...",
"kind": "rfid_card",
"rooms": ["rmu_01..."],
"validFrom": "2026-04-30T15:00:00Z",
"validUntil": "2026-05-01T11:00:00Z",
"vendorRef": "<encoder card serial>",
"certSerial": "lock-oic-2026-04-30-01HX...",
"issuedAt": "2026-04-30T15:01:32Z"
}
},
{
"topic": "melmastoon.lock.audit.attempt.local.v1",
"payload": { /* KeyCredentialAttempt observed at the encoder */ }
},
{
"topic": "melmastoon.lock.credential.revoked.local.v1",
"payload": { "localId": "prov_keycred_01HY...", "reason": "checkout", "revokedAt": "..." }
},
{
"topic": "melmastoon.lock.encoder_session.opened.local.v1",
"payload": { ... }
}
]
}

The cloud responds with the per-event reconciliation outcome:

{
"results": [
{
"localId": "prov_keycred_01HX...",
"outcome": "materialized",
"serverId": "key_01HX...",
"state": "active",
"warnings": []
},
{
"localId": "prov_keycred_01HY...",
"outcome": "revoked",
"reason": "reservation.cancelled",
"serverId": "key_01HY...",
"state": "revoked"
}
],
"idMap": { "prov_keycred_01HX...": "key_01HX...", "prov_keycred_01HY...": "key_01HY..." }
}

5. Conflict policies (per aggregate)

5.1 KeyCredentialserver_authoritative on issuance state

The cloud is the source of truth for the credential's state. When the desktop pushes a provisional: true credential, the reconciler:

  1. Validates the offline cert (signature, not expired, not in CRL). On failure → outcome: 'rejected'; the desktop must locally revoke the credential at the encoder.
  2. Loads the matching reservation from reservation-service.
  3. Decision matrix:
Reservation state at reconcileProvisional stateOutcome
confirmed, dates matchactivematerialized: mint server key_id, persist with provisional: false, vendor reconciliation if cloud-side adapter exists, emit canonical lock.credential.issued.v1
confirmed, dates extendedactivematerialized + updated: persist, then run update saga to extend validUntil
confirmed, dates contractedactivematerialized + updated: persist with new validUntil; vendor adapter shortens window
confirmed, room changedactivere-issued: revoke provisional at vendor; new credential for new room; cancel old
cancelledactiverevoked: revoke at vendor (best-effort), persist state=revoked with metadata.wasProvisional=true; emit lock.credential.revoked.v1 (reason='cancellation')
no_showactivesuspended: persist state=suspended per policy
not found (race / wrong device)activerejected: emit lock.credential.failed.v1; instruct desktop to revoke locally

The desktop never wins. If the desktop pushes a state change for a non-provisional credential, the cloud rejects with MELMASTOON.SYNC.MUTATION_REJECTED (409) and the desktop must rebase its local state from the next pull.

5.2 KeyCredentialAttemptappend_only

Door-attempt rows are append-only on both sides. Conflicts are impossible by construction; deduplication is by (vendor, vendorEventId) unique key in key_credential_attempts. If the same attempt is observed both locally (during offline) and via vendor webhook later, the unique constraint silently drops the duplicate; the desktop receives outcome: 'duplicate' and discards its local row.

5.3 MasterKeyserver_authoritative on issuance state

Same shape as KeyCredential. Offline-minted master keys (rare, used only when shift starts during a connectivity outage) are reconciled identically: cloud verifies the staff member's current shift state via staff-service/iam-service and either materializes or revokes.

5.4 EncoderSessionappend_only

Open/close events are append-only audit; no state to reconcile.

5.5 LockDevice — pull-only

Desktop never pushes device updates. Pairing the desktop wizard to a new device hits cloud REST (POST /api/v1/lock-devices) directly, which fails with a clear error if offline (no provisional registration permitted).

5.6 KeyKindPolicy — pull-only

Tenants edit policies through the backoffice surface against the cloud REST API; the desktop only consumes.

6. Idempotency on push

Every push event carries localId (ULID generated on the desktop). The cloud reconciler dedupes by (deviceId, localId); replays return the prior outcome and serverId. Replays with mutated payloads are rejected with MELMASTOON.SYNC.IDEMPOTENCY_KEY_REUSED (409); the desktop must reconcile its local state.

7. Cursor + rebase semantics

  • Desktop pulls with since cursors per aggregate; cloud returns cursors to use on the next pull.
  • If the desktop's since is older than the operational retention window (24 h hot history), the cloud returns MELMASTOON.SYNC.CURSOR_OUT_OF_RANGE (410) and the desktop must perform a full rebase pull — fetch the full active horizon from cloud and reset local mirror state for the lock aggregates.
  • Rebase preserves local_outbox rows (still pushable) but drops the local mirror of key_credentials_local and re-populates it from the rebase response.

8. Per-batch size limits

LimitValueBehavior on breach
Push events per batch500MELMASTOON.SYNC.PAYLOAD_TOO_LARGE (413); split
Push payload bytes1 MiBsame
Pull horizon credentials5 000server-side pagination via cursor

9. Security on the sync path

  • All /sync/v1/* traffic is over TLS with mutual TLS optional but recommended for corporate property networks.
  • The desktop signs each push batch with its OS-keychain Ed25519 device key; signature verified against the bound Device row in iam-service.
  • The reconciler re-validates the offline issuance certificate's signature on every push; the cert's public key is fetched from offline_issuance.public_key_ed25519 keyed by serial.
  • Push events that reference tenantId ≠ deviceTenantId are rejected with MELMASTOON.SYNC.DEVICE_UNBOUND (403); the desktop must re-pair.

10. Cloud → desktop projection latency

SLOTarget
Time from lock.credential.issued.v1 published in cloud → visible on a connected desktop's next pullp95 < 30 s
Time from desktop push of lock.credential.issued.local.v1 → canonical event publishedp95 < 5 s
Time from lock.credential.revoked.v1 published → mirrored on desktopp95 < 30 s

11. Anti-patterns

  • No LWW on KeyCredential.state. Last-write-wins is forbidden because two desktops issuing simultaneously for the same room would silently overwrite each other. The advisory lock + server-authoritative policy prevent this.
  • No replication of vendor_credentials, webhook_inbox, lock_audit to desktop. Vendor secrets stay in the cloud; audit immutability stays in the cloud.
  • No replication of vendorRef in any direction.
  • No client-decided revoke that overrides cloud state. Desktop revoke creates a local outbox revoked.local.v1 event; cloud confirms.

12. Cross-references