Consent Ledger Service — Application Logic
Version: 1.0 Status: Draft Owner: Trust & Safety Last Updated: 2026-04-21 Companion: DOMAIN_MODEL · API_CONTRACTS · DATA_MODEL · EVENT_SCHEMAS
1. Use Cases
UC-CheckConsent (gRPC handler — hot path)
Trigger: compliance-engine (CONSENT rule type), routing-engine (last-mile veto), sms-firewall-service (inbound MT veto) call ConsentLedgerService/CheckConsent for every candidate dispatch.
Input: CheckConsentRequest { tenantId, msisdn, scope, traceId }
Output: CheckConsentResponse { allowed: bool, reason: CheckConsentReason, recordId?: string, cachedAt: timestamp }
SLA: P50 ≤ 2 ms · P95 ≤ 5 ms · P99 ≤ 20 ms · Sustained 5,000 RPS per replica (CONS-US-004 §6).
Steps:
-
Input validation. Reject with
INVALID_ARGUMENTiftenantIdis not UUIDv4,msisdnis not E.164, orscopeis not in the canonical enum. DefaultscopetoTRANSACTIONALwhen omitted (CONS-US-005 §3). -
Composite cache lookup. Compute
cacheKey = consent:state:{tenantId}:{msisdnHash}:{scope}wheremsisdnHash = sha256(msisdn || pepper)[:32hex]. Issue a single RedisMGETfor the tenant key and the National-DND keyconsent:dnd:{msisdnHash}. P95 budget for this step: ≤ 2 ms. -
National DND short-circuit. If
dnd:{msisdnHash}is present and the request scope is not lane =P0_EMERGENCY, return{ allowed: false, reason: BLOCKED_NATIONAL_DND, cachedAt }immediately; record metricconsent_check_dnd_block_total{scope}. P0_EMERGENCY proceeds through the rest of the pipeline with an audit-row reasonNATIONAL_DND_BYPASS_P0_EMERGENCY. -
Tenant cache hit. If
consent:state:…returns a serialised{ status, validUntil, recordId, computedAt }, evaluate it:status = OPT_INand (validUntil IS NULLorvalidUntil > now()) →{ allowed: true, reason: ALLOWED_TENANT_RECORD }.status = OPT_OUT→{ allowed: false, reason: BLOCKED_OPT_OUT }.status = EXPIREDorvalidUntil ≤ now()→{ allowed: false, reason: BLOCKED_EXPIRED }. Incrementconsent_check_cache_hits_total.
-
Cache miss → Postgres read. Execute the prepared query
SELECT consent_id, status, valid_untilFROM consent.recordsWHERE tenant_id = $1 AND msisdn_hash = $2 AND scope = $3AND replaced_by IS NULLLIMIT 1;Acquire a connection from the read-replica-routed pool; the read-replica is acceptable because
CheckConsentreads the eventually-consistent current row (gap window ≤ 200 ms; bounded by replication lag alert).- Row found → fill cache
SET consent:state:… EX 300and return verdict from §4 logic. - No row found → apply scope default policy:
TRANSACTIONALreturns{ allowed: true, reason: ALLOWED_DEFAULT_TRANSACTIONAL }(transactional traffic is opt-out world per Afghan regulator interpretation);MARKETING,OTP,EMERGENCYrequire explicit opt-in →{ allowed: false, reason: BLOCKED_NO_RECORD }.
- Row found → fill cache
-
Fail-closed branch. If both Redis and Postgres are unavailable (connection error, deadline exceeded), return
{ allowed: false, reason: CONSENT_UNKNOWN }. Incrementconsent_check_failclosed_total. See FAILURE_MODES §FM-01. -
No audit on read.
CheckConsentdoes not write toconsent.audit; the consumer (compliance-engine) writes its owncompliance.audit.v1row referencing the recordId. This keeps the hot path under 5 ms.
Error codes:
| gRPC status | Condition |
|---|---|
OK | Verdict returned |
INVALID_ARGUMENT | Bad MSISDN, unknown scope, malformed tenantId |
UNAVAILABLE | Caller should retry with backoff (transient Postgres/Redis) |
INTERNAL | Unhandled exception (logged, not exposed) |
Caller fail-closed posture.
compliance-engineandrouting-engineMUST treat any non-OKresponse asallowed: falsefor non-emergency lanes.CheckConsentitself returnsallowed: falseon its own internal fail-closed branch — but downstream services must defend in depth.
UC-RecordConsent
Trigger: Tenant calls RecordConsent (gRPC, CONS-US-016) or POST /v1/consent/records (REST, CONS-US-002), or a DoubleOptin confirmation handler invokes the service-internal command.
Input: { tenantId, msisdn, scope, source: { type, ref, capturedAt }, verificationMethod, validUntil? }
SLA: P95 ≤ 80 ms (write path includes audit chain insert).
Steps:
-
Input validation. Tenant scope check (the caller's
tenantIdclaim must match the bodytenantId). MSISDN E.164. Scope in catalog.verificationMethod = DOUBLE_OPT_INrequiressource.refto point to aCONFIRMEDDoubleOptinrow. -
Tenant suspension check. Reject with
FAILED_PRECONDITIONiftenant.lifecycle.status = SUSPENDED(cached fromtenant.lifecycle.suspended.v1). -
Idempotency. If header
Idempotency-Keymatches a row inconsent.idempotency_keysfrom the past 24 h, return the prior response. -
Begin transaction.
- Look up the current row for
(tenantId, msisdnHash, scope, replaced_by IS NULL). If present and equivalent (samestatus, samevalidUntil), no-op return — emit no event. - Insert new
consent.recordsrow withstatus = OPT_IN. If a prior row existed, set itsreplaced_by = new.consent_id. - Compute the audit hash chain row (see §1
UC-WriteAudit) and insert intoconsent.auditforeventType = RECORD_CREATED. - Insert into
consent.outboxforconsent.granted.v1. - Commit.
- Look up the current row for
-
Cache fill.
SET consent:state:{tenantId}:{msisdnHash}:{scope}with the new state, TTL 300 s. Cache fill outside the transaction is acceptable because the nextCheckConsentcache miss would re-read from Postgres anyway. -
Outbox relay (background) publishes to
CONSENT_EVENTS.consent.granted.v1. -
Return
{ recordId, createdAt }.
UC-RevokeConsent
Trigger: Tenant RevokeConsent (gRPC, CONS-US-017) or DELETE /v1/consent/records/:msisdn, or citizen-portal one-click revoke (CONS-US-006), or STOP-keyword handler (UC-StopKeywordHandler), or validUntil expiry processor.
Steps:
- Validate inputs as in UC-RecordConsent.
- Begin transaction.
- Find the current row for
(tenantId, msisdnHash, scope, replaced_by IS NULL). Ifstatus = OPT_OUTalready → no-op. - Insert a new row with
status = OPT_OUT,revokedAt = now(),revokedReasonfrom the trigger. - Set
replaced_byon the old row. - Insert audit row
RECORD_REVOKEDwith payload{ previousRecordId, newRecordId, source }. - Insert outbox row
consent.revoked.v1. - Commit.
- Find the current row for
- Cache invalidation.
DEL consent:state:{tenantId}:{msisdnHash}:{scope}so the nextCheckConsenthits Postgres and gets the new row. Optionally pre-warm. - Return
{ recordId, revokedAt }.
When the policy consent.policy.stop_scope = GLOBAL is in effect and the trigger is STOP, the handler iterates over all tenant rows for the MSISDN and revokes each. The bulk path uses a single transaction with a row-fetch in chunks of 200 to bound lock duration.
UC-StopKeywordHandler (NATS consumer)
Trigger: Inbound MO event on subject sms.mo.inbound, produced by channel-router-service after SMPP-MO acknowledgement.
SLA: End-to-end MO → consent revoke + ack-back queued ≤ 2 s P95 (regulator expectation: subscriber should perceive STOP as immediate).
Steps:
-
Deserialize the event. Required fields:
{ moId, msisdn, body, language?, senderIdReceived, receivedAt, traceId }.senderIdReceivedis the platform sender ID the subscriber typed STOP at; we revoke the tenant whose sender ID it is. -
Normalize body. Apply Unicode NFKC, lowercase, collapse whitespace, trim. The output is the matcher input.
-
Detect language. If
languageis not provided bychannel-router, run a lightweight script-detection heuristic (Arabic-script vs Latin-script; Arabic-script defaults to PS / DR / AR via per-keyword catalog match — see UC-MultilingualMatching §2 below). -
Match against
consent.stop_keywords.- Load the per-language keyword set from process memory (refreshed by
KeywordCatalogReloadWorkerevery 60 s; see DEPLOYMENT_TOPOLOGY §2). - First-match wins: exact match against the normalised body or the first whitespace-separated token (
STOP NOWmatchesSTOP). STOPALLand the platform-defaultSTOPfor citizen-portal STOP-ALL invocations haverevokeAction = REVOKE_GLOBAL. All others default toREVOKE_TENANT_SCOPE.- No match → ACK the NATS message, increment
consent_stop_mo_no_match_total, do nothing else.
- Load the per-language keyword set from process memory (refreshed by
-
Resolve target tenant. Reverse-look the
senderIdReceivedto the owning tenant viaauth.sender_id_registry. If the sender ID is not registered (orphan MO), audit-logSTOP_MO_RECEIVEDwithtenantId = nulland ACK; this is a low-priority anomaly. -
Apply policy. If
consent.policy.stop_scope = GLOBALand the matched keyword'srevokeAction = REVOKE_TENANT_SCOPE, escalate toREVOKE_GLOBAL. Audit-row reason:POLICY_GLOBAL_OVERRIDE. -
Invoke UC-RevokeConsent. With
source.type = STOP_MO,source.ref = moId. ForREVOKE_GLOBAL, iterate over all tenant rows for the MSISDN. -
Schedule ack-back (CONS-US-008). Look up the language-specific template from
consent.ack_back_templates. Construct an MT request:{"tenantId": "PLATFORM","lane": "P2_TRANSACTIONAL","senderId": "<senderIdReceived>","to": "<msisdn>","body": "<localised template>","metadata": { "consentAckBack": true, "moId": "<moId>" },"skipConsent": true}Publish on
sms.outbound.requestvia NATS. TheskipConsent: trueflag is honoured bycompliance-engineonly when the producer isconsent-ledger-service(signed payload, mesh identity verified). One-shot guarantee: theconsentAckBackflag prevents re-entry on the subscriber's next STOP within a 24 h dedup window. -
Audit and emit events.
STOP_MO_RECEIVED,RECORD_REVOKED(one per affected tenant scope),ACK_BACK_SENT. Outbox-relayedconsent.revoked.v1,consent.stop_mo.received.v1,consent.ack_back.sent.v1. -
ACK the NATS delivery. Failure to complete steps 4–9 leaves the message un-ACKed → JetStream redelivery (≤ 3 attempts; then dead-letter on
sms.mo.deadletterwith reasonconsent_stop_processor_failed).
UC-DndSync (cron worker)
Trigger: Cron 0 3 * * * Asia/Kabul (CONS-US-001). Distributed Redis lock consent:lock:dnd_sync (TTL 30 min) ensures multi-replica safety.
Steps:
- Fetch. Pull the latest DND list from ATRA via SFTP (
sftp://atra-dnd.gov.af/dnd/latest.csv) or, where ATRA exposes one, HTTPS endpoint with mTLS client cert from Vault PKI. Verify upstream signature (PGP signature accompanying the CSV; key fingerprint stored inconsent.dnd_sync_config). - Parse. Streaming CSV parse; reject the run if more than 5% of rows fail validation. Fields:
msisdn,registered_at,category. - Diff. Stream the parsed feed and compare against
consent.dnd_registry:- Rows in feed and not in DB → INSERT.
- Rows in DB and not in feed → set
removed_at = now()(soft removal). - Rows in both →
UPDATE last_seen_at = now().
- Cache fan-out. For added rows:
SET consent:dnd:{msisdnHash} EX 86400. For removed rows:DEL. Use Redis pipelining (chunks of 1,000) to avoid a slow loop. - Audit. Insert one summary
DND_SYNC_APPLIEDaudit row withpayload = { added, removed, total, sourceFeedRunId, sourceSignatureValid }. - Emit
dnd.registry.synced.v1with{ runId, addedCount, removedCount, total, completedAt, durationMs }. - Mark lastSyncAt on
consent.dnd_sync_runs. AlertConsentDndStale(HIGH) fires ifnow() - lastSyncAt > 24h.
Manual override POST /v1/admin/consent/dnd/resync runs the same procedure on demand under the same lock.
UC-DoubleOptIn
Initiate (CONS-US-003 §1):
POST /v1/consent/double-opt-in/initiate { msisdn, scope }.- Validate inputs; reject if a
PENDINGrow already exists for(tenantId, msisdn, scope)within last 60 min (rate-limit). - Generate
optinId(do_<ULID>) andconfirmationToken(HMAC-SHA256 of(optinId, msisdn, salt); URL-safe base64; 256-bit entropy). - Insert
consent.double_optinsrow withstatus = PENDING,expiresAt = now() + 24h. - Construct opt-in SMS body using tenant template, embedding
https://opt-in.ghasi.gov.af/c/{token}. - Publish to
sms.outbound.requestlane =P2_TRANSACTIONAL,metadata.doubleOptinId = optinId,skipConsent = true(the message itself is consent solicitation; signed byconsent-ledger-servicemesh identity). - Insert audit
DOUBLE_OPTIN_INITIATED. Emitconsent.double_optin.initiated.v1. - Return
{ optinId, expiresAt }.
Confirm (CONS-US-003 §2):
GET /v1/consent/double-opt-in/confirm?token=…(citizen-facing; rate-limited at Kong: 60 req/min per IP).- HMAC-verify the token; look up the matching
optinId. - If
status != PENDINGornow() > expiresAt→ render the appropriate page (already-confirmed,expired). - Begin transaction:
- Update
consent.double_optinstoCONFIRMED, confirmedAt = now(). - Invoke UC-RecordConsent with
verificationMethod = DOUBLE_OPT_IN, source.ref = optinId. - Insert audit
DOUBLE_OPTIN_CONFIRMED. Insert outboxconsent.granted.v1andconsent.double_optin.confirmed.v1.
- Update
- Render the localised confirmation page.
Expiry processor (cron, every 5 min):
UPDATE consent.double_optins SET status='EXPIRED' WHERE status='PENDING' AND expires_at < now() RETURNING optin_id.- For each, insert audit
DOUBLE_OPTIN_EXPIRED, outboxconsent.double_optin.expired.v1.
UC-Erasure (CONS-US-015)
Initiate:
POST /v1/consent/erasure?msisdn=from citizen-portal after MSISDN-OTP verification (handled by citizen-portal;consent-ledger-servicevalidates the OTP receipt token by callingauth-service.VerifyOtpReceipt).- Insert
consent.erasure_requests { erasureId, msisdnHash, requestedVia, slaDueAt = now() + 30d }. - Audit
ERASURE_REQUESTED. Emitconsent.erasure.requested.v1.
Process (background ErasureProcessor, every 1 h):
- Fetch
PENDINGrows. Acquire row-level advisory lock per erasure to prevent double processing. - For the target MSISDN:
- Compute
tombstoneToken = sha256("erasure:" || msisdnHash || pepper). - For all
consent.recordswithmsisdn_hash = msisdnHash, UPDATEmsisdn = tombstoneToken, msisdn_encrypted = NULL. - For all
consent.auditrows withmsisdn_hash = msisdnHash, UPDATEmsisdn_encrypted = NULLand overwritepayload.msisdn = "<erased>". Appendredacted_fields = ["payload.msisdn", "msisdn_encrypted"]to the row metadata. Do not modifypayload_hash,prev_hash, orrecord_hash. - National-DND row remains untouched (regulator override per CONS-US-015 §3).
- Compute
- Insert audit
ERASURE_COMPLETED. Emitconsent.erased.v1. - Invalidate Redis:
DEL consent:state:*:{msisdnHash}:*. - Update
consent.erasure_requests SET status='COMPLETED', completedAt=now().
UC-WriteAudit (internal)
Used by all write use cases.
- Begin a Postgres transaction (the same as the parent state mutation).
- Acquire the partition's
seqadvisory lock to serialise inserts within a partition (SELECT pg_advisory_xact_lock(hashtext(partition_name))). - Read the previous row's
record_hashfor that partition (SELECT record_hash FROM consent.audit WHERE partition = $1 ORDER BY seq DESC LIMIT 1). For the first row of a partition,prev_hash = repeat('00', 32)::bytea. - Compose the canonical payload (see SECURITY_MODEL §3.4 for canonicalisation rules — sorted keys, Unicode NFC, RFC 8785 JSON Canonicalization Scheme).
- Compute
payload_hash = sha256(canonical_bytes)andrecord_hash = sha256(payload_hash || prev_hash). - Insert. Commit with the parent.
Failure of the audit insert aborts the parent transaction — there is no consent state change without a corresponding audit row.
UC-VerifyAuditChain (cron, daily 02:00 Asia/Kabul)
- For each partition active in the last 24 h, walk rows ordered by
seq. - Recompute
prev_hashshould equal previous row'srecord_hash. Recomputepayload_hashfrom the (possibly redacted) canonical payload, applying theredacted_fieldsmarkers. - On any mismatch, insert
consent.auditroweventType = AUDIT_INTEGRITY_BROKENwith the offending range; emitconsent.audit.chain_broken.v1(CRITICAL); page Trust & Safety on-call. - On success, emit
consent.audit.chain_verified.v1and updateconsent.audit_verifier_runs.last_success_at.
UC-BulkImport (CONS-US-018)
- Tenant uploads CSV via
POST /v1/consent/bulk-import(multipart). Signed S3 pre-signed URL pattern is preferred for files > 1 MB; the endpoint then accepts a JSON body referencing the upload. - Compute SHA-256 of the file as
import_id. - Validate header columns:
msisdn,scope,source_ref,captured_at. - Stream-parse rows, validating each:
- Reject rows with bad MSISDN, unknown scope, captured_at > now or > 5 years old, missing source_ref.
- Accepted rows enter UC-RecordConsent with
verificationMethod = BULK_IMPORT_ATTESTATION, source.type = BULK_IMPORT, source.ref = "{import_id}:row{N}".
- Process in chunks of 500 within a single transaction per chunk (audit chain locking is per-partition; chunks must respect
seqordering). - Return JSON report
{ accepted, rejected, errors: [{ row, reason }] }. - Audit
BULK_IMPORT_COMPLETEDwith the original CSV hash for tamper-evidence (regulator can retrieve the file from the audit reference and re-hash to verify provenance).
UC-CitizenInspection (CONS-US-006)
- Citizen authenticates via MSISDN-OTP (handled by
auth-serviceOtpStartFlow→OtpVerifyFlow; result is a short-lived JWT scopedcitizen:consent:readand bound to the verified MSISDN). GET /v1/consent/audit?msisdn=enforcesjwt.msisdn = query.msisdn(constant-time comparison).- Service queries:
SELECT cr.tenant_id, t.display_name, cr.scope, cr.status, cr.valid_from, cr.sourceFROM consent.records crJOIN auth.tenants t ON t.tenant_id = cr.tenant_idWHERE cr.msisdn_hash = $1 AND cr.replaced_by IS NULLORDER BY t.display_name, cr.scope;
- Returns paginated list (max 100/page); each row carries a
revokeUrltoken (HMAC-bound) for one-click revoke. - Insert audit
CITIZEN_INSPECTION_VIEW(the inspection itself is a regulator-relevant event under GDPR Art. 15 right of access).
2. Multilingual STOP-Keyword Matching
| Concern | Implementation |
|---|---|
| Normalisation | Unicode NFKC; case-fold per Unicode UAX #29; collapse multiple whitespace; strip ZWJ/ZWNJ characters that adversaries may insert |
| Right-to-left scripts | Stored both in source script and the NFKC form; matcher operates on NFKC |
| Token-window | First-match against (a) full normalised body, (b) first whitespace-separated token, (c) first 32 grapheme clusters. Limits matcher cost to constant time. |
| Diacritic tolerance | Optional per-keyword accept_diacritic_variants flag — Arabic إيقاف matches ايقاف |
| In-process catalog | All keywords mirrored to a hash-set per language at startup; KeywordCatalogReloadWorker polls every 60 s for consent.stop_keywords.updated_at > last_load |
| Tenant additions | Merged into the platform set at lookup time, scoped to the tenant resolved from senderIdReceived |
3. Performance Budgets
| Operation | Internal budget | External SLA |
|---|---|---|
CheckConsent cache hit | 2 ms | 5 ms P95 |
CheckConsent cache miss + PG | 12 ms | 20 ms P99 |
RecordConsent write + audit | 60 ms | 80 ms P95 |
RevokeConsent write + audit | 60 ms | 80 ms P95 |
| MO STOP processing | 1.5 s (incl. ack-back enqueue) | 2 s P95 |
| DND sync (15k rows typical Afghanistan list) | 90 s | 5 min hard cap |
Budget exhaustion in CheckConsent falls back to fail-closed; budget exhaustion in writes returns 503 — the caller may retry.
4. Cron / Worker Inventory
| Worker | Schedule | Purpose |
|---|---|---|
DndSyncWorker | Daily 03:00 Asia/Kabul + manual override | Pull ATRA DND feed |
KeywordCatalogReloadWorker | Every 60 s | Hot-reload consent.stop_keywords |
DoubleOptinExpiryWorker | Every 5 min | Mark expired pending opt-ins |
ConsentRecordExpiryWorker | Every 15 min | Move records past validUntil to EXPIRED |
ErasureProcessor | Every 1 h | Process PENDING erasure requests |
AuditChainVerifier | Daily 02:00 Asia/Kabul | Verify hash chain for last 24 h partitions |
AuditPartitionMaintainer | Daily 02:30 Asia/Kabul | Provision next month's partitions; drop > 13-month partitions after S3 archive confirmation |
OutboxRelay | Continuous (every 1 s tick) | Publish unsent outbox rows to NATS |
RedisCacheWarmer | On startup, then every 6 h | Pre-warm hot tenants' top-N MSISDN cache |
Workers acquire Redis distributed locks (SET NX EX) under consent:lock:<worker> to be multi-replica safe.
5. Composition with Other Services
| Caller | Use case | Failure mode if consent-ledger-service unavailable |
|---|---|---|
compliance-engine (CONSENT rule type) | CheckConsent per evaluation | compliance-engine treats UNAVAILABLE as BLOCK (fail-closed) per its own SECURITY_MODEL; downstream message → BLOCKED |
routing-engine (last-mile veto) | CheckConsent post-routing pre-dispatch | Same as above; lane = P0_EMERGENCY may bypass with audit per routing-engine FAILURE_MODES |
sms-firewall-service (inbound MT) | CheckConsent for MT-from-foreign | Firewall returns 503 to upstream peer; peer retries |
channel-router-service | publishes sms.mo.inbound | Backpressure on NATS; consumer lag visible via consent_stop_consumer_lag_seconds |
regulator-portal-service | reads /v1/admin/consent/audit | Regulator UI shows degraded state; audit query falls back to S3 cold archive job |
notification-service | consumes consent.revoked.v1 | Tenant portal notification of revocation may be delayed |