Compliance Engine — Event Schemas
Status: populated Owner: Platform Engineering / Trust & Safety Last updated: 2026-04-19 Companion: DOMAIN_MODEL · SYNC_CONTRACT · SECURITY_MODEL · 01-enterprise-architecture §6
All events are published to NATS JetStream via the transactional outbox pattern. Every event carries schemaVersion, eventId (UUID), traceId, and at (RFC 3339 timestamp). Event bodies are PII-safe: no raw message body, no unmasked destination numbers.
1. Streams, subjects, retention
| Stream | Subjects | Retention | Replicas | Deduplication window |
|---|---|---|---|---|
COMPLIANCE_AUDIT | compliance.audit.v1 | 13 months (regulatory) | 3 | 2 min |
COMPLIANCE_MESSAGES | compliance.message.held.v1, compliance.message.blocked.v1, compliance.message.released.v1, compliance.message.rejected.v1, compliance.message.expired.v1 | 7 days (projection stored in compliance.hold_queue + consumers' DBs) | 3 | 2 min |
COMPLIANCE_TENANT | compliance.tenant.tier.changed.v1, compliance.tenant.suspended.v1 | 365 days | 3 | 2 min |
COMPLIANCE_RULES | compliance.rule.changed.v1 | 90 days | 3 | 2 min |
COMPLIANCE_REPORTS | compliance.report.generated.v1 | 30 days | 3 | — |
All streams have a corresponding .deadletter suffix. Consumers are durable with explicit ack per PLT-REQ-008/009.
2. Produced events
2.1 compliance.audit.v1
Emitted for every EvaluateCompliance call, regardless of verdict. This is the primary regulatory evidence feed.
interface ComplianceAudit {
schemaVersion: '1';
eventId: string; // UUIDv4
evaluationId: string; // UUIDv4
messageId: string; // UUIDv4
tenantId: string;
accountId: string;
verdict: 'ALLOW' | 'FLAG' | 'HOLD' | 'BLOCK';
findings: Array<{
ruleId: string;
ruleName: string;
ruleType: string; // RuleType enum
action: 'ALLOW' | 'FLAG' | 'HOLD' | 'BLOCK';
evidence: string; // redacted
confidence?: number; // 0..1 (AI rules only)
}>;
ruleSetId: string | null;
ruleSetVersion: number | null;
evaluationLatencyMs: number;
budgetExceeded: boolean;
aiCached: boolean | null;
toMasked: string; // '+CCNNN***'
senderId: string;
messageType: 'SMS' | 'FLASH' | 'WAP';
segments: number;
encoding: 'GSM7' | 'UCS2';
traceId: string;
at: string; // RFC 3339
}
2.2 compliance.message.held.v1
interface ComplianceMessageHeld {
schemaVersion: '1';
eventId: string;
holdId: string;
messageId: string;
evaluationId: string;
tenantId: string;
accountId: string;
reviewPriority: number;
triggerRuleIds: string[];
reasonCode: string; // e.g. 'rule_match' | 'tenant_suspended' | 'ai_classify_fraud'
autoExpiresAt: string; // RFC 3339
traceId: string;
at: string;
}
2.3 compliance.message.blocked.v1
interface ComplianceMessageBlocked {
schemaVersion: '1';
eventId: string;
messageId: string;
evaluationId: string;
tenantId: string;
accountId: string;
triggerRuleIds: string[];
reasonCode: string;
traceId: string;
at: string;
}
2.4 compliance.message.released.v1
interface ComplianceMessageReleased {
schemaVersion: '1';
eventId: string;
holdId: string;
messageId: string;
tenantId: string;
accountId: string;
reviewerUserId: string;
reviewNotes: string | null;
reviewedAt: string;
traceId: string;
at: string;
}
2.5 compliance.message.rejected.v1
Identical shape to released.v1 above (reviewer chose REJECT instead of RELEASE).
2.6 compliance.message.expired.v1
interface ComplianceMessageExpired {
schemaVersion: '1';
eventId: string;
holdId: string;
messageId: string;
tenantId: string;
accountId: string;
autoExpiresAt: string;
expiredAt: string;
traceId: string;
at: string;
}
2.7 compliance.tenant.tier.changed.v1
interface ComplianceTenantTierChanged {
schemaVersion: '1';
eventId: string;
tenantId: string;
previousTier: 'CLEAR' | 'MONITOR' | 'RESTRICTED' | 'SUSPENDED';
newTier: 'CLEAR' | 'MONITOR' | 'RESTRICTED' | 'SUSPENDED';
overallScore: number; // 0..100
dimensions: {
content: number;
volume: number;
dlr: number;
optout: number;
complaint: number;
tenure: number;
};
trigger: 'scoring_worker' | 'manual_override';
overrideUserId?: string; // present when trigger = manual_override
overrideReason?: string;
overrideExpiresAt?: string;
traceId: string;
at: string;
}
2.8 compliance.tenant.suspended.v1
A convenience event emitted in addition to tier.changed.v1 specifically when newTier = SUSPENDED. Subscribers who only care about the hard-enforcement transition can subscribe narrowly.
interface ComplianceTenantSuspended {
schemaVersion: '1';
eventId: string;
tenantId: string;
overallScore: number;
trigger: 'scoring_worker' | 'manual_override';
reason?: string;
traceId: string;
at: string;
}
2.9 compliance.rule.changed.v1
interface ComplianceRuleChanged {
schemaVersion: '1';
eventId: string;
entityType: 'RULE' | 'RULE_SET' | 'BLOCKLIST' | 'KEYWORD_LIST' | 'ASSIGNMENT';
entityId: string;
action: 'CREATE' | 'UPDATE' | 'DELETE';
actorUserId: string;
version: number | null; // new version after the change
impactedTenantIds: string[] | null; // non-null for ASSIGNMENT changes
traceId: string;
at: string;
}
Consumers treat this as a cache-invalidation signal. They MUST NOT rely on the event for the new state; they fetch it via the REST API if needed.
2.10 compliance.report.generated.v1
interface ComplianceReportGenerated {
schemaVersion: '1';
eventId: string;
reportId: string;
reportType:
| 'TENANT_RANKING' | 'VIOLATION_SUMMARY' | 'HOLD_QUEUE_SUMMARY'
| 'TIER_TRANSITIONS' | 'TOP_TRIGGERED_RULES' | 'TENANT_AUDIT';
params: Record<string, unknown>;
outputRef: string; // object-store URI
outputFormat: 'json' | 'csv' | 'pdf';
requestedBy: string;
requestedAt: string;
completedAt: string;
traceId: string;
at: string;
}
3. Consumed events
| Subject | Producer | Purpose |
|---|---|---|
sms.dlr.inbound | dlr-processor (upstream), smpp-connector (ultimate source) | Maintain compliance.dlr_stats for DLR_ABUSE rules and for the scoring worker |
auth.user.erased.v1 | auth-service | Tenant/user GDPR erasure — trigger compliance-side PII redaction in hold_queue.payload for affected users |
auth.idp.disabled.v1 | auth-service | Recompute tenant caches when IdP changes (no direct compliance effect today; reserved for policy-linked rules) |
4. Consumer contracts (by subscribing service)
| Service | Subjects | Action |
|---|---|---|
notification-service | compliance.message.held.v1, .blocked.v1, .released.v1, .rejected.v1, .expired.v1, compliance.tenant.tier.changed.v1 | Push portal notifications + optional email to the tenant |
analytics-service | compliance.audit.v1, compliance.message.*, compliance.tenant.tier.changed.v1 | Long-term archival + compliance dashboards |
billing-service | compliance.message.blocked.v1, .rejected.v1, .expired.v1, .released.v1 | Waive billing for non-dispatched messages; bill normally after release |
admin-dashboard (SSE) | compliance.message.held.v1, .released.v1, .rejected.v1, .expired.v1 | Live hold-queue view |
compliance-engine (self) | sms.dlr.inbound, auth.user.erased.v1 | DLR stats + PII redaction |
5. PII & security rules for events
- Raw message body is forbidden in any compliance event payload.
- Destination phone number is transmitted only as
toMasked(+CCNNN***). - Rule
evidencestrings are redacted: keyword and regex matches are truncated with the match span replaced by***. - Event payloads are signed by the producer's RS256 key (platform-wide signing) and verified opportunistically by critical consumers (billing, notification).
- Event bodies with PII-adjacent fields (
toMasked,senderId) are classified as CONFIDENTIAL-INTERNAL and must not be replicated to any data sink outside the platform without explicit Legal review.
6. Outbox pattern
All compliance state changes write an event row to compliance.outbox in the same PostgreSQL transaction as the aggregate change. A relay publishes to NATS with retry and ack. This guarantees no emitted events without a persisted state change and no unpublished state change for more than a few seconds.
7. Schema evolution
- Additive fields with non-required defaults are non-breaking within the same
schemaVersion. - Breaking changes bump to
compliance.<topic>.v2. Subjects coexist during a deprecation window (≥ 90 days); subscribers migrate offv1before it is removed. - Enum values are additive. Consumers MUST treat unknown enum values as
UNKNOWNand not fail.