Skip to main content

sender-id-registry-service — Application Logic

Version: 1.0 Status: Draft Owner: Trust & Safety + Regulator-facing Last Updated: 2026-04-21 Companion: DOMAIN_MODEL · API_CONTRACTS · EVENT_SCHEMAS · SECURITY_MODEL


1. Use Cases

UC-01 · SubmitRegistration (REST POST /v1/sender-ids)

Trigger: Tenant developer / customer-portal posts a sender-ID submission with KYC document references (US-SID-001).

Input: { value, type, category, registrantOrgName, registrantContactEmail, registrantContactMsisdn, kycDocs: [{ docType, signedUrl, sha256Hex, sizeBytes, mimeType }], requestedDomain? }.

Output: 201 Created with { senderIdInternalId, value, type, state: 'SUBMITTED', requiredVerificationLevel, restrictedPatternMatched, kycDocs: [...] }.

SLA: P95 ≤ 400 ms (synchronous KYC ingest); KYC document copy from signed URL into encrypted bucket runs async with status webhook on completion.

Steps:

  1. Authn / Authz. Kong has validated tenant JWT. Service confirms X-Tenant-Id matches the JWT-bound tenant (defence in depth).
  2. Idempotency. Idempotency-Key header (mandatory) → SET NX EX 86400 sid:idem:{tenantId}:{key}. Replay returns the original 201.
  3. Normalise value. Apply SenderIdValue VO normalisation rules from DOMAIN_MODEL §3: trim, upper-case alpha, strip non-digits for short, validate E.164 for long.
  4. Shape validation. Reject 400 SID_VALUE_INVALID if regex per type fails.
  5. Uniqueness check. SELECT against sender_ids for active states + REVOKED with reservedUntil > now(). On conflict → 409 SID_VALUE_TAKEN with reservedUntil if revoked-reserved.
  6. Restricted-pattern match. Run value through every active RestrictedPattern.pattern (re2). On any match: capture restrictedPatternId, derive requiredVerificationLevel and requiredDocTypes. Reject 422 SID_RESTRICTED_REQUIREMENTS_UNMET if submission lacks the required doc types.
  7. KYC document staging. For each kycDocs[i]:
    • Verify the signed URL points to a known tenant upload bucket.
    • Stream-copy into s3://ghasi-sid-kyc-{region}/{tenantId}/{kycDocId}.{ext} with server-side encryption using per-tenant KEK (Vault Transit).
    • Compute SHA-256 of the encrypted blob; compare against caller-supplied sha256Hex for tamper detection at handoff. On mismatch → 422 SID_KYC_HASH_MISMATCH.
    • Insert kyc_documents row.
  8. Insert sender_ids row in state SUBMITTED with firstSubmittedAt = now().
  9. Outbox. Insert sender.id.submitted.v1 row in outbox table within the same transaction.
  10. Cache invalidation. DEL sid:verify:{senderIdValue}:{type} (covers any stale lookup).
  11. Return 201.

Error codes: SID_VALUE_INVALID (400), SID_VALUE_TAKEN (409), SID_RESTRICTED_REQUIREMENTS_UNMET (422), SID_KYC_HASH_MISMATCH (422), SID_KYC_TOO_LARGE (413), SID_RATE_LIMITED (429).


UC-02 · ReviewKyc (Admin REST — claim, approve, reject, request_info)

Trigger: T&S reviewer in admin-dashboard reviewer workbench (per EP-ADMDASH-11) actions a registration row (US-SID-002, US-SID-004).

Authz: platform.sid.reviewer or platform.sid.admin.

Steps:

  1. POST /v1/admin/sender-ids/{id}/claim — atomic UPDATE to bind reviewer; rejects 409 if already claimed by someone else within the SLA window.
  2. Reviewer fetches KYC documents inline (UC-03).
  3. Reviewer POST /v1/admin/sender-ids/{id}/decision with { action: 'APPROVE' | 'REJECT' | 'REQUEST_INFO', reason, missingDocTypes? }:
    • APPROVE → state KYC_APPROVED, set kycApprovedAt, emit sender.id.kyc_approved.v1.
    • REJECT → state KYC_REJECTED (terminal — re-submission requires a new row), emit sender.id.kyc_rejected.v1 with reason.
    • REQUEST_INFO → state INFO_REQUESTED, emit sender.id.info_requested.v1 with checklist.
  4. Audit row written with before/after snapshots, actor, role, IP.
  5. Notification fans out via notification-service to tenant compliance officer.

SLA: Default reviewer SLA 5 business days from SUBMITTED → APPROVE/REJECT/REQUEST_INFO. Breach surfaces in admin-dashboard with red banner + alert SidKycReviewSlaBreach.


UC-03 · ViewKycDocument (Reviewer inline view)

Trigger: Reviewer clicks a KYC doc thumbnail in admin-dashboard (US-SID-003).

Steps:

  1. GET /v1/admin/sender-ids/{id}/kyc-docs/{kycDocId}/view returns a 15-minute pre-signed URL.
  2. Service composes a watermark layer ("GHASI INTERNAL — DO NOT DISTRIBUTE — viewer={userId} at={ts}") rendered as a dynamic overlay via the document-watermark sidecar (PDF: pdf-lib; image: sharp).
  3. Audit row written: entityType = KYC_DOC_VIEW, entityId = kycDocId, actorUserId, ip.
  4. The watermarked stream is delivered as a one-shot S3 pre-signed object generated on-the-fly into s3://ghasi-sid-kyc-views-{region}/{viewerUserId}/{watermarkedDocId}.pdf with Expires=900.

Operational note: Inline view never re-uploads to a public CDN. The watermarked artefact is purged after 1 hour by lifecycle policy.


UC-04 · VerifyOtp (Tenant)

Trigger: Tenant in customer-portal clicks "Verify by OTP" (US-SID-007).

Steps:

  1. POST /v1/sender-ids/{id}/verifications with { method: 'OTP' }. Service:
    • Generates a 6-digit OTP (cryptographically random).
    • Stores sha256(otp) in verifications.challenge; plaintext OTP in Redis sid:otp:{verificationId} EX 300.
    • Submits via channel-router-service on lane P1 to registrantContactMsisdn with template sid_otp_verification.
    • Rate-limit: INCR sid:otp:rl:{registrantMsisdn}:{hour} ≤ 3.
  2. Tenant POST /v1/sender-ids/{id}/verifications/{verificationId}/submit with { otp }. Service:
    • INCR verifications.attempts. If > 3 → FAILED with failureReason = max_attempts_exceeded.
    • Compare sha256(submitted) to stored. On match → SUCCEEDED, raise currentVerificationLevel to OTP.
    • Emit sender.id.verified.v1 if level changed.
  3. Failed attempts increment sid_verify_otp_failures_total{result}.

UC-05 · VerifyDnsTxt (Tenant)

Trigger: Tenant requests DNS-TXT verification of a domain-affiliated alpha-ID (US-SID-006).

Steps:

  1. POST /v1/sender-ids/{id}/verifications with { method: 'DOMAIN_DNS', domain }. Service:
    • Validates domain shape (RFC 1035 + IDN allowed).
    • Generates nonce = base64url(random(16)); computes challengeValue = sha256(senderIdInternalId || nonce) truncated to 32 chars.
    • Stores in verifications.challenge = { domain, challengeValue, expectedRecord }.
    • Returns to tenant: { recordName: '_ghasi-sid-verify.{domain}', recordType: 'TXT', recordValue: 'ghasi-sid-verify={challengeValue}' }.
  2. Tenant publishes the TXT record at their DNS provider, then POST /v1/sender-ids/{id}/verifications/{verificationId}/check. Service:
    • Resolves the TXT record using a DoT (DNS-over-TLS) resolver pinned at 1.1.1.1 and 8.8.8.8 for two-resolver consensus.
    • On match → SUCCEEDED, raise currentVerificationLevel to include DOMAIN_DNS (orthogonal flag, see DOMAIN_MODEL §3).
    • On mismatch → INCR verifications.attempts. If > 5 in rolling 24 h → FAILED; tenant must restart.
  3. Background poller dns-verification-poller re-checks IN_PROGRESS DNS verifications every 30 minutes for 24 h before marking EXPIRED.

UC-06 · VerifyNotarised (Reviewer dual-control)

Trigger: Reviewer manually verifies a notarised document attached to a SenderId (US-SID-008).

Steps:

  1. Primary reviewer inspects the notarised document via UC-03, validates the notary against the platform whitelist (per SID-OPEN-002).
  2. Primary reviewer POST /v1/admin/sender-ids/{id}/verifications/{verificationId}/notarised-approve with { notaryRef, notaryWhitelistEntryId, notes }. State → IN_PROGRESS pending second reviewer.
  3. Second reviewer (different userId, also platform.sid.reviewer) POST .../notarised-co-approve. Service:
    • Asserts actorUserId != primaryReviewerUserId.
    • Marks Verification.state = SUCCEEDED, raises currentVerificationLevel to NOTARISED.
    • Emits sender.id.verified.v1.
  4. Audit captures both reviewers and the notary identity.

Failure path: If second reviewer rejects → Verification.state = FAILED with both reviewer ids; notary entry is not auto-removed from whitelist (separate workflow).


UC-07 · VerifyDocument (Basic)

Trigger: Reviewer accepts commercial-licence + national-ID combo as basic document verification (US-SID-009).

Steps: Single-reviewer approval; sets currentVerificationLevel to DOCUMENT. Used as the default level for non-restricted submissions.


UC-08 · Verify (gRPC hot path)

Trigger: Per-message lookup from compliance-engine (SENDER_ID rule), routing-engine (last-mile veto), sms-firewall-service (inbound MT firewall) on every dispatched message.

Input: { senderId: string, type: SenderIdType, tenantId: string }.

Output: { status: REGISTRY_STATUS, verificationLevel, lastVerifiedAt, reputationScore, restrictedCategory?, exceededRequiredLevel: boolean }.

SLA: P95 ≤ 5 ms, P99 ≤ 15 ms. This is the platform's hottest gRPC after compliance-engine.EvaluateCompliance.

Steps:

  1. GET sid:verify:{senderId}:{type}:{tenantId} from Redis (TTL 300 s). On HIT → return.
  2. On MISS → SELECT from sender_ids indexed by (value, type):
    • state = ACTIVE AND tenantId matches → status = ACTIVE.
    • state = ACTIVE AND tenantId mismatches → status = TENANT_MISMATCH (impersonation attempt).
    • state ∈ {SUSPENDED, REVOKED} → corresponding status.
    • Not found → status = UNKNOWN.
  3. Hydrate latest ReputationSnapshot.score (default 50 if none).
  4. Compose response; SET sid:verify:... EX 300.
  5. Emit sid_verify_total{status} metric and OTel span.

Cache-invalidation. State transitions emit DEL sid:verify:{senderId}:{type}:* plus a sender.id.cache_invalidate.v1 mesh broadcast (NATS sender.id.cache.invalidate) so peer pods drop their local in-process cache mirrors within 30 s of any state change (per US-SID-011 acceptance criterion).

Fail-closed semantics for callers: status = UNKNOWN, TENANT_MISMATCH, SUSPENDED, REVOKED MUST be treated by callers as non-allow; the lane-specific fail-closed rule applies (compliance HOLDs; routing rejects).


UC-09 · GetReputation (gRPC)

Trigger: compliance-engine reputation rule, fraud-intel-service enrichment, customer-portal "your sender-ID" panel (US-SID-018).

Input: { senderId, type }.

Output: { score, lastComputedAt, trend90d: [{ date, score }] } (trend optional via flag).

SLA: P95 ≤ 5 ms (Redis hot path).


UC-10 · Suspend (Manual or Auto)

Trigger:

  • Manual (US-SID-011): admin POST /v1/admin/sender-ids/{id}/suspend with { reason }.
  • Auto (US-SID-012): reputation cron detects score < 30 for an ACTIVE sender-ID.

Steps:

  1. Check current state ∈ {ACTIVE}. Reject 409 otherwise.
  2. UPDATE state → SUSPENDED; set suspendedAt, lastSuspendReason.
  3. Outbox sender.id.suspended.v1.
  4. Cache invalidation per UC-08 (tight 30 s SLA per US-SID-011 AC-3).
  5. Audit row.

UC-11 · Reactivate (US-SID-013)

Trigger: Admin POST /v1/admin/sender-ids/{id}/reactivate with { reason, remediationEvidenceUrl }.

Steps:

  1. State must be SUSPENDED.
  2. Validate remediationEvidenceUrl belongs to the platform's evidence S3 bucket.
  3. UPDATE state → ACTIVE; reset reputation snapshot to neutral 50; flip probationFlag = true for 30 days.
  4. Outbox sender.id.reactivated.v1.

UC-12 · Revoke (US-SID-014)

Trigger: Admin POST /v1/admin/sender-ids/{id}/revoke with { reason } for severe abuse (typically after multiple suspensions or a single high-severity fraud determination).

Steps:

  1. State must be ∈ {ACTIVE, SUSPENDED}.
  2. UPDATE state → REVOKED; set revokedAt = now(), reservedUntil = now() + 365d, lastRevokeReason.
  3. Outbox sender.id.revoked.v1.
  4. Cache invalidation.
  5. Re-registration of the value is impossible until reservedUntil. After expiry, a new submission creates a new senderIdInternalId and re-runs the full flow.

UC-13 · PublicSearch (Citizen)

Trigger: Anonymous GET /v1/sender-ids/public/search?q=BANK-XYZ (US-SID-015).

Steps:

  1. Edge cache (Cloudflare) keyed sid_pub:{q} TTL 60 s.
  2. Origin: GET sid:public_search:{q} Redis TTL 300 s.
  3. Origin miss: SELECT registry rows where LOWER(value) ILIKE LOWER(q) LIMIT 20, projecting only public fields: value, registrantOrgName, verificationLevel, state, firstSubmittedAt.
  4. Per-IP rate limit: INCR sid:public_search:rl:{ip}:{minute} ≤ 100; over → 429 SID_PUBLIC_SEARCH_RATE_LIMITED plus tarpit (1 s sleep then 503).
  5. Suspicious-pattern detection: > 1000 distinct queries in 1 h from one IP → soft-block + alert SidPublicSearchAbuse.

KYC documents are never returned by this endpoint.


UC-14 · RegulatorExport (Cron + on-demand)

Trigger:

  • Cron: daily 04:00 UTC.
  • On-demand: regulator-portal-service calls POST /v1/admin/sender-ids/export with { from, to, format } (US-SID-016).

Steps:

  1. Acquire Redis distributed lock sid:export:lock SET NX EX 1800.
  2. Stream all sender-IDs whose updatedAt ∈ [from, to) to a JSON-Lines file (one row per sender-ID) including: value, type, category, registrantOrgName, currentVerificationLevel, state, firstSubmittedAt, kycApprovedAt, verifiedAt, suspendedAt, revokedAt, reputationScore.
  3. Sign the file with the regulator-export key (HSM-held PKCS#11) producing a detached .sig.
  4. Upload both to s3://ghasi-sid-regulator-export-{region}/{exportId}.{ext}.
  5. Insert regulator_exports row.
  6. SFTP transmit to ATRA endpoint configured in regulator_export_targets. Capture acknowledgement reference.
  7. Outbox sender.id.regulator.exported.v1.

Failure modes:

  • HSM unavailable → defer signing; export queued; alert SidExportSignerDown.
  • SFTP unavailable → exponential backoff retry up to 6 h; if still failing, escalate to regulator-liaison via PagerDuty.

UC-15 · ComputeReputation (Daily cron + intra-day deltas)

Trigger: Cron 00:30 UTC for full recompute (US-SID-017); NATS consumer for fraud.detected.* and compliance.message.blocked.v1 for intra-day deltas (US-SID-020).

Daily steps:

  1. Acquire sid:reputation:lock:daily SET NX EX 7200.
  2. For each ACTIVE and SUSPENDED sender-ID:
    • Query compliance.evaluation_log count by senderId over trailing 7 d.
    • Query regulator-portal-service.complaints count by senderId over trailing 7 d.
    • Query fraud-intel-service.fraud_events weighted count over trailing 7 d.
    • Query dlr-processor aggregates for delivery rate over trailing 7 d.
    • Apply formula from DOMAIN_MODEL §6.
    • Clamp to [0, 100].
    • INSERT reputation_history row with trigger = DAILY_CRON.
  3. For threshold crossings (30, 50, 70, 90): outbox sender.id.reputation.changed.v1 with previous & new score.
  4. For score < 30 on ACTIVE: invoke UC-10 (auto-suspend).
  5. Cache write: SET sid:reputation:{senderId} EX 86400.

Intra-day delta worker:

  • Consumer groups: sid-reputation-fraud on fraud.detected.*, sid-reputation-compliance on compliance.message.blocked.v1.
  • Per event: compute delta, INSERT reputation_history with trigger = FRAUD_EVENT/COMPLIANCE_BLOCK, update Redis cache.
  • Threshold-crossing emits the same event as cron.

2. Performance & Latency Budgets

OperationBudgetHot path?
Verify gRPCP95 ≤ 5 ms, P99 ≤ 15 msYes — called per message
GetReputation gRPCP95 ≤ 5 msYes
POST /v1/sender-idsP95 ≤ 400 msNo (registration is rare)
Public searchP95 ≤ 100 ms (with edge cache); P95 ≤ 250 ms cache missPublic — must be DDoS-resistant
KYC view (watermarked)P95 ≤ 1.5 s for ≤ 5 MB docNo
Daily reputation cronfull recompute < 30 min for 1 M sender-IDsNo
Regulator export< 10 min for full registryNo

3. Caching Strategy

CacheKeyTTLPopulation
Verify hotsid:verify:{value}:{type}:{tenantId}300 sRead-through; invalidated on state change
Reputation hotsid:reputation:{senderIdInternalId}86400 sCron + intra-day delta
Public searchsid:public_search:{normalisedQuery}300 s + 60 s edgeRead-through
OTP plaintextsid:otp:{verificationId}300 sSubmit-time write only
Restricted-pattern compiled regexin-process Map<patternId, RegExp>5 min refreshLoader poll
Notary whitelistin-process Set<notaryId>5 min refreshLoader poll

4. Concurrency & Idempotency

  • Submission idempotency: mandatory Idempotency-Key header per UC-01.
  • State transitions: optimistic lock via version column; conflicting writer gets 409 SID_VERSION_CONFLICT and must re-fetch.
  • Verification submit: atomic INCR + COMPARE against the OTP attempt counter to prevent race-attack on max-attempts.
  • Cron jobs: Redis distributed locks ensure exactly-one execution across replicas.

5. Cross-Service Orchestration

Tenant → POST /v1/sender-ids

├─→ object-storage (KYC encrypt + write)

├─→ Postgres INSERT sender_ids (state=SUBMITTED) + outbox

└─→ NATS sender.id.submitted.v1 ──► notification-service (tenant ack)
└─► admin-dashboard reviewer queue (SSE)

Reviewer claim → APPROVE

├─→ UPDATE state KYC_APPROVED + outbox

└─→ NATS sender.id.kyc_approved.v1 ──► notification-service
└─► customer-portal "ready to verify" banner

Tenant → DNS verify → success

├─→ UPDATE currentVerificationLevel + state VERIFIED + outbox

└─→ NATS sender.id.verified.v1

Admin → activate

├─→ state ACTIVE + cache invalidation

└─→ NATS sender.id.activated.v1 ──► compliance-engine cache refresh
└─► routing-engine cache refresh
└─► sms-firewall-service cache refresh

6. Failure Posture per Use Case

Use casePostgres downRedis downNATS downVault/HSM down
VerifyTry Redis; on miss → status: UNKNOWN; metric sid_verify_db_unavail_totalBypass cache; DB direct (P95 degrades to ~30 ms)Local cache stale OKn/a
Submit503 DEPENDENCY_UNAVAILABLESubmit completes; cache populates lazilyOutbox row written; relay drains when NATS returnsReject 503 (cannot encrypt KYC)
Verify-DNS check503DB directn/an/a
Verify-OTP submit503OTP unsubmittable (no plaintext to compare) → 503 retryn/an/a
Reputation cronSkip cycle; alertSkip cycle; alertOutbox queues; relay drainsn/a
Regulator exportDefern/aOutbox queuesCannot sign — defer + alert