sender-id-registry-service — Domain Model
Version: 1.0 Status: Draft Owner: Trust & Safety + Regulator-facing Last Updated: 2026-04-21 Companion: SERVICE_OVERVIEW · APPLICATION_LOGIC · DATA_MODEL · EVENT_SCHEMAS · API_CONTRACTS · SECURITY_MODEL
1. Bounded Context
National Sender-ID Authority. This service owns the canonical record of every alphanumeric sender-ID, short-code, and long-code authorised on the Ghasi national SMS backbone, including KYC of the registrant, verification artefacts, lifecycle state, reputation, and the regulator-facing export to ATRA. It is a peer to compliance-engine, routing-engine, and sms-firewall-service; none of those services maintain their own copy of sender-ID truth — they consume Verify(senderId, tenantId) over gRPC.
The benchmark precedents this service mirrors:
- India DLT (Distributed Ledger Telecom) — Principal Entity registration + Header registration + Template registration enforced via mandatory pre-clearance.
- KSA CITC Sender-ID Registry — bank/government category enforcement and notarised verification.
- US 10DLC Brand & Campaign Registry (TCR) — brand vetting + use-case categorisation + reputation throttling.
The context boundary is drawn such that:
- Inside the boundary: sender-IDs (alpha/short/long), KYC documents (S3 references + hashes), verification challenges & artefacts, restricted-name pattern catalogue, registry state machine, reputation snapshots, regulator export records, audit log.
- Outside the boundary: message storage (
sms-orchestrator), per-message rule evaluation (compliance-engine), routing decisions (routing-engine), tenant identity (auth-service), MSISDN inventory & leasing (numbering-service), fraud detection itself (fraud-intel-serviceproduces signals that feed reputation; this service only consumes them), DLR processing (dlr-processor), tenant compliance score (compliance-engineowns it).
Per ADR-0004 §3, sender-id-registry-service is one of the twelve new bounded contexts introduced for national-backbone GA. Per ADR-0004 §5, sender-ID data is multi-master across kbl and mzr with conflict-free updates (logical replication + per-row LWW with HLC) — this is control-plane data, not data-plane traffic.
2. Aggregates
SenderId
The root aggregate. One row per registered sender-ID across the national platform. Once REVOKED, the value is reserved for 12 months (US-SID-014) and any re-registration must traverse the full registration + verification cycle.
| Field | Type | Notes |
|---|---|---|
senderIdInternalId | UUIDv4 | Internal identity (stable across renames) |
value | SenderIdValue VO | Normalised display form (e.g. BANK-XYZ, 7000, +93701234567) |
type | enum SenderIdType | ALPHA · SHORT · LONG |
category | enum SenderIdCategory | BANKING · GOVERNMENT · HEALTHCARE · UTILITIES · MNO_INTERNAL · RETAIL · TRANSPORT · EDUCATION · OTHER |
tenantId | UUIDv4 | Owning tenant (the registrant tenant, not the brand) |
registrantOrgName | string | Legal name of the registering organisation, as filed in commercial registry |
registrantContactEmail | string | Verified email of registrant compliance officer |
registrantContactMsisdn | E.164 | Used for OTP verification |
restrictedPatternId | UUIDv4 | null | Set when value matched a RestrictedPattern at submission time |
requiredVerificationLevel | VerificationLevel | Minimum level required for ACTIVE state (derived from category + restricted-pattern match) |
currentVerificationLevel | VerificationLevel | Currently achieved level (see §3) |
state | RegistryState | See §4 — state machine |
reservedUntil | Instant | null | For REVOKED rows, the date the value becomes available again (12 months) |
firstSubmittedAt, kycApprovedAt, verifiedAt, activatedAt, suspendedAt, revokedAt, lastReviewedAt | Instant | null | Lifecycle timestamps |
lastSuspendReason, lastRevokeReason | string | null | Mandatory on transition |
version | int | Optimistic-lock counter |
createdAt, updatedAt | Instant | — |
Invariants
value+typeis globally unique across allstate ∈ {SUBMITTED, KYC_REVIEW, KYC_APPROVED, VERIFIED, ACTIVE, SUSPENDED}rows.REVOKEDrows reserve the value untilreservedUntil.type = ALPHA:valuematches^[A-Za-z0-9]{1,11}$(per 3GPP TS 23.038, GSM-7 alphanumeric, 11-char alpha-ID limit). Lowercase normalised to uppercase for uniqueness checks.type = SHORT:valuematches^[0-9]{4,6}$.type = LONG:valuematches E.164 (^\+[1-9][0-9]{6,14}$).restrictedPatternId IS NOT NULL⇒requiredVerificationLevel = NOTARISEDAND a regulator-letter document is present inKycDocumentset (US-SID-005).- Transition into
ACTIVErequirescurrentVerificationLevel ≥ requiredVerificationLevel(ordering in §3). state = REVOKEDis terminal; no transitions out. Re-registration creates a newSenderIdrow only afternow() ≥ reservedUntil.tenantIdis immutable afterKYC_APPROVED. Transfers of brand ownership requireREVOKE+ new registration.
KycDocument
A KYC artefact attached to a SenderId. The actual file lives in object storage (per-tenant encrypted bucket); this aggregate stores the pointer, hash, classification, and audit trail of who has viewed it.
| Field | Type | Notes |
|---|---|---|
kycDocId | UUIDv4 | Identity |
senderIdInternalId | UUIDv4 | Parent |
docType | enum | COMMERCIAL_LICENCE · NATIONAL_ID · REGULATOR_LETTER · NOTARISED_AUTHORITY · BOARD_RESOLUTION · DOMAIN_OWNERSHIP_PROOF · OTHER |
s3Ref | string | s3://ghasi-sid-kyc-{region}/{tenantId}/{kycDocId}.{ext} |
mimeType | string | application/pdf · image/jpeg · image/png · image/heic |
sizeBytes | int | ≤ 25 MB enforced at upload |
sha256Hex | string | SHA-256 of the encrypted blob; immutable post-upload |
encryptionKeyId | string | Vault Transit key reference (per-tenant KEK) |
uploadedBy | UUIDv4 | User who uploaded |
uploadedAt | Instant | — |
verificationOutcome | enum | null | ACCEPTED · REJECTED · PENDING |
verificationNotes | string | null | Reviewer notes on this specific document |
Invariants
sha256Hexis computed at upload time and never recomputed; tamper detection runs nightly comparing stored hash to S3 ETag-extended attribute.- A
SenderIdwithrestrictedPatternId IS NOT NULLMUST have ≥ 1KycDocumentofdocType = REGULATOR_LETTERand ≥ 1 ofdocType = NOTARISED_AUTHORITY. - KYC documents are never deleted — on tenant erasure, the row is tombstoned and the S3 blob is overwritten with a sealed redaction marker (per GDPR Art. 17 reconciled with regulatory evidence retention).
Verification
A verification challenge + outcome record. One SenderId may accumulate many Verification rows over its lifetime (multiple attempts, level upgrades, periodic re-verification).
| Field | Type | Notes |
|---|---|---|
verificationId | UUIDv4 | Identity |
senderIdInternalId | UUIDv4 | Parent |
method | enum VerificationMethod | OTP · DOCUMENT · NOTARISED · DOMAIN_DNS |
challenge | JSONB | Method-specific (DNS TXT value to publish, OTP hash, doc reference) |
attempts | int | Count of validation attempts on this challenge |
maxAttempts | int | Method-specific cap (OTP=3, DNS=5/24h, NOTARISED=N/A) |
state | enum | PENDING · IN_PROGRESS · SUCCEEDED · FAILED · EXPIRED |
expiresAt | Instant | OTP=5 min; DNS=24 h window; DOCUMENT/NOTARISED=14 d reviewer SLA |
levelOnSuccess | VerificationLevel | Level granted on SUCCEEDED |
attemptedBy | UUIDv4 | Initiating user |
succeededAt, failedAt, failureReason | — | — |
Invariants
attempts ≤ maxAttempts. On overflow, state becomesFAILEDwithfailureReason = max_attempts_exceeded.OTPchallenges store only the SHA-256 hash of the code; the plaintext OTP exists for at most 5 min in Redis.DOMAIN_DNSchallenge value issha256(senderIdInternalId || nonce)truncated to 32 chars, expected as_ghasi-sid-verify.{domain}TXT record.- Successful
Verificationwrites updateSenderId.currentVerificationLeveltoMAX(prev, levelOnSuccess)(level monotone-increasing within a registration).
RestrictedPattern
The platform's catalogue of regex patterns matching impersonation-prone names. Maintained by Trust & Safety + Legal; admin-only CRUD.
| Field | Type | Notes |
|---|---|---|
patternId | UUIDv4 | Identity |
pattern | string | Compiled with re2; ReDoS-screened |
category | enum | BANK · GOV · MNO · JUDICIAL · HEALTH · EMERGENCY · OTHER_RESERVED |
requiredVerificationLevel | VerificationLevel | Minimum (typically NOTARISED) |
requiredDocTypes | enum[] | E.g. [REGULATOR_LETTER, NOTARISED_AUTHORITY] |
regulatorRef | string | null | ATRA circular / law reference justifying restriction |
isActive | boolean | Soft-disable |
notes | string | Reviewer-facing rationale |
createdBy, updatedBy, createdAt, updatedAt, version | — | Audit |
Invariants
- A submission matching ≥ 1 active pattern is forced to the strictest matching pattern's requirements (max of required levels, union of required doc types).
- Patterns are never deleted — soft-disable only — so historical match decisions remain explainable.
- Default seed patterns (US-SID-005):
^BANK[A-Z0-9]*$,^GOV[A-Z0-9]*$,^MOJ[A-Z0-9]*$,^AWCC[A-Z0-9]*$,^ROSHAN[A-Z0-9]*$,^ETISALAT[A-Z0-9]*$,^MTN[A-Z0-9]*$,^SALAAM[A-Z0-9]*$,^DAB[A-Z0-9]*$(Da Afghanistan Bank),^MOPH[A-Z0-9]*$(Ministry of Public Health),^ATRA[A-Z0-9]*$(regulator itself),^EMERG[A-Z0-9]*$,^POLICE[A-Z0-9]*$.
ReputationSnapshot
Append-only daily snapshot of a sender-ID's reputation score. The most recent row is the authoritative current score; history is preserved for trend analysis (US-SID-019) and for explaining auto-suspension decisions.
| Field | Type | Notes |
|---|---|---|
senderIdInternalId | UUIDv4 | — |
snapshotAt | Instant | Daily 00:30 UTC + intra-day delta updates |
score | int 0..100 | Per ReputationScore VO |
complianceHits7d, complaints7d, fraudHits7d, deliveryRate7d | — | Inputs (cached for UI explainability) |
delta | int | Change from previous snapshot |
trigger | enum | DAILY_CRON · FRAUD_EVENT · COMPLIANCE_BLOCK · MANUAL_RESET |
triggerEventId | UUIDv4 | null | For non-cron triggers |
Invariants
- Append-only — UPDATE/DELETE rejected at the DB level (see DATA_MODEL §3.1).
scoreis clamped to[0, 100]. The cron computation may produce out-of-range arithmetic; the persistence layer clamps before INSERT.- A sender-ID without ≥ 1 snapshot defaults to neutral 50 in
Verifyresponses (no signal yet).
RegulatorExport
A record of each export of the registry to ATRA (US-SID-016). Maintained for regulator-side reconciliation and for our own audit of what ATRA has been told.
| Field | Type | Notes |
|---|---|---|
exportId | UUIDv4 | Identity |
exportedAt | Instant | When the file was generated |
windowFrom, windowTo | Instant | Export coverage |
format | enum | JSONL · CSV · XML_ATRA_v1 |
s3Ref | string | s3://ghasi-sid-regulator-export-{region}/{exportId}.{ext} |
s3RefSignature | string | Detached signature using regulator-export key (HSM-held) |
rowCount | int | Sender-IDs included |
transmittedTo | string | null | SFTP host or API endpoint receipt |
transmittedAt | Instant | null | — |
acknowledgmentRef | string | null | ATRA-side receipt id |
triggeredBy | enum | CRON · ON_DEMAND · REGULATOR_REQUEST |
Invariants
- The export file is immutable post-creation; corrections require a new
RegulatorExportrow withformat = XML_ATRA_v1adjustment record (analogous to CDR adjustment per ADR-0004 §15). - KYC document content is never included in any export (per US-SID-016) — only registry metadata.
AuditEntry
Append-only audit row for every state change, KYC document view, restricted-pattern change, and regulator export. Long retention (≥ 13 months hot, ≥ 7 years cold per ADR-0004 §9).
| Field | Type | Notes |
|---|---|---|
auditId | UUIDv4 | — |
entityType | enum | SENDER_ID · KYC_DOC · KYC_DOC_VIEW · VERIFICATION · RESTRICTED_PATTERN · REGULATOR_EXPORT · REPUTATION |
entityId | UUIDv4 | — |
action | enum | CREATE · UPDATE · APPROVE · REJECT · REQUEST_INFO · SUSPEND · REACTIVATE · REVOKE · VIEW · EXPORT · RESET |
actorUserId | UUIDv4 | — |
actorRole | string | Captured at the moment of the action |
before, after | JSONB | Snapshot |
reason | string | Mandatory on suspend/revoke/reject |
ip, userAgent, traceId | — | Forensic |
occurredAt | Instant | — |
Invariants
- Append-only at the database level (Postgres rules reject UPDATE/DELETE — see DATA_MODEL §3.1).
- Every
SUSPEND,REVOKE,REJECT,REQUEST_INFOaction requires a non-emptyreason; enforced at the API boundary and re-checked in the persistence layer. - KYC document views write
entityType = KYC_DOC_VIEWso unauthorised reading patterns are detectable.
3. Value Objects
| VO | Shape | Invariants |
|---|---|---|
SenderIdValue | string normalised | Trim; for ALPHA upper-case for uniqueness; for SHORT strip non-digits; for LONG enforce E.164 |
SenderIdType | ALPHA | SHORT | LONG | — |
VerificationLevel | NONE | OTP | DOCUMENT | NOTARISED | DOMAIN_DNS | Ordering: NONE < OTP < DOCUMENT < NOTARISED. DOMAIN_DNS is orthogonal — a sender-ID can hold both NOTARISED and DOMAIN_DNS simultaneously. The combined level (NOTARISED + DOMAIN_DNS) is required by P0/P1 lanes for restricted categories. |
RegistryState | SUBMITTED | KYC_REVIEW | KYC_APPROVED | KYC_REJECTED | INFO_REQUESTED | VERIFIED | ACTIVE | SUSPENDED | REVOKED | See §4 |
ReputationScore | int 0..100 | Clamp; < 30 ⇒ AUTO_SUSPEND; 30..49 ⇒ POOR; 50..69 ⇒ NEUTRAL; 70..89 ⇒ GOOD; ≥ 90 ⇒ EXCELLENT |
VerifyContext | { senderIdValue, senderIdType, tenantId } | Used by Verify gRPC; tenantId cross-checked against SenderId.tenantId |
4. Registry State Machine
┌───────────┐
│ SUBMITTED │ ← POST /v1/sender-ids
└─────┬─────┘
│ assignReviewer / KYC complete
▼
┌───────────────┐
│ KYC_REVIEW │
└──┬─────┬──┬───┘
request_info│ reject│ approve
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌──────────────┐ ┌──────────────┐
│ INFO_REQUESTED │ │ KYC_REJECTED │ │ KYC_APPROVED │
└────────┬────────┘ └──────────────┘ └──────┬───────┘
│ resubmit │ first verification succeeds
└──────────────────────► KYC_REVIEW │
▼
┌─────────────┐
│ VERIFIED │
└──────┬──────┘
│ admin activate
▼
┌─────────────┐
┌────────────────────────────│ ACTIVE │◄───┐
│ reactivate └──────┬──────┘ │
│ │ suspend │
▼ ▼ │
┌─────────────┐ ┌─────────────┐ │
│ REVOKED │ ◄───── revoke ──────│ SUSPENDED │────┘
└─────────────┘ └─────────────┘
(terminal, (auto on rep<30
12-mo reserved) or manual)
Allowed transitions (enforced as a database CHECK + service-layer guard):
| From | To | Trigger | Authoriser |
|---|---|---|---|
| (n/a) | SUBMITTED | Tenant submits | Tenant |
SUBMITTED | KYC_REVIEW | Reviewer claims | T&S reviewer |
KYC_REVIEW | KYC_APPROVED | Reviewer approves | T&S reviewer |
KYC_REVIEW | KYC_REJECTED | Reviewer rejects | T&S reviewer |
KYC_REVIEW | INFO_REQUESTED | Reviewer requests info | T&S reviewer |
INFO_REQUESTED | KYC_REVIEW | Tenant resubmits | Tenant |
KYC_APPROVED | VERIFIED | First successful verification ≥ required level | system |
VERIFIED | ACTIVE | Admin activate | T&S admin |
ACTIVE | SUSPENDED | Manual or auto (rep < 30) | T&S admin / system |
SUSPENDED | ACTIVE | Reactivate (with remediation evidence) | T&S admin |
SUSPENDED | REVOKED | Revoke for cause | T&S admin |
ACTIVE | REVOKED | Revoke for cause | T&S admin |
REVOKED | (none) | terminal | — |
KYC_REJECTED | (none) | terminal — re-registration creates a new row | — |
5. Domain Events (produced)
Detailed schemas in EVENT_SCHEMAS.md. Stream SENDER_ID_EVENTS (mirrored across kbl ↔ mzr per ADR-0004 §13).
| Event | Trigger |
|---|---|
sender.id.submitted.v1 | Tenant submits via POST /v1/sender-ids |
sender.id.kyc_approved.v1 | Reviewer approves KYC |
sender.id.kyc_rejected.v1 | Reviewer rejects |
sender.id.info_requested.v1 | Reviewer requests more info |
sender.id.verified.v1 | A successful Verification raises currentVerificationLevel |
sender.id.activated.v1 | Admin transitions to ACTIVE |
sender.id.suspended.v1 | Manual or auto suspension |
sender.id.reactivated.v1 | Reactivation from SUSPENDED |
sender.id.revoked.v1 | Revoke transition |
sender.id.reputation.changed.v1 | Reputation crosses 30/50/70/90 boundary |
sender.id.regulator.exported.v1 | Regulator export completes |
6. Reputation Scoring Formula
The daily cron (US-SID-017, see APPLICATION_LOGIC §1 UC-12) computes:
score = 100
− (complianceHits_7d × 2)
− (complaints_7d × 5)
− (fraudHits_7d × 10)
− ((1 - deliveryRate_7d) × 30)
score = clamp(score, 0, 100)
Inputs:
complianceHits_7d— count ofcompliance.message.blocked.v1andcompliance.message.held.v1events whosesenderIdmatches, in the trailing 7 days.complaints_7d— count of citizen complaints fromregulator-portal-servicecomplaint ingest in the trailing 7 days.fraudHits_7d— weighted count offraud.detected.*events (AIT=1, SIM-box=2, OTP harvesting=3) in the trailing 7 days.deliveryRate_7d— DLR success ratio fromdlr-processoraggregates in the trailing 7 days.
Threshold actions:
score < 30⇒ auto-suspend (US-SID-012). Tenant notified; T&S can override.score ∈ [30, 49]⇒ POOR — eligible for restricted-lane only (P3/P4); not allowed on P1 OTP lane.score ∈ [50, 69]⇒ NEUTRAL — default after first successful registration.score ∈ [70, 89]⇒ GOOD.score ≥ 90⇒ EXCELLENT — eligible for trusted-tenant fast path (per ADR-0004 §10).
7. Global Invariants
- Public-but-private. The fact of registration (value, registrant org name, verification level, status) is public. KYC documents are never public (US-SID-015).
- Audit-immortal. Sender-IDs are never hard-deleted. Every state change is captured in
AuditEntrywith before/after JSON. - Restricted-name protection. A submission whose normalised value matches an active
RestrictedPatterncannot reachACTIVEwithoutNOTARISEDAND a regulator-letter document. This invariant is enforced at submission (refuses incomplete submissions) and re-checked at activation. - Verification monotone.
currentVerificationLevelnever decreases through a successfulVerification. Suspension/revocation does not lower the level — it gates use, not the achieved level. - Tenant isolation on KYC. A tenant can only fetch
KycDocumentbelonging to aSenderIdthey own. T&S reviewer access is logged asKYC_DOC_VIEW. - Verify is the source of truth. Consumers (
compliance-engine,routing-engine,sms-firewall-service) never read the registry tables directly. They callVerifyover gRPC and respect cache invalidation events. - Fail-closed on
Verify. IfVerifycannot return a verdict (DB down, cache cold-miss + DB timeout), the consumer treats it asstatus: UNKNOWNand applies the fail-closed posture for its lane (compliance HOLDs the message; routing rejects withsender_id_unknown). - 12-month name reservation on revoke.
valuereserved byreservedUntil = revokedAt + 365dto prevent drive-by re-registration of revoked impersonator names.