Skip to main content

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:

  1. Input validation. Reject with INVALID_ARGUMENT if tenantId is not UUIDv4, msisdn is not E.164, or scope is not in the canonical enum. Default scope to TRANSACTIONAL when omitted (CONS-US-005 §3).

  2. Composite cache lookup. Compute cacheKey = consent:state:{tenantId}:{msisdnHash}:{scope} where msisdnHash = sha256(msisdn || pepper)[:32hex]. Issue a single Redis MGET for the tenant key and the National-DND key consent:dnd:{msisdnHash}. P95 budget for this step: ≤ 2 ms.

  3. 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 metric consent_check_dnd_block_total{scope}. P0_EMERGENCY proceeds through the rest of the pipeline with an audit-row reason NATIONAL_DND_BYPASS_P0_EMERGENCY.

  4. Tenant cache hit. If consent:state:… returns a serialised { status, validUntil, recordId, computedAt }, evaluate it:

    • status = OPT_IN and (validUntil IS NULL or validUntil > now()) → { allowed: true, reason: ALLOWED_TENANT_RECORD }.
    • status = OPT_OUT{ allowed: false, reason: BLOCKED_OPT_OUT }.
    • status = EXPIRED or validUntil ≤ now(){ allowed: false, reason: BLOCKED_EXPIRED }. Increment consent_check_cache_hits_total.
  5. Cache miss → Postgres read. Execute the prepared query

    SELECT consent_id, status, valid_until
    FROM consent.records
    WHERE tenant_id = $1 AND msisdn_hash = $2 AND scope = $3
    AND replaced_by IS NULL
    LIMIT 1;

    Acquire a connection from the read-replica-routed pool; the read-replica is acceptable because CheckConsent reads the eventually-consistent current row (gap window ≤ 200 ms; bounded by replication lag alert).

    • Row found → fill cache SET consent:state:… EX 300 and return verdict from §4 logic.
    • No row found → apply scope default policy: TRANSACTIONAL returns { allowed: true, reason: ALLOWED_DEFAULT_TRANSACTIONAL } (transactional traffic is opt-out world per Afghan regulator interpretation); MARKETING, OTP, EMERGENCY require explicit opt-in → { allowed: false, reason: BLOCKED_NO_RECORD }.
  6. Fail-closed branch. If both Redis and Postgres are unavailable (connection error, deadline exceeded), return { allowed: false, reason: CONSENT_UNKNOWN }. Increment consent_check_failclosed_total. See FAILURE_MODES §FM-01.

  7. No audit on read. CheckConsent does not write to consent.audit; the consumer (compliance-engine) writes its own compliance.audit.v1 row referencing the recordId. This keeps the hot path under 5 ms.

Error codes:

gRPC statusCondition
OKVerdict returned
INVALID_ARGUMENTBad MSISDN, unknown scope, malformed tenantId
UNAVAILABLECaller should retry with backoff (transient Postgres/Redis)
INTERNALUnhandled exception (logged, not exposed)

Caller fail-closed posture. compliance-engine and routing-engine MUST treat any non-OK response as allowed: false for non-emergency lanes. CheckConsent itself returns allowed: false on 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:

  1. Input validation. Tenant scope check (the caller's tenantId claim must match the body tenantId). MSISDN E.164. Scope in catalog. verificationMethod = DOUBLE_OPT_IN requires source.ref to point to a CONFIRMED DoubleOptin row.

  2. Tenant suspension check. Reject with FAILED_PRECONDITION if tenant.lifecycle.status = SUSPENDED (cached from tenant.lifecycle.suspended.v1).

  3. Idempotency. If header Idempotency-Key matches a row in consent.idempotency_keys from the past 24 h, return the prior response.

  4. Begin transaction.

    • Look up the current row for (tenantId, msisdnHash, scope, replaced_by IS NULL). If present and equivalent (same status, same validUntil), no-op return — emit no event.
    • Insert new consent.records row with status = OPT_IN. If a prior row existed, set its replaced_by = new.consent_id.
    • Compute the audit hash chain row (see §1 UC-WriteAudit) and insert into consent.audit for eventType = RECORD_CREATED.
    • Insert into consent.outbox for consent.granted.v1.
    • Commit.
  5. Cache fill. SET consent:state:{tenantId}:{msisdnHash}:{scope} with the new state, TTL 300 s. Cache fill outside the transaction is acceptable because the next CheckConsent cache miss would re-read from Postgres anyway.

  6. Outbox relay (background) publishes to CONSENT_EVENTS.consent.granted.v1.

  7. 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:

  1. Validate inputs as in UC-RecordConsent.
  2. Begin transaction.
    • Find the current row for (tenantId, msisdnHash, scope, replaced_by IS NULL). If status = OPT_OUT already → no-op.
    • Insert a new row with status = OPT_OUT, revokedAt = now(), revokedReason from the trigger.
    • Set replaced_by on the old row.
    • Insert audit row RECORD_REVOKED with payload { previousRecordId, newRecordId, source }.
    • Insert outbox row consent.revoked.v1.
    • Commit.
  3. Cache invalidation. DEL consent:state:{tenantId}:{msisdnHash}:{scope} so the next CheckConsent hits Postgres and gets the new row. Optionally pre-warm.
  4. 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:

  1. Deserialize the event. Required fields: { moId, msisdn, body, language?, senderIdReceived, receivedAt, traceId }. senderIdReceived is the platform sender ID the subscriber typed STOP at; we revoke the tenant whose sender ID it is.

  2. Normalize body. Apply Unicode NFKC, lowercase, collapse whitespace, trim. The output is the matcher input.

  3. Detect language. If language is not provided by channel-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).

  4. Match against consent.stop_keywords.

    • Load the per-language keyword set from process memory (refreshed by KeywordCatalogReloadWorker every 60 s; see DEPLOYMENT_TOPOLOGY §2).
    • First-match wins: exact match against the normalised body or the first whitespace-separated token (STOP NOW matches STOP).
    • STOPALL and the platform-default STOP for citizen-portal STOP-ALL invocations have revokeAction = REVOKE_GLOBAL. All others default to REVOKE_TENANT_SCOPE.
    • No match → ACK the NATS message, increment consent_stop_mo_no_match_total, do nothing else.
  5. Resolve target tenant. Reverse-look the senderIdReceived to the owning tenant via auth.sender_id_registry. If the sender ID is not registered (orphan MO), audit-log STOP_MO_RECEIVED with tenantId = null and ACK; this is a low-priority anomaly.

  6. Apply policy. If consent.policy.stop_scope = GLOBAL and the matched keyword's revokeAction = REVOKE_TENANT_SCOPE, escalate to REVOKE_GLOBAL. Audit-row reason: POLICY_GLOBAL_OVERRIDE.

  7. Invoke UC-RevokeConsent. With source.type = STOP_MO, source.ref = moId. For REVOKE_GLOBAL, iterate over all tenant rows for the MSISDN.

  8. 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.request via NATS. The skipConsent: true flag is honoured by compliance-engine only when the producer is consent-ledger-service (signed payload, mesh identity verified). One-shot guarantee: the consentAckBack flag prevents re-entry on the subscriber's next STOP within a 24 h dedup window.

  9. Audit and emit events. STOP_MO_RECEIVED, RECORD_REVOKED (one per affected tenant scope), ACK_BACK_SENT. Outbox-relayed consent.revoked.v1, consent.stop_mo.received.v1, consent.ack_back.sent.v1.

  10. 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.deadletter with reason consent_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:

  1. 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 in consent.dnd_sync_config).
  2. Parse. Streaming CSV parse; reject the run if more than 5% of rows fail validation. Fields: msisdn,registered_at,category.
  3. 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().
  4. 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.
  5. Audit. Insert one summary DND_SYNC_APPLIED audit row with payload = { added, removed, total, sourceFeedRunId, sourceSignatureValid }.
  6. Emit dnd.registry.synced.v1 with { runId, addedCount, removedCount, total, completedAt, durationMs }.
  7. Mark lastSyncAt on consent.dnd_sync_runs. Alert ConsentDndStale (HIGH) fires if now() - 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):

  1. POST /v1/consent/double-opt-in/initiate { msisdn, scope }.
  2. Validate inputs; reject if a PENDING row already exists for (tenantId, msisdn, scope) within last 60 min (rate-limit).
  3. Generate optinId (do_<ULID>) and confirmationToken (HMAC-SHA256 of (optinId, msisdn, salt); URL-safe base64; 256-bit entropy).
  4. Insert consent.double_optins row with status = PENDING, expiresAt = now() + 24h.
  5. Construct opt-in SMS body using tenant template, embedding https://opt-in.ghasi.gov.af/c/{token}.
  6. Publish to sms.outbound.request lane = P2_TRANSACTIONAL, metadata.doubleOptinId = optinId, skipConsent = true (the message itself is consent solicitation; signed by consent-ledger-service mesh identity).
  7. Insert audit DOUBLE_OPTIN_INITIATED. Emit consent.double_optin.initiated.v1.
  8. Return { optinId, expiresAt }.

Confirm (CONS-US-003 §2):

  1. GET /v1/consent/double-opt-in/confirm?token=… (citizen-facing; rate-limited at Kong: 60 req/min per IP).
  2. HMAC-verify the token; look up the matching optinId.
  3. If status != PENDING or now() > expiresAt → render the appropriate page (already-confirmed, expired).
  4. Begin transaction:
    • Update consent.double_optins to CONFIRMED, confirmedAt = now().
    • Invoke UC-RecordConsent with verificationMethod = DOUBLE_OPT_IN, source.ref = optinId.
    • Insert audit DOUBLE_OPTIN_CONFIRMED. Insert outbox consent.granted.v1 and consent.double_optin.confirmed.v1.
  5. Render the localised confirmation page.

Expiry processor (cron, every 5 min):

  1. UPDATE consent.double_optins SET status='EXPIRED' WHERE status='PENDING' AND expires_at < now() RETURNING optin_id.
  2. For each, insert audit DOUBLE_OPTIN_EXPIRED, outbox consent.double_optin.expired.v1.

UC-Erasure (CONS-US-015)

Initiate:

  1. POST /v1/consent/erasure?msisdn= from citizen-portal after MSISDN-OTP verification (handled by citizen-portal; consent-ledger-service validates the OTP receipt token by calling auth-service.VerifyOtpReceipt).
  2. Insert consent.erasure_requests { erasureId, msisdnHash, requestedVia, slaDueAt = now() + 30d }.
  3. Audit ERASURE_REQUESTED. Emit consent.erasure.requested.v1.

Process (background ErasureProcessor, every 1 h):

  1. Fetch PENDING rows. Acquire row-level advisory lock per erasure to prevent double processing.
  2. For the target MSISDN:
    • Compute tombstoneToken = sha256("erasure:" || msisdnHash || pepper).
    • For all consent.records with msisdn_hash = msisdnHash, UPDATE msisdn = tombstoneToken, msisdn_encrypted = NULL.
    • For all consent.audit rows with msisdn_hash = msisdnHash, UPDATE msisdn_encrypted = NULL and overwrite payload.msisdn = "<erased>". Append redacted_fields = ["payload.msisdn", "msisdn_encrypted"] to the row metadata. Do not modify payload_hash, prev_hash, or record_hash.
    • National-DND row remains untouched (regulator override per CONS-US-015 §3).
  3. Insert audit ERASURE_COMPLETED. Emit consent.erased.v1.
  4. Invalidate Redis: DEL consent:state:*:{msisdnHash}:*.
  5. Update consent.erasure_requests SET status='COMPLETED', completedAt=now().

UC-WriteAudit (internal)

Used by all write use cases.

  1. Begin a Postgres transaction (the same as the parent state mutation).
  2. Acquire the partition's seq advisory lock to serialise inserts within a partition (SELECT pg_advisory_xact_lock(hashtext(partition_name))).
  3. Read the previous row's record_hash for 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.
  4. Compose the canonical payload (see SECURITY_MODEL §3.4 for canonicalisation rules — sorted keys, Unicode NFC, RFC 8785 JSON Canonicalization Scheme).
  5. Compute payload_hash = sha256(canonical_bytes) and record_hash = sha256(payload_hash || prev_hash).
  6. 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)

  1. For each partition active in the last 24 h, walk rows ordered by seq.
  2. Recompute prev_hash should equal previous row's record_hash. Recompute payload_hash from the (possibly redacted) canonical payload, applying the redacted_fields markers.
  3. On any mismatch, insert consent.audit row eventType = AUDIT_INTEGRITY_BROKEN with the offending range; emit consent.audit.chain_broken.v1 (CRITICAL); page Trust & Safety on-call.
  4. On success, emit consent.audit.chain_verified.v1 and update consent.audit_verifier_runs.last_success_at.

UC-BulkImport (CONS-US-018)

  1. 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.
  2. Compute SHA-256 of the file as import_id.
  3. Validate header columns: msisdn,scope,source_ref,captured_at.
  4. 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}".
  5. Process in chunks of 500 within a single transaction per chunk (audit chain locking is per-partition; chunks must respect seq ordering).
  6. Return JSON report { accepted, rejected, errors: [{ row, reason }] }.
  7. Audit BULK_IMPORT_COMPLETED with 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)

  1. Citizen authenticates via MSISDN-OTP (handled by auth-service OtpStartFlowOtpVerifyFlow; result is a short-lived JWT scoped citizen:consent:read and bound to the verified MSISDN).
  2. GET /v1/consent/audit?msisdn= enforces jwt.msisdn = query.msisdn (constant-time comparison).
  3. Service queries:
    SELECT cr.tenant_id, t.display_name, cr.scope, cr.status, cr.valid_from, cr.source
    FROM consent.records cr
    JOIN auth.tenants t ON t.tenant_id = cr.tenant_id
    WHERE cr.msisdn_hash = $1 AND cr.replaced_by IS NULL
    ORDER BY t.display_name, cr.scope;
  4. Returns paginated list (max 100/page); each row carries a revokeUrl token (HMAC-bound) for one-click revoke.
  5. 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

ConcernImplementation
NormalisationUnicode NFKC; case-fold per Unicode UAX #29; collapse multiple whitespace; strip ZWJ/ZWNJ characters that adversaries may insert
Right-to-left scriptsStored both in source script and the NFKC form; matcher operates on NFKC
Token-windowFirst-match against (a) full normalised body, (b) first whitespace-separated token, (c) first 32 grapheme clusters. Limits matcher cost to constant time.
Diacritic toleranceOptional per-keyword accept_diacritic_variants flag — Arabic إيقاف matches ايقاف
In-process catalogAll keywords mirrored to a hash-set per language at startup; KeywordCatalogReloadWorker polls every 60 s for consent.stop_keywords.updated_at > last_load
Tenant additionsMerged into the platform set at lookup time, scoped to the tenant resolved from senderIdReceived

3. Performance Budgets

OperationInternal budgetExternal SLA
CheckConsent cache hit2 ms5 ms P95
CheckConsent cache miss + PG12 ms20 ms P99
RecordConsent write + audit60 ms80 ms P95
RevokeConsent write + audit60 ms80 ms P95
MO STOP processing1.5 s (incl. ack-back enqueue)2 s P95
DND sync (15k rows typical Afghanistan list)90 s5 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

WorkerSchedulePurpose
DndSyncWorkerDaily 03:00 Asia/Kabul + manual overridePull ATRA DND feed
KeywordCatalogReloadWorkerEvery 60 sHot-reload consent.stop_keywords
DoubleOptinExpiryWorkerEvery 5 minMark expired pending opt-ins
ConsentRecordExpiryWorkerEvery 15 minMove records past validUntil to EXPIRED
ErasureProcessorEvery 1 hProcess PENDING erasure requests
AuditChainVerifierDaily 02:00 Asia/KabulVerify hash chain for last 24 h partitions
AuditPartitionMaintainerDaily 02:30 Asia/KabulProvision next month's partitions; drop > 13-month partitions after S3 archive confirmation
OutboxRelayContinuous (every 1 s tick)Publish unsent outbox rows to NATS
RedisCacheWarmerOn startup, then every 6 hPre-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

CallerUse caseFailure mode if consent-ledger-service unavailable
compliance-engine (CONSENT rule type)CheckConsent per evaluationcompliance-engine treats UNAVAILABLE as BLOCK (fail-closed) per its own SECURITY_MODEL; downstream message → BLOCKED
routing-engine (last-mile veto)CheckConsent post-routing pre-dispatchSame as above; lane = P0_EMERGENCY may bypass with audit per routing-engine FAILURE_MODES
sms-firewall-service (inbound MT)CheckConsent for MT-from-foreignFirewall returns 503 to upstream peer; peer retries
channel-router-servicepublishes sms.mo.inboundBackpressure on NATS; consumer lag visible via consent_stop_consumer_lag_seconds
regulator-portal-servicereads /v1/admin/consent/auditRegulator UI shows degraded state; audit query falls back to S3 cold archive job
notification-serviceconsumes consent.revoked.v1Tenant portal notification of revocation may be delayed