SMS Firewall Service — Domain Model
Version: 1.0 Status: Draft Owner: Trust & Safety Last Updated: 2026-04-21 Companion: SERVICE_OVERVIEW · APPLICATION_LOGIC · API_CONTRACTS · DATA_MODEL · EVENT_SCHEMAS · SECURITY_MODEL Related ADR: ADR-0004 National-Backbone Resilience §3
1. Bounded Context
National Perimeter / Trust & Safety. The sms-firewall-service owns every policy decision about whether an SMS PDU may enter the platform — both inbound MO (deliver_sm from MNO peers) and inbound transit MT (submit_sm from peer aggregators) — together with the regulator-grade evidence trail that proves it. It is a peer to compliance-engine (outbound tenant SMS), consent-ledger-service (subscriber consent), and routing-engine (egress operator selection); it is not an aspect of any of those services.
The context boundary is drawn such that:
- Inside the boundary: firewall rules, rule versions, verdicts, evaluation log (audit), national + federated blocklists, blocklist history, MNO bind registry, peer ASN allowlist, peer sender-ID allowlist, peer-aggregator hygiene scores, peer quarantine, AIT/SIM-box signatures, DND projection, quarantine queue, operating-mode switches.
- Outside the boundary: subscriber consent ownership (
consent-ledger-service), ML model training (fraud-intel-service), sender-ID registration (sender-id-registry-service), HLR/MNP truth (number-intelligence-service), CDR generation (cdr-mediation-service), routing decisions (routing-engine), tenant identity (tenant-service/auth-service).
The firewall consumes signals from these adjacent services but never persists their authoritative state; it persists only its own verdicts and the evidence underpinning them.
2. Aggregates
2.1 FirewallRule
The authoring unit of perimeter policy. One rule is one CEL-style sandboxed predicate over a MoContext or TransitMtContext.
| Field | Type | Notes |
|---|---|---|
ruleId | UUIDv4 | Identity (external prefix fr_) |
name | string | Human-readable, unique within scope |
description | string | null | Reviewer-facing rationale |
scope | enum FirewallDirection | MO · TRANSIT_MT · EGRESS_DND_CHECK |
type | enum RuleType | ORIGIN_BLOCKLIST · CONTENT_KEYWORD · CONTENT_REGEX · RATE_VOLUME · GEO_RESTRICTION · DND_PRESENT · AIT_SIGNATURE · SIMBOX_SIGNATURE · GREY_ROUTE · SENDER_ID_VERIFY · PEER_ASN · CLASSIFIER · COMPOSITE |
expression | string | CEL-style expression over typed inputs (src.msisdn, dst.msisdn, mno.id, pdu.body, pdu.coding, peer.asn, consent.dndPresent, senderId) |
action | enum FirewallAction | ALLOW · FLAG · BLOCK · QUARANTINE · RATE_LIMIT |
blockReasonCode | enum BlockReason | null | Mandatory when action ∈ {BLOCK, QUARANTINE} |
priority | int | Lower = evaluated earlier (default 1000) |
severity | enum | CRITICAL · HIGH · MEDIUM · LOW |
enabled | boolean | Soft-disable without deletion |
version | int | Monotonic; bumped on every mutation |
createdBy, updatedBy | UUIDv4 | Operator identities |
createdAt, updatedAt, deletedAt | TIMESTAMPTZ | null | Soft-delete |
Invariants
scope = MOrules MUST NOT referencepeer.asn(transit-only field); admission rejects withRULE_INVALID_INPUT_REF.scope = TRANSIT_MTrules MUST NOT referenceconsent.dndPresent(no DND on transit MT — that is a routing-engine concern at egress).type = REGEXpatterns must compile under there2engine, pass the ReDoS screen, and be ≤ 500 chars (per SECURITY_MODEL §3).type = COMPOSITEchildren resolve withinmaxDepth = 4and contain no cycles (DFS at save time + runtime guard).type = CLASSIFIERrules must declare afallbackAction; the platform default isQUARANTINE(fail-closed per ADR-0004 §3).- Rule mutations are append-only at the version level — a
FirewallRuleVersionsnapshot is written for every change.
2.2 FirewallRuleVersion
Append-only snapshot of a FirewallRule at a specific version. Never mutated.
| Field | Notes |
|---|---|
ruleId + version | Composite identity |
snapshot | JSONB of full rule state |
changedBy | Operator UUID |
changedAt | TIMESTAMPTZ |
changeReason | string | null |
2.3 FirewallVerdict (transient value, persisted into evidence)
The atomic unit of evaluation output, returned over the gRPC response and persisted to firewall.audit.
| Field | Type | Notes |
|---|---|---|
verdictId | UUIDv4 | External prefix fv_ |
traceId | string | OTel trace context (W3C) |
verdict | enum FirewallAction | ALLOW · FLAG · BLOCK · QUARANTINE |
direction | enum FirewallDirection | MO · TRANSIT_MT |
mnoBindId | string | null | Present for MO; null for transit |
peerAsn | int | null | Present for transit; null for MO |
srcMsisdn | E.164 string | A-number |
dstMsisdn | E.164 string | B-number |
senderId | string | null | Alphanumeric or numeric originator (transit MT) |
pduFingerprint | string | sha256 of srcMsisdn:dstMsisdn:senderId:body (cache key) |
evaluatedRuleIds | UUIDv4[] | Ordered list of rules that ran |
ruleHits | RuleHit[] | Rules that matched (with ruleId, severity, evidence redacted) |
blockReason | enum BlockReason | null | Populated when verdict = BLOCK/QUARANTINE |
holdId | UUIDv4 | null | Present when verdict = QUARANTINE |
evaluationLatencyMs | int | Hot-path metric |
effectiveTtlSeconds | int | Caller may cache this verdict for repeat fingerprints |
evaluatedAt | TIMESTAMPTZ | UTC, microsecond precision |
flags | string[] | MAINTENANCE_MODE, RATE_GOVERNOR_DEGRADED, BLOOM_DEGRADED, NUMINT_UNAVAILABLE, BUDGET_EXCEEDED |
2.4 BlocklistEntry
A single blocklist tuple — the smallest unit a verdict can match.
| Field | Type | Notes |
|---|---|---|
entryId | UUIDv4 | External prefix be_ |
type | enum | MSISDN · MSISDN_RANGE · SENDER_ID · KEYWORD · KEYWORD_REGEX · MCC_MNC · PEER_ASN |
value | string | Canonicalised value (E.164 for MSISDN; uppercase for senderId; re2 for KEYWORD_REGEX) |
source | enum BlocklistSource | REGULATOR · PEER_MNO · INTERNAL · FRAUD_INTEL · OPERATOR_MANUAL |
regulatorRef | string | null | Regulator-issued reference (mandatory when source = REGULATOR) |
sources | JSONB | Array of {sourceId, sourceType, reportedAt} for federation |
confidenceScore | numeric(3,2) | 0.00–1.00 (see formula §6) |
autoApply | boolean | Derived: confidenceScore >= 0.8 |
shareWithPeers | boolean | If true, included in daily federation export |
active | boolean | Soft-delete flag |
addedBy | UUIDv4 | null | Operator (null for federation imports) |
addedAt, deactivatedAt, expiresAt | TIMESTAMPTZ | Lifecycle |
Invariants
(source, regulatorRef, type, value)is unique — federation replay is idempotent.activemay transitionTRUE → FALSE(soft-delete). OnceFALSE, the row is never deleted; it remains for regulator audit.- An entry with
confidenceScore < 0.4is auto-deactivated by the reputation worker.
2.5 NationalBlocklist
The authoritative collection of blocklist entries for a single direction (MO, TRANSIT_MT, or EGRESS_DND_CHECK).
| Field | Notes |
|---|---|
blocklistId | UUIDv4 (external prefix bl_) |
name | Human-readable (e.g. national-mo-blocklist, national-transit-mt-blocklist, dnd-snapshot) |
direction | enum FirewallDirection |
entryCount | int (cached; recomputed nightly) |
bloomFilterCapacity | int |
bloomFalsePositiveRate | numeric(4,3) (default 0.010) |
lastFederatedAt | TIMESTAMPTZ | null |
version | int |
entries | BlocklistEntry[] (1:N, lazy) |
2.6 FirewallAuditEntry
Append-only record of every evaluation (regardless of verdict). The primary regulator-defensible evidence feed.
| Field | Notes |
|---|---|
auditId | UUIDv4 |
verdictId | UUIDv4 (FK) |
traceId, evaluationLatencyMs | Operational |
verdict, direction, srcMsisdn, dstMsisdn, senderId, mnoBindId, peerAsn, pduFingerprint, blockReason, evaluatedRuleIds[], ruleHits (JSONB) | Verdict snapshot |
pduBodySha256 | hex string |
prevHash | hex string |
rowHash | hex string |
verdictAt | TIMESTAMPTZ |
Invariants
- Append-only at the database level. UPDATE/DELETE blocked by Postgres rules (see DATA_MODEL §3.1).
- Every row carries
rowHashchained to the previous row'srowHashwithin the partition, enabling offline integrity verification by the audit-verifier worker. - Retention ≥ 7 years for regulated deployments (ATRA national-asset SLA).
2.7 FirewallQuarantineEntry
A PDU placed on hold for NOC manual review.
| Field | Notes |
|---|---|
holdId | UUIDv4 (external prefix fq_) |
verdictId | UUIDv4 (FK to verdict that triggered hold) |
direction | MO · TRANSIT_MT |
originalPduCipher | bytea (AES-256-GCM, per-tenant or per-MNO KEK) |
triggerRuleIds | UUIDv4[] |
reasonCode | enum (e.g. SENDER_ID_UNKNOWN, PEER_HYGIENE_SCORE_LOW, FRAUD_INTEL_SIGNAL) |
status | enum |
reviewerUserId, reviewNotes, reviewedAt | Review fields |
heldAt, expiresAt | TIMESTAMPTZ (default expiresAt = heldAt + 24h) |
Invariants
- One-way state machine:
PENDING → REVIEWING → {RELEASED, REJECTED}.AUTO_EXPIREDreachable only fromPENDINGvia the auto-expiry cron. RELEASEDre-injects the original PDU through the originating connector viafirewall.quarantine.released.v1; the connector skips firewall re-evaluation (decision is governing).- After a terminal state, the row is retained 7 years in cold archive.
2.8 MnoBindRegistryEntry
The registry of every authorised smpp-connector-{mno}-{rx|trx|tx} deployment.
| Field | Notes |
|---|---|
mnoBindId | string PK (e.g. awcc-rx-01) |
mnoId | enum (AWCC, Roshan, Etisalat, MTN_AF, Salaam, Transit) |
direction | MO · MT · MO_MT |
permittedCountryCodes | string[] (E.164 CCs, e.g. ["+93"]) |
permittedSenderIds | string[] |
lastHeartbeatAt | TIMESTAMPTZ |
active | boolean |
2.9 PeerAggregator
External transit-MT peer (international or domestic non-MNO).
| Field | Notes |
|---|---|
peerId | UUIDv4 (external prefix pa_) |
peerSystemId | string (SMPP system_id) |
peerAsn | int (BGP ASN of source) |
permittedSenderIds | string[] |
permittedDstMnoIds | string[] (peer routes only to these MNOs) |
quarantined | boolean |
quarantinedReason | string | null |
hygieneScore | int 0–100 (rolling 24h) |
2.10 SimBoxSignal and AitPattern
Derived evidence aggregates fed by fraud-intel-service and the firewall's own per-source counters.
SimBoxSignal field | Notes |
|---|---|
signalId | UUIDv4 |
originator | E.164 string |
confidence | numeric(3,2) |
evidenceJson | JSONB (call-detail patterns, IMEI churn, A-number rotation) |
firstSeenAt, lastSeenAt | TIMESTAMPTZ |
active | boolean |
AitPattern field | Notes |
|---|---|
patternId | UUIDv4 |
patternType | OTP_HARVEST · PUMPED_TRAFFIC · IRSF (International Revenue Share Fraud per GSMA FF.21) |
dstMsisdnRange | string (e.g. +9370012XXXX) |
confidence, evidenceJson, firstSeenAt, lastSeenAt, active | As above |
3. Value Objects
| VO | Shape | Invariants |
|---|---|---|
Msisdn | E.164 string | Validated against ^\+[1-9]\d{6,14}$; canonicalised on construction |
SenderId | string ≤ 11 chars (alphanumeric) or E.164 numeric | Uppercased; whitespace trimmed; control chars rejected |
FirewallDirection | enum | MO · TRANSIT_MT · EGRESS_DND_CHECK |
FirewallAction | enum | ALLOW · FLAG · BLOCK · QUARANTINE · RATE_LIMIT |
BlockReason | enum | ORIGIN_BLOCKLIST · CONTENT_FORBIDDEN · RATE_EXCEEDED · GEO_FORBIDDEN · DND_PRESENT · AIT_SIGNATURE · SIMBOX_SIGNATURE · REGULATOR_BLOCK · PEER_ASN_UNKNOWN · SENDER_ID_SPOOFED · SENDER_ID_SUSPENDED · GREY_ROUTE · PEER_QUARANTINED |
MoContext | { srcMsisdn, dstMsisdn, mnoBindId, pduBody, pduCoding (0/3/8), pduTon, pduNpi, recvTs, traceId, smppSequenceNumber } | pduBody ≤ 1600 chars; recvTs within 60 s of now |
TransitMtContext | { peerAsn, peerSystemId, srcAddr, dstMsisdn, senderId, pduBody, pduTon, pduNpi, registeredDelivery, esmClass } | peerAsn ∈ [0, 4294967295] |
RuleHit | { ruleId, ruleName, ruleType, action, severity, evidence (redacted), confidence? } | evidence is redacted; matched span replaced by *** with ≥ 4-char prefix/suffix context |
PduFingerprint | sha256(srcMsisdn:dstMsisdn:senderId:body) | Deterministic; cache key (fw:verdict:{fp}) |
4. Domain Events (produced)
Detailed schemas in EVENT_SCHEMAS.md.
| Event | Trigger | Payload core |
|---|---|---|
firewall.audit.v1 | Every FilterInbound / EvaluateTransit call (regardless of verdict) | verdictId, verdict, direction, pduFingerprint, evaluatedRuleIds, ruleHits, blockReason?, holdId? |
firewall.alert.mo.blocked.v1 | Verdict = BLOCK on MO direction | verdictId, srcMsisdn (masked), dstMsisdn (masked), mnoBindId, blockReason |
firewall.alert.transit.blocked.v1 | Verdict = BLOCK on TRANSIT_MT | verdictId, peerAsn, peerSystemId, senderId, dstMsisdn (masked), blockReason |
firewall.alert.greyroute.detected.v1 | GREY_ROUTE rule triggered | peerAsn, dstHomeMnoId, evidence |
firewall.alert.greyroute.heuristic.v1 | Peer >30% MT to non-peered MNO over last 1000 submissions | peerId, nonPeeredFraction |
firewall.simbox.detected.v1 | New SimBoxSignal added with confidence ≥ 0.7 | originator, confidence, evidenceJson |
firewall.alert.ait.detected.v1 | New AitPattern added with confidence ≥ 0.7 | patternType, dstMsisdnRange, confidence |
firewall.quarantine.held.v1 | Verdict = QUARANTINE | holdId, triggerRuleIds, expiresAt |
firewall.quarantine.released.v1 | NOC release | holdId, reviewerUserId, reviewNotes |
firewall.quarantine.rejected.v1 | NOC reject | Same shape |
firewall.quarantine.expired.v1 | Auto-expiry | holdId, expiredAt |
firewall.blocklist.changed.v1 | Blocklist entry add/remove/deactivate | entryId, action, actorUserId |
firewall.blocklist.federated.v1 | Federation import complete | source, addedCount, removedCount, regulatorRef |
firewall.federation.exported.v1 | Daily export job complete | exportSha256, signature, presignedUrl |
firewall.federation.heartbeat.v1 | Daily even on zero-diff | exportedAt |
firewall.alert.federation.signature.invalid.v1 | HSM signature check failed | eventId, signer |
firewall.peer_quarantine.entered.v1 | Peer auto-quarantined (hygiene < 30) | peerId, score, reasons |
firewall.alert.peer.degraded.v1 | Hygiene score < 60 for 3 windows | peerId, scoreHistory |
firewall.rule.changed.v1 | Rule CRUD | entityType, entityId, action, version, actorUserId |
firewall.rule.degraded.v1 | Rule auto-disabled (e.g. regex timeout) | ruleId, reason |
firewall.mode.changed.v1 | Operating-mode switch | previousMode, newMode, actorUserIds[], reason |
firewall.alert.mode.auto_panic.v1 | Auto-trip to PANIC | latencyP95Ms, breachedFor |
firewall.alert.dnd.snapshot.stale.v1 | DND snapshot older than 6h | lastSnapshotAt, ageSeconds |
firewall.transit.unavailable.v1 | Connector observed firewall UNAVAILABLE | peerAsn, errorCode |
5. Consumed Events
| Subject | Producer | Effect |
|---|---|---|
fraud.detected.simbox.v1 | fraud-intel-service | Upsert SimBoxSignal; matching MOs auto-BLOCK with SIMBOX_SIGNATURE |
fraud.detected.ait.v1 | fraud-intel-service | Upsert AitPattern; matching MOs auto-BLOCK with AIT_SIGNATURE |
fraud.detected.greyroute.v1 | fraud-intel-service | Add implicated peer to firewall.peer_quarantine; future MT auto-QUARANTINE |
consent.dnd.snapshot.v1 | consent-ledger-service | Materialise national DND projection into fw:dnd:bloom and firewall.dnd_snapshot |
consent.revoked.v1 | consent-ledger-service | Add msisdn to in-memory DND delta until next snapshot rebuild |
regulator.blocklist.published.v1 | regulator-portal-service | Validate HSM signature; upsert into firewall.blocklist_entries with source=REGULATOR |
sender.id.suspended.v1 | sender-id-registry-service | Refresh local firewall.peer_senderid_allowlist cache; matching transit MT now BLOCK with SENDER_ID_SUSPENDED |
sender.id.registered.v1 | sender-id-registry-service | Refresh local cache for the affected peer |
6. Confidence-Score Formula (Federated Blocklist)
Blocklist entries carry a confidenceScore derived from federation source attribution:
confidence_score = clamp(
regulator_count * 1.0
+ peer_count * 0.5
+ internal_count * 0.7
+ fraud_intel_count * 0.6,
0.0, 1.0
)
regulator_count = count(sources WHERE sourceType='REGULATOR')
peer_count = count(sources WHERE sourceType='PEER_MNO')
internal_count = count(sources WHERE sourceType='INTERNAL' OR 'OPERATOR_MANUAL')
fraud_intel_count = count(sources WHERE sourceType='FRAUD_INTEL')
Tier mapping:
score ≥ 0.8→auto_apply = TRUE(MATCH → BLOCK)0.4 ≤ score < 0.8→ probation: MATCH → QUARANTINE for 24h, then re-evaluatescore < 0.4→ auto-deactivate; emitfirewall.blocklist.entry.deactivated.v1
7. Global Invariants
- Fail-closed (national-asset bar). No
FirewallVerdictother thanALLOWorFLAGallows a PDU to proceed. Absence of a verdict (timeout, Postgres unavailable + Redis cold) prevents proceeding — the connector returnsESME_RSUBMITFAIL(command_status = 0x00000045) for transit MT and routes MO to local-disk WAL for replay (preserving subscriber relationship per SERVICE_OVERVIEW §10). - Allowlist-first. Rules with
action = ALLOWevaluate before any restriction rule and short-circuit on match. - Append-only audit.
firewall.auditrejects UPDATE/DELETE at the DB level. Hash-chain integrity is verified offline by the audit-verifier worker every 24h. - No tenant ID on inbound MO firewall path. Inbound MO does not yet belong to a tenant — keying is by
srcMsisdn,dstMsisdn,mnoBindId. Tenant resolution is performed downstream byconsent-ledger/routing-engine. - Body confidentiality. Message body is never stored in plain text. Audit stores only
pduBodySha256. Quarantine stores AES-256-GCM ciphertext under per-MNO KEK; only NOC reviewers (role=tns-noc) with quorum can decrypt. - Rule selection determinism. Same
(MoContext|TransitMtContext, ruleSetVersion)always produces the same verdict; cached in Redis underfw:verdict:{pduFingerprint}foreffectiveTtlSeconds(default 60s for ALLOW, 0 for BLOCK to force re-evaluation). - Release is authoritative. Once NOC releases a quarantined PDU, re-injection skips the firewall (
skipFirewall: trueflag onfirewall.quarantine.released.v1) to prevent quarantine→release→quarantine loops. The reviewer's decision is governing, captured in audit. - Hash-chain continuity. Every audit-row's
prevHashMUST equal the previous row'srowHashwithin the partition. A break is a CRITICAL alert (FirewallAuditChainBreak).
8. State Machines
8.1 Quarantine Entry State Machine
┌──────────┐
│ PENDING │◀── verdict = QUARANTINE
└────┬─────┘
│ NOC opens hold (REST GET)
▼
┌──────────┐
│REVIEWING │
└─┬──────┬─┘
release(notes) reject(reason) expires_at < now()
│ │ │
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌──────────────┐
│RELEASED│ │REJECTED│ ◀───┐ │ AUTO_EXPIRED │
└────────┘ └────────┘ │ └──────────────┘
│ ▲
└─── from PENDING only ──┘
8.2 Operating-Mode State Machine (per SERVICE_OVERVIEW §11)
┌────────┐ P95 > 100ms for 60s ┌─────────┐
│ NORMAL │ ─────────────────────▶ │ PANIC │
└───┬────┘ └────┬────┘
PG/Redis│ unhealthy │ P95 < 30ms 5m
▼ ▼
┌─────────┐ cluster recovers ┌────────┐
│DEGRADED │ ─────────────────────▶│ NORMAL │
└─────────┘ └────────┘
admin POST /v1/admin/firewall/mode {targetMode: MAINTENANCE}
requires dual-approval (60s window)
─────────────────────────▶ MAINTENANCE
8.3 Peer Hygiene State Machine
score >= 60 ─── NORMAL
60 > score >= 30 (3 windows) ─── DEGRADED → alert.peer.degraded.v1
score < 30 ─── AUTO_QUARANTINE → peer_quarantine.entered.v1
(manual dual-approval to release)
9. Aggregate Lifecycle Summary
| Aggregate | Created by | Mutated by | Retired by |
|---|---|---|---|
FirewallRule | T&S admin via REST POST /v1/admin/firewall/rules | T&S admin via PUT (new version row); auto-disable on regex timeout | Soft-delete (admin) |
BlocklistEntry | Federation import OR operator REST OR fraud-intel signal | Reputation worker (score) OR operator | Soft-delete (active=FALSE); never hard-deleted |
FirewallVerdict / FirewallAuditEntry | Every FilterInbound/EvaluateTransit call | Immutable | Partition pruned after 7 years (regulated) |
FirewallQuarantineEntry | Verdict = QUARANTINE | NOC review OR auto-expire cron | Terminal state; cold archive 7y |
MnoBindRegistryEntry | Carrier-relations admin | Heartbeat updates lastHeartbeatAt | Soft-delete |
PeerAggregator | Carrier-relations admin | Hygiene-score worker; quarantine worker | Soft-delete (de-peer) |
SimBoxSignal / AitPattern | fraud-intel-service event | Reputation expiry (60d sliding window) | active=FALSE |