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

  1. Single source of truth for outbound dispo activity. If a send can happen, it happens through this module.
  2. 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.
  3. Every send writes to pipeline_events. No silent successes, no silent failures. Observability is non-optional.
  4. Structured return values. Callers never have to guess. Every function returns {ok, status, reason, details}.
  5. 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.
  6. No fire-and-forget. Every async operation is awaitable and surfaces errors.
  7. 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).
  8. 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):

FieldTypeRequiredDescription
phonestringYESE.164 format, e.g. +15551234567
messagestringYESThe message body (will be HTML-stripped and name-redacted before send)
inboxIdstringYESSalesMsg inbox ID for the sending number
dealIdstringnoHubSpot deal ID for audit correlation
mediaUrlsstring[]noMMS media URLs, max 10
contactNamestringnoFor audit + personalization
sourcestringnoCaller identifier for audit — e.g. 'saved-reply', 'ai-draft', 'blast:dispo-engine', 'recovery', 'manual'. Default: 'unknown'
overrideReasonstringnoIf set, bypasses rate limiter with this explanation logged to pipeline_events. Use only for operator-initiated exceptions.
traceIdstringnoLangfuse 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):

  1. Input validation — phone format E.164, message non-empty, inboxId present
  2. Compliance gate (lib/compliance-gate.js) — opt-out check, quiet hours, rate limit
  3. Circuit breaker — 5+ sends to same phone in last 1h → halt 4h
  4. Per-phone cooldown — 60s minimum between any sends to same number (defer if violated)
  5. Draft output validator — reject messages containing reasoning leaks, scoring artifacts, oversized content, Aurora:, @inbox, etc.
  6. HTML strip — remove <br>, <a>, &#039;, etc. via html-to-text
  7. Internal name redaction — strip Todd/Chandler/Troy/Angel/Henry
  8. Message length check — SMS max 320 chars, warn + truncate if exceeded
  9. Rate limiter — per-phone-per-24h, per-phone-per-7d, per-deal-per-day caps (skippable via overrideReason)

Side effects:

  • Writes pipeline_events row with event_type='send_attempt' before gate chain
  • Writes pipeline_events row with event_type='send_completed' | 'send_blocked' | 'send_deferred' | 'send_failed' after
  • On deferred: enqueues pgmq.delayed_sms message 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.jsonl as 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_failed reason

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):

FieldTypeRequiredDescription
conversationIdstringYESSalesMsg conversation ID
bodystringYESNote body text (supports {tag:inbox} and {tag:user:ID} tags)
approvalIdstringnoLinks note to message_approvals.id for audit
contactPhonestringnoFor pipeline_events
dealIdstringnoFor pipeline_events
tagstringnoOverride default {tag:inbox} with {tag:user:ID} for targeted mention
sourcestringnoCaller identifier
retryOnFailbooleannoDefault 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:

  1. Body non-empty and < 4000 chars
  2. Duplicate suppression: if same (conversationId, bodyHash) posted in last 5 min → skip with duplicate_suppressed
  3. HTML strip applied to body
  4. Internal name redaction applied

Side effects:

  • pipeline_events row event_type='note_post_attempt' before
  • pipeline_events row event_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):

FieldTypeRequiredDescription
conversationIdstringYES
contactNamestringYES
contactPhonestringYES
inboundMessagestringYESThe buyer’s message Aurora responded to
sentMessagestringYESWhat Aurora actually sent
confidenceScorenumbernoScorer output
intentstringnoClassifier output
escalationReasonstringnoIf the send should have been escalated
traceIdstringnoLangfuse 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:

FieldTypeRequiredDescription
conversationIdstringYES
recipientNamestringYES
newDraftstringYESThe regenerated draft
whatChangedstringnoWhat the feedback actually changed (for reviewer context)
senderUserIdstringYESOriginal 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:

FieldTypeRequiredDescription
conversationIdstringYES
approvalIdstringYESmessage_approvals.id
contactNamestringYES
draftstringYESThe AI draft
inboundMessagestringYES
scoringResultobjectno{score, routing, violated, passed, reason}
intentstringnoClassifier output
offerDetailsobjectnoExtracted offer fields if detected
templateTypestringno'standard'

Return value: same as postNote()

Template dispatch:

  • If offerDetails?.amount → uses offer review template + fires Slack notifyOffer()
  • If scoringResult?.routing === 'require_review' or score < 70 → uses low confidence template + fires Slack notifyLowConfidence()
  • If intent === 'showing_request' or intent === 'call_request' → uses escalation template + fires Slack notifyEscalation()
  • 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_events row event_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:

FieldTypeRequiredDescription
conversationIdstringYES
bodystringYESShort status message
typestringno'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:

FieldTypeRequiredDescription
phonestringYES
messagesarrayYES[{body, mediaUrls?}, ...]
inboxIdstringYES
dealIdstringno
interMessageDelayMsnumbernoDefault 1500. Minimum 1000.
sourcestringYESCaller 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:

FieldTypeRequiredDescription
channelstringYES'dispo_team'
typestringYES'offer'
payloadobjectYESPassed through to slack-notify template
relatedApprovalIdstringnoFor correlation in pipeline_events
contactPhonestringnoFor 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):

  1. webhooks/salesmessage-handler-v4-complete.js — replace ALL direct fetch calls to /pub/v2.1/messages/* and ALL note POSTs. Replace sendSms(...) calls with library version. Replace inline Slack calls via slack-notify.js with notifySlackChannel(). Remove the 8 inline token lookups. ~20 call sites.

  2. scripts/workers/pgmq-sms-consumer.js — replace sendSms import from lib/salesmsg-send.js with lib/dispo-send.js. Remove the inline OAuth token readers (they move into the library). Replace blast_saved_reply handler’s send path.

  3. scripts/dispo-blast-engine.js — delete the private sendSms() function on line 410, import from lib/dispo-send.js. Remove inline SalesMsg API calls.

  4. scripts/dispo-propstream-blast.js — delete wrapper sendSms() on line 1072, import from lib/dispo-send.js.

  5. scripts/dispo-investorbase-blast.js — delete wrapper on line 509, import from lib/dispo-send.js.

  6. scripts/dispo-crmls-agent-blast.js — delete wrapper on line 1178, import from lib/dispo-send.js.

  7. scripts/lovable-api-server.js — replace direct require('./lib/salesmsg-send') with require('./lib/dispo-send'). Most call sites are already compatible.

  8. scripts/workers/inbound-alert-watcher.js — no direct SMS sends but may use notifySlackChannel() for alerts instead of direct slack-notify.js calls.

  9. scripts/workers/dispo-approval-poller.js — replace inline note POST fetch calls with postNote().

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

  1. All 9 exported functions have unit tests in scripts/tests/test-dispo-send.js
  2. Every gate is tested with a fixture that should block
  3. End-to-end fixture: sendSms to opted-out number → blocked + pipeline_events row + no SalesMsg API call made
  4. Stress test: 100 concurrent sendSms calls to 100 different phones → p95 latency < 500ms
  5. Every existing production caller migrated with zero behavior regression measured over a 7-day shadow period
  6. lib/salesmsg-send.js shim deleted

Files this spec will produce

  1. scripts/lib/dispo-send.js (main, ~400 lines)
  2. scripts/lib/dispo-send/gates.js (~200 lines)
  3. scripts/lib/dispo-send/tokens.js (~80 lines)
  4. scripts/lib/dispo-send/sanitize.js (~150 lines — imports from existing draft-validator.js once that exists)
  5. scripts/lib/dispo-send/events.js (~100 lines — thin wrapper around pipeline-events.js)
  6. scripts/tests/test-dispo-send.js (~300 lines, full unit + integration tests)
  7. 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.