Skip to main content

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-service and smpp-connector), per-message blocking (compliance-engine enforces using consent verdicts as input), tenant identity (auth-service), national DND list authorship (owned by ATRA), citizen-portal frontend (owned by regulator-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.

FieldTypeNotes
consentIdcn_<ULID>Identity (externally exposed)
tenantIdUUIDv4FK → auth.accounts.tenant_id; RLS partition key
msisdnMsisdn VOE.164, validated; for Afghanistan must conform to +93[0-9]{9}
scopeenum ConsentScopeTRANSACTIONAL · MARKETING · OTP · EMERGENCY (extensible per US-CONS-005)
statusenum ConsentStatusOPT_IN · OPT_OUT · UNKNOWN · EXPIRED
sourceConsentSource VO{ type, ref, capturedAt, capturedIp?, capturedUserAgent? }
verificationMethodenum VerificationMethodDOUBLE_OPT_IN · KYC_AT_PURCHASE · WET_SIGNATURE_SCAN · BULK_IMPORT_ATTESTATION · TENANT_API · CITIZEN_PORTAL · STOP_MO
validFromInstantWhen the record began applying
validUntilInstant | nullOptional expiry (e.g., marketing consent with finite life under tenant policy)
revokedAtInstant | nullSet when status = OPT_OUT; never overwritten
revokedReasonenum RevokedReason | nullSTOP_KEYWORD · CITIZEN_PORTAL · TENANT_API · DOUBLE_OPT_IN_EXPIRED · ERASURE_REQUEST · NATIONAL_DND_OVERRIDE
replacedByconsentId | nullPointer to the record that superseded this one (records are immutable; mutations create new rows)
createdAt, updatedAtInstantupdatedAt only changes when the record is logically superseded

Invariants

  • A ConsentRecord is immutable once persisted; every state change yields a new record with replacedBy populated on the old row. The "current" row per (tenantId, msisdn, scope) is the one with replacedBy IS NULL.
  • A record with validUntil < now() is treated as EXPIRED for CheckConsent purposes regardless of status.
  • verificationMethod = DOUBLE_OPT_IN requires a confirmed DoubleOptin aggregate referenced via source.ref (its optinId); the application layer rejects the record otherwise.
  • revokedAt and revokedReason MUST be set if and only if status = OPT_OUT.
  • The (tenantId, msisdn, scope) triple is subject to Postgres Row-Level Security: a tenant session may read only records where tenantId = 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.

FieldTypeNotes
dndIddnd_<ULID>Identity
msisdnMsisdn VOE.164; uniqueness constraint
registeredAtInstantReported by ATRA in the source feed
categoryenum DndCategoryFULL_BLOCK · MARKETING_ONLY (per ATRA spec; extensible)
sourceFeedRunIdddr_<ULID>The DND sync run that introduced this row
lastSeenAtInstantRefreshed on every sync where the row is still present in the feed
removedAtInstant | nullSoft-removed when ATRA drops the row from the feed; preserved for audit

Invariants

  • A National DND match short-circuits CheckConsent to { allowed: false, reason: NATIONAL_DND } regardless of any tenant ConsentRecord (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 DndSyncWorker and explicit admin override may write. Citizen erasure does not purge a NationalDndEntry — 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.

FieldTypeNotes
auditIdcna_<ULID>Identity
seqbigintMonotonic per partition; supports replay verification
eventTypeenum ConsentAuditEventTypeRECORD_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
tenantIdUUIDv4 | nullNull for cross-tenant policy rows
msisdnHashbytes(32)sha256 of MSISDN with platform pepper; supports targeted query without storing raw MSISDN in audit
msisdnEncryptedbytes | nullAES-256-GCM ciphertext of MSISDN per SECURITY_MODEL §3; null after erasure
payloadJSONBEvent-specific snapshot (consent record, source, scope, etc.); PII-minimal
prevHashbytes(32)SHA-256 of the previous row's recordHash within the same partition; 0x00… for the first row
payloadHashbytes(32)sha256 of canonical serialisation of (eventType, tenantId, msisdnHash, payload, occurredAt)
recordHashbytes(32)sha256(payloadHashprevHash) — what the next row binds to
signingKeyIdtextKey version used for hashing/signing; supports key rotation
actorUserIdUUIDv4 | nullFor human-driven events
traceIdtextOTel trace
occurredAtInstantRFC 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 > 1 in a partition, prevHash[n] == recordHash[n-1]. The daily verifier reads each partition in seq order; any mismatch raises ConsentAuditChainBroken (CRITICAL).
  • Erasure preserves the chain. GDPR erasure (CONS-US-015) replaces msisdnEncrypted with null and rewrites payload.msisdn to a tombstone, then writes a new ERASURE_COMPLETED audit row. It does not modify payloadHash, prevHash, or recordHash of past rows. Verifiers can still confirm chain integrity over redacted fields because payloadHash was pinned at write time over the original (now-overwritten) payload bytes — the canonical serialisation includes a redactedFields[] 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).

FieldTypeNotes
keywordIdkw_<ULID>Identity
languageenum LanguageEN · DR (Dari) · PS (Pashto) · AR (Arabic). ISO 639-1 where defined.
keywordtextCase-folded form (Unicode NFKC); matched after the same normalisation on MO body
isPlatformDefaultbooleantrue for the catalog seeded per SERVICE_OVERVIEW §6.4; defaults cannot be removed (CONS-US-009)
tenantOverrideOfUUIDv4 | nullFor tenant-added variants; null for platform defaults
revokeActionenum StopKeywordActionREVOKE_TENANT_SCOPE (default) · REVOKE_GLOBAL (rare; only STOPALL and citizen-portal STOP-ALL invocations)
addedByUUIDv4Authoring admin
addedAt, updatedAt, deletedAtInstantSoft delete; defaults rejected from deletedAt set

Invariants

  • Platform-default keywords have tenantOverrideOf = null and isPlatformDefault = 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).

FieldTypeNotes
optinIddo_<ULID>Identity
tenantIdUUIDv4Owner
msisdnMsisdn VOTarget
scopeenum ConsentScopeScope being requested
statusenumPENDING · CONFIRMED · EXPIRED
confirmationTokentextSingle-use; HMAC of (optinId, msisdn, salt); URL-safe
expiresAtInstantDefault initiatedAt + 24h
initiatedAtInstant
confirmedAtInstant | nullSet on the GET callback
senderUsedForOptinSmstextThe sender ID used for the confirmation SMS — captured for audit traceability

Invariants

  • State transitions: PENDING → CONFIRMED (terminal) or PENDING → EXPIRED (terminal). No reverse transitions.
  • A CONFIRMED DoubleOptin MUST yield exactly one ConsentRecord insert with verificationMethod = DOUBLE_OPT_IN and source.ref = optinId.
  • The confirmation SMS itself is dispatched via channel-router-service on lane P2_TRANSACTIONAL and is exempt from consent gating because the subject is consent solicitation itself.

ErasureRequest

Tracks GDPR-equivalent right-to-erasure requests (CONS-US-015).

FieldTypeNotes
erasureIder_<ULID>Identity
msisdnHashbytes(32)Used to find associated rows without storing the raw MSISDN
requestedViaenumCITIZEN_PORTAL · REGULATOR_PORTAL · SUPPORT_TICKET
requestedAtInstant
slaDueAtInstantDefault requestedAt + 30 days
statusenumPENDING · IN_PROGRESS · COMPLETED · REJECTED
rejectedReasontext | nullE.g., MSISDN failed verification
completedAtInstant | null
tombstoneTokentextDeterministic hash placeholder used in consent.records.msisdn and consent.audit.payload after redaction

Invariants

  • NationalDndEntry rows 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

VOShapeInvariants
MsisdnstringE.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)
ConsentScopeTRANSACTIONAL | MARKETING | OTP | EMERGENCYClosed set in v1; extensible via consent.tenant_scope_config for tenant-defined scopes that resolve to one of the canonical four for routing
ConsentStatusOPT_IN | OPT_OUT | UNKNOWN | EXPIREDUNKNOWN is an inferred status returned by CheckConsent when no record exists; never persisted
VerificationMethodenum (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}
CheckConsentReasonALLOWED_TENANT_RECORD | ALLOWED_DEFAULT_TRANSACTIONAL | BLOCKED_NO_RECORD | BLOCKED_OPT_OUT | BLOCKED_EXPIRED | BLOCKED_NATIONAL_DND | CONSENT_UNKNOWNReturned by gRPC; consumers map to user-facing messages
LanguageEN | DR | PS | ARLanguage 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.

EventTrigger
consent.granted.v1RecordConsent (REST or gRPC), DoubleOptin confirmation, BulkImport row accepted
consent.revoked.v1RevokeConsent (REST or gRPC), STOP-keyword detection, citizen-portal revoke, expiry processor (validUntil reached), NATIONAL_DND_OVERRIDE
consent.erased.v1ErasureRequest completed
consent.double_optin.initiated.v1POST /v1/consent/double-opt-in/initiate accepted
consent.double_optin.confirmed.v1Confirmation token redeemed
consent.double_optin.expired.v1TTL reached without confirmation
consent.stop_mo.received.v1STOP keyword match in MO body
consent.ack_back.sent.v1Localised ack-back enqueued via channel-router
consent.audit.chain_verified.v1Daily chain-integrity verifier finishes a run successfully
consent.audit.chain_broken.v1Daily chain-integrity verifier detects a mismatch (CRITICAL)
dnd.registry.synced.v1DND sync worker successfully merges a feed
consent.policy.changed.v1consent.policy.stop_scope changed (dual-control workflow)
consent.keyword_catalog.changed.v1STOP-keyword catalog mutation

5. Consumed events

EventProducerReason
sms.mo.inboundchannel-router-service (ultimate origin: smpp-connector MO)STOP-keyword detection
auth.user.erased.v1auth-serviceWhen 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.v1tenant-serviceDrop tenant from cache; future RecordConsent calls rejected

6. Global invariants

  • Fail-closed on CheckConsent. When the Redis hot cache misses and Postgres is unavailable, CheckConsent returns { 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 NationalDndEntry match short-circuits CheckConsent; only lane P0_EMERGENCY (Common Alerting Protocol bridge) may proceed, and that override is recorded in audit with reason NATIONAL_DND_BYPASS_P0_EMERGENCY.
  • Scope is first-class. OPT_OUT from MARKETING does not affect OTP or TRANSACTIONAL. STOPALL revokes 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.audit is 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 = GLOBAL or the citizen-portal STOP-ALL is used.
  • Body confidentiality. MO body is recorded in consent.audit.payload only 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. consent schema 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.