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/v1protocol.
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
| Aggregate | Cloud → Desktop (pull) | Desktop → Cloud (push) | Conflict policy on push |
|---|---|---|---|
KeyCredential (active set for the property's near horizon) | Yes | Yes (only provisional: true rows minted offline) | server_authoritative on issuance state |
KeyCredentialAttempt | No (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 property | Yes | No (desktop reads only; pairing wizard hits cloud REST directly) | n/a |
MasterKey for the staff currently bound to this device | Yes | Yes (only provisional: true minted offline) | server_authoritative on issuance state |
KeyKindPolicy (per property) | Yes | No | n/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) | No | Yes | append_only |
VendorAdapter, VendorCredential, WebhookInbound, lock_audit | No — never replicated to desktop | No | n/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 KeyCredential — server_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:
- Validates the offline cert (signature, not expired, not in CRL). On failure →
outcome: 'rejected'; the desktop must locally revoke the credential at the encoder. - Loads the matching reservation from
reservation-service. - Decision matrix:
| Reservation state at reconcile | Provisional state | Outcome |
|---|---|---|
confirmed, dates match | active | materialized: mint server key_id, persist with provisional: false, vendor reconciliation if cloud-side adapter exists, emit canonical lock.credential.issued.v1 |
confirmed, dates extended | active | materialized + updated: persist, then run update saga to extend validUntil |
confirmed, dates contracted | active | materialized + updated: persist with new validUntil; vendor adapter shortens window |
confirmed, room changed | active | re-issued: revoke provisional at vendor; new credential for new room; cancel old |
cancelled | active | revoked: revoke at vendor (best-effort), persist state=revoked with metadata.wasProvisional=true; emit lock.credential.revoked.v1 (reason='cancellation') |
no_show | active | suspended: persist state=suspended per policy |
| not found (race / wrong device) | active | rejected: 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 KeyCredentialAttempt — append_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 MasterKey — server_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 EncoderSession — append_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
sincecursors per aggregate; cloud returnscursorsto use on the next pull. - If the desktop's
sinceis older than the operational retention window (24 h hot history), the cloud returnsMELMASTOON.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_outboxrows (still pushable) but drops the local mirror ofkey_credentials_localand re-populates it from the rebase response.
8. Per-batch size limits
| Limit | Value | Behavior on breach |
|---|---|---|
| Push events per batch | 500 | MELMASTOON.SYNC.PAYLOAD_TOO_LARGE (413); split |
| Push payload bytes | 1 MiB | same |
| Pull horizon credentials | 5 000 | server-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
Devicerow iniam-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_ed25519keyed byserial. - Push events that reference
tenantId ≠ deviceTenantIdare rejected withMELMASTOON.SYNC.DEVICE_UNBOUND(403); the desktop must re-pair.
10. Cloud → desktop projection latency
| SLO | Target |
|---|---|
Time from lock.credential.issued.v1 published in cloud → visible on a connected desktop's next pull | p95 < 30 s |
Time from desktop push of lock.credential.issued.local.v1 → canonical event published | p95 < 5 s |
Time from lock.credential.revoked.v1 published → mirrored on desktop | p95 < 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_auditto desktop. Vendor secrets stay in the cloud; audit immutability stays in the cloud. - No replication of
vendorRefin any direction. - No client-decided revoke that overrides cloud state. Desktop revoke creates a local outbox
revoked.local.v1event; cloud confirms.