Compliance Layer — Sync Contract
Status: populated | Last updated: 2026-04-18
This document defines what other services depend on from the Compliance Layer and what it depends on from others.
1. Consumers of the Compliance Layer
| Service | Interface | Dependency type | SLA expectation |
|---|---|---|---|
sms-orchestrator (NATS consumer pipeline) | gRPC ComplianceService/EvaluateCompliance | Synchronous call within the async message pipeline | P95 ≤ 500 ms; availability 99.9% |
admin-dashboard | HTTP REST /compliance/* | Synchronous admin management surface | P95 ≤ 500 ms; availability 99.5% |
Async contract semantics
Although the gRPC call itself is synchronous, the caller's context is asynchronous — the sms-orchestrator NATS consumer is processing messages from the sms.outbound.request queue, and the tenant has already received a 202 response from the HTTP endpoint.
sms-orchestrator must not proceed with routing unless the compliance verdict is ALLOW or FLAG. On BLOCK, it updates the message to BLOCKED status. On HOLD, it updates to ON_HOLD and waits for manual review.
Fail-closed is mandatory: If the gRPC call fails, times out, or returns INTERNAL, the NATS consumer does NOT ACK the message. JetStream redelivers after the ack wait. After 3 deliveries, the message moves to sms.outbound.deadletter with reason compliance_unavailable. The tenant is notified via the web portal. The message is never dispatched to a carrier.
2. Dependencies of the Compliance Layer
| Dependency | Interface | Failure mode if unavailable |
|---|---|---|
PostgreSQL compliance schema | Read/write SQL via connection pool | Service returns INTERNAL; caller retries via NATS redelivery |
| Redis | GET/SET/ZADD/ZCOUNT | Cache miss fallback to DB; evaluation continues with higher latency |
NATS JetStream sms.dlr.inbound | DLR consumer | DLR stats become stale; DLR_ABUSE rules evaluate against last known stats; no crash |
| Local LLM service (primary) | HTTPS / gRPC | AI_CLASSIFICATION rules fall back to fallbackAction (typically HOLD — fail-closed) |
| External LLM API (secondary, optional) | HTTPS | Same as local — fallbackAction applies |
3. Proto Definition
syntax = "proto3";
package ghasi.sms.compliance.v1;
option go_package = "github.com/ghasi/sms-gateway/compliance/v1";
service ComplianceService {
// Called by sms-orchestrator NATS consumer for every dequeued message,
// before routing. Returns verdict governing downstream message flow.
rpc EvaluateCompliance (EvaluateComplianceRequest) returns (EvaluateComplianceResponse);
}
message EvaluateComplianceRequest {
string message_id = 1;
string tenant_id = 2;
string account_id = 3;
string to = 4; // E.164 destination number
string from_id = 5; // Sender ID
string body = 6; // Full message body (UTF-8)
string message_type = 7; // 'SMS' | 'FLASH' | 'WAP'
int32 segments = 8;
string encoding = 9; // 'GSM7' | 'UCS2'
string idempotency_key = 10;
map<string, string> metadata = 11;
}
message EvaluateComplianceResponse {
string evaluation_id = 1;
ComplianceVerdict verdict = 2;
repeated Finding findings = 3;
string rule_set_id = 4;
int64 evaluation_latency_ms = 5;
string hold_id = 6; // Populated only when verdict = HOLD
}
enum ComplianceVerdict {
COMPLIANCE_VERDICT_UNSPECIFIED = 0;
ALLOW = 1; // Proceed to routing
BLOCK = 2; // Terminal; do not route
HOLD = 3; // Queue for manual review; do not route
FLAG = 4; // Proceed to routing; annotate message
}
message Finding {
string rule_id = 1;
string rule_name = 2;
string rule_type = 3;
ComplianceVerdict action = 4;
string evidence = 5;
float confidence = 6;
}
4. Integration Point in sms-orchestrator (NATS Consumer)
The compliance call lives in the orchestrator's NATS consumer, after dequeue and status update, before routing:
// sms-orchestrator NATS consumer
async handleOutboundRequest(msg: JsMsg): Promise<void> {
const ctx = parseMessageContext(msg.data);
try {
// Step 1: Mark message as being evaluated
await this.repo.updateStatus(ctx.messageId, 'EVALUATING');
// Step 2: Compliance evaluation (fail-closed)
const result = await this.complianceClient.evaluateCompliance({
messageId: ctx.messageId,
tenantId: ctx.tenantId,
accountId: ctx.accountId,
to: ctx.to,
fromId: ctx.senderId,
body: ctx.body,
messageType: ctx.messageType,
segments: ctx.segments,
encoding: ctx.encoding,
idempotencyKey: ctx.idempotencyKey,
});
// Step 3: Act on verdict
switch (result.verdict) {
case 'ALLOW':
case 'FLAG':
await this.repo.updateStatus(ctx.messageId, 'ROUTING', {
flagged: result.verdict === 'FLAG',
evaluationId: result.evaluationId,
});
await this.routingPipeline.route(ctx);
break;
case 'BLOCK':
await this.repo.updateStatus(ctx.messageId, 'BLOCKED', {
blockedReason: summarizeFindings(result.findings),
evaluationId: result.evaluationId,
});
// notification-service consumes compliance.message.blocked event → web portal alert
break;
case 'HOLD':
await this.repo.updateStatus(ctx.messageId, 'ON_HOLD', {
holdId: result.holdId,
evaluationId: result.evaluationId,
});
// notification-service consumes compliance.message.held event → web portal alert
break;
}
msg.ack();
} catch (err) {
// FAIL-CLOSED: do not ack. NATS redelivers. After max retries → deadletter.
logger.error('compliance evaluation failed; message will retry', {
messageId: ctx.messageId,
err,
});
metrics.increment('compliance_unavailable_retry_total');
// Do NOT call msg.ack() — JetStream will redeliver
}
}
Release flow (for held messages):
// Triggered by admin REST: POST /compliance/hold-queue/:holdId/review { action: 'RELEASE' }
async onMessageReleased(holdId: string, messageId: string): Promise<void> {
// Update message status and re-publish for routing (compliance already cleared)
await this.repo.updateStatus(messageId, 'ROUTING', { complianceOverride: true });
await this.nats.publish('sms.outbound.retry', {
messageId,
skipCompliance: true, // honoured by the consumer — skips compliance re-eval
});
}
5. Schema Stability Guarantees
gRPC Proto
| Field | Stability |
|---|---|
EvaluateComplianceRequest.* required fields | Stable |
metadata map | Stable — new keys may be added |
ComplianceVerdict enum values | Stable — new values may be added; callers must handle UNSPECIFIED |
Finding.* | Stable |
| New fields with default values | Non-breaking (proto3 semantics) |
REST API
- Routes under
/compliance/v1/maintain backwards compatibility within the major version. - Breaking changes require
/v2/prefix and a 90-day deprecation window.
6. Versioning Policy
- gRPC package:
ghasi.sms.compliance.v1 - Breaking changes require a
v2package and co-ordinated migration. - REST API follows semantic versioning.