Consent Ledger Service — Domain Model
Version: 1.0 Status: Draft Owner: Trust & Safety Last Updated: 2026-04-21 Companion: SERVICE_OVERVIEW · APPLICATION_LOGIC · DATA_MODEL · EVENT_SCHEMAS · API_CONTRACTS · SECURITY_MODEL
1. Bounded Context
Trust & Safety / Subscriber Consent Authority. consent-ledger-service owns every recorded fact about whether a subscriber (identified by an E.164 MSISDN per ITU-T E.164) has granted, denied, or revoked permission for a tenant to send SMS in a particular scope. It is the single source of truth that compliance-engine (CONSENT rule type), routing-engine (last-mile veto, see routing-engine SERVICE_OVERVIEW) and sms-firewall-service (inbound MT veto) call before any downstream dispatch.
Ledger membership and its hash-chained audit trail constitute the regulator-defensible evidence that the platform claims to honour TCPA-equivalent and GDPR Article 7 consent obligations. A failure to keep this evidence consistent is a national-level compliance incident.
The context boundary is drawn such that:
- Inside the boundary: consent records, scope catalog, double-opt-in lifecycle, STOP-keyword catalog and matcher, National DND mirror, ack-back dispatch decision, append-only hash-chained audit, erasure-token lifecycle, citizen self-service consent inspection.
- Outside the boundary: subscriber identity / KYC (owned by MNO), MO transport (owned by
channel-router-serviceandsmpp-connector), per-message blocking (compliance-engineenforces using consent verdicts as input), tenant identity (auth-service), national DND list authorship (owned by ATRA), citizen-portal frontend (owned byregulator-portal-service/ public web).
2. Aggregates
ConsentRecord
The atomic unit of consent. One record represents one tenant's claim of permission to send to one MSISDN in one scope. Per CONS-US-005 the primary key is (tenantId, msisdn, scope) — consent is never platform-wide-boolean.
| Field | Type | Notes |
|---|---|---|
consentId | cn_<ULID> | Identity (externally exposed) |
tenantId | UUIDv4 | FK → auth.accounts.tenant_id; RLS partition key |
msisdn | Msisdn VO | E.164, validated; for Afghanistan must conform to +93[0-9]{9} |
scope | enum ConsentScope | TRANSACTIONAL · MARKETING · OTP · EMERGENCY (extensible per US-CONS-005) |
status | enum ConsentStatus | OPT_IN · OPT_OUT · UNKNOWN · EXPIRED |
source | ConsentSource VO | { type, ref, capturedAt, capturedIp?, capturedUserAgent? } |
verificationMethod | enum VerificationMethod | DOUBLE_OPT_IN · KYC_AT_PURCHASE · WET_SIGNATURE_SCAN · BULK_IMPORT_ATTESTATION · TENANT_API · CITIZEN_PORTAL · STOP_MO |
validFrom | Instant | When the record began applying |
validUntil | Instant | null | Optional expiry (e.g., marketing consent with finite life under tenant policy) |
revokedAt | Instant | null | Set when status = OPT_OUT; never overwritten |
revokedReason | enum RevokedReason | null | STOP_KEYWORD · CITIZEN_PORTAL · TENANT_API · DOUBLE_OPT_IN_EXPIRED · ERASURE_REQUEST · NATIONAL_DND_OVERRIDE |
replacedBy | consentId | null | Pointer to the record that superseded this one (records are immutable; mutations create new rows) |
createdAt, updatedAt | Instant | updatedAt only changes when the record is logically superseded |
Invariants
- A
ConsentRecordis immutable once persisted; every state change yields a new record withreplacedBypopulated on the old row. The "current" row per(tenantId, msisdn, scope)is the one withreplacedBy IS NULL. - A record with
validUntil < now()is treated asEXPIREDforCheckConsentpurposes regardless ofstatus. verificationMethod = DOUBLE_OPT_INrequires a confirmedDoubleOptinaggregate referenced viasource.ref(itsoptinId); the application layer rejects the record otherwise.revokedAtandrevokedReasonMUST be set if and only ifstatus = OPT_OUT.- The
(tenantId, msisdn, scope)triple is subject to Postgres Row-Level Security: a tenant session may read only records wheretenantId = current_setting('app.current_tenant_id')::uuid.
NationalDndEntry
A mirror of one row of the ATRA National Do-Not-Disturb registry. Sourced from the regulator (CONS-US-001), authoritative for the platform-wide block.
| Field | Type | Notes |
|---|---|---|
dndId | dnd_<ULID> | Identity |
msisdn | Msisdn VO | E.164; uniqueness constraint |
registeredAt | Instant | Reported by ATRA in the source feed |
category | enum DndCategory | FULL_BLOCK · MARKETING_ONLY (per ATRA spec; extensible) |
sourceFeedRunId | ddr_<ULID> | The DND sync run that introduced this row |
lastSeenAt | Instant | Refreshed on every sync where the row is still present in the feed |
removedAt | Instant | null | Soft-removed when ATRA drops the row from the feed; preserved for audit |
Invariants
- A National DND match short-circuits
CheckConsentto{ allowed: false, reason: NATIONAL_DND }regardless of any tenantConsentRecord(per SERVICE_OVERVIEW §6.3). The only exception is lane =P0_EMERGENCY(CBC bridge), which carries its own opt-out semantics; emergency override is audit-logged. - The aggregate is read-only for tenants and citizens; only the
DndSyncWorkerand explicit admin override may write. Citizen erasure does not purge aNationalDndEntry— the regulator-provided record overrides the citizen's removal request.
ConsentAuditEntry
Append-only, hash-chained record of every consent state transition. The aggregate that makes the platform regulator-defensible.
| Field | Type | Notes |
|---|---|---|
auditId | cna_<ULID> | Identity |
seq | bigint | Monotonic per partition; supports replay verification |
eventType | enum ConsentAuditEventType | RECORD_CREATED · RECORD_REVOKED · RECORD_EXPIRED · DOUBLE_OPTIN_INITIATED · DOUBLE_OPTIN_CONFIRMED · DOUBLE_OPTIN_EXPIRED · STOP_MO_RECEIVED · ACK_BACK_SENT · DND_SYNC_APPLIED · ERASURE_REQUESTED · ERASURE_COMPLETED · STOP_KEYWORD_FALSE_POSITIVE_REPORTED · POLICY_CHANGED · KEYWORD_CATALOG_CHANGED |
tenantId | UUIDv4 | null | Null for cross-tenant policy rows |
msisdnHash | bytes(32) | sha256 of MSISDN with platform pepper; supports targeted query without storing raw MSISDN in audit |
msisdnEncrypted | bytes | null | AES-256-GCM ciphertext of MSISDN per SECURITY_MODEL §3; null after erasure |
payload | JSONB | Event-specific snapshot (consent record, source, scope, etc.); PII-minimal |
prevHash | bytes(32) | SHA-256 of the previous row's recordHash within the same partition; 0x00… for the first row |
payloadHash | bytes(32) | sha256 of canonical serialisation of (eventType, tenantId, msisdnHash, payload, occurredAt) |
recordHash | bytes(32) | sha256(payloadHash ‖ prevHash) — what the next row binds to |
signingKeyId | text | Key version used for hashing/signing; supports key rotation |
actorUserId | UUIDv4 | null | For human-driven events |
traceId | text | OTel trace |
occurredAt | Instant | RFC 3339 |
Invariants
- Append-only at the database level. A Postgres rule rejects UPDATE and DELETE on
consent.audit(see DATA_MODEL §3.1). Retention is enforced by partition pruning (drop) followed by cold archive to S3, never by row deletion. - Hash-chain continuity. For any row
n > 1in a partition,prevHash[n] == recordHash[n-1]. The daily verifier reads each partition inseqorder; any mismatch raisesConsentAuditChainBroken(CRITICAL). - Erasure preserves the chain. GDPR erasure (CONS-US-015) replaces
msisdnEncryptedwithnulland rewritespayload.msisdnto a tombstone, then writes a newERASURE_COMPLETEDaudit row. It does not modifypayloadHash,prevHash, orrecordHashof past rows. Verifiers can still confirm chain integrity over redacted fields becausepayloadHashwas pinned at write time over the original (now-overwritten) payload bytes — the canonical serialisation includes aredactedFields[]marker so verifiers know to skip them on replay.
StopKeyword
A configurable opt-out keyword recognised in inbound MO bodies, multi-language (CONS-US-007, CONS-US-009).
| Field | Type | Notes |
|---|---|---|
keywordId | kw_<ULID> | Identity |
language | enum Language | EN · DR (Dari) · PS (Pashto) · AR (Arabic). ISO 639-1 where defined. |
keyword | text | Case-folded form (Unicode NFKC); matched after the same normalisation on MO body |
isPlatformDefault | boolean | true for the catalog seeded per SERVICE_OVERVIEW §6.4; defaults cannot be removed (CONS-US-009) |
tenantOverrideOf | UUIDv4 | null | For tenant-added variants; null for platform defaults |
revokeAction | enum StopKeywordAction | REVOKE_TENANT_SCOPE (default) · REVOKE_GLOBAL (rare; only STOPALL and citizen-portal STOP-ALL invocations) |
addedBy | UUIDv4 | Authoring admin |
addedAt, updatedAt, deletedAt | Instant | Soft delete; defaults rejected from deletedAt set |
Invariants
- Platform-default keywords have
tenantOverrideOf = nullandisPlatformDefault = true. Defaults cannot be deleted (a Postgres trigger rejects soft-delete of those rows); they may only be supplemented by tenant additions. - All matching is performed on Unicode NFKC-normalised, lowercase, whitespace-collapsed input (see APPLICATION_LOGIC §1 UC-StopKeywordHandler).
DoubleOptin
The lifecycle tracking object for tenant-initiated double-opt-in flows (CONS-US-003).
| Field | Type | Notes |
|---|---|---|
optinId | do_<ULID> | Identity |
tenantId | UUIDv4 | Owner |
msisdn | Msisdn VO | Target |
scope | enum ConsentScope | Scope being requested |
status | enum | PENDING · CONFIRMED · EXPIRED |
confirmationToken | text | Single-use; HMAC of (optinId, msisdn, salt); URL-safe |
expiresAt | Instant | Default initiatedAt + 24h |
initiatedAt | Instant | |
confirmedAt | Instant | null | Set on the GET callback |
senderUsedForOptinSms | text | The sender ID used for the confirmation SMS — captured for audit traceability |
Invariants
- State transitions:
PENDING → CONFIRMED(terminal) orPENDING → EXPIRED(terminal). No reverse transitions. - A
CONFIRMEDDoubleOptinMUST yield exactly oneConsentRecordinsert withverificationMethod = DOUBLE_OPT_INandsource.ref = optinId. - The confirmation SMS itself is dispatched via
channel-router-serviceon laneP2_TRANSACTIONALand is exempt from consent gating because the subject is consent solicitation itself.
ErasureRequest
Tracks GDPR-equivalent right-to-erasure requests (CONS-US-015).
| Field | Type | Notes |
|---|---|---|
erasureId | er_<ULID> | Identity |
msisdnHash | bytes(32) | Used to find associated rows without storing the raw MSISDN |
requestedVia | enum | CITIZEN_PORTAL · REGULATOR_PORTAL · SUPPORT_TICKET |
requestedAt | Instant | |
slaDueAt | Instant | Default requestedAt + 30 days |
status | enum | PENDING · IN_PROGRESS · COMPLETED · REJECTED |
rejectedReason | text | null | E.g., MSISDN failed verification |
completedAt | Instant | null | |
tombstoneToken | text | Deterministic hash placeholder used in consent.records.msisdn and consent.audit.payload after redaction |
Invariants
NationalDndEntryrows are not purged on erasure (regulator override).- After completion, querying
CheckConsent(tenantId, originalMsisdn, scope)returns{ allowed: false, reason: CONSENT_UNKNOWN }because the tenant record now keys on the tombstone token, not the original MSISDN.
3. Value Objects
| VO | Shape | Invariants |
|---|---|---|
Msisdn | string | E.164 (+[1-9][0-9]{6,14}); platform-default validation also requires Afghanistan country code +93 for inbound MO (foreign roamers handled by per-tenant policy) |
ConsentScope | TRANSACTIONAL | MARKETING | OTP | EMERGENCY | Closed set in v1; extensible via consent.tenant_scope_config for tenant-defined scopes that resolve to one of the canonical four for routing |
ConsentStatus | OPT_IN | OPT_OUT | UNKNOWN | EXPIRED | UNKNOWN is an inferred status returned by CheckConsent when no record exists; never persisted |
VerificationMethod | enum (see ConsentRecord) | Source-system attestation strength influences hold-queue routing in compliance-engine |
ConsentSource | { type, ref, capturedAt, capturedIp?, capturedUserAgent? } | type ∈ {WEB_FORM, MOBILE_APP, USSD, IVR, BULK_IMPORT, TENANT_API, DOUBLE_OPT_IN, CITIZEN_PORTAL, KYC_AT_PURCHASE, WET_SIGNATURE_SCAN} |
CheckConsentReason | ALLOWED_TENANT_RECORD | ALLOWED_DEFAULT_TRANSACTIONAL | BLOCKED_NO_RECORD | BLOCKED_OPT_OUT | BLOCKED_EXPIRED | BLOCKED_NATIONAL_DND | CONSENT_UNKNOWN | Returned by gRPC; consumers map to user-facing messages |
Language | EN | DR | PS | AR | Language of MO content; used by STOP-keyword matcher and ack-back template selection |
HashChainPosition | { partition, seq, recordHash } | Cursor used by the chain verifier |
4. Domain Events (produced)
Detailed schemas in EVENT_SCHEMAS.md.
| Event | Trigger |
|---|---|
consent.granted.v1 | RecordConsent (REST or gRPC), DoubleOptin confirmation, BulkImport row accepted |
consent.revoked.v1 | RevokeConsent (REST or gRPC), STOP-keyword detection, citizen-portal revoke, expiry processor (validUntil reached), NATIONAL_DND_OVERRIDE |
consent.erased.v1 | ErasureRequest completed |
consent.double_optin.initiated.v1 | POST /v1/consent/double-opt-in/initiate accepted |
consent.double_optin.confirmed.v1 | Confirmation token redeemed |
consent.double_optin.expired.v1 | TTL reached without confirmation |
consent.stop_mo.received.v1 | STOP keyword match in MO body |
consent.ack_back.sent.v1 | Localised ack-back enqueued via channel-router |
consent.audit.chain_verified.v1 | Daily chain-integrity verifier finishes a run successfully |
consent.audit.chain_broken.v1 | Daily chain-integrity verifier detects a mismatch (CRITICAL) |
dnd.registry.synced.v1 | DND sync worker successfully merges a feed |
consent.policy.changed.v1 | consent.policy.stop_scope changed (dual-control workflow) |
consent.keyword_catalog.changed.v1 | STOP-keyword catalog mutation |
5. Consumed events
| Event | Producer | Reason |
|---|---|---|
sms.mo.inbound | channel-router-service (ultimate origin: smpp-connector MO) | STOP-keyword detection |
auth.user.erased.v1 | auth-service | When a tenant user is erased upstream, recompute caches; never erase consent rows on this signal alone (consent erasure runs through the citizen ErasureRequest flow) |
tenant.lifecycle.suspended.v1 | tenant-service | Drop tenant from cache; future RecordConsent calls rejected |
6. Global invariants
- Fail-closed on
CheckConsent. When the Redis hot cache misses and Postgres is unavailable,CheckConsentreturns{ allowed: false, reason: CONSENT_UNKNOWN }. The cost of a single false block is acceptable; the cost of a systemic violation of consent law is not. See FAILURE_MODES §FM-01. - National DND overrides everything except P0 emergency. A
NationalDndEntrymatch short-circuitsCheckConsent; only laneP0_EMERGENCY(Common Alerting Protocol bridge) may proceed, and that override is recorded in audit with reasonNATIONAL_DND_BYPASS_P0_EMERGENCY. - Scope is first-class.
OPT_OUTfromMARKETINGdoes not affectOTPorTRANSACTIONAL.STOPALLrevokes across all scopes for the matching tenant; citizen-portal "STOP ALL" revokes across all tenants. - Records are immutable; audit is append-only. Mutations produce new rows. Hash-chained
consent.auditis reject-on-UPDATE/DELETE at the DB level. Erasure preserves the chain via field redaction marker. - Multi-language STOP defaults are sealed. Default keywords (English:
STOP,STOPALL,UNSUBSCRIBE,QUIT,END,CANCEL; Dari:بند,لغو,پایان; Pashto:بنديدل,لغو,ودرول; Arabic:إلغاء,وقف,إيقاف) cannot be removed; only added to per tenant. - Per-tenant default for STOP propagation. A subscriber-typed STOP revokes consent only for the tenant whose sender ID was used in the original MT, unless
consent.policy.stop_scope = GLOBALor the citizen-portal STOP-ALL is used. - Body confidentiality. MO body is recorded in
consent.audit.payloadonly with the matched STOP keyword span — never the full body. Free-text false-positive feedback (CONS-US-011) is encrypted at rest. - Consent never leaves Afghanistan.
consentschema rows, audit chain, and Redis cache reside in the Kabul region (per ADR-0004 §3 multi-region partitioning). Cross-region replication is permitted only between Afghan regions; no offshore replica.