SMS Firewall Service — Event Schemas
Version: 1.0 Status: Draft Owner: Trust & Safety Last Updated: 2026-04-21 Companion: DOMAIN_MODEL · SYNC_CONTRACT · SECURITY_MODEL Related ADR: ADR-0004 §6
All events are published to NATS JetStream via the transactional outbox pattern. Every event carries schemaVersion, eventId (UUIDv4), traceId (W3C), and at (RFC 3339 µs UTC). All MSISDNs in event payloads appear masked (+CCNNN***); raw PDU bodies never appear; PDU body SHA-256 hash may appear.
1. Streams, subjects, retention
| Stream | Subjects | Retention | Replicas | Mirroring | Dedup window |
|---|---|---|---|---|---|
FIREWALL_AUDIT | firewall.audit.v1 | 13 months hot in JetStream; daily Parquet+zstd archive to MinIO Object-Lock 7 yr | 5 (3 in kbl, 2 in mzr) | leaf mirror to dxb cold | 2 min |
FIREWALL_ALERTS | firewall.alert.*.v1 | 90 days | 3 | — | 2 min |
FIREWALL_QUARANTINE | firewall.quarantine.held.v1, .released.v1, .rejected.v1, .expired.v1 | 90 days hot; cold archive 7 yr | 3 | — | 2 min |
FIREWALL_RULES | firewall.rule.changed.v1, .degraded.v1, .audit.policy.changed.v1 | 365 days | 3 | — | 2 min |
FIREWALL_BLOCKLIST | firewall.blocklist.changed.v1, .federated.v1, .entry.deactivated.v1 | 365 days | 3 | — | 5 min |
FIREWALL_FEDERATION | firewall.federation.exported.v1, .heartbeat.v1 | 90 days | 3 | — | — |
FIREWALL_PEERS | firewall.peer_quarantine.entered.v1, firewall.alert.peer.degraded.v1, firewall.simbox.detected.v1, firewall.alert.ait.detected.v1 | 365 days | 3 | — | 2 min |
FIREWALL_OPS | firewall.mode.changed.v1, firewall.alert.mode.auto_panic.v1, firewall.alert.bind.missing.v1, firewall.transit.unavailable.v1 | 365 days | 3 | — | 1 min |
All streams have a corresponding .deadletter suffix. Consumers are durable with explicit ack per PLT-REQ-008/009.
2. Produced events — full schemas
2.1 firewall.audit.v1 (regulator-grade evidence)
Emitted for every FilterInbound / EvaluateTransit call regardless of verdict.
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "firewall.audit.v1",
"type": "object",
"required": ["schemaVersion","eventId","verdictId","verdict","direction",
"srcMsisdnMasked","dstMsisdnMasked","pduFingerprint",
"evaluatedRuleIds","evaluatedAt","at","traceId"],
"properties": {
"schemaVersion": { "const": "1" },
"eventId": { "type": "string", "format": "uuid" },
"verdictId": { "type": "string", "pattern": "^fv_[0-9a-f-]+$" },
"verdict": { "enum": ["ALLOW","FLAG","BLOCK","QUARANTINE"] },
"direction": { "enum": ["MO","TRANSIT_MT","EGRESS_DND_CHECK"] },
"srcMsisdnMasked":{ "type": "string", "pattern": "^\\+\\d{1,3}\\d{2,3}\\*+$" },
"dstMsisdnMasked":{ "type": "string", "pattern": "^\\+\\d{1,3}\\d{2,3}\\*+$" },
"senderId": { "type": ["string","null"], "maxLength": 11 },
"mnoBindId": { "type": ["string","null"] },
"peerAsn": { "type": ["integer","null"] },
"pduFingerprint": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
"pduBodySha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
"blockReason": { "type": ["string","null"], "enum":
[null,"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"] },
"evaluatedRuleIds": { "type": "array", "items": { "type": "string" } },
"ruleHits": {
"type": "array",
"items": {
"type": "object",
"required": ["ruleId","ruleType","action","severity"],
"properties": {
"ruleId": { "type": "string" },
"ruleType": { "type": "string" },
"action": { "enum": ["ALLOW","FLAG","BLOCK","QUARANTINE","RATE_LIMIT"] },
"severity": { "enum": ["CRITICAL","HIGH","MEDIUM","LOW"] },
"evidence": { "type": "string" },
"confidence": { "type": "number", "minimum": 0, "maximum": 1 }
}
}
},
"holdId": { "type": ["string","null"] },
"evaluationLatencyMs": { "type": "integer", "minimum": 0 },
"flags": { "type": "array", "items": { "type": "string" } },
"operatingMode": { "enum": ["NORMAL","DEGRADED","PANIC","MAINTENANCE"] },
"ruleSetVersion": { "type": "integer", "minimum": 1 },
"evaluatedAt": { "type": "string", "format": "date-time" },
"at": { "type": "string", "format": "date-time" },
"traceId": { "type": "string" }
}
}
2.2 firewall.alert.mo.blocked.v1
Subscriber-impacting BLOCK on inbound MO direction.
interface FirewallAlertMoBlocked {
schemaVersion: '1';
eventId: string;
verdictId: string;
srcMsisdnMasked: string;
dstMsisdnMasked: string;
mnoBindId: string;
blockReason: 'ORIGIN_BLOCKLIST'|'CONTENT_FORBIDDEN'|'RATE_EXCEEDED'|'GEO_FORBIDDEN'|
'DND_PRESENT'|'AIT_SIGNATURE'|'SIMBOX_SIGNATURE'|'REGULATOR_BLOCK';
triggerRuleIds: string[];
traceId: string;
at: string;
}
2.3 firewall.alert.transit.blocked.v1
interface FirewallAlertTransitBlocked {
schemaVersion: '1';
eventId: string;
verdictId: string;
peerAsn: number;
peerSystemId: string;
senderId: string | null;
dstMsisdnMasked: string;
blockReason: 'PEER_ASN_UNKNOWN'|'SENDER_ID_SPOOFED'|'SENDER_ID_SUSPENDED'|
'GREY_ROUTE'|'PEER_QUARANTINED'|'CONTENT_FORBIDDEN';
traceId: string;
at: string;
}
2.4 firewall.alert.greyroute.detected.v1
interface FirewallAlertGreyrouteDetected {
schemaVersion: '1';
eventId: string;
verdictId: string;
peerAsn: number;
peerSystemId: string;
dstHomeMnoId: 'AWCC'|'Roshan'|'Etisalat'|'MTN_AF'|'Salaam';
evidence: { hlrLookupSource: string; resolvedAt: string };
traceId: string;
at: string;
}
2.5 firewall.simbox.detected.v1
interface FirewallSimboxDetected {
schemaVersion: '1';
eventId: string;
signalId: string;
originatorMasked: string;
confidence: number; // 0..1
evidenceJson: {
imeiChurnCount24h?: number;
aNumberRotationCount24h?: number;
geoCellIdVolatility?: number;
fraudIntelModelId?: string;
};
firstSeenAt: string;
traceId: string;
at: string;
}
2.6 firewall.alert.ait.detected.v1
interface FirewallAlertAitDetected {
schemaVersion: '1';
eventId: string;
patternId: string;
patternType: 'OTP_HARVEST'|'PUMPED_TRAFFIC'|'IRSF'; // GSMA FF.21
dstMsisdnRange: string; // e.g. "+9370012XXXX"
confidence: number;
evidenceJson: Record<string, unknown>;
traceId: string;
at: string;
}
2.7 firewall.quarantine.held.v1
interface FirewallQuarantineHeld {
schemaVersion: '1';
eventId: string;
holdId: string;
verdictId: string;
direction: 'MO'|'TRANSIT_MT';
triggerRuleIds: string[];
reasonCode: string; // 'SENDER_ID_UNKNOWN' | 'PEER_HYGIENE_SCORE_LOW' | ...
expiresAt: string;
traceId: string;
at: string;
}
2.8 firewall.quarantine.released.v1
Carries skipFirewall: true so the re-injecting connector bypasses re-evaluation.
interface FirewallQuarantineReleased {
schemaVersion: '1';
eventId: string;
holdId: string;
reviewerUserId: string;
reviewNotes: string;
reInjectInstruction: {
skipFirewall: true;
targetConnectorBindId: string;
originalSmppSequenceNumber: number;
};
traceId: string;
at: string;
}
2.9 firewall.quarantine.rejected.v1
Identical shape to released.v1 minus reInjectInstruction; adds rejectionReason.
2.10 firewall.quarantine.expired.v1
interface FirewallQuarantineExpired {
schemaVersion: '1';
eventId: string;
holdId: string;
expiredAt: string;
traceId: string;
at: string;
}
2.11 firewall.blocklist.federated.v1
interface FirewallBlocklistFederated {
schemaVersion: '1';
eventId: string;
source: 'REGULATOR'|'PEER_MNO'|'INTERNAL'|'FRAUD_INTEL';
regulatorRef: string | null;
addedCount: number;
removedCount: number;
entryCountTotal: number;
bloomRebuildScheduled: boolean;
importBatchId: string;
traceId: string;
at: string;
}
2.12 firewall.federation.exported.v1
interface FirewallFederationExported {
schemaVersion: '1';
eventId: string;
exportedAt: string;
exportSha256: string; // hex
signature: string; // base64 PKCS#1v1.5 over exportSha256
signerKeyId: string; // pkcs11:object=ghasi-firewall-fed-signer
presignedUrl: string; // 24h validity
entryCountAdded: number;
entryCountRemoved: number;
diffPeriod: { from: string; to: string };
traceId: string;
at: string;
}
2.13 firewall.federation.heartbeat.v1
Even on zero-diff days. Consumers (peer MNOs) detect a stalled exporter when this stops.
interface FirewallFederationHeartbeat {
schemaVersion: '1';
eventId: string;
exportedAt: string; // last successful export ts
emptyDiff: boolean;
traceId: string;
at: string;
}
2.14 firewall.alert.federation.signature.invalid.v1
PagerDuty severity. Emitted when an inbound regulator event fails HSM signature validation.
interface FirewallAlertFederationSignatureInvalid {
schemaVersion: '1';
eventId: string;
inboundEventId: string;
expectedSigner: string;
observedSignerHint: string | null;
payloadSha256: string;
traceId: string;
at: string;
}
2.15 firewall.peer_quarantine.entered.v1
interface FirewallPeerQuarantineEntered {
schemaVersion: '1';
eventId: string;
peerId: string;
peerSystemId: string;
peerAsn: number;
hygieneScore: number;
reasons: string[]; // 'GREY_ROUTE_HEURISTIC' | 'SENDER_ID_SPOOF_RATE' | 'CONTENT_VIOLATION_RATE'
triggeredBy: 'AUTO_HYGIENE'|'FRAUD_INTEL_SIGNAL'|'OPERATOR_MANUAL';
traceId: string;
at: string;
}
2.16 firewall.alert.peer.degraded.v1
Hygiene score < 60 for 3 consecutive 5-minute windows.
interface FirewallAlertPeerDegraded {
schemaVersion: '1';
eventId: string;
peerId: string;
scoreHistory: number[]; // last 3 windows
contributingReasons: string[];
traceId: string;
at: string;
}
2.17 firewall.rule.changed.v1
Cache-invalidation signal. Consumers fetch new state via REST.
interface FirewallRuleChanged {
schemaVersion: '1';
eventId: string;
entityType: 'RULE'|'RULESET'|'BLOCKLIST_ENTRY'|'MNO_BIND'|'PEER_AGGREGATOR';
entityId: string;
action: 'CREATE'|'UPDATE'|'DELETE'|'ENABLE'|'DISABLE';
version: number | null;
actorUserId: string;
reason: string | null;
traceId: string;
at: string;
}
2.18 firewall.rule.degraded.v1
interface FirewallRuleDegraded {
schemaVersion: '1';
eventId: string;
ruleId: string;
reason: 'REGEX_TIMEOUT'|'CLASSIFIER_UNAVAILABLE'|'BUDGET_EXCEEDED';
observedLatencyMs: number;
autoDisabled: boolean;
traceId: string;
at: string;
}
2.19 firewall.mode.changed.v1
interface FirewallModeChanged {
schemaVersion: '1';
eventId: string;
previousMode: 'NORMAL'|'DEGRADED'|'PANIC'|'MAINTENANCE';
newMode: 'NORMAL'|'DEGRADED'|'PANIC'|'MAINTENANCE';
trigger: 'MANUAL_DUAL_APPROVAL'|'AUTO_LATENCY_BREACH'|'AUTO_LATENCY_RECOVERY'|'AUTO_PG_UNAVAILABLE';
approverUserIds: string[];
reason: string;
traceId: string;
at: string;
}
2.20 firewall.alert.mode.auto_panic.v1
interface FirewallAlertModeAutoPanic {
schemaVersion: '1';
eventId: string;
latencyP95Ms: number;
breachedForSeconds: number;
disabledRuleTypes: ('REGEX'|'CLASSIFIER')[];
traceId: string;
at: string;
}
2.21 firewall.alert.dnd.snapshot.stale.v1
interface FirewallAlertDndSnapshotStale {
schemaVersion: '1';
eventId: string;
lastSnapshotAt: string;
ageSeconds: number;
traceId: string;
at: string;
}
2.22 firewall.transit.unavailable.v1
interface FirewallTransitUnavailable {
schemaVersion: '1';
eventId: string;
observedBy: string; // smpp-connector-transit-rx pod
peerAsn: number;
errorCode: 'UNAVAILABLE'|'DEADLINE_EXCEEDED'|'INTERNAL';
failClosedAction: 'ESME_RSUBMITFAIL';
traceId: string;
at: string;
}
2.23 firewall.alert.bind.missing.v1
Connector heartbeat missing > 60 s.
interface FirewallAlertBindMissing {
schemaVersion: '1';
eventId: string;
mnoBindId: string;
mnoId: string;
lastHeartbeatAt: string;
ageSeconds: number;
traceId: string;
at: string;
}
3. Consumed events
| Subject | Producer | Effect |
|---|---|---|
consent.dnd.snapshot.v1 | consent-ledger-service | Hourly snapshot URL → rebuild fw:dnd:bloom and firewall.dnd_snapshot |
consent.revoked.v1 | consent-ledger-service | Add MSISDN to in-memory DND delta until next snapshot |
fraud.detected.simbox.v1 | fraud-intel-service | Upsert firewall.simbox_signals; matching MOs auto-BLOCK |
fraud.detected.ait.v1 | fraud-intel-service | Upsert firewall.ait_patterns; matching MOs auto-BLOCK |
fraud.detected.greyroute.v1 | fraud-intel-service | Add implicated peer to firewall.peer_quarantine |
regulator.blocklist.published.v1 | regulator-portal-service | HSM-validated; upsert firewall.blocklist_entries source=REGULATOR |
sender.id.suspended.v1 | sender-id-registry-service | Refresh firewall.peer_senderid_allowlist cache |
sender.id.registered.v1 | sender-id-registry-service | Same — additive |
tenant.data_residency.changed.v1 | tenant-service | Refresh per-tenant geo policy cache for EGRESS_DND_CHECK rules |
3.1 Consumed-event schema example: fraud.detected.simbox.v1
interface FraudDetectedSimbox {
schemaVersion: '1';
eventId: string;
modelId: string; // e.g. 'simbox-graph-v3'
modelVersion: string;
originator: string; // E.164 (full — fraud-intel scope)
confidence: number;
evidence: {
imeiChurnCount24h: number;
aNumberRotationCount24h: number;
cellIdSwitchCount24h: number;
correlatedSignals: string[];
};
emittedAt: string;
}
The firewall masks the originator (+CCNNN***) before re-emitting downstream.
4. Outbox pattern
All firewall state changes (audit insert, quarantine insert, blocklist mutation) write an event row to firewall.outbox in the same Postgres transaction. A relay worker publishes to NATS with at-least-once delivery and ack. Guarantees: no event without a persisted state change; no unpublished state change for more than a few seconds (see SYNC_CONTRACT §3).
CREATE TABLE firewall.outbox (
event_id UUID PRIMARY KEY,
subject TEXT NOT NULL,
payload JSONB NOT NULL,
partition_key TEXT, -- for sticky partition routing (e.g. mnoBindId)
published_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ix_outbox_unpublished
ON firewall.outbox (created_at) WHERE published_at IS NULL;
5. Consumer contracts (downstream services)
| Service | Subjects | Action |
|---|---|---|
cdr-mediation-service | firewall.audit.v1 | Append firewall verdict to inbound MO CDR |
fraud-intel-service | firewall.audit.v1, firewall.simbox.detected.v1, firewall.alert.greyroute.* | Feed ML training set; close-loop verdict-vs-truth comparisons |
regulator-portal-service | firewall.audit.v1, firewall.blocklist.federated.v1, firewall.federation.exported.v1 | Regulator submission pipeline |
analytics-service | firewall.audit.v1, firewall.alert.*, firewall.mode.changed.v1 | Long-term archival + dashboards |
notification-service | firewall.alert.*, firewall.peer_quarantine.entered.v1 | NOC + carrier-relations notifications |
admin-dashboard (SSE) | firewall.quarantine.*, firewall.alert.*, firewall.mode.changed.v1 | Live console |
6. PII & security rules for events
- Raw PDU body never appears in any firewall event.
- MSISDNs in audit + alert events appear masked (
+CCNNN***); raw MSISDNs persist only in Postgresfirewall.audit(RLS-gated). pduBodySha256may appear (one-way hash — no PII content recoverable).- All event payloads signed by the producer's Ed25519 key (platform-wide signing); critical consumers (
regulator-portal-service,cdr-mediation-service) verify opportunistically. - Events with PII-adjacent fields (masked MSISDN, senderId) are classified CONFIDENTIAL-INTERNAL; replication outside the platform requires Legal review.
- Federation events (
firewall.blocklist.federated.v1,firewall.federation.exported.v1) are signed with the dedicated PKCS#11 HSM key (pkcs11:object=ghasi-firewall-fed-signer).
7. Schema evolution
- Additive optional fields are non-breaking within
schemaVersion. - Breaking changes bump to
firewall.<topic>.v2; subjects coexist for ≥ 90 days. - Enum values are additive. Consumers MUST treat unknown values as
UNKNOWNand not fail. - A schema registry (Apicurio) is the source of truth; CI verifies all producers' published schemas are backward-compatible against the latest registered version.
8. Stream durability + DR
Per ADR-0004 §6:
FIREWALL_AUDITis replicated 3× inkbl, mirrored 2× inmzr, and leaf-mirrored todxbcold archive. Loss ofkbldoes not lose audit evidence.- Stream snapshots taken every 4h and uploaded to MinIO
firewall-jetstream-snapshots/for cold-recovery. - DR drill: quarterly, restore a snapshot into a clean cluster, verify hash-chain continuity over the last 24h of audit rows.