Skip to main content

SYNC_CONTRACT — notification-service

Sibling: DATA_MODEL · APPLICATION_LOGIC · DOMAIN_MODEL

Strategic anchors: 02 §8 Sync & Offline · 05 §10 Sync API · ADR-0003 Electron Offline-First

The Electron backoffice desktop must keep doing useful work during connectivity loss. For notifications, the desktop is mostly a read surface (front-desk staff want to see "did the booking confirmation email reach the guest?") and a light command surface (re-send confirmation, mark in-app notification read, draft template). The hard work — actually sending email/SMS/WhatsApp — is online-only: the desktop has no SMTP, no Twilio key, and no WhatsApp Business credentials. Offline staff actions are queued locally and pushed to the server when connectivity returns.

This document declares what is replicated, the conflict policies, and the push/pull semantics that sync-service enforces on our behalf.


1. Replicated aggregates and scope

AggregateReplicated?Scope (per device)
Notification (read-only projection)yesAll notifications for the assigned property in the last 30 days + all queued/scheduled/dispatched notifications irrespective of age
DeliveryAttempt (last 5 per notification)yesSent with parent Notification
Notification (in-app feed for the staff user themselves)yesAll inapp notifications addressed to the logged-in staff user, last 90 days
Template + active TemplateVersionyesAll active platform-global templates + all tenant overrides (small dataset; ~hundreds of rows)
TemplateVersion (drafts authored on this device)yes (writable)Local drafts the user is editing
RecipientPreferences (for the staff user only)yesThe current user's own preferences
Recipient (display projection)yesRecipients referenced by replicated Notification rows
SuppressionRecordyes (read-only)Active suppressions for the assigned property — supports staff "why didn't this go out?" diagnostics
Channel configsyes (read-only)Per-tenant channel configs (no credentials)
WebhookInbound, ChannelCredential, OptOutToken, DispatchBatchnoServer-only
outbox, consumed_events, idempotency_keysnoServer-internal

Walk-in send flow: a front-desk staff scanning a guest at the desk can type a custom note and tap "Send via WhatsApp"; the device queues a notifications.create command in the local outbox. While offline, the staff sees a "pending sync" badge; once back online, the command is pushed and the actual send happens server-side.

No PII on disk in plaintext: replicated Recipient rows on the device only contain displayName and addressKindHash; the human-readable address (email/phone) is fetched on-demand via authenticated REST and is never written to local SQLite.


2. Conflict policy per field-class

Field classPolicyRationale
Notification.status, dispatchedAt, deliveredAt, failedAt, attempts[*]server_authoritativeOnly the server sees vendors; client never authors send state
Notification.readAt (in-app)max-of by timestamp; null < non-nullMultiple devices may mark read; once read, stays read
Notification.renderSnapshot, templateVersionId, sender, aiProvenanceserver_authoritativeFrozen at enqueue; client never modifies
Template and `TemplateVersion(status='active''archived')`server_authoritative
TemplateVersion(status='draft') authored locallyclient_lww until first push, then server_authoritativeLocal drafting must work offline; once persisted, the server owns versioning
RecipientPreferences (own user)lww by updatedAt with diff merge on channels.* mapMultiple personal devices update the same row
SuppressionRecordserver_authoritative (read-only on device)Compliance state is platform-owned
Channel configsserver_authoritativeOperational config is staff-only via online API

There is no LWW for status on Notification. Any client attempting to push a status transition is rejected with MELMASTOON.GENERAL.ILLEGAL_TRANSITION — the device may only push commands (mark_read, resend, create_local_command).


3. Pull contract

POST /sync/v1/pull HTTP/1.1
Authorization: Bearer <jwt>
X-Tenant-Id: tnt_…
X-Property-Id: ppt_…
X-Device-Id: dev_…
Content-Type: application/json

{
"since": "<opaque cursor or null on first sync>",
"aggregates": [
"notification",
"delivery_attempt",
"template",
"template_version",
"recipient",
"recipient_preferences",
"suppression",
"channel"
],
"maxBatch": 500,
"scopes": {
"notification": {
"windowDaysPast": 30,
"includeOpenStates": true,
"myInappWindowDaysPast": 90
}
}
}

Response:

{
"cursor": "eyJ0Ijo…",
"hasMore": false,
"changes": {
"notification": [
{ "op": "upsert", "id": "ntf_01J4A…", "version": 4, "data": { /* NotificationDTO without raw addresses */ } }
],
"delivery_attempt": [
{ "op": "append", "id": "dat_01J…", "parentId": "ntf_01J4A…", "data": { /* … */ } }
],
"template": [
{ "op": "upsert", "id": "tpl_01J…", "version": 6, "data": { /* … */ } }
],
"template_version": [
{ "op": "upsert", "id": "tpv_01J…", "version": 1, "data": { /* … */ } }
],
"recipient_preferences": [
{ "op": "upsert", "id": "rpf_01J…", "version": 3, "data": { /* … */ } }
]
},
"deletions": {
"notification": ["ntf_01J3A…"],
"template_version": ["tpv_01J3…"]
}
}

Pages are bounded by maxBatch and an internal byte cap (~1.5 MB); the client follows hasMore=true with successive cursors. Cursors are opaque ({ topic, lastChangeId, version }) and stable across server restarts.

Bandwidth-aware variant (X-Sync-Profile: minimal): the server returns only id, status, channel, templateKey, deliveredAt, failedAt for each Notification — used on slow networks.


4. Push contract

The device pushes commands, not raw rows. The sync-service translates each command into a REST call against notification-service (with proper Idempotency-Key):

POST /sync/v1/push HTTP/1.1

{
"deviceClock": "2026-04-22T15:32:18.211Z",
"commands": [
{
"id": "cmd_01J4Z…",
"kind": "notifications.create",
"issuedAt": "2026-04-22T15:30:01.122Z",
"tenantId": "tnt_01H…",
"payload": {
"templateKey": "reservation.confirmed.email.resend",
"category": "operational",
"channel": "email",
"locale": "ps-AF",
"recipient": { "by": "guestId", "guestId": "gst_01H…AHMED" },
"variables": { "reservationCode": "MELM-2026-04-001234" }
}
},
{
"id": "cmd_01J4Z…2",
"kind": "notifications.markRead",
"issuedAt": "2026-04-22T15:30:05.310Z",
"tenantId": "tnt_01H…",
"payload": { "notificationIds": ["ntf_01J4A…", "ntf_01J4A…2"] }
},
{
"id": "cmd_01J4Z…3",
"kind": "templates.saveDraft",
"issuedAt": "2026-04-22T15:30:09.000Z",
"tenantId": "tnt_01H…",
"payload": { "templateId": "tpl_01J…", "draft": { /* TemplateVersion draft body */ } }
}
]
}

Response:

{
"results": [
{ "commandId": "cmd_01J4Z…", "status": "accepted", "remoteId": "ntf_01J4B…", "version": 1 },
{ "commandId": "cmd_01J4Z…2", "status": "accepted" },
{ "commandId": "cmd_01J4Z…3", "status": "rejected", "code": "MELMASTOON.GENERAL.PRECONDITION_FAILED", "message": "Template version was published by another user; refresh and re-edit." }
],
"serverClock": "2026-04-22T15:32:18.901Z"
}

Each commandId is the local idempotency key; replays from the device after a network hiccup are deduped server-side.

4.1 Allowed commands

KindNotes
notifications.createAd-hoc send; subject to all server-side guards (preference, suppression, rate limit, WhatsApp template, sender id)
notifications.resendResend a previous notification (sibling created)
notifications.markReadIn-app feed; bulk allowed
notifications.preferences.update.selfUpdate own preferences
notifications.preferences.update.recipientStaff updating a guest's preferences (requires staff role + recorded consent)
templates.saveDraftPersist a local draft as a server-side TemplateVersion(status='draft')
templates.publishPublish a draft (HITL gate enforced; rejects when source='ai_drafted' without approverUserId)
templates.archiveArchive a draft authored by self
suppressions.releaseRestricted to OWNER/BILLING_ADMIN; high-friction confirmation in UI

Push commands NOT permitted from desktop:

  • channel/credentials mutations (online-only via bff-backoffice-service direct API)
  • webhook ingestion (vendor → server only)
  • batch creation (online-only — needs server-side segment resolution)

5. Causal ordering and clock skew

  • The server treats device clocks as advisory; it stamps serverClock on accepted commands.
  • For LWW fields (readAt, RecipientPreferences.*), the resolver compares (serverClock, deviceClock) lexically; ties resolved by device id (deterministic).
  • Drift > 5 min between device and server triggers a soft warning in the UI ("Your device clock seems off; your sync may be delayed"). Drift > 30 min blocks pushes for command kinds that are timestamp-sensitive (e.g., notifications.create with scheduledFor in the past).

6. Selective sync and entitlements

A device only replicates notifications for properties the user has staff:notifications.read on. The pull endpoint enforces this via JWT claims; cross-property leakage is rejected with MELMASTOON.TENANT.MISMATCH. When a user's property assignment changes, the next pull returns deletions for the previously-visible-but-now-out-of-scope rows.


7. Local SQLite schema (Electron)

The desktop uses SQLite (better-sqlite3) with FTS5 for the in-app feed search. Mirror tables (subset of DATA_MODEL §3):

CREATE TABLE local_notifications (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
template_key TEXT NOT NULL,
channel TEXT NOT NULL,
category TEXT NOT NULL,
status TEXT NOT NULL,
recipient_id TEXT NOT NULL,
recipient_display_name TEXT,
address_kind_hash TEXT NOT NULL,
subject_preview TEXT,
body_preview TEXT,
scheduled_for TEXT,
queued_at TEXT NOT NULL,
delivered_at TEXT,
read_at TEXT,
failed_at TEXT,
failure_reason TEXT,
remote_version INTEGER NOT NULL,
local_dirty INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX local_notifications_inapp_unread ON local_notifications(channel, read_at) WHERE channel='inapp';
CREATE VIRTUAL TABLE local_notifications_fts USING fts5(subject_preview, body_preview, recipient_display_name, content='local_notifications', content_rowid='rowid');

PII never lands here in plaintext: only the display name (typically already public) and a hash of the address are stored.


8. Failure modes during sync

ScenarioBehaviour
Device offline at command issuanceCommand persisted in local outbox; UI shows "queued" badge; retried on reconnect with exponential backoff
Push rejected with MELMASTOON.SYNC.IDEMPOTENCY_KEY_REUSEDTreat as accepted; reconcile by reading the canonical record
Push rejected with MELMASTOON.GENERAL.PRECONDITION_FAILEDSurface to user with refresh affordance; re-pull then prompt user to re-author
Push rejected with MELMASTOON.NOTIFICATION.RATE_LIMITEDShow toast; client retries after Retry-After
Push rejected with MELMASTOON.AI.HITL_REQUIREDUI guides user to approve in-place; once approved, command re-issued
Pull cursor invalid (server reset)Client falls back to a full sync respecting current scope
Local SQLite corruptionDrop replica; full re-pull; keep local outbox commands for replay

9. Cross-references