Skip to main content

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​

StreamSubjectsRetentionReplicasDeduplication window
COMPLIANCE_AUDITcompliance.audit.v113 months (regulatory)32 min
COMPLIANCE_MESSAGEScompliance.message.held.v1, compliance.message.blocked.v1, compliance.message.released.v1, compliance.message.rejected.v1, compliance.message.expired.v17 days (projection stored in compliance.hold_queue + consumers' DBs)32 min
COMPLIANCE_TENANTcompliance.tenant.tier.changed.v1, compliance.tenant.suspended.v1365 days32 min
COMPLIANCE_RULEScompliance.rule.changed.v190 days32 min
COMPLIANCE_REPORTScompliance.report.generated.v130 days3—

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​

SubjectProducerPurpose
sms.dlr.inbounddlr-processor (upstream), smpp-connector (ultimate source)Maintain compliance.dlr_stats for DLR_ABUSE rules and for the scoring worker
auth.user.erased.v1auth-serviceTenant/user GDPR erasure — trigger compliance-side PII redaction in hold_queue.payload for affected users
auth.idp.disabled.v1auth-serviceRecompute tenant caches when IdP changes (no direct compliance effect today; reserved for policy-linked rules)

4. Consumer contracts (by subscribing service)​

ServiceSubjectsAction
notification-servicecompliance.message.held.v1, .blocked.v1, .released.v1, .rejected.v1, .expired.v1, compliance.tenant.tier.changed.v1Push portal notifications + optional email to the tenant
analytics-servicecompliance.audit.v1, compliance.message.*, compliance.tenant.tier.changed.v1Long-term archival + compliance dashboards
billing-servicecompliance.message.blocked.v1, .rejected.v1, .expired.v1, .released.v1Waive billing for non-dispatched messages; bill normally after release
admin-dashboard (SSE)compliance.message.held.v1, .released.v1, .rejected.v1, .expired.v1Live hold-queue view
compliance-engine (self)sms.dlr.inbound, auth.user.erased.v1DLR 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 evidence strings 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 off v1 before it is removed.
  • Enum values are additive. Consumers MUST treat unknown enum values as UNKNOWN and not fail.