Skip to main content

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.

FieldTypeNotes
ruleIdUUIDv4Identity
namestringHuman-readable, unique within its rule set
descriptionstring | nullReviewer-facing rationale
typeenum RuleTypeKEYWORD · REGEX · SENDER_ID · RECIPIENT · RATE_VOLUME · GEO_RESTRICTION · TEMPORAL · DLR_ABUSE · AI_CLASSIFICATION · COMPOSITE
actionenum ComplianceVerdictALLOW · FLAG · HOLD · BLOCK
priorityintLower = evaluated earlier
configRuleConfig VOType-specific configuration (polymorphic per type)
isActivebooleanSoft-disable without deletion
versionintMonotonic; bumped on every mutation
createdBy, updatedByUUIDv4userId of the authoring admin
createdAt, updatedAt, deletedAtInstant | nullSoft-delete

Invariants

  • A rule with type = COMPOSITE has children resolvable within maxDepth = 5 and contains no cycles (enforced at save time).
  • action = ALLOW is valid only as a whitelist rule — it shortcircuits evaluation in EvaluateCompliance.
  • type = AI_CLASSIFICATION must declare a fallbackAction in its config; the platform default is HOLD (fail-closed).
  • type = REGEX config must compile successfully under the re2 engine, 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.

FieldNotes
ruleId + versionComposite identity
snapshotJSONB of the full rule state at that version
changedByActor
changedAtInstant
changeReasonstring | 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.

FieldNotes
ruleSetIdUUIDv4
nameHuman-readable (unique)
descriptionstring | null
isDefaultboolean — exactly one true platform-wide (enforced by partial unique index)
ruleIdsRuleId[] — ordered membership
statusenum — draft · active · retired
versionint
activatedAt, retiredAtInstant | 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.

FieldNotes
tenantIdFK → auth.accounts.tenant_id
accountIdUUIDv4 | null — null means tenant-wide
ruleSetIdFK → RuleSet
priorityint — when multiple assignments match, highest priority wins
assignedBy, assignedAtAudit fields

HeldMessage

A message parked in the hold queue awaiting manual review.

FieldNotes
holdIdUUIDv4
messageIdFK to orch.sms_messages (logical)
tenantId, accountIdScope
evaluationIdFK → EvaluationLog
payloadJSONB — full original MessageContext, retained for review
triggerFindingsFinding[]
reviewPriorityint — computed per APPLICATION_LOGIC §3
statusenum — PENDING · REVIEWING · REVIEWED_RELEASED · REVIEWED_REJECTED · AUTO_EXPIRED
reviewerUserIdUUIDv4 | null
reviewNotesstring | null
heldAt, reviewedAt, autoExpiresAtInstants

Invariants

  • Transitions PENDING → REVIEWING → {REVIEWED_RELEASED, REVIEWED_REJECTED} are one-way. AUTO_EXPIRED is terminal and can only be reached from PENDING by the background worker.
  • autoExpiresAt = heldAt + 24h unless overridden by RuleConfig.holdTtl.

TenantComplianceScore

Current score + risk tier per tenant (one row per tenant). Derived; refreshed every 15 minutes by the scoring worker.

FieldNotes
tenantIdIdentity
overallScoreint 0–100
contentScore, volumeScore, dlrScore, optoutScore, complaintScore, tenureScoreSix score dimensions
riskTierenum — CLEAR (80–100) · MONITOR (60–79) · RESTRICTED (30–59) · SUSPENDED (0–29)
overrideTier, overrideReason, overrideExpiresAt, overrideSetByManual override (platform admin)
lastComputedAtInstant
messagesSent7d, violations7d, dlrSuccessRate, optoutRate, complaintRateInputs (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.

FieldNotes
evaluationIdUUIDv4
messageId, tenantId, accountIdScope
fingerprintsha256 of accountId:senderId:to:body — used for 5-minute evaluation cache
verdictComplianceVerdict
findingsFinding[]
ruleSetId, ruleSetVersionSnapshot pointer
evaluationLatencyMsPerformance metric
budgetExceededboolean
aiCachedboolean | null — whether the AI verdict came from the 24h cache
evaluatedAtInstant

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.

FieldNotes
auditIdUUIDv4
entityTypeenum — RULE · RULE_SET · HOLD · TENANT_TIER · BLOCKLIST · KEYWORD_LIST · REPORT · ASSIGNMENT
entityIdUUIDv4
actionenum — CREATE · UPDATE · DELETE · REVIEW_RELEASE · REVIEW_REJECT · BULK_REVIEW · OVERRIDE
actorUserIdUUIDv4
before, afterJSONB snapshots
ip, userAgent, traceIdContext
occurredAtInstant

Invariants

  • Append-only. UPDATE and DELETE are rejected by a Postgres rule at the schema level. See DATA_MODEL §3.1.

3. Value Objects

VOShapeInvariants
MessageContext{ messageId, tenantId, accountId, to, senderId, body, messageType, segments, encoding, idempotencyKey, metadata }to is E.164; encoding ∈ {GSM7, UCS2}; segments ∈ [1..255]
ComplianceVerdictALLOW | FLAG | HOLD | BLOCK
Finding{ ruleId, ruleName, ruleType, action, evidence, confidence? }evidence is redacted (no raw body)
RuleConfigDiscriminated union on RuleTypePer-type schema enforced by Zod at write-time
RiskTierCLEAR | MONITOR | RESTRICTED | SUSPENDEDDerived from overallScore unless overridden
FingerprintKeysha256(accountId:senderId:to:body)Deterministic; cache key

4. Domain Events (produced)

Detailed schemas in EVENT_SCHEMAS.md.

EventTrigger
compliance.audit.v1Every EvaluateCompliance call (regardless of verdict)
compliance.message.held.v1Verdict = HOLD
compliance.message.blocked.v1Verdict = BLOCK
compliance.message.released.v1Hold reviewer issues RELEASE
compliance.message.rejected.v1Hold reviewer issues REJECT
compliance.message.expired.v1Hold auto-expired by worker
compliance.tenant.tier.changed.v1Scoring worker detects tier transition OR manual override
compliance.tenant.suspended.v1Tier transitioned to SUSPENDED specifically
compliance.rule.changed.v1Rule / rule-set / blocklist / keyword-list mutation
compliance.report.generated.v1Async 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 ComplianceVerdict other than ALLOW or FLAG causes a dispatched message. Absence of a verdict (timeout, error) also prevents dispatch — the orchestrator never acks the NATS delivery.
  • Allowlist-first. ALLOW rules evaluate before any restriction rule and short-circuit with verdict ALLOW on first match.
  • SUSPENDED tenant → auto-HOLD. Skips full evaluation; places a HOLD with reason tenant_suspended.
  • Append-only logs. EvaluationLog and AuditLog reject UPDATE and DELETE at the DB level.
  • Evidence window. AuditLog retained ≥ 13 months; EvaluationLog retained 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) and EvaluationLog.findings as 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.