Skip to main content

Sync Contract

:::info Source Sourced from services/notification-service/SYNC_CONTRACT.md in the documentation repo. :::

Replication Scope

The notification-service participates in the offline-first sync protocol for a narrow, user-facing slice: the in-app notification feed and user preferences. Transactional notifications to external channels (email/SMS/push) are NOT replicated - those are server-side side effects. Webhook subscriptions, templates, and suppression lists are server-authoritative administrative data and not synced to user devices.

SyncRegistrations (registered at boot with sync-service)

[
{
service: "notification-service",
entityType: "InAppNotification",
conflictPolicy: "server_authoritative", // server assigns id, client cannot author
deltaProjector: "notifications.inapp.delta",
pushHandler: "notifications.inapp.markRead",
versionField: "updatedAt",
schemaRef: "https://schemas.ghasi.dev/sync/inapp-notification.v1.json"
},
{
service: "notification-service",
entityType: "NotificationPreference",
conflictPolicy: "lww", // last-writer-wins via vector clock
deltaProjector: "notifications.preferences.delta",
pushHandler: "notifications.preferences.patch",
versionField: "version",
schemaRef: "https://schemas.ghasi.dev/sync/notification-preference.v1.json"
}
]

Replicable Entities

InAppNotification (subset of Notification aggregate)

Projected shape on the wire (omits server-only fields):

interface InAppNotificationDelta {
id: ULID;
tenantId: TenantId;
userId: UserId;
templateKey: string;
category: NotificationCategory;
priority: 'low'|'normal'|'high'|'critical';
title: string; // rendered
bodyMarkdown: string; // rendered
iconUrl?: string;
actions: Array<{label: string; href: string}>;
createdAt: ISODate;
readAt: ISODate | null;
expiresAt: ISODate;
op: 'upsert' | 'delete'; // delete when expired/erased
lamport: number;
}

NotificationPreference (full aggregate)

interface NotificationPreferenceDelta {
tenantId: TenantId;
userId: UserId;
payload: NotificationPreference; // whole document
vectorClock: VectorClock;
op: 'upsert';
lamport: number;
}

Conflict Policies

InAppNotification: server_authoritative

  • Client cannot create or modify body/metadata.
  • Only readAt is client-mutable; all other fields server-only.
  • On conflict (server received reader's mark-read out of order), server applies read-semilattice: readAt := min(nonNull(client, server)) - earliest read timestamp wins (reading is monotonic).
  • Server-initiated deletes (expiry, GDPR scrub) propagate via op: 'delete'.

NotificationPreference: lww (last-writer-wins)

  • Each field compared via vector clock.
  • If vector clocks concurrent, server picks the one with the higher version number; if equal, higher updatedAt wins; if equal, break ties deterministically by updatedBy sort.
  • Regulatory fields (security channel cannot be off) are enforced server-side after merge; if client tries to disable regulated field, server resets it and returns conflicted resolution with resetFields.

Pull Projection (server → client)

Implements POST /sync/v1/pull handler registered with sync-service under scopes:

  • notifications.inapp.{userId}
  • notifications.preferences.{userId}

Algorithm:

input: { cursor: {lamport: L}, scope, limit }
steps:
1. authorize: scope matches caller userId
2. SELECT * FROM inapp_delta_projection
WHERE user_id = X AND lamport > L
ORDER BY lamport ASC
LIMIT limit
3. return { items, nextCursor: {lamport: max(L, maxItemLamport)} }

The inapp_delta_projection materialized view is maintained by a transactional trigger + NATS-driven projector - every write to notifications (channel='inapp') or status/readAt change appends a delta row with a monotonic Lamport clock from a per-user sequence.

Push Handler (client → server)

Registered with sync-service; sync-service calls these handlers when it receives mutations from clients.

notifications.inapp.markRead

handler: (mutation: LocalMutation) => Promise<ApplyResult>
input: { clientMutationId, entityId, payload: { readAt: ISODate }, baseVersion }
steps:
1. load notification (enforce tenant + user scope from mutation metadata)
2. if notification.readAt already set and earlier than payload.readAt:
return { applied: true, serverState: notification } // idempotent no-op
3. if notification.readAt null or later than payload.readAt:
update readAt = payload.readAt, emit domain side-effect (analytics)
4. record delta row (lamport++)
5. return { applied: true, serverState: updated }
errors:
NOT_FOUND → { applied: false, error: 'NOTIFICATION_NOT_FOUND' } (client drops)
WRONG_OWNER → { applied: false, error: 'FORBIDDEN' } (security flag)

notifications.preferences.patch

input: { clientMutationId, payload: NotificationPreferencePatch, baseVersion, vectorClock }
steps:
1. load current preferences
2. if baseVersion != current.version:
compute merged = lwwMerge(current, payload, vectorClock)
if merged differs from both:
create ConflictRecord (server-auto-resolved, inform client)
3. apply regulated-field guards (reset security.email to instant if client tried off)
4. persist with version++
5. emit notification.preference.updated.v1
6. return { applied: true, serverState: merged, resetFields }

Delta Projector

Each domain event relevant to sync triggers a delta projection entry:

Domain EventDelta Row
notification.sent.v1 (channel=inapp)upsert inapp row
notification.delivered.v1 (channel=inapp)upsert (status change)
notification.opened.v1 / .clicked.v1no sync (server-only analytics)
manual scrub / expirydelete row
notification.preference.updated.v1upsert preference row

Projection is idempotent - events have stable IDs and deltas are upserts by (entityId, lamport).

Vector Clock Semantics

For NotificationPreference only (the one field with concurrent write potential - a user editing prefs on two devices while offline):

vectorClock: Record<deviceId, counter>
  • Each write on device D increments vc[D].
  • On pull: server returns its vector clock.
  • On push: client includes its vector clock.
  • Merge comparison:
    • A < B iff ∀d: A[d] ≤ B[d] and ∃d: A[d] < B[d] → B wins.
    • Else concurrent → field-level LWW using updatedAt tiebreaker.

See sync-service VectorClock VO (F06 freeze).

Client-Side Data Shape

The offline mobile/web clients keep:

  • Last 200 in-app notifications (SQLite).
  • Preference document (single row).

No attachment bodies beyond markdown. No images cached beyond iconUrl (handled by generic image cache layer).

Web client outbox (Sync Center, EP-10)

The web application uses @ghasi/sync-client with OutboxDomain including notifications. Failed or offline PATCH /api/v1/notification-preferences calls are enqueued so they replay on reconnect with the same Idempotency-Key header, aligning with server-side idempotency_keys (see API_CONTRACTS).

Offline Read/Write Behavior

OperationOfflineOnline sync
Read feedreads local SQLitepull updates
Mark readlocal timestamp + enqueue mutationpush on reconnect
Change preferencelocal update + enqueuepush merges, may bounce back a reset
Receive new notificationnot possible offline (server pushes via WS when online)sync delta on next pull

Ordering Guarantees

  • Per-user, per-entityType: total order via per-user Lamport counter.
  • Across entityTypes: no cross-ordering guarantee needed.
  • Mark-read mutations are commutative (min-semilattice), so out-of-order delivery is safe.

Security Considerations

  • userId in mutation envelope must match the authenticated user (enforced by sync-service via token scope).
  • Device binding: mutations carry deviceId; server verifies device is currently registered to the user (via identity-service device registry).
  • Tamper detection: mutations signed with device-bound key (ed25519 from identity-service device registration).
  • Replay protection: (clientMutationId) uniqueness for 30 days.

Slice Delivery Plan

  • S0: Preference sync only (simple, low-volume).
  • S1: In-app notification sync (full feed replication for mobile offline).
  • S2+: Optimized delta compression for low-bandwidth markets (gzip + binary format).

Projection Performance

  • Delta projection writes happen synchronously in the same transaction as the notification write (no dual-write gap).
  • Projection table is narrow (~300 bytes/row) and partitioned by month.
  • Pull query returns in <50ms for p95 with indexes on (user_id, lamport).
  • Client batches mutations every 10s while offline; burst of 100 mutations handled by push handler in <500ms.