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
readAtis 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
updatedAtwins; if equal, break ties deterministically byupdatedBysort. - 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 Event | Delta Row |
|---|---|
notification.sent.v1 (channel=inapp) | upsert inapp row |
notification.delivered.v1 (channel=inapp) | upsert (status change) |
notification.opened.v1 / .clicked.v1 | no sync (server-only analytics) |
| manual scrub / expiry | delete row |
notification.preference.updated.v1 | upsert 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 < Biff∀d: A[d] ≤ B[d]and∃d: A[d] < B[d]→ B wins.- Else concurrent → field-level LWW using
updatedAttiebreaker.
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
| Operation | Offline | Online sync |
|---|---|---|
| Read feed | reads local SQLite | pull updates |
| Mark read | local timestamp + enqueue mutation | push on reconnect |
| Change preference | local update + enqueue | push merges, may bounce back a reset |
| Receive new notification | not 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
userIdin 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.