NO-SEND-GATE — Messaging Vendor Hard Gate v1
Purpose: This document defines the 11 mandatory pre-send gate checks that every new messaging vendor must pass before any live production traffic is routed through them. No vendor sends more than 50 live messages until every gate check shows PASS. This document is the canonical authority for the gate; the
messaging_compliance_gateskill enforces it at runtime.
Why this gate exists
Past SMS incidents at RERI have included duplicate sends to 374 contacts (Supabase pagination bug), blast sends on the wrong inbox (Woodale incident: 205 msgs on wrong number), and untracked carrier failures. The no-send gate prevents these classes of failure by requiring evidence artifacts before any volume traffic.
This gate is not optional. It is not subject to “we’ll get to it later.” Every vendor onboarding skips no checks.
Gate authority
- Maintained by: Henry Hill (owner), Claude Code (enforcer)
- Enforced by:
messaging_compliance_gateskill +tools/messaging-compliance-gate.js - Override authority: Henry only, in writing, for a named vendor + specific date window
- Sandbox vs live: Gate checks 3-11 MUST pass in sandbox first. Gate checks 1-2 (10DLC) require production accounts per carrier policy.
The 11 gate checks
Each check requires a named evidence artifact before it is considered PASS. “I believe it works” is not an artifact.
Check 1: 10DLC brand approved (SMS vendors)
| Field | Value |
|---|---|
| Check ID | G1 |
| Applies to | SMS vendors: Telnyx, Bandwidth, sent.dm, Bird, SalesMsg |
| Does NOT apply to | iMessage-only vendors: LoopMessage, Linq, BlueBubbles |
| Pass criteria | TCR brand status = APPROVED |
| Evidence artifact | Screenshot of TCR portal showing APPROVED status + TCR Brand ID stored in messaging_providers.tcr_brand_id column |
| Sandbox workaround | None. 10DLC registration requires a production carrier account. Begin registration early (typical: 5-10 business days). |
| Failure consequence | No live SMS sends on that vendor. Period. |
What to check: Log into the vendor’s portal or TCR Brand Registry portal. Confirm brand status = APPROVED. Copy the Brand ID into the messaging_providers row for this vendor.
Check 2: 10DLC campaign approved (SMS vendors)
| Field | Value |
|---|---|
| Check ID | G2 |
| Applies to | SMS vendors (same as G1) |
| Pass criteria | TCR campaign status = APPROVED |
| Evidence artifact | Screenshot of campaign status = APPROVED + campaign ID stored in messaging_campaigns.tcr_campaign_id |
| Sandbox workaround | None. Campaign registration requires production. Submit early. |
| Failure consequence | No marketing SMS sends, even on approved numbers. |
Note for sent.dm: TCR campaign submitted 2026-04-24. Awaiting TCR approval (10-15 business days from submission). SMS with DRAFT templates is technically available (per Appendix D.3 of the plan) but gate G2 must be PASS before Phase 1 live smoke.
Check 3: Outbound API test
| Field | Value |
|---|---|
| Check ID | G3 |
| Applies to | All vendors |
| Pass criteria | POST to send endpoint returns HTTP 200 or 202, AND message is delivered to the recipient handset |
| Evidence artifact | API response body logged + screenshot of received message on recipient phone |
| Sandbox mode | Use vendor sandbox/trial; recipient = sandbox test number or Henry’s internal test number |
| Failure consequence | Block; investigate auth, phone number provisioning, or template approval issues |
Common failure modes: wrong auth header format (Bearer vs AccessKey vs Basic), phone number not provisioned, template not approved (sent.dm), missing MESSAGING_PROFILE_ID (Telnyx).
Check 4: Inbound webhook verified
| Field | Value |
|---|---|
| Check ID | G4 |
| Applies to | All vendors |
| Pass criteria | A reply from the test recipient triggers the vendor’s webhook, which reaches the local handler, which writes a row to messaging_inbound_messages |
| Evidence artifact | Database row in messaging_inbound_messages with correct from_phone, body, received_at + raw webhook body in webhook_audit_log |
| Sandbox mode | Most vendors support sandbox webhooks. Confirm sandbox inbound path works. |
| Failure consequence | Block; without confirmed inbound, reply tracking and STOP processing are broken |
How to test: After sending a message in G3, reply from the recipient phone. Verify the reply appears in the database within 30 seconds.
Check 5: Delivery receipt (DLR) verified
| Field | Value |
|---|---|
| Check ID | G5 |
| Applies to | All SMS vendors (Telnyx, Bandwidth, sent.dm, Bird, SalesMsg, Twilio) |
| Pass criteria | Delivery status update from vendor triggers webhook, which writes a row to messaging_delivery_events |
| Evidence artifact | Row in messaging_delivery_events with status = 'delivered' + occurred_at timestamp |
| Sandbox mode | Most sandbox environments emit synthetic DLRs. Verify. |
| Failure consequence | Block; without DLR tracking, delivery rate metrics for the scorecard are unreliable |
Check 6: STOP suppression enforced
| Field | Value |
|---|---|
| Check ID | G6 |
| Applies to | All vendors |
| Pass criteria | (a) Replying STOP from test number writes a row to messaging_suppression_events with reason = 'stop_keyword', AND (b) attempting a subsequent send to that number is blocked by the compliance gate with reason = 'suppressed' in the gate log |
| Evidence artifact | Suppression row + gate log entry showing blocked send |
| Sandbox mode | Test with test numbers. Verify both the suppression write and the blocked-send behavior. |
| Failure consequence | Hard block. Sending to STOP-opted contacts is a TCPA violation. This check is non-negotiable. |
Implementation note: The compliance gate at tools/messaging-compliance-gate.js queries messaging_suppression_events before every send. The gate must be in the send path before G6 can PASS.
Check 7: Quiet-hours enforcement
| Field | Value |
|---|---|
| Check ID | G7 |
| Applies to | All vendors |
| Pass criteria | A send attempted after 22:00 local time (recipient’s timezone, defaulting to PT) is blocked with reason = 'quiet_hours' in the gate log |
| Evidence artifact | Gate log entry with reason = quiet_hours, attempt_time, recipient_tz |
| Sandbox mode | Fake the timestamp in the gate logic test suite; no need to wait until 22:00 |
| Failure consequence | Block; off-hours sends generate opt-outs and carrier complaints |
Time window enforced: no sends from 22:00 to 08:00 recipient local time. Carriers and TCPA enforcement treat quiet-hours violations seriously.
Check 8: Per-phone cooldown enforced
| Field | Value |
|---|---|
| Check ID | G8 |
| Applies to | All vendors |
| Pass criteria | A second send to the same phone number within the cooldown window is blocked with reason = 'cooldown' in the gate log |
| Evidence artifact | Gate log entry with reason = cooldown, last_sent_at, cooldown_minutes |
| Cooldown default | 60 minutes for marketing, 5 minutes for 2FA/transactional |
| Sandbox mode | Adjust cooldown to 1 minute for testing; restore before live |
| Failure consequence | Block; double-sends cause opt-outs and erode sender reputation |
Check 9: Dedup via processed_webhook_events
| Field | Value |
|---|---|
| Check ID | G9 |
| Applies to | All vendors (inbound webhook handlers) |
| Pass criteria | Replaying the same webhook payload (same event ID) short-circuits: the handler returns 200 immediately without re-processing, and a log entry shows duplicate = true |
| Evidence artifact | Handler log showing duplicate=true for the replayed request |
| How to test | Send the same raw webhook body twice (use cURL or test script) and observe log |
| Failure consequence | Block; without dedup, retry storms from the vendor cause double-writes and possible double-replies |
Check 10: webhook_audit_log verified + local-file fallback
| Field | Value |
|---|---|
| Check ID | G10 |
| Applies to | All vendors |
| Pass criteria | (a) Every inbound webhook event writes a row to webhook_audit_log within 5 seconds. (b) BRIN index is active on occurred_at. (c) When Supabase is unreachable (simulated), the handler writes to local fallback file at /tmp/openclaw/webhook-audit-fallback-<date>.ndjson instead of crashing |
| Evidence artifact | Sample rows in webhook_audit_log + disk fallback file with entries from the simulated-outage test |
| How to simulate Supabase outage | Point SUPABASE_URL to a non-existent host for one test run |
| Failure consequence | Block; audit trail is required for TCPA compliance and debugging. A handler that crashes on DB outage drops messages silently. |
Check 11: Dead-letter + cost tracking
| Field | Value |
|---|---|
| Check ID | G11 |
| Applies to | All vendors |
| Pass criteria | (a) Failed sends persist in messaging_outbound_messages with status = 'failed' and are not silently dropped. (b) cost_cents column is populated for delivered messages. (c) Failed messages appear in the vendor’s dead-letter file at workspace/reports/dead-letter/<vendor>-<date>.ndjson |
| Evidence artifact | Row in messaging_outbound_messages with status=failed + cost row + dead-letter file entry |
| How to test | Send to an invalid number or a number in DND mode; confirm the row shows failure, not absence |
| Failure consequence | Block; without cost tracking the Phase 2 scorecard cannot compute cost-per-qualified-reply |
Gate check matrix (summary)
| # | Check name | SMS vendors | iMessage vendors | Sandbox-first? | Evidence table / log |
|---|---|---|---|---|---|
| G1 | 10DLC brand approved | Required | Not applicable | No (production only) | messaging_providers.tcr_brand_id |
| G2 | 10DLC campaign approved | Required | Not applicable | No (production only) | messaging_campaigns.tcr_campaign_id |
| G3 | Outbound API test | Required | Required | Yes | API response + handset screenshot |
| G4 | Inbound webhook | Required | Required | Yes | messaging_inbound_messages row |
| G5 | Delivery receipt | Required | SMS only | Yes | messaging_delivery_events row |
| G6 | STOP suppression | Required | Required | Yes | messaging_suppression_events + gate log |
| G7 | Quiet-hours enforcement | Required | Required | Yes | Gate log reason=quiet_hours |
| G8 | Per-phone cooldown | Required | Required | Yes | Gate log reason=cooldown |
| G9 | Dedup via processed_webhook_events | Required | Required | Yes | Handler log duplicate=true |
| G10 | webhook_audit_log + fallback | Required | Required | Yes | webhook_audit_log rows + disk file |
| G11 | Dead-letter + cost tracking | Required | Required | Yes | messaging_outbound_messages.status=failed + cost row |
Sandbox-first rule (non-negotiable)
Gate checks G3 through G11 MUST pass in the vendor’s sandbox/trial environment before any live paid message is sent. The only exceptions are G1 and G2, which require production carrier accounts per TCR policy.
Confirmed sandbox availability (from plan Section B.2):
| Vendor | Sandbox available | Notes |
|---|---|---|
| Telnyx | Yes ($2 free credit) | Full API surface |
| Bandwidth | Yes (sandbox account via sales) | Full API surface |
| sent.dm | Provisional (sandbox: true flag in API) | sandbox: true in POST body validates without sending |
| Bird | Yes (auto trial credit) | Full API + WhatsApp sandbox |
| LoopMessage | Partial (5 verified contacts) | G3-G11 possible; real recipient limited |
| Linq | Yes (dev/test tier) | API audit + gate testing |
| BlueBubbles | Yes (self-hosted) | Already proven |
Dry-run modes in bulk_sms_split_test skill
The split-test skill supports three operating modes that map to this gate’s phases:
--dry-run=plan: outputs group assignment + template selection per contact without calling any vendor API. No network calls. Used to verify the population query, group hash logic, and template mapping.--dry-run=sandbox: routes all sends through each vendor’s sandbox (where available). Real API calls; no paid sends. Used to validate gate checks G3-G11 before live traffic.--live: actual production traffic. Requires Phase 1 Approval C on record before this flag is valid. Gate checks G1-G11 must ALL be PASS for the vendor before--liveis accepted.
How to record gate check results
For each vendor, create an evidence packet file at:
workspace/reports/vendor-evidence/<vendor>-gates-<date>.md
Each file must contain one row per gate check:
- PASS: G1 10DLC Brand — TCR Brand ID: BXXXXXXXXX (screenshot: /reports/vendor-evidence/<vendor>-brand-screenshot.png)
- PASS: G3 Outbound API — HTTP 202, message_id=abc123 (2026-05-01 14:32 PT)
- FAIL: G4 Inbound webhook — no row in messaging_inbound_messages after 120s
...
All 11 checks must show PASS before the vendor is marked smoke_test_passed in messaging_providers.status.
Escalation path for gate failures
- G1/G2 (10DLC failures): Contact vendor’s compliance team directly. Timeline is carrier-controlled. Do not attempt workarounds.
- G3-G5 (API/delivery failures): Check vendor KB at
workspace/knowledge-base/<vendor>/API.md. Re-read auth type. Check rate limits. Try with explicit timeout. - G6-G8 (compliance logic failures): These indicate bugs in
tools/messaging-compliance-gate.js. Fix the gate code; do not bypass. - G9 (dedup failure): Check
processed_webhook_eventstable for the event ID. Ensure the handler is inserting before processing. - G10-G11 (audit/cost failures): Check Supabase connection. Check local fallback path permissions.
If any gate check fails and the vendor is in the Phase 1 smoke critical path, escalate to Henry with a written remediation plan before attempting to advance to Phase 2.
Change log
| Date | Change | Author |
|---|---|---|
| 2026-04-24 | Initial document created from plan Section B | Claude Code |
Version: v1 Owner: Henry Hill Last updated: 2026-04-24 Sourced from: messaging-vendor-phase-0-1-2026-04-23 plan Section B