Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Chapter 39 — The Outreach Agent

Policy returned PASS on O-1001. Maya approved the draft. Customer contact is the irreversible step — only Outreach may deliver.

Outreach receives an already-reviewed draft and runs it through preference, opt-out, authorization (outreach:send), Policy re-check at send time (send window for SMS/push, required fields), approval binding, idempotency, and audit before any channel adapter acts.

approved draft → prefs/opt-out → authz → Policy re-check (window + fields) → approval_id → idempotent send → audit

Drafting without this gate stack is how duplicate sends and after-hours SMS happen. A draft written at 20:00 can still fail at 21:30 when the send window closes. Opt-out can flip between draft and delivery. Retries without idempotency spam Jordan twice.

On O-1001, Outreach checks channel preference, confirms not opted out, verifies approval, sends with idempotency key O-1001:wa:1, and records a consequential audit event. If any gate fails, no transport call.

This chapter adds Outreach: the only component with customer-delivery capability — and every gate in front of it.

First principles

  1. Use the customer’s selected channel. Multi-channel support means preference plus Policy, not “blast all.”
  2. Separate drafting from delivery. Only delivery needs the outreach:send capability (Ch 27–28).
  3. Re-check Policy at send time. Drafts can age; send window and opt-out can change between draft and delivery.
  4. Treat opt-out as a hard stop. This design requires a separate, out-of-band consent process before any later contact.
  5. Make delivery idempotent. Every send carries a key so retries cannot duplicate an order update (Ch 6, 32).
  6. Start with recording adapters. A mock makes every attempted side effect visible before a real provider is connected.
  7. Leave evidence behind. Audit every consequential delivery (Ch 30).

Concrete example

O-1001 has a stalled shipment. The customer selected WhatsApp, Policy returned PASS, a reviewer approved the send, and the idempotency key is O-1001:wa:1.

DraftMessage(channel=whatsapp, body=shipment update for O-1001)
 → OutreachAgent.send(principal with outreach:send, approval_id=…)
 → MockTransport records one send
 → AuditLog consequential_action

If opted_out flips to true before send, Outreach raises PermissionError and makes no transport call. If the approval is missing, it also denies delivery.

Diagram

sequenceDiagram
  participant P as Policy
  participant O as Outreach
  participant A as Authz
  participant T as Channel transport
  participant U as Audit
  P->>O: approved draft
  O->>A: require outreach:send
  A-->>O: allow / deny
  O->>T: send(idem_key)
  O->>U: record_consequential_action
 prefs / opt-out ──► draft
 policy PASS ──► gate
 authz send scope ──► gate
 approval (voice/threshold) ──► gate
 idempotent transport ──► side effect
 audit append ──► evidence

Caption: Notice how many gates sit in front of the carrier — the model is not among the gatekeepers.

Implementation

Module: shopops/agents/outreach.py.

from shopops.agents.outreach import (
    Channel,
    OutreachAgent,
    MockTransport,
    PreferenceStore,
)
from shopops.agents.policy_agent import PolicyReview, PolicyReviewStatus
from shopops.authz import Authz
from shopops.audit import AuditLog
from shopops.policy.packs.shop_v1 import POLICY_VERSION

prefs = PreferenceStore()
prefs.set("C-7781", preferred_channel="whatsapp", opted_out=False)
transport = MockTransport()
audit = AuditLog()
authz = Authz()
outreach = OutreachAgent(prefs=prefs, transport=transport, authz=authz, audit=audit)

draft = outreach.draft(
    case_id="O-1001",
    customer_id="C-7781",
    body="Your shipment for order O-1001 has stalled. We are reviewing a reship or refund.",
    to="+15550001111",  # fictional
    order_evidence_complete=True,
)
assert draft.channel == Channel.WHATSAPP

principal = authz.with_send_capability(
    authz.principal_for_agent("contact", "store_northline")
)
policy_review = PolicyReview(
    case_id="O-1001",
    status=PolicyReviewStatus.PASS,
    policy_version=POLICY_VERSION,
    rule_ids=("contact_ok",),
    reasons=("ok",),
    required_fields_ok=True,
)
result = outreach.send(
    principal=principal,
    draft=draft,
    policy_review=policy_review,
    approval_id="appr-77",
    idem_key="O-1001:wa:1",
    policy_version=POLICY_VERSION,
    tenant_id="store_northline",
    state_snapshot={"fsm": "executing"},
)
# double send same key → same message_id
result2 = outreach.send(
    principal=principal,
    draft=draft,
    policy_review=policy_review,
    approval_id="appr-77",
    idem_key="O-1001:wa:1",
    policy_version=POLICY_VERSION,
    tenant_id="store_northline",
    state_snapshot={"fsm": "executing"},
)
assert result["message_id"] == result2["message_id"]
assert len(transport.sends) == 1

prefs.opt_out("C-7781")
try:
    outreach.send(
        principal=principal,
        draft=draft,
        policy_review=policy_review,
        approval_id="appr-77",
        idem_key="O-1001:wa:2",
        policy_version=POLICY_VERSION,
        tenant_id="store_northline",
        state_snapshot={},
    )
    raise AssertionError("opt-out must block")
except PermissionError:
    pass

Failure modes

FailureIncidentFix
Send without policy PASSIllegal contactRequire PASS
Ignoring opt-outRegulatory eventHard check forever
No idempotencyDouble WhatsAppKeys at adapter
Model picks channelPreference bypassPref store owns channel
Audit after crashLost evidenceAppend-before-ack patterns

Production considerations

  • Real adapters: timeouts, provider webhooks, delivery receipts → ledger.
  • Recheck the policy verdict immediately before delivery; order state may have changed since the draft.
  • Frequency counters from ledger, not from model memory.
  • Redact phone/email in traces; full values only in sealed audit store.

Chapter summary

  • Outreach owns channels under prefs and gates.
  • Draft ≠ send; delivery needs approval.
  • Opt-out honored forever in this design.
  • Idempotent transports prevent double sends.
  • Policy PASS and authz scopes are mandatory.
  • Audited consequential actions only.
  • Mocks first; real carriers later.
  • Never unrestricted autonomous customer outreach.

Exercises

  1. Mechanical. Opt out; prove draft-for-send path raises; escalate path remains available at orchestrator level.
  2. Idempotency. Concurrent double send with same key; one message_id.
  3. Design. Specify webhook handling when WhatsApp delivery fails after ack — compensation without second marketing ping.

References

  • Chapters 6, 27–30, 32, 38
  • Channel provider idempotency docs. [VERIFY]