Skip to main content

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

ServiceInterfaceDependency typeSLA expectation
sms-orchestrator (NATS consumer pipeline)gRPC ComplianceService/EvaluateComplianceSynchronous call within the async message pipelineP95 ≤ 500 ms; availability 99.9%
admin-dashboardHTTP REST /compliance/*Synchronous admin management surfaceP95 ≤ 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

DependencyInterfaceFailure mode if unavailable
PostgreSQL compliance schemaRead/write SQL via connection poolService returns INTERNAL; caller retries via NATS redelivery
RedisGET/SET/ZADD/ZCOUNTCache miss fallback to DB; evaluation continues with higher latency
NATS JetStream sms.dlr.inboundDLR consumerDLR stats become stale; DLR_ABUSE rules evaluate against last known stats; no crash
Local LLM service (primary)HTTPS / gRPCAI_CLASSIFICATION rules fall back to fallbackAction (typically HOLD — fail-closed)
External LLM API (secondary, optional)HTTPSSame 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

FieldStability
EvaluateComplianceRequest.* required fieldsStable
metadata mapStable — new keys may be added
ComplianceVerdict enum valuesStable — new values may be added; callers must handle UNSPECIFIED
Finding.*Stable
New fields with default valuesNon-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 v2 package and co-ordinated migration.
  • REST API follows semantic versioning.