Skip to main content

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:

FieldValue
producedBy.servicenotification-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>
causationIdthe 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

SubjectRetention classPartition key
melmastoon.notification.requested.v1operational (1y)tenantId:notificationId
melmastoon.notification.scheduled.v1operational (1y)tenantId:notificationId
melmastoon.notification.dispatched.v1operational (1y)tenantId:notificationId
melmastoon.notification.delivered.v1operational (1y)tenantId:notificationId
melmastoon.notification.failed.v1operational (1y)tenantId:notificationId
melmastoon.notification.bounced.v1regulated (7y)tenantId:notificationId
melmastoon.notification.opened.v1operational (90d)tenantId:notificationId
melmastoon.notification.clicked.v1operational (90d)tenantId:notificationId
melmastoon.notification.opted_out.v1regulated (7y)tenantId:recipientId
melmastoon.notification.suppressed.v1regulated (7y)tenantId:suppressionId
melmastoon.notification.preferences.updated.v1regulated (7y)tenantId:recipientId
melmastoon.notification.template.published.v1regulated (7y)tenantId|platform:templateId
melmastoon.notification.template.archived.v1regulated (7y)tenantId|platform:templateId
melmastoon.notification.channel.health_changed.v1operational (90d)tenantId:channelId
melmastoon.notification.batch.completed.v1operational (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.

SubjectWhy we consumeAction
melmastoon.reservation.confirmed.v1booking confirmation + pre-arrival scheduleenqueue reservation.confirmed.{email,sms,whatsapp}; insert notification_scheduled for stay_start - 24h reminder
melmastoon.reservation.cancelled.v1cancellation notice; cancel pending scheduled sendsenqueue reservation.cancelled.{email,sms}; mark scheduled rows obsolete
melmastoon.reservation.modified.v1informationalenqueue reservation.modified.{email,whatsapp}
melmastoon.reservation.dates_changed.v1re-schedule remindersre-base pre_arrival schedule; emit notice
melmastoon.reservation.checked_in.v1welcomeenqueue reservation.welcome.{inapp,whatsapp}
melmastoon.reservation.checked_out.v1thank-you + invoice (T+24h)insert notification_scheduled for checked_out_at + 24h
melmastoon.lock_integration.key_credential.issued.v1mobile-key deliveryenqueue mobile_key.issued.{whatsapp,sms,email} per recipient pref; embed token reference
melmastoon.lock_integration.key_credential.revoked.v1inform guestenqueue mobile_key.revoked.sms when reason in {early_checkout,cancelled}
melmastoon.billing.invoice.generated.v1send invoice email with PDF attachmentenqueue billing.invoice.email; attach invoice PDF by reference
melmastoon.billing.subscription.payment_failed.v1dunning sequenceinsert 3 notification_scheduled rows (T+0, T+72h, T+168h) for tenant admins
melmastoon.billing.payment.captured.v1optional receiptenqueue billing.receipt.email if tenant policy enables it
melmastoon.iam.password.reset_requested.v1security emailenqueue iam.password_reset.email (security category — bypasses opt-out)
melmastoon.iam.session.suspicious_login.v1security email + smsenqueue iam.suspicious_login.{email,sms}
melmastoon.tenant.invitation.sent.v1onboard inviteeenqueue tenant.invitation.email
melmastoon.tenant.profile.updated.v1refresh ThemeTokens cacheinvalidate cache key
melmastoon.maintenance.work_order.assigned.v1vendor SMSenqueue maintenance.work_order.assigned.sms
melmastoon.ai.draft_content.ready.v1AI-drafted template/personalisationrun RegisterAIDraftedTemplateUseCase (HITL)
melmastoon.iam.user.deleted.v1GDPR/consent revocationmark 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 vN bump). 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 includes originalSubject, 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.