lib/dispo-send.js — Full Library Contract
Status: SPEC — not implemented Plan reference: Section 71 Item 9, Canonical Action Registry entry pending Purpose: Single chokepoint for ALL dispo outbound activity. Every SMS, every internal note, every Slack alert goes through here. Callers know nothing about SalesMsg API endpoints, OAuth files, channel IDs, rate limits, or audit writes.
Design principles
- Single source of truth for outbound dispo activity. If a send can happen, it happens through this module.
- Every gate in exactly one place. Opt-out, cooldown, circuit breaker, HTML strip, name redact, draft validator, rate limit, reasoning-leak detector — all implemented once, applied to every outbound.
- Every send writes to
pipeline_events. No silent successes, no silent failures. Observability is non-optional. - Structured return values. Callers never have to guess. Every function returns
{ok, status, reason, details}. - Fail-closed on compliance, fail-open on observability. If opt-out check errors, DO NOT send. If pipeline_events write fails, still send and write to backlog file.
- No fire-and-forget. Every async operation is awaitable and surfaces errors.
- Token logic is internal. Callers do not pick tokens. The library decides: Aurora for SMS sends and note posts per canonical ruling 2026-04-10 (Section 71).
- HTTP details are internal. Callers do not construct URLs or headers.
File layout
scripts/lib/dispo-send.js — main module, exports all functions below
scripts/lib/dispo-send/gates.js — per-send gates (opt-out, cooldown, etc.)
scripts/lib/dispo-send/tokens.js — OAuth token management (Aurora-only)
scripts/lib/dispo-send/sanitize.js — HTML strip, name redact, validator
scripts/lib/dispo-send/events.js — pipeline_events write helpers
All existing callers import from scripts/lib/dispo-send.js (main module). Internal files are not exported.
Exported functions
1. sendSms(options) — send an SMS to a buyer
Purpose: Send a single SMS message through the chokepoint with all gates applied.
Signature:
async function sendSms(options)Options (object):
| Field | Type | Required | Description |
|---|---|---|---|
phone | string | YES | E.164 format, e.g. +15551234567 |
message | string | YES | The message body (will be HTML-stripped and name-redacted before send) |
inboxId | string | YES | SalesMsg inbox ID for the sending number |
dealId | string | no | HubSpot deal ID for audit correlation |
mediaUrls | string[] | no | MMS media URLs, max 10 |
contactName | string | no | For audit + personalization |
source | string | no | Caller identifier for audit — e.g. 'saved-reply', 'ai-draft', 'blast:dispo-engine', 'recovery', 'manual'. Default: 'unknown' |
overrideReason | string | no | If set, bypasses rate limiter with this explanation logged to pipeline_events. Use only for operator-initiated exceptions. |
traceId | string | no | Langfuse trace correlation ID |
Return value:
{
ok: true,
status: 'sent', // 'sent' | 'blocked' | 'deferred' | 'error'
messageId: 'sm_msg_12345', // SalesMsg message ID on success
conversationId: 'sm_conv_67890',
latencyMs: 234,
gatesPassed: ['compliance', 'cooldown', 'circuit_breaker', 'validator', 'sanitize'],
pipelineEventId: 'uuid', // the pipeline_events row id
}
// OR on block
{
ok: false,
status: 'blocked',
reason: 'opted_out' | 'cooldown' | 'circuit_breaker_halted' | 'rate_limit_exceeded' | 'validator_rejected' | 'bot_loop_detected',
details: { /* gate-specific context */ },
pipelineEventId: 'uuid',
}
// OR on deferred (queued for delayed send)
{
ok: false,
status: 'deferred',
reason: 'cooldown_deferred',
deferredUntil: '2026-04-10T14:31:00Z',
pgmqMessageId: 123,
pipelineEventId: 'uuid',
}
// OR on error (upstream failure, not a gate block)
{
ok: false,
status: 'error',
reason: 'salesmsg_api_error',
details: { httpStatus: 500, body: '...' },
pipelineEventId: 'uuid',
}Gates applied (in order):
- Input validation — phone format E.164, message non-empty, inboxId present
- Compliance gate (
lib/compliance-gate.js) — opt-out check, quiet hours, rate limit - Circuit breaker — 5+ sends to same phone in last 1h → halt 4h
- Per-phone cooldown — 60s minimum between any sends to same number (defer if violated)
- Draft output validator — reject messages containing reasoning leaks, scoring artifacts, oversized content,
Aurora:,@inbox, etc. - HTML strip — remove
<br>,<a>,', etc. viahtml-to-text - Internal name redaction — strip Todd/Chandler/Troy/Angel/Henry
- Message length check — SMS max 320 chars, warn + truncate if exceeded
- Rate limiter — per-phone-per-24h, per-phone-per-7d, per-deal-per-day caps (skippable via
overrideReason)
Side effects:
- Writes
pipeline_eventsrow withevent_type='send_attempt'before gate chain - Writes
pipeline_eventsrow withevent_type='send_completed'|'send_blocked'|'send_deferred'|'send_failed'after - On deferred: enqueues
pgmq.delayed_smsmessage with exact resume time - On blocked by opt-out: no-op (does NOT cleanup dispo_blast_recipients row — caller’s responsibility)
- On error: writes to
/tmp/dispo_send_errors.jsonlas backlog - On success: writes outbound row to
salesmsg_inbox
Failure posture:
- Compliance check fails → FAIL-CLOSED, return blocked
- pipeline_events write fails → FAIL-OPEN, log to backlog, continue
- SalesMsg API 5xx → retry 1x with 500ms delay, then return error
- SalesMsg API 4xx (other than 429) → return error with details
- SalesMsg API 429 rate limited → defer via pgmq
- OAuth token read fails → return error with
token_load_failedreason
Token used: Aurora (per canonical ruling 2026-04-10, Section 71)
2. postNote(options) — post an internal SalesMsg note
Purpose: Post a {tag:inbox} review note to a SalesMsg conversation for human reviewer visibility.
Signature:
async function postNote(options)Options (object):
| Field | Type | Required | Description |
|---|---|---|---|
conversationId | string | YES | SalesMsg conversation ID |
body | string | YES | Note body text (supports {tag:inbox} and {tag:user:ID} tags) |
approvalId | string | no | Links note to message_approvals.id for audit |
contactPhone | string | no | For pipeline_events |
dealId | string | no | For pipeline_events |
tag | string | no | Override default {tag:inbox} with {tag:user:ID} for targeted mention |
source | string | no | Caller identifier |
retryOnFail | boolean | no | Default true. Retry once on 5xx. |
Return value:
{
ok: true,
noteId: 'sm_note_12345',
latencyMs: 189,
pipelineEventId: 'uuid',
}
// OR on failure
{
ok: false,
status: 'blocked' | 'error',
reason: 'invalid_body' | 'api_error' | 'token_error' | 'duplicate_suppressed',
details: { httpStatus, body },
pipelineEventId: 'uuid',
}Gates applied:
- Body non-empty and < 4000 chars
- Duplicate suppression: if same
(conversationId, bodyHash)posted in last 5 min → skip withduplicate_suppressed - HTML strip applied to body
- Internal name redaction applied
Side effects:
pipeline_eventsrowevent_type='note_post_attempt'beforepipeline_eventsrowevent_type='note_posted'|'note_post_failed'after- On 5xx after retry: also posts Slack fallback via
notifySlack()with orphan approval template
Token used: Aurora (canonical ruling)
Endpoint used: POST /pub/v2.1/messages/{conversationId} with body {message, type:'note'}
3. postBriefing(options) — post Aurora auto-send briefing note
Purpose: After an AI auto-send succeeds, post a briefing note to the conversation explaining what was sent and why, so the human reviewer sees context.
Signature:
async function postBriefing(options)Options (object):
| Field | Type | Required | Description |
|---|---|---|---|
conversationId | string | YES | — |
contactName | string | YES | — |
contactPhone | string | YES | — |
inboundMessage | string | YES | The buyer’s message Aurora responded to |
sentMessage | string | YES | What Aurora actually sent |
confidenceScore | number | no | Scorer output |
intent | string | no | Classifier output |
escalationReason | string | no | If the send should have been escalated |
traceId | string | no | Langfuse correlation |
Return value: same shape as postNote() but with eventType: 'briefing_posted'
Body composition: Auto-generated from template:
Aurora: Responded to {contactName} ({contactPhone})
Their message: "{inboundMessage_truncated_100}"
Sent: "{sentMessage_truncated_120}"
Confidence: {score}/100 | Intent: {intent}
{optional escalation line}
Token used: Aurora
Internally calls: postNote() with composed body
4. postFeedbackDraft(options) — post a revised draft note after @aurora feedback
Purpose: When a human @mentions Aurora with feedback, this posts the regenerated draft for re-approval.
Signature:
async function postFeedbackDraft(options)Options:
| Field | Type | Required | Description |
|---|---|---|---|
conversationId | string | YES | — |
recipientName | string | YES | — |
newDraft | string | YES | The regenerated draft |
whatChanged | string | no | What the feedback actually changed (for reviewer context) |
senderUserId | string | YES | Original feedback sender (for {tag:user:ID}) |
Return value: same as postNote()
Internally calls: postNote() with tag={tag:user:${senderUserId}}
5. postApprovalReview(options) — post the primary approval review note
Purpose: After an AI draft is created and enters the approval queue, this posts the review note for the reviewer to see and action.
Signature:
async function postApprovalReview(options)Options:
| Field | Type | Required | Description |
|---|---|---|---|
conversationId | string | YES | — |
approvalId | string | YES | message_approvals.id |
contactName | string | YES | — |
draft | string | YES | The AI draft |
inboundMessage | string | YES | — |
scoringResult | object | no | {score, routing, violated, passed, reason} |
intent | string | no | Classifier output |
offerDetails | object | no | Extracted offer fields if detected |
templateType | string | no | 'standard' |
Return value: same as postNote()
Template dispatch:
- If
offerDetails?.amount→ uses offer review template + fires SlacknotifyOffer() - If
scoringResult?.routing === 'require_review'or score < 70 → uses low confidence template + fires SlacknotifyLowConfidence() - If
intent === 'showing_request'orintent === 'call_request'→ uses escalation template + fires SlacknotifyEscalation() - Else → standard draft review template
Side effects:
- Calls
postNote()internally - Calls
notifyOffer()/notifyEscalation()/notifyLowConfidence()from slack-notify.js as appropriate - If note post AND Slack both fail: writes
pipeline_eventsrowevent_type='both_paths_failed'with severity critical (triggers PagerDuty-equivalent escalation per Section 49.7)
6. postAckMessage(options) — post acknowledgment / status notes
Purpose: Lightweight note post for one-off operational messages like ”✅ Got it — AI resumed” or ”❌ Send failed for all messages”.
Signature:
async function postAckMessage(options)Options:
| Field | Type | Required | Description |
|---|---|---|---|
conversationId | string | YES | — |
body | string | YES | Short status message |
type | string | no | 'cancel' |
Return value: same as postNote()
Internally calls: postNote() with a short body and source: 'ack'
7. sendBatch(options) — send multiple messages to one contact with per-message delay
Purpose: Multi-message sends like saved reply + qualifier question. Handles per-message cooldown internally, returns per-message results.
Signature:
async function sendBatch(options)Options:
| Field | Type | Required | Description |
|---|---|---|---|
phone | string | YES | — |
messages | array | YES | [{body, mediaUrls?}, ...] |
inboxId | string | YES | — |
dealId | string | no | — |
interMessageDelayMs | number | no | Default 1500. Minimum 1000. |
source | string | YES | Caller identifier |
Return value:
{
ok: true | false, // true only if ALL messages sent successfully
totalSent: 3,
totalFailed: 0,
results: [
{ ok: true, status: 'sent', messageId: 'sm_msg_1', ... },
{ ok: true, status: 'sent', messageId: 'sm_msg_2', ... },
{ ok: true, status: 'sent', messageId: 'sm_msg_3', ... },
],
}Internally calls: sendSms() per message, awaiting delay between each. First failure does NOT abort; continues with remaining messages and reports per-message status.
8. notifySlackChannel(options) — wrapped Slack post with pipeline_events
Purpose: Post to any Slack channel via slack-notify.js with a guaranteed pipeline_events write.
Signature:
async function notifySlackChannel(options)Options:
| Field | Type | Required | Description |
|---|---|---|---|
channel | string | YES | 'dispo_team' |
type | string | YES | 'offer' |
payload | object | YES | Passed through to slack-notify template |
relatedApprovalId | string | no | For correlation in pipeline_events |
contactPhone | string | no | For correlation |
Return value:
{
ok: true,
slackTs: '1775804235.597479',
channelId: 'C0AFQEH3KB8',
pipelineEventId: 'uuid',
}Internally calls: appropriate slack-notify.js function based on type, always writes pipeline_events row event_type='slack_alerted' or 'slack_failed'.
9. getLastSendTime(phone) — cooldown state query
Purpose: Helper for callers that need to know when a phone last received a send (without triggering the send itself). Used by rate limit lookups, the daily health cron, and the watcher.
Signature:
async function getLastSendTime(phone)Return value:
{
lastSentAt: '2026-04-10T14:05:00Z' | null,
cooldownExpires: '2026-04-10T14:06:00Z' | null,
sentInLast1h: 2,
sentInLast24h: 4,
sentInLast7d: 12,
}Reads from: pipeline_events table, filtering by contact_phone and event_type='send_completed'.
10. getCircuitBreakerState(phone) — circuit breaker state query
Purpose: Check if a phone is in circuit-breaker halt.
Signature:
async function getCircuitBreakerState(phone)Return value:
{
halted: true | false,
haltUntil: '2026-04-10T18:05:00Z' | null,
tripReason: 'send_count_exceeded' | null,
sendCountInWindow: 6,
}Reads from: in-memory state + pipeline_events for cold start.
Internal helpers (NOT exported, used only within the library)
_getAuroraToken() — OAuth token reader
Reads from /home/opsadmin/.openclaw/workspace/data/salesmessage-aurora-oauth.json. Caches for 55 min. Fall back to main token if Aurora file unreadable. Used for both SMS sends AND note posts per canonical ruling.
_smFetch(url, opts) — SalesMsg HTTP client wrapper
Wraps fetch() with: request timeout (30s), retry on 5xx (1x), response parsing. Returns {ok, status, data, error}.
_writeEvent(event) — pipeline_events write
Wraps lib/pipeline-events.js writePipelineEvent(). Adds source_system: 'dispo-send' automatically. Fail-open.
_applyGates(ctx) — gate chain executor
Runs all 9 gates in order. Returns {passed: ['gate1','gate2',...], blocked: false, reason}. On first failure, stops and returns.
_sanitize(message) — HTML + name + validator chain
Applies HTML strip → name redact → validator. Returns {clean, valid, reason}.
_resolveContactConversation(phone, inboxId) — SalesMsg contact+conv lookup
Find-or-create pattern, caches contact + conversation IDs per-phone for 5 min to reduce API calls.
_buildNoteBody(template, vars) — note template composer
Dispatches to one of 4 templates (standard, offer_review, escalation, low_confidence) from data/note-templates.json. Applies sanitize + variable substitution.
Migration path from current state
Existing callers to refactor (ordered by priority):
-
webhooks/salesmessage-handler-v4-complete.js— replace ALL directfetchcalls to/pub/v2.1/messages/*and ALL note POSTs. ReplacesendSms(...)calls with library version. Replace inline Slack calls via slack-notify.js withnotifySlackChannel(). Remove the 8 inline token lookups. ~20 call sites. -
scripts/workers/pgmq-sms-consumer.js— replacesendSmsimport fromlib/salesmsg-send.jswithlib/dispo-send.js. Remove the inline OAuth token readers (they move into the library). Replace blast_saved_reply handler’s send path. -
scripts/dispo-blast-engine.js— delete the privatesendSms()function on line 410, import fromlib/dispo-send.js. Remove inline SalesMsg API calls. -
scripts/dispo-propstream-blast.js— delete wrappersendSms()on line 1072, import fromlib/dispo-send.js. -
scripts/dispo-investorbase-blast.js— delete wrapper on line 509, import fromlib/dispo-send.js. -
scripts/dispo-crmls-agent-blast.js— delete wrapper on line 1178, import fromlib/dispo-send.js. -
scripts/lovable-api-server.js— replace directrequire('./lib/salesmsg-send')withrequire('./lib/dispo-send'). Most call sites are already compatible. -
scripts/workers/inbound-alert-watcher.js— no direct SMS sends but may usenotifySlackChannel()for alerts instead of direct slack-notify.js calls. -
scripts/workers/dispo-approval-poller.js— replace inline note POST fetch calls withpostNote().
Migration rule: scripts/lib/salesmsg-send.js stays as a thin backward-compat shim that re-exports from dispo-send.js during the transition. Once all callers are migrated, salesmsg-send.js is deleted.
Backward compatibility during migration
Phase A: Create lib/dispo-send.js alongside existing lib/salesmsg-send.js. No caller changes yet.
Phase B: Update lib/salesmsg-send.js to be a 20-line shim that re-exports from dispo-send.js. Existing sendSms(...) positional signature is preserved by a wrapper that translates positional args to the new options object. Any errors get wrapped in the new return shape.
Phase C: Refactor callers file-by-file. Each caller moves from the shim to the direct import. Old positional signature deprecated but still works.
Phase D: Delete the shim once zero callers reference it.
Observability guarantees after migration
Every send/note/slack/query function writes at least one pipeline_events row. After migration, answering “what happened to contact X at time T” is a single query:
SELECT event_type, status, reason, latency_ms, metadata, source_system
FROM pipeline_events
WHERE contact_phone = '+15551234567'
AND ts BETWEEN '2026-04-10 01:00' AND '2026-04-10 02:00'
ORDER BY ts ASC;No more grep across 15 log files. No more silent catches hiding root causes. No more “which token was used?” ambiguity.
Success criteria
- All 9 exported functions have unit tests in
scripts/tests/test-dispo-send.js - Every gate is tested with a fixture that should block
- End-to-end fixture:
sendSmsto opted-out number → blocked + pipeline_events row + no SalesMsg API call made - Stress test: 100 concurrent
sendSmscalls to 100 different phones → p95 latency < 500ms - Every existing production caller migrated with zero behavior regression measured over a 7-day shadow period
lib/salesmsg-send.jsshim deleted
Files this spec will produce
scripts/lib/dispo-send.js(main, ~400 lines)scripts/lib/dispo-send/gates.js(~200 lines)scripts/lib/dispo-send/tokens.js(~80 lines)scripts/lib/dispo-send/sanitize.js(~150 lines — imports from existing draft-validator.js once that exists)scripts/lib/dispo-send/events.js(~100 lines — thin wrapper around pipeline-events.js)scripts/tests/test-dispo-send.js(~300 lines, full unit + integration tests)data/note-templates.json(template bodies for the 4 note types)
Total new code: ~1,200 lines.
Total old code removed after migration: ~800 lines (private sendSms implementations across 5 files + scattered token/gate/validation logic).
Net: +400 lines, concentrated in one testable module.