Consent Ledger Service — Event Schemas
Version: 1.0 Status: Draft Owner: Trust & Safety Last Updated: 2026-04-21 Companion: DOMAIN_MODEL · SYNC_CONTRACT · SECURITY_MODEL
All events are published to NATS JetStream via the transactional outbox pattern — every emitted event corresponds to a row in consent.outbox written in the same Postgres transaction as the source state change. Every event carries schemaVersion, eventId (UUIDv4), traceId (W3C trace context), and at (RFC 3339 UTC). Bodies are PII-minimal: MSISDN appears only as msisdnHash (sha256 with platform pepper) and, when policy permits, the masked msisdnMasked (+93701***). Raw MSISDN never traverses NATS.
1. Streams, subjects, retention
| Stream | Subjects | Retention | Replicas | Deduplication window |
|---|---|---|---|---|
CONSENT_EVENTS | consent.granted.v1, consent.revoked.v1, consent.erased.v1, consent.double_optin.initiated.v1, consent.double_optin.confirmed.v1, consent.double_optin.expired.v1, consent.stop_mo.received.v1, consent.ack_back.sent.v1 | 13 months (regulatory hot window) | 3 | 2 min |
CONSENT_DND | dnd.registry.synced.v1 | 365 days | 3 | 5 min |
CONSENT_AUDIT_OPS | consent.audit.chain_verified.v1, consent.audit.chain_broken.v1 | 365 days | 3 | 2 min |
CONSENT_POLICY | consent.policy.changed.v1, consent.keyword_catalog.changed.v1 | 365 days | 3 | 2 min |
CONSENT_ERASURE | consent.erasure.requested.v1, consent.erasure.completed.v1 | 7 years (regulatory; cold archive after 13 months) | 3 | 2 min |
All streams have a sibling *.deadletter subject. Consumers are durable with explicit ack per platform requirements PLT-REQ-008/009. JetStream cluster topology and replica placement follow ADR-0004 §3 (Kabul control-plane region).
2. Produced events
2.1 consent.granted.v1
Emitted when a ConsentRecord row is inserted with status = OPT_IN. One event per record (batch and double-opt-in confirmation each yield one per record).
{
"schemaVersion": "1",
"eventId": "0193abcd-…",
"tenantId": "11111111-2222-3333-4444-555555555555",
"recordId": "cn_01HZX7ABCD23YJ",
"msisdnHash": "8d4f...e91a",
"msisdnMasked": "+93701***",
"scope": "MARKETING",
"verificationMethod": "DOUBLE_OPT_IN",
"source": {
"type": "DOUBLE_OPT_IN",
"ref": "do_01HZX7DEFGH456",
"capturedAt": "2026-04-21T10:14:22.812Z"
},
"validFrom": "2026-04-21T10:14:22.812Z",
"validUntil": "2027-04-21T00:00:00Z",
"previousRecordId": null,
"traceId": "00-abc-def-01",
"at": "2026-04-21T10:14:22.900Z"
}
2.2 consent.revoked.v1
{
"schemaVersion": "1",
"eventId": "…",
"tenantId": "1111…",
"recordId": "cn_01HZX7XYZ123",
"previousRecordId": "cn_01HZX7ABCD23YJ",
"msisdnHash": "8d4f...e91a",
"msisdnMasked": "+93701***",
"scope": "MARKETING",
"revokedReason": "STOP_KEYWORD",
"revokedAt": "2026-04-21T11:00:00Z",
"source": {
"type": "STOP_MO",
"ref": "mo_01HZX7P0Q1RST",
"matchedKeyword": "stop",
"matchedLanguage": "EN",
"senderIdReceived": "ACMEBANK"
},
"policyApplied": "PER_TENANT",
"traceId": "00-abc-def-02",
"at": "2026-04-21T11:00:00.123Z"
}
When consent.policy.stop_scope = GLOBAL produces multiple events (one per affected tenant), all share the same source.ref so downstream consumers can deduplicate or correlate.
2.3 consent.erased.v1
{
"schemaVersion": "1",
"eventId": "…",
"erasureId": "er_01HZX7…",
"msisdnHash": "8d4f...e91a",
"tombstoneToken": "ts_01HZX7…",
"tenantsAffected": 4,
"recordsRedacted": 7,
"auditRowsRedacted": 23,
"nationalDndRetained": true,
"completedAt": "2026-05-21T08:00:00Z",
"traceId": "00-abc-def-03",
"at": "2026-05-21T08:00:00.500Z"
}
tenantId is intentionally absent; consumers treating per-tenant fan-out should look at downstream consent.revoked.v1 rows that also accompany the erasure.
2.4 consent.double_optin.initiated.v1
{
"schemaVersion": "1",
"eventId": "…",
"optinId": "do_01HZX7DEFGH456",
"tenantId": "1111…",
"msisdnHash": "8d4f...e91a",
"scope": "MARKETING",
"expiresAt": "2026-04-22T10:14:22Z",
"senderUsedForOptinSms": "ACMEBANK",
"traceId": "…",
"at": "2026-04-21T10:14:22Z"
}
2.5 consent.double_optin.confirmed.v1 / consent.double_optin.expired.v1
confirmed.v1:
{
"schemaVersion": "1",
"eventId": "…",
"optinId": "do_01HZX7DEFGH456",
"tenantId": "1111…",
"msisdnHash": "8d4f...e91a",
"scope": "MARKETING",
"recordId": "cn_01HZX7ABCD23YJ",
"confirmedAt": "2026-04-21T10:18:14Z",
"traceId": "…",
"at": "2026-04-21T10:18:14Z"
}
expired.v1: same shape minus recordId and confirmedAt, plus expiredAt.
2.6 consent.stop_mo.received.v1
{
"schemaVersion": "1",
"eventId": "…",
"moId": "mo_01HZX7P0Q1RST",
"msisdnHash": "8d4f...e91a",
"msisdnMasked": "+93701***",
"senderIdReceived": "ACMEBANK",
"matchedKeyword": "stop",
"matchedLanguage": "EN",
"matchedKeywordId": "kw_01HZX7…",
"tenantsRevoked": ["1111…"],
"policyApplied": "PER_TENANT",
"traceId": "…",
"at": "2026-04-21T11:00:00Z"
}
tenantsRevoked is an array because GLOBAL policy may revoke across many tenants in one event.
2.7 consent.ack_back.sent.v1
{
"schemaVersion": "1",
"eventId": "…",
"moId": "mo_01HZX7P0Q1RST",
"ackBackMessageId": "msg_01HZX7…",
"msisdnMasked": "+93701***",
"language": "EN",
"templateId": "tpl_ack_back_en_v3",
"lane": "P2_TRANSACTIONAL",
"traceId": "…",
"at": "2026-04-21T11:00:00.500Z"
}
2.8 dnd.registry.synced.v1
{
"schemaVersion": "1",
"eventId": "…",
"runId": "ddr_01HZX7…",
"sourceFeedHash": "sha256:bd…",
"sourceSignatureValid": true,
"addedCount": 1234,
"removedCount": 12,
"updatedCount": 287654,
"totalCount": 287978,
"durationMs": 87340,
"completedAt": "2026-04-21T03:01:27Z",
"at": "2026-04-21T03:01:27Z"
}
2.9 consent.audit.chain_verified.v1 / consent.audit.chain_broken.v1
chain_verified.v1:
{
"schemaVersion": "1",
"eventId": "…",
"verifierRunId": "cvr_01HZX7…",
"fromPartition": "consent_audit_2026_03",
"toPartition": "consent_audit_2026_04",
"rowsVerified": 4582011,
"durationMs": 91234,
"at": "2026-04-21T02:01:31Z"
}
chain_broken.v1 (CRITICAL):
{
"schemaVersion": "1",
"eventId": "…",
"verifierRunId": "cvr_01HZX7…",
"partition": "consent_audit_2026_04",
"firstBadSeq": 4837521,
"expectedPrevHash": "ab12…",
"actualPrevHash": "cd34…",
"auditId": "cna_01HZX7…",
"at": "2026-04-21T02:01:31Z"
}
2.10 consent.policy.changed.v1
{
"schemaVersion": "1",
"eventId": "…",
"policyKey": "consent.policy.stop_scope",
"previousValue": "PER_TENANT",
"newValue": "GLOBAL",
"changedBy": "user-uuid",
"approvedBy": "second-user-uuid",
"reason": "Regulator instruction 2026-Q2",
"effectiveAt": "2026-04-21T12:00:00Z",
"traceId": "…",
"at": "2026-04-21T11:55:00Z"
}
2.11 consent.keyword_catalog.changed.v1
{
"schemaVersion": "1",
"eventId": "…",
"action": "ADD" ,
"keywordId": "kw_01HZX7…",
"language": "PS",
"keyword": "<redacted>",
"tenantOverrideOf": null,
"actorUserId": "user-uuid",
"traceId": "…",
"at": "2026-04-21T09:00:00Z"
}
The keyword field appears redacted in INTERNAL log/event surfaces; the full text is available only via the admin REST API to authorised admins.
2.12 consent.erasure.requested.v1
{
"schemaVersion": "1",
"eventId": "…",
"erasureId": "er_01HZX7…",
"msisdnHash": "8d4f...e91a",
"requestedVia": "CITIZEN_PORTAL",
"requestedAt": "2026-04-21T13:00:00Z",
"slaDueAt": "2026-05-21T13:00:00Z",
"traceId": "…",
"at": "2026-04-21T13:00:00Z"
}
3. Consumed events
| Subject | Producer | Action |
|---|---|---|
sms.mo.inbound | channel-router-service (ultimate origin: smpp-connector MO) | STOP-keyword detection (UC-StopKeywordHandler in APPLICATION_LOGIC) |
auth.user.erased.v1 | auth-service | Cache invalidation; never deletes consent.records (citizen erasure flow is the only path that does) |
tenant.lifecycle.suspended.v1 | tenant-service | Drop tenant from in-process tenant cache; subsequent RecordConsent returns FAILED_PRECONDITION |
tenant.lifecycle.deleted.v1 | tenant-service | Soft-delete all that tenant's consent.records (mark replaced_by=DELETED); audit rows preserved |
Schema (consumed): sms.mo.inbound
{
"schemaVersion": "1",
"eventId": "…",
"moId": "mo_01HZX7P0Q1RST",
"msisdn": "+93701234567",
"senderIdReceived": "ACMEBANK",
"body": "STOP",
"encoding": "GSM7",
"language": "EN",
"smscReceivedAt": "2026-04-21T10:59:59Z",
"traceId": "…",
"at": "2026-04-21T11:00:00Z"
}
The
msisdnfield is the only place where the raw MSISDN appears on a NATS event in the consent flow. It enters through thesms.mo.inboundconsumer, is hashed inside this service, and never re-emitted inconsent.*events (which usemsisdnHashandmsisdnMasked).
4. Consumer contracts (by subscribing service)
| Service | Subjects | Action |
|---|---|---|
compliance-engine | consent.revoked.v1, consent.granted.v1 | Cache invalidation for CONSENT-rule cache |
notification-service | consent.revoked.v1, consent.granted.v1, consent.double_optin.confirmed.v1, consent.double_optin.expired.v1 | Tenant-portal notifications |
analytics-service | All consent.* and dnd.* | Long-term archival, dashboards, regulator reporting |
regulator-portal-service | consent.audit.chain_broken.v1, consent.audit.chain_verified.v1, dnd.registry.synced.v1 | SIEM forwarding to regulator (mirrored to ATRA SOC) |
billing-service | consent.revoked.v1 | Future: charge waiver for consent-blocked traffic that was previously billed |
routing-engine | consent.revoked.v1 | Local cache hot-evict |
consent-ledger-service (self) | sms.mo.inbound, auth.user.erased.v1, tenant.lifecycle.* | Self-consumed for use cases above |
5. PII & security rules for events
- Raw MSISDN MUST NOT appear in any
consent.*event. OnlymsisdnHash(sha256 with platform pepper) andmsisdnMasked(+CC NNN ***). - MO body is forbidden in
consent.stop_mo.received.v1. Only the matched keyword and language. Free-text false-positive feedback (CONS-US-011) is stored encrypted at rest and never published as an event. - All consent events are signed by the producer's RS256 key (per platform signing convention); critical consumers (regulator-portal-service, analytics-service archive) verify signatures.
- Event payloads classified CONFIDENTIAL-INTERNAL; no replication to data sinks outside Afghanistan without explicit Legal sign-off (per ADR-0004 §3 data residency).
- The
sourceFeedHashondnd.registry.synced.v1is the only field that allows external auditors to verify which ATRA file produced a given DND state without revealing per-MSISDN content.
6. Outbox pattern
All consent state changes write a row to consent.outbox in the same Postgres transaction as the aggregate change. The OutboxRelay worker (see APPLICATION_LOGIC §4) publishes to NATS with retry and ack:
- Retry: 3 attempts with 100 ms / 1 s / 5 s backoff.
- Permanent failure after 3 attempts → row stays unpublished;
ConsentOutboxBacklogalert fires whencount(unpublished) > 1000oroldest_unpublished_age > 60 s. - Idempotency on the consumer side via
eventId(NATS Msg-Id header).
This guarantees no events without a persisted state change and no unpublished state change for more than a few seconds.
7. Schema evolution
- Additive fields with default values are non-breaking within the same
schemaVersion. - Breaking changes bump to
consent.<topic>.v2.v1andv2coexist for ≥ 90 days; subscribers migrate beforev1is removed. - Enum values are additive only. Consumers MUST treat unknown enum values as
UNKNOWNand not fail. - Removal of a field requires a
v2bump and explicit consumer migration tracking in the Service Readiness checklist.