EVENT_SCHEMAS — notification-service
Sibling: DOMAIN_MODEL · APPLICATION_LOGIC · API_CONTRACTS · DATA_MODEL
Strategic anchors: 04 Event-Driven Architecture · standards/NAMING · standards/ERROR_CODES
All events follow the platform event subject grammar: melmastoon.<service>.<aggregate>.<verb-past-tense>.v<n>. Schemas live at event-schemas/melmastoon/notification/<aggregate>/<verb>.v<n>.json and are validated in CI on every PR. Any breaking change requires a new vN.
1. Common envelope
Every event is wrapped in the canonical EventEnvelope<T> from 04 §4. Notification-specific defaults:
| Field | Value |
|---|---|
producedBy.service | notification-service |
metadata.dataResidency | <tenant.region> (gcp-me-central1 or gcp-asia-south1) |
metadata.orderingKey | <tenantId>:<notificationId> (per-notification ordering); for template/preferences/channel events: <tenantId>:<aggregateId> |
causationId | the consumed event id when the notification was triggered by an upstream event; the originating notification id when emitting webhook-derived state events; absent for command-driven emissions (e.g., template.published.v1) |
Notification payloads carry only identifiers and small denormalised projections (channel, status, vendor metadata). They never carry rendered HTML, full templates, or attachments — large payloads stay in GCS, referenced by URI.
2. Subject taxonomy + retention + partition
| Subject | Retention class | Partition key |
|---|---|---|
melmastoon.notification.requested.v1 | operational (1y) | tenantId:notificationId |
melmastoon.notification.scheduled.v1 | operational (1y) | tenantId:notificationId |
melmastoon.notification.dispatched.v1 | operational (1y) | tenantId:notificationId |
melmastoon.notification.delivered.v1 | operational (1y) | tenantId:notificationId |
melmastoon.notification.failed.v1 | operational (1y) | tenantId:notificationId |
melmastoon.notification.bounced.v1 | regulated (7y) | tenantId:notificationId |
melmastoon.notification.opened.v1 | operational (90d) | tenantId:notificationId |
melmastoon.notification.clicked.v1 | operational (90d) | tenantId:notificationId |
melmastoon.notification.opted_out.v1 | regulated (7y) | tenantId:recipientId |
melmastoon.notification.suppressed.v1 | regulated (7y) | tenantId:suppressionId |
melmastoon.notification.preferences.updated.v1 | regulated (7y) | tenantId:recipientId |
melmastoon.notification.template.published.v1 | regulated (7y) | tenantId|platform:templateId |
melmastoon.notification.template.archived.v1 | regulated (7y) | tenantId|platform:templateId |
melmastoon.notification.channel.health_changed.v1 | operational (90d) | tenantId:channelId |
melmastoon.notification.batch.completed.v1 | operational (1y) | tenantId:batchId |
Retention semantics follow 04 §5. regulated events sink to BigQuery events_regulated.* (7-year hot); operational to events_operational.*. PII (recipient address) is never in the event body — only addressKindHash (sha256 of tenantSalt + lowercased(address)).
3. Published events — payload + JSON schema + example
3.1 melmastoon.notification.requested.v1
interface NotificationRequestedV1 {
notificationId: string; // ntf_…
tenantId: string; // tnt_…
templateKey: string; // e.g. 'reservation.confirmed.email'
templateVersionId: string; // tpv_…
templateSemver: string; // '1.4.2'
channel: 'email'|'sms'|'whatsapp'|'push'|'inapp'|'voice';
category: 'transactional'|'operational'|'security'|'reminder'|'marketing'|'system';
priority: 'urgent'|'normal'|'low';
locale: string; // BCP-47; the resolved locale, post-fallback
recipient: {
recipientId: string; // rcp_…
addressKindHash: string; // sha256:<hex>
identityRef?: { type: 'guest'|'user'|'vendor'; id: string };
};
scheduledFor?: string | null; // null for immediate
sourceEvent?: { id: string; type: string; producedAt: string };
batchId?: string; // dbt_… when part of a batch
aiProvenance?: AIProvenanceRef; // see AI_INTEGRATION
variablesHash: string; // sha256 over canonical-JSON of variables (no PII)
renderRef: { uri: string; checksum: string }; // GCS pointer to renderSnapshot.body
createdBy: { type: 'system'|'staff'; id: string };
}
Example
{
"envelope": {
"id": "01J4A8K…",
"subject": "melmastoon.notification.requested.v1",
"producedAt": "2026-04-22T15:32:18.231Z",
"producedBy": { "service": "notification-service", "version": "1.18.3" },
"tenantId": "tnt_01H3Z…",
"correlationId": "01J3Z…BOOKING",
"causationId": "01J3Z…RESCONFV1",
"metadata": { "orderingKey": "tnt_01H3Z…:ntf_01J4A…", "dataResidency": "gcp-asia-south1" }
},
"data": {
"notificationId": "ntf_01J4A…",
"tenantId": "tnt_01H3Z…",
"templateKey": "reservation.confirmed.email",
"templateVersionId": "tpv_01H…",
"templateSemver": "1.4.2",
"channel": "email",
"category": "transactional",
"priority": "normal",
"locale": "ps-AF",
"recipient": {
"recipientId": "rcp_01H…",
"addressKindHash": "sha256:9f1c4a…",
"identityRef": { "type": "guest", "id": "gst_01H3Z…AHMED" }
},
"scheduledFor": null,
"sourceEvent": { "id": "01J3Z…RESCONFV1", "type": "melmastoon.reservation.confirmed.v1", "producedAt": "2026-04-22T15:32:17.000Z" },
"variablesHash": "sha256:bb2…",
"renderRef": { "uri": "gs://melmastoon-notifications-prod/tnt_01H3Z…/rendered/2026/04/22/ntf_01J4A….html", "checksum": "sha256:1ab…" },
"createdBy": { "type": "system", "id": "notif.router" }
}
}
3.2 melmastoon.notification.scheduled.v1
interface NotificationScheduledV1 {
notificationId: string;
tenantId: string;
channel: ChannelKind;
scheduledFor: string; // ISO; absolute time
reason: 'quiet_hours'|'rate_limit'|'pre_arrival'|'post_stay'|'dunning'|'staff_request'|'whatsapp_template_pending';
originalRequestedAt: string;
}
3.3 melmastoon.notification.dispatched.v1
interface NotificationDispatchedV1 {
notificationId: string;
tenantId: string;
channel: ChannelKind;
attempt: { number: number; startedAt: string; latencyMs: number };
vendor: string;
vendorMessageId?: string;
outcome: 'accepted'; // dispatched is always accepted; rejections emit failed.v1
}
3.4 melmastoon.notification.delivered.v1
interface NotificationDeliveredV1 {
notificationId: string;
tenantId: string;
channel: ChannelKind;
vendor: string;
vendorMessageId: string;
deliveredAt: string;
providerMetadata?: Record<string, string>;
}
3.5 melmastoon.notification.failed.v1 (terminal)
interface NotificationFailedV1 {
notificationId: string;
tenantId: string;
channel: ChannelKind;
attempts: number;
reason:
| 'render_error'
| 'sender_id_missing'
| 'whatsapp_template_not_approved'
| 'rate_limit_exhausted'
| 'invalid_recipient'
| 'vendor_terminal_4xx'
| 'vendor_unreachable'
| 'timeout'
| 'channel_disabled'
| 'budget_exhausted'
| 'token_expired';
vendor?: string;
vendorErrorCode?: string;
vendorErrorMessage?: string; // truncated, no PII
failedAt: string;
}
3.6 melmastoon.notification.bounced.v1
interface NotificationBouncedV1 {
notificationId: string;
tenantId: string;
channel: ChannelKind; // typically email or sms
bounceType: 'hard'|'soft'|'complaint';
vendor: string;
vendorMessageId: string;
bouncedAt: string;
diagnostic?: string; // truncated; no full headers
triggeredSuppressionId?: string; // sup_… when this bounce caused a suppression
}
3.7 melmastoon.notification.opened.v1
interface NotificationOpenedV1 {
notificationId: string;
tenantId: string;
channel: 'email'; // for now only email open tracking
vendor: string;
openedAt: string;
userAgent?: string;
ipCountry?: string; // coarse — not full IP
}
3.8 melmastoon.notification.clicked.v1
interface NotificationClickedV1 {
notificationId: string;
tenantId: string;
channel: 'email'|'sms'|'whatsapp';
vendor: string;
url: string; // canonicalised, query stripped of tracking
clickedAt: string;
userAgent?: string;
ipCountry?: string;
}
3.9 melmastoon.notification.opted_out.v1
interface NotificationOptedOutV1 {
recipientId: string; // rcp_…
tenantId: string;
channel: ChannelKind;
scope: 'channel'|'channel_category';
category?: NotificationCategory; // when scope='channel_category'
source: 'user_link'|'staff'|'reply_stop'|'compliance';
occurredAt: string;
}
3.10 melmastoon.notification.suppressed.v1
interface NotificationSuppressedV1 {
suppressionId: string; // sup_…
tenantId: string;
channel: ChannelKind;
reason: 'hard_bounce'|'complaint'|'invalid_address'|'opt_out'|'manual'|'compliance'|'rate_limit';
addressKindHash: string;
triggeredByNotificationId?: string;
occurredAt: string;
expiresAt?: string; // for time-bound suppressions
}
3.11 melmastoon.notification.preferences.updated.v1
interface NotificationPreferencesUpdatedV1 {
recipientId: string;
tenantId: string;
changes: Array<{
field: 'locale'|'timezone'|'quietHours'|`channels.${ChannelKind}`|`marketingConsent.${ChannelKind}`;
before?: unknown;
after?: unknown;
}>;
source: 'self'|'staff'|'opt_out_token'|'compliance';
actor: { type: 'guest'|'user'|'system'; id: string };
occurredAt: string;
}
3.12 melmastoon.notification.template.published.v1
interface TemplatePublishedV1 {
templateId: string; // tpl_…
templateVersionId: string; // tpv_…
tenantId?: string; // omitted for platform-global templates
key: string;
channel: ChannelKind;
category: NotificationCategory;
semver: string;
publishedAt: string;
publishedBy: { type: 'user'; id: string };
source: 'platform'|'tenant'|'ai_drafted';
aiProvenance?: AIProvenanceRef; // present when source='ai_drafted'
approverUserId?: string; // mandatory when source='ai_drafted'
supersedesVersionId?: string;
}
3.13 melmastoon.notification.template.archived.v1
interface TemplateArchivedV1 {
templateId: string;
templateVersionId?: string; // when archiving a single version
tenantId?: string;
key: string;
archivedAt: string;
archivedBy: { type: 'user'; id: string };
reason?: string;
}
3.14 melmastoon.notification.channel.health_changed.v1
interface ChannelHealthChangedV1 {
channelId: string; // ch_…
tenantId: string;
channel: ChannelKind;
vendor: string;
status: 'active'|'degraded'|'down';
previousStatus: 'active'|'degraded'|'down';
reason: 'consecutive_failures'|'probe_failed'|'manual'|'recovered';
consecutiveFailures: number;
occurredAt: string;
}
3.15 melmastoon.notification.batch.completed.v1
interface BatchCompletedV1 {
batchId: string; // dbt_…
tenantId: string;
templateKey: string;
channel: ChannelKind;
totalsByStatus: { queued: number; dispatched: number; delivered: number; failed: number; suppressed: number };
startedAt: string;
completedAt: string;
}
4. JSON Schema example (notification.requested.v1)
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://schemas.melmastoon.com/notification/notification/requested.v1.json",
"title": "NotificationRequestedV1",
"type": "object",
"required": ["notificationId","tenantId","templateKey","templateVersionId","templateSemver","channel","category","priority","locale","recipient","variablesHash","renderRef","createdBy"],
"properties": {
"notificationId": { "type": "string", "pattern": "^ntf_[0-9A-HJKMNP-TV-Z]{26}$" },
"tenantId": { "type": "string", "pattern": "^tnt_[0-9A-HJKMNP-TV-Z]{26}$" },
"templateKey": { "type": "string", "minLength": 1, "maxLength": 200 },
"templateVersionId": { "type": "string", "pattern": "^tpv_[0-9A-HJKMNP-TV-Z]{26}$" },
"templateSemver": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:-[A-Za-z0-9.-]+)?$" },
"channel": { "type": "string", "enum": ["email","sms","whatsapp","push","inapp","voice"] },
"category": { "type": "string", "enum": ["transactional","operational","security","reminder","marketing","system"] },
"priority": { "type": "string", "enum": ["urgent","normal","low"] },
"locale": { "type": "string", "pattern": "^[a-z]{2,3}(-[A-Z][a-z]{3})?(-[A-Z]{2})?$" },
"recipient": {
"type": "object",
"required": ["recipientId","addressKindHash"],
"properties": {
"recipientId": { "type": "string", "pattern": "^rcp_[0-9A-HJKMNP-TV-Z]{26}$" },
"addressKindHash":{ "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" },
"identityRef": {
"type": "object",
"required": ["type","id"],
"properties": {
"type": { "type": "string", "enum": ["guest","user","vendor"] },
"id": { "type": "string" }
}
}
}
},
"scheduledFor": { "type": ["string","null"], "format": "date-time" },
"sourceEvent": {
"type": "object",
"required": ["id","type","producedAt"],
"properties": {
"id": { "type": "string" },
"type": { "type": "string" },
"producedAt": { "type": "string", "format": "date-time" }
}
},
"batchId": { "type": "string", "pattern": "^dbt_[0-9A-HJKMNP-TV-Z]{26}$" },
"aiProvenance": { "type": "object" },
"variablesHash": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" },
"renderRef": {
"type": "object",
"required": ["uri","checksum"],
"properties": {
"uri": { "type": "string", "format": "uri" },
"checksum": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }
}
},
"createdBy": {
"type": "object",
"required": ["type","id"],
"properties": {
"type": { "type": "string", "enum": ["system","staff"] },
"id": { "type": "string" }
}
}
},
"additionalProperties": false
}
5. Consumed events
notification-service is a fan-in subscriber for these subjects. Each subscription is named notif.router.<topic> so the consumer dedup table is per-topic.
| Subject | Why we consume | Action |
|---|---|---|
melmastoon.reservation.confirmed.v1 | booking confirmation + pre-arrival schedule | enqueue reservation.confirmed.{email,sms,whatsapp}; insert notification_scheduled for stay_start - 24h reminder |
melmastoon.reservation.cancelled.v1 | cancellation notice; cancel pending scheduled sends | enqueue reservation.cancelled.{email,sms}; mark scheduled rows obsolete |
melmastoon.reservation.modified.v1 | informational | enqueue reservation.modified.{email,whatsapp} |
melmastoon.reservation.dates_changed.v1 | re-schedule reminders | re-base pre_arrival schedule; emit notice |
melmastoon.reservation.checked_in.v1 | welcome | enqueue reservation.welcome.{inapp,whatsapp} |
melmastoon.reservation.checked_out.v1 | thank-you + invoice (T+24h) | insert notification_scheduled for checked_out_at + 24h |
melmastoon.lock_integration.key_credential.issued.v1 | mobile-key delivery | enqueue mobile_key.issued.{whatsapp,sms,email} per recipient pref; embed token reference |
melmastoon.lock_integration.key_credential.revoked.v1 | inform guest | enqueue mobile_key.revoked.sms when reason in {early_checkout,cancelled} |
melmastoon.billing.invoice.generated.v1 | send invoice email with PDF attachment | enqueue billing.invoice.email; attach invoice PDF by reference |
melmastoon.billing.subscription.payment_failed.v1 | dunning sequence | insert 3 notification_scheduled rows (T+0, T+72h, T+168h) for tenant admins |
melmastoon.billing.payment.captured.v1 | optional receipt | enqueue billing.receipt.email if tenant policy enables it |
melmastoon.iam.password.reset_requested.v1 | security email | enqueue iam.password_reset.email (security category — bypasses opt-out) |
melmastoon.iam.session.suspicious_login.v1 | security email + sms | enqueue iam.suspicious_login.{email,sms} |
melmastoon.tenant.invitation.sent.v1 | onboard invitee | enqueue tenant.invitation.email |
melmastoon.tenant.profile.updated.v1 | refresh ThemeTokens cache | invalidate cache key |
melmastoon.maintenance.work_order.assigned.v1 | vendor SMS | enqueue maintenance.work_order.assigned.sms |
melmastoon.ai.draft_content.ready.v1 | AI-drafted template/personalisation | run RegisterAIDraftedTemplateUseCase (HITL) |
melmastoon.iam.user.deleted.v1 | GDPR/consent revocation | mark Recipient.identityRef and addresses for crypto-shred |
For each consumed subject, a contract test (pact-style schema check) lives in tests/contracts/consumed/<subject>.test.ts. Drift between published and our expected shape fails the build of the consumer.
6. Compatibility & versioning
- Adding optional fields → minor (no
vNbump). Producers SHOULD start writing the new field; consumers MUST tolerate its absence. - Removing/renaming a field, changing semantics, narrowing an enum → breaking → publish
vN+1; both versions co-exist for ≥1 release; old version retained ≥1 year per 04 §6. - All consumed events parsed by a tolerant zod-schema with
.passthrough(); we never reject unknown fields.
7. Replay and DLQ
- Replay topic suffix per 04 §8:
…replay. Subscribers can re-read up to 7 days of operational events and 90 days of regulated events. - DLQ subjects per consumer:
melmastoon.dlq.notif.<consumer>. Each DLQ entry includesoriginalSubject,attempts,lastError,payload. Operator runbooks in FAILURE_MODES. - For our own published events, we do not own replay logic; consumers re-subscribe with their own cursors.