Messaging Compliance Gate

Purpose

Consolidates per-send compliance decisions into one function so no outbound path can skip them. Reads from five Supabase tables to produce a deterministic allow/deny decision for each intended send. Fails closed on any error.

Replaces per-vendor or per-worker ad-hoc compliance logic. No outbound send path in OpenClaw may bypass this gate.

When to use

  • As the gate function called by bulk-sms-split-test.js, smart-outreach-worker.js, and future vendor send paths
  • As a standalone CLI tool to debug a specific phone/body/provider combination
  • As the pre-check in the /sms-opt-in API route for inbound consent handling (checks if the phone is already suppressed before recording new consent)

Do NOT use for:

  • Post-send auditing (the gate is pre-send only)
  • Webhook ingestion compliance (suppression writes happen in handlers, not this gate)

Inputs

Function call:

const gate = require('/home/opsadmin/.openclaw/tools/messaging-compliance-gate');
const decision = await gate.complianceGate({
  phone: '+1XXXXXXXXXX',       // E.164 required
  body: '...',                   // rendered template body
  provider: 'sent_dm',           // messaging_providers.name
  sender_number: '+1YYYYYYYYYY'  // from which number; optional if provider has one default
});
// decision: { allow: boolean, reason?: string, details?: object }

CLI:

node messaging-compliance-gate.js \
  --phone=+1... --body='...' --provider=sent_dm

Outputs

Returns a JSON object:

  • { allow: true } on pass
  • { allow: false, reason: '<enum>', details: {...} } on block

Reason enums:

  • suppressed — contact has STOP/compliance suppression on file
  • quiet_hours — send attempted 21:00-08:00 recipient-local time (area-code → TZ mapping; standard-time offsets used, conservative in DST)
  • cooldown — a prior outbound to same phone within 24h AND no inbound reply since
  • duplicate_content — same SHA256(body) to same phone within last 1h
  • no_campaign — provider has no approved TCR campaign row in messaging_campaigns
  • invalid_phone — phone does not match ^\+1\d{10}$
  • no_consent — phone has no sms_opt_in=true row in contact_consent

Acceptance tests

  1. A phone with a stop_keyword row in messaging_suppression_events (active partial-unique index) always returns { allow: false, reason: 'suppressed' }
  2. Send attempted at 22:00 local time blocks with reason: quiet_hours
  3. Second send attempt within 24h to same phone when no reply happened in between blocks with reason: cooldown
  4. Second send of same body-hash to same phone within 1h blocks with reason: duplicate_content
  5. Provider without a row in messaging_campaigns blocks with reason: no_campaign
  6. Phone "(555) 123-4567" blocks with reason: invalid_phone (not E.164)
  7. Phone with no consent row or sms_opt_in=false blocks with reason: no_consent
  8. Calling twice with identical inputs returns same decision (idempotent)
  9. DB error or timeout: returns { allow: false, reason: 'gate_error' } (fail closed)

Rollback behavior

Read-only gate; no writes to rollback. Calling code is responsible for logging gate decisions to its own audit trail.

If gate is malfunctioning (blocking everything) in production:

  1. Set MESSAGING_COMPLIANCE_GATE_BYPASS=true in master.env
  2. Re-source + restart consumers
  3. Investigate + fix root cause
  4. Unset bypass

Bypass should be logged as an incident in workspace/reports/compliance-gate-bypass/<date>.md including reason, scope, duration.

  • Plan section: Section B (no-send gate) + Section G.4 in /home/opsadmin/.claude/plans/put-you-full-last-functional-sparrow.md
  • Implementation: /home/opsadmin/.openclaw/tools/messaging-compliance-gate.js
  • Reads from: messaging_suppression_events, messaging_outbound_messages, messaging_campaigns, contact_consent
  • Uses: area-code-to-UTC-offset map (US codes, standard-time offsets)
  • Consumers: bulk-sms-split-test.js, smart-outreach-worker.js, future /sms-opt-in API
  • Gate doc: workspace/docs/NO-SEND-GATE-v1.md