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:
- Authn / Authz. Kong has validated tenant JWT. Service confirms
X-Tenant-Idmatches the JWT-bound tenant (defence in depth). - Idempotency.
Idempotency-Keyheader (mandatory) →SET NX EX 86400 sid:idem:{tenantId}:{key}. Replay returns the original 201. - Normalise
value. ApplySenderIdValueVO normalisation rules from DOMAIN_MODEL §3: trim, upper-case alpha, strip non-digits for short, validate E.164 for long. - Shape validation. Reject 400
SID_VALUE_INVALIDif regex per type fails. - Uniqueness check. SELECT against
sender_idsfor active states +REVOKED with reservedUntil > now(). On conflict → 409SID_VALUE_TAKENwithreservedUntilif revoked-reserved. - Restricted-pattern match. Run
valuethrough every activeRestrictedPattern.pattern(re2). On any match: capturerestrictedPatternId, deriverequiredVerificationLevelandrequiredDocTypes. Reject 422SID_RESTRICTED_REQUIREMENTS_UNMETif submission lacks the required doc types. - 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
sha256Hexfor tamper detection at handoff. On mismatch → 422SID_KYC_HASH_MISMATCH. - Insert
kyc_documentsrow.
- Insert
sender_idsrow in stateSUBMITTEDwithfirstSubmittedAt = now(). - Outbox. Insert
sender.id.submitted.v1row inoutboxtable within the same transaction. - Cache invalidation.
DEL sid:verify:{senderIdValue}:{type}(covers any stale lookup). - 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:
POST /v1/admin/sender-ids/{id}/claim— atomic UPDATE to bind reviewer; rejects 409 if already claimed by someone else within the SLA window.- Reviewer fetches KYC documents inline (UC-03).
- Reviewer
POST /v1/admin/sender-ids/{id}/decisionwith{ action: 'APPROVE' | 'REJECT' | 'REQUEST_INFO', reason, missingDocTypes? }:APPROVE→ stateKYC_APPROVED, setkycApprovedAt, emitsender.id.kyc_approved.v1.REJECT→ stateKYC_REJECTED(terminal — re-submission requires a new row), emitsender.id.kyc_rejected.v1with reason.REQUEST_INFO→ stateINFO_REQUESTED, emitsender.id.info_requested.v1with checklist.
- Audit row written with
before/aftersnapshots, actor, role, IP. - Notification fans out via
notification-serviceto 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:
GET /v1/admin/sender-ids/{id}/kyc-docs/{kycDocId}/viewreturns a 15-minute pre-signed URL.- 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). - Audit row written:
entityType = KYC_DOC_VIEW,entityId = kycDocId,actorUserId,ip. - 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}.pdfwithExpires=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:
POST /v1/sender-ids/{id}/verificationswith{ method: 'OTP' }. Service:- Generates a 6-digit OTP (cryptographically random).
- Stores
sha256(otp)inverifications.challenge; plaintext OTP in Redissid:otp:{verificationId}EX 300. - Submits via
channel-router-serviceon laneP1toregistrantContactMsisdnwith templatesid_otp_verification. - Rate-limit:
INCR sid:otp:rl:{registrantMsisdn}:{hour}≤ 3.
- Tenant
POST /v1/sender-ids/{id}/verifications/{verificationId}/submitwith{ otp }. Service:INCR verifications.attempts. If > 3 →FAILEDwithfailureReason = max_attempts_exceeded.- Compare
sha256(submitted)to stored. On match →SUCCEEDED, raisecurrentVerificationLeveltoOTP. - Emit
sender.id.verified.v1if level changed.
- 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:
POST /v1/sender-ids/{id}/verificationswith{ method: 'DOMAIN_DNS', domain }. Service:- Validates domain shape (RFC 1035 + IDN allowed).
- Generates
nonce = base64url(random(16)); computeschallengeValue = 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}' }.
- 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.1and8.8.8.8for two-resolver consensus. - On match →
SUCCEEDED, raisecurrentVerificationLevelto includeDOMAIN_DNS(orthogonal flag, see DOMAIN_MODEL §3). - On mismatch →
INCR verifications.attempts. If > 5 in rolling 24 h →FAILED; tenant must restart.
- Resolves the TXT record using a DoT (DNS-over-TLS) resolver pinned at
- Background poller
dns-verification-pollerre-checksIN_PROGRESSDNS verifications every 30 minutes for 24 h before markingEXPIRED.
UC-06 · VerifyNotarised (Reviewer dual-control)
Trigger: Reviewer manually verifies a notarised document attached to a SenderId (US-SID-008).
Steps:
- Primary reviewer inspects the notarised document via UC-03, validates the notary against the platform whitelist (per SID-OPEN-002).
- Primary reviewer
POST /v1/admin/sender-ids/{id}/verifications/{verificationId}/notarised-approvewith{ notaryRef, notaryWhitelistEntryId, notes }. State →IN_PROGRESSpending second reviewer. - Second reviewer (different
userId, alsoplatform.sid.reviewer)POST .../notarised-co-approve. Service:- Asserts
actorUserId != primaryReviewerUserId. - Marks
Verification.state = SUCCEEDED, raisescurrentVerificationLeveltoNOTARISED. - Emits
sender.id.verified.v1.
- Asserts
- 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:
GET sid:verify:{senderId}:{type}:{tenantId}from Redis (TTL 300 s). On HIT → return.- On MISS → SELECT from
sender_idsindexed by(value, type):state = ACTIVEANDtenantIdmatches →status = ACTIVE.state = ACTIVEANDtenantIdmismatches →status = TENANT_MISMATCH(impersonation attempt).state ∈ {SUSPENDED, REVOKED}→ corresponding status.- Not found →
status = UNKNOWN.
- Hydrate latest
ReputationSnapshot.score(default 50 if none). - Compose response;
SET sid:verify:...EX 300. - 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}/suspendwith{ reason }. - Auto (US-SID-012): reputation cron detects
score < 30for anACTIVEsender-ID.
Steps:
- Check current state ∈
{ACTIVE}. Reject 409 otherwise. - UPDATE state →
SUSPENDED; setsuspendedAt,lastSuspendReason. - Outbox
sender.id.suspended.v1. - Cache invalidation per UC-08 (tight 30 s SLA per US-SID-011 AC-3).
- Audit row.
UC-11 · Reactivate (US-SID-013)
Trigger: Admin POST /v1/admin/sender-ids/{id}/reactivate with { reason, remediationEvidenceUrl }.
Steps:
- State must be
SUSPENDED. - Validate
remediationEvidenceUrlbelongs to the platform's evidence S3 bucket. - UPDATE state →
ACTIVE; reset reputation snapshot to neutral 50; flipprobationFlag = truefor 30 days. - 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:
- State must be ∈
{ACTIVE, SUSPENDED}. - UPDATE state →
REVOKED; setrevokedAt = now(),reservedUntil = now() + 365d,lastRevokeReason. - Outbox
sender.id.revoked.v1. - Cache invalidation.
- Re-registration of the
valueis impossible untilreservedUntil. After expiry, a new submission creates a newsenderIdInternalIdand re-runs the full flow.
UC-13 · PublicSearch (Citizen)
Trigger: Anonymous GET /v1/sender-ids/public/search?q=BANK-XYZ (US-SID-015).
Steps:
- Edge cache (Cloudflare) keyed
sid_pub:{q}TTL 60 s. - Origin:
GET sid:public_search:{q}Redis TTL 300 s. - Origin miss: SELECT registry rows where
LOWER(value) ILIKE LOWER(q)LIMIT 20, projecting only public fields:value,registrantOrgName,verificationLevel,state,firstSubmittedAt. - Per-IP rate limit:
INCR sid:public_search:rl:{ip}:{minute}≤ 100; over → 429SID_PUBLIC_SEARCH_RATE_LIMITEDplus tarpit (1 s sleep then 503). - Suspicious-pattern detection:
> 1000distinct queries in 1 h from one IP → soft-block + alertSidPublicSearchAbuse.
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/exportwith{ from, to, format }(US-SID-016).
Steps:
- Acquire Redis distributed lock
sid:export:lockSET NX EX 1800. - 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. - Sign the file with the regulator-export key (HSM-held PKCS#11) producing a detached
.sig. - Upload both to
s3://ghasi-sid-regulator-export-{region}/{exportId}.{ext}. - Insert
regulator_exportsrow. - SFTP transmit to ATRA endpoint configured in
regulator_export_targets. Capture acknowledgement reference. - 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:
- Acquire
sid:reputation:lock:dailySET NX EX 7200. - For each
ACTIVEandSUSPENDEDsender-ID:- Query
compliance.evaluation_logcount bysenderIdover trailing 7 d. - Query
regulator-portal-service.complaintscount bysenderIdover trailing 7 d. - Query
fraud-intel-service.fraud_eventsweighted count over trailing 7 d. - Query
dlr-processoraggregates for delivery rate over trailing 7 d. - Apply formula from DOMAIN_MODEL §6.
- Clamp to
[0, 100]. - INSERT
reputation_historyrow withtrigger = DAILY_CRON.
- Query
- For threshold crossings (30, 50, 70, 90): outbox
sender.id.reputation.changed.v1with previous & new score. - For
score < 30onACTIVE: invoke UC-10 (auto-suspend). - Cache write:
SET sid:reputation:{senderId}EX 86400.
Intra-day delta worker:
- Consumer groups:
sid-reputation-fraudonfraud.detected.*,sid-reputation-complianceoncompliance.message.blocked.v1. - Per event: compute delta, INSERT
reputation_historywithtrigger = FRAUD_EVENT/COMPLIANCE_BLOCK, update Redis cache. - Threshold-crossing emits the same event as cron.
2. Performance & Latency Budgets
| Operation | Budget | Hot path? |
|---|---|---|
Verify gRPC | P95 ≤ 5 ms, P99 ≤ 15 ms | Yes — called per message |
GetReputation gRPC | P95 ≤ 5 ms | Yes |
POST /v1/sender-ids | P95 ≤ 400 ms | No (registration is rare) |
| Public search | P95 ≤ 100 ms (with edge cache); P95 ≤ 250 ms cache miss | Public — must be DDoS-resistant |
| KYC view (watermarked) | P95 ≤ 1.5 s for ≤ 5 MB doc | No |
| Daily reputation cron | full recompute < 30 min for 1 M sender-IDs | No |
| Regulator export | < 10 min for full registry | No |
3. Caching Strategy
| Cache | Key | TTL | Population |
|---|---|---|---|
| Verify hot | sid:verify:{value}:{type}:{tenantId} | 300 s | Read-through; invalidated on state change |
| Reputation hot | sid:reputation:{senderIdInternalId} | 86400 s | Cron + intra-day delta |
| Public search | sid:public_search:{normalisedQuery} | 300 s + 60 s edge | Read-through |
| OTP plaintext | sid:otp:{verificationId} | 300 s | Submit-time write only |
| Restricted-pattern compiled regex | in-process Map<patternId, RegExp> | 5 min refresh | Loader poll |
| Notary whitelist | in-process Set<notaryId> | 5 min refresh | Loader poll |
4. Concurrency & Idempotency
- Submission idempotency: mandatory
Idempotency-Keyheader per UC-01. - State transitions: optimistic lock via
versioncolumn; conflicting writer gets 409SID_VERSION_CONFLICTand must re-fetch. - Verification submit: atomic
INCR + COMPAREagainst 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 case | Postgres down | Redis down | NATS down | Vault/HSM down |
|---|---|---|---|---|
Verify | Try Redis; on miss → status: UNKNOWN; metric sid_verify_db_unavail_total | Bypass cache; DB direct (P95 degrades to ~30 ms) | Local cache stale OK | n/a |
| Submit | 503 DEPENDENCY_UNAVAILABLE | Submit completes; cache populates lazily | Outbox row written; relay drains when NATS returns | Reject 503 (cannot encrypt KYC) |
| Verify-DNS check | 503 | DB direct | n/a | n/a |
| Verify-OTP submit | 503 | OTP unsubmittable (no plaintext to compare) → 503 retry | n/a | n/a |
| Reputation cron | Skip cycle; alert | Skip cycle; alert | Outbox queues; relay drains | n/a |
| Regulator export | Defer | n/a | Outbox queues | Cannot sign — defer + alert |