Compliance Engine — Domain Model
Status: populated Owner: Platform Engineering / Trust & Safety Last updated: 2026-04-19 Companion: SERVICE_OVERVIEW · APPLICATION_LOGIC · DATA_MODEL · EVENT_SCHEMAS · API_CONTRACTS · SECURITY_MODEL
1. Bounded Context
Trust & Safety / Regulatory Compliance. The compliance-engine owns every policy decision about whether an outbound SMS may be dispatched, plus the evidence trail that proves it. It is a peer to sms-orchestrator (ingestion), routing-engine (routing), and smpp-connector (transport); it is not an aspect of any of those services.
The context boundary is drawn such that:
- Inside the boundary: rules, rule sets, rule versions, verdicts, findings, hold queue, tenant compliance scores, score history, blocklists, keyword lists, evaluation log, audit log, DLR statistics aggregated for abuse rules.
- Outside the boundary: message storage (
sms-orchestrator), DLR correlation (dlr-processor), routing decisions (routing-engine), billing (billing-service), tenant/account identity (auth-service), tenant notifications (notification-service).
2. Aggregates
Rule
The authoring unit of policy. One rule is one testable predicate over a MessageContext.
| Field | Type | Notes |
|---|---|---|
ruleId | UUIDv4 | Identity |
name | string | Human-readable, unique within its rule set |
description | string | null | Reviewer-facing rationale |
type | enum RuleType | KEYWORD · REGEX · SENDER_ID · RECIPIENT · RATE_VOLUME · GEO_RESTRICTION · TEMPORAL · DLR_ABUSE · AI_CLASSIFICATION · COMPOSITE |
action | enum ComplianceVerdict | ALLOW · FLAG · HOLD · BLOCK |
priority | int | Lower = evaluated earlier |
config | RuleConfig VO | Type-specific configuration (polymorphic per type) |
isActive | boolean | Soft-disable without deletion |
version | int | Monotonic; bumped on every mutation |
createdBy, updatedBy | UUIDv4 | userId of the authoring admin |
createdAt, updatedAt, deletedAt | Instant | null | Soft-delete |
Invariants
- A rule with
type = COMPOSITEhas children resolvable withinmaxDepth = 5and contains no cycles (enforced at save time). action = ALLOWis valid only as a whitelist rule — it shortcircuits evaluation inEvaluateCompliance.type = AI_CLASSIFICATIONmust declare afallbackActionin its config; the platform default isHOLD(fail-closed).type = REGEXconfig must compile successfully under there2engine, pass the ReDoS screen, and its pattern length ≤ 500 chars.
RuleVersion
Append-only audit snapshot of a Rule at a specific version. Never mutated. Produced on every Rule insert/update; used for audit and rollback queries.
| Field | Notes |
|---|---|
ruleId + version | Composite identity |
snapshot | JSONB of the full rule state at that version |
changedBy | Actor |
changedAt | Instant |
changeReason | string | null |
RuleSet
A named grouping of rules applied to some subset of tenants. Exactly one RuleSet is isDefault = true platform-wide; tenants inherit the default unless they have a specific assignment.
| Field | Notes |
|---|---|
ruleSetId | UUIDv4 |
name | Human-readable (unique) |
description | string | null |
isDefault | boolean — exactly one true platform-wide (enforced by partial unique index) |
ruleIds | RuleId[] — ordered membership |
status | enum — draft · active · retired |
version | int |
activatedAt, retiredAt | Instant | null |
TenantRuleSetAssignment
Binds a tenant (+ optionally an account) to a non-default rule set. Absence of an assignment means the tenant uses the default rule set.
| Field | Notes |
|---|---|
tenantId | FK → auth.accounts.tenant_id |
accountId | UUIDv4 | null — null means tenant-wide |
ruleSetId | FK → RuleSet |
priority | int — when multiple assignments match, highest priority wins |
assignedBy, assignedAt | Audit fields |
HeldMessage
A message parked in the hold queue awaiting manual review.
| Field | Notes |
|---|---|
holdId | UUIDv4 |
messageId | FK to orch.sms_messages (logical) |
tenantId, accountId | Scope |
evaluationId | FK → EvaluationLog |
payload | JSONB — full original MessageContext, retained for review |
triggerFindings | Finding[] |
reviewPriority | int — computed per APPLICATION_LOGIC §3 |
status | enum — PENDING · REVIEWING · REVIEWED_RELEASED · REVIEWED_REJECTED · AUTO_EXPIRED |
reviewerUserId | UUIDv4 | null |
reviewNotes | string | null |
heldAt, reviewedAt, autoExpiresAt | Instants |
Invariants
- Transitions
PENDING → REVIEWING → {REVIEWED_RELEASED, REVIEWED_REJECTED}are one-way.AUTO_EXPIREDis terminal and can only be reached fromPENDINGby the background worker. autoExpiresAt = heldAt + 24hunless overridden byRuleConfig.holdTtl.
TenantComplianceScore
Current score + risk tier per tenant (one row per tenant). Derived; refreshed every 15 minutes by the scoring worker.
| Field | Notes |
|---|---|
tenantId | Identity |
overallScore | int 0–100 |
contentScore, volumeScore, dlrScore, optoutScore, complaintScore, tenureScore | Six score dimensions |
riskTier | enum — CLEAR (80–100) · MONITOR (60–79) · RESTRICTED (30–59) · SUSPENDED (0–29) |
overrideTier, overrideReason, overrideExpiresAt, overrideSetBy | Manual override (platform admin) |
lastComputedAt | Instant |
messagesSent7d, violations7d, dlrSuccessRate, optoutRate, complaintRate | Inputs (cached for UI) |
ScoreHistory
Append-only time series per tenant. Partitioned by month for fast range queries.
DlrStats
Per-(tenant, account) rolling counters across 1h / 24h / 7d windows. Consumed by DLR_ABUSE rules. Maintained by the sms.dlr.inbound consumer.
Blocklist / BlocklistEntry
Named blocklists (sender, recipient, keyword-as-regex, country, IP) composed of entries. Entries can carry TTL; entries are EXACT / REGEX / PREFIX / CONTAINS / SUFFIX.
KeywordList / KeywordEntry
Multi-language keyword lists (ISO 639-1). Keyword entries carry a weight for weighted-scoring rule types.
EvaluationLog
Append-only record of every EvaluateCompliance call. Partitioned monthly.
| Field | Notes |
|---|---|
evaluationId | UUIDv4 |
messageId, tenantId, accountId | Scope |
fingerprint | sha256 of accountId:senderId:to:body — used for 5-minute evaluation cache |
verdict | ComplianceVerdict |
findings | Finding[] |
ruleSetId, ruleSetVersion | Snapshot pointer |
evaluationLatencyMs | Performance metric |
budgetExceeded | boolean |
aiCached | boolean | null — whether the AI verdict came from the 24h cache |
evaluatedAt | Instant |
AuditLog
Append-only record of every state change anywhere in the bounded context — rule CRUD, rule-set CRUD, hold-queue review, tenant tier override, blocklist change, report generation. Partitioned monthly; retained ≥ 13 months.
| Field | Notes |
|---|---|
auditId | UUIDv4 |
entityType | enum — RULE · RULE_SET · HOLD · TENANT_TIER · BLOCKLIST · KEYWORD_LIST · REPORT · ASSIGNMENT |
entityId | UUIDv4 |
action | enum — CREATE · UPDATE · DELETE · REVIEW_RELEASE · REVIEW_REJECT · BULK_REVIEW · OVERRIDE |
actorUserId | UUIDv4 |
before, after | JSONB snapshots |
ip, userAgent, traceId | Context |
occurredAt | Instant |
Invariants
- Append-only. UPDATE and DELETE are rejected by a Postgres rule at the schema level. See DATA_MODEL §3.1.
3. Value Objects
| VO | Shape | Invariants |
|---|---|---|
MessageContext | { messageId, tenantId, accountId, to, senderId, body, messageType, segments, encoding, idempotencyKey, metadata } | to is E.164; encoding ∈ {GSM7, UCS2}; segments ∈ [1..255] |
ComplianceVerdict | ALLOW | FLAG | HOLD | BLOCK | — |
Finding | { ruleId, ruleName, ruleType, action, evidence, confidence? } | evidence is redacted (no raw body) |
RuleConfig | Discriminated union on RuleType | Per-type schema enforced by Zod at write-time |
RiskTier | CLEAR | MONITOR | RESTRICTED | SUSPENDED | Derived from overallScore unless overridden |
FingerprintKey | sha256(accountId:senderId:to:body) | Deterministic; cache key |
4. Domain Events (produced)
Detailed schemas in EVENT_SCHEMAS.md.
| Event | Trigger |
|---|---|
compliance.audit.v1 | Every EvaluateCompliance call (regardless of verdict) |
compliance.message.held.v1 | Verdict = HOLD |
compliance.message.blocked.v1 | Verdict = BLOCK |
compliance.message.released.v1 | Hold reviewer issues RELEASE |
compliance.message.rejected.v1 | Hold reviewer issues REJECT |
compliance.message.expired.v1 | Hold auto-expired by worker |
compliance.tenant.tier.changed.v1 | Scoring worker detects tier transition OR manual override |
compliance.tenant.suspended.v1 | Tier transitioned to SUSPENDED specifically |
compliance.rule.changed.v1 | Rule / rule-set / blocklist / keyword-list mutation |
compliance.report.generated.v1 | Async report finished generating |
5. Tenant Scoring Formula
Scores are bounded to [0, 100]. Tier is derived deterministically from overallScore unless an override is in force.
contentScore = max(0, 25 × (1 - violations_7d / max(messages_sent_7d, 1)))
volumeScore = max(0, 20 × (1 - rate_limit_violations_7d / max(messages_sent_7d, 1)))
dlrScore = 20 × dlr_success_rate # [0..1]
optoutScore = 15 × (1 - min(optout_rate / 0.05, 1)) # cap at 5% = 0
complaintScore = 10 × (1 - min(complaint_rate / 0.01, 1)) # cap at 1% = 0
tenureScore = min(10, account_age_days / 90)
overallScore = contentScore + volumeScore + dlrScore
+ optoutScore + complaintScore + tenureScore
Tier mapping: overallScore ≥ 80 → CLEAR; 60..79 → MONITOR; 30..59 → RESTRICTED; < 30 → SUSPENDED.
6. Global invariants
- Fail-closed. No
ComplianceVerdictother thanALLOWorFLAGcauses a dispatched message. Absence of a verdict (timeout, error) also prevents dispatch — the orchestrator never acks the NATS delivery. - Allowlist-first.
ALLOWrules evaluate before any restriction rule and short-circuit with verdictALLOWon first match. - SUSPENDED tenant → auto-HOLD. Skips full evaluation; places a
HOLDwith reasontenant_suspended. - Append-only logs.
EvaluationLogandAuditLogreject UPDATE and DELETE at the DB level. - Evidence window.
AuditLogretained ≥ 13 months;EvaluationLogretained 90 days + cold archive. - Rule set selection. Tenant-specific assignment wins over default. If multiple assignments match, highest
priority. - Body confidentiality. Message body is never emitted in domain events, never logged, and stored only on
HoldMessage.payload(for reviewer access, role-gated) andEvaluationLog.findingsas redacted evidence. - Release is authoritative. Once a reviewer
RELEASEs a held message, re-entry into the compliance pipeline is skipped (skipCompliance: true) to prevent a hold → release → re-hold loop. The reviewer's decision is the governing record, captured in the audit log.