# Routing and outbound

Depth for procedure steps 6 and 7, and the policy format `scripts/route_dry_run.py` reads. The planned lead-routing-and-speed-to-lead, outbound-automation-guardrails and email-sender-infrastructure skills go further; until they are installed, use this file.

## Four ownership roles

- Account owner: the CRM owner field on the company or account. Changes only through an approved reassignment rule.
- Request owner: who answers this request now. Can be a covering rep or a pool member without changing the account owner.
- Opportunity owner: owns the deal. A signal on an account with an open opportunity becomes a note or task for this person.
- Meeting host: who runs the meeting. Booking a meeting never reassigns the account.
- The owner is always the CRM owner field, never the record creator or the integration user. Apollo documents Created By and Owner as different fields, and records created through a team connection can end up owned by the integration user.

## Precedence, first match wins

1. Replay (`replay`): a repeat delivery never acts twice.
2. Suppressed (`no_action`): no proactive action in any scope. A direct request still gets a human reply, so it falls through.
3. Expired evidence (`no_action`, reason `expired`): evidence older than its `expiry_days` creates no work for anyone. Direct requests never expire.
4. Service intent (`service`): support, billing and access questions leave sales routing.
5. Existing customer (`cs_task`): the account team decides; prospecting never touches customers.
6. Open opportunity (`opp_owner_task`): the opportunity owner hears about it; nobody else starts a thread.
7. Identity unresolved: a direct request goes to the monitored fallback; anything else is held with its candidate IDs (`hold`).
8. Unknown source date or no expiry (`research`, reason `unknown_source_date`): research only. Customer and open-opportunity rows reached their owners at 5 and 6, as a note to verify the date; research needs a matched account, so this sits below 7.
9. Named account owner (`owner_task`): the covering rep acts while the owner is away; ownership is unchanged.
10. Active conversation (`attach_existing`): an active sequence or a human reply in the last 30 days gets the evidence attached, not a second thread.
11. Missing territory (`fallback`, reason `territory_missing`), then new-business direct request to the territory pool (`pool_task`).
12. Proactive band: an approved sequence if every step 7 condition passes (`sequence`), otherwise an SDR task (`pool_task`). Inferred signals without corroboration never reach the SDR task either; they fall through to research.
13. Research band (`research`): incomplete fit, inferred or public-change signals outside the gate.
14. Below band with engagement (`nurture`), then the monitored fallback (`fallback`) with a named duty owner and an escalation deadline.

Why this order: every protective rule (suppression, expiry, customer, opportunity, undated evidence, owner, conversation) sits above every rule that creates new work, the fallback included. Moving suppression, customer, opportunity or owner below such a rule is what invariants I2 to I5 catch, and moving the expiry or undated rule is what I12 catches. An active conversation is checked only for sequences (I7), so confirm by reading the policy that the conversation rule sits above the pool rules.

## Sequence or task

- A sequence only when the signal class allows it and every I7 condition holds: accepted validation status, approved message version for this segment and signal, evidence within expiry, no active sequence, no human reply inside the window, matched identity, and for inferred signals a recorded corroboration.
- Anything else in the band becomes a task for a person or research.

## Distribution

- Round robin when the work is similar; territory or segment when expertise or ownership matters; capacity-based when open accepted work differs; weighted when hours differ. Start with the simplest method that fits.
- `route_dry_run.py` simulates round robin in row order (deterministic). Other methods are checked by their invariants, not simulated.

## Platform notes (verify live)

developer.salesforce.com refuses automated fetches. help.salesforce.com answers, but some pages return an error shell with no article, so read those in a browser. The Salesforce notes below were checked in a browser on 2026-11-06.

- HubSpot owner rotation (help page updated 2026-09-23, checked 2026-11-06):
  - Needs paid Sales or Service seats for the owners in the rotation.
  - "Overwrite if [object] has an existing owner" must stay off unless the action is an approved reassignment. Lead and ticket workflows label it "Overwrite any existing lead owner" and "Overwrite any existing ticket owner".
  - Adding or removing owners in the rotation resets assignment counts.
  - When everyone in the pool is away, the record can be left without an owner, so that branch must lead to the fallback.
  - With the Salesforce integration, a synced owner can push the previous owner back. Decide which system owns the owner field.
- Salesforce assignment rules: one lead rule is active at a time, its entries run in order and the first match wins, and the target is a user or a queue. A queue assignment is not acceptance.
  - No match: a web lead goes to the default owner in Lead Settings and an imported lead goes to the admin running the import. With no active rule, web leads go to that default owner. Point the default at the monitored fallback (W13).
  - Creation paths differ: REST applies the active rule unless the Assignment Rule header says FALSE, and omitting the header means TRUE. SOAP applies a rule only with AssignmentRuleHeader. Bulk API 2.0 takes `assignmentRuleId` when the job is created. Data Loader runs a rule when its ID is in the Assignment rule setting. Web-to-Lead and the Data Import Wizard can run rules. Confirm which applies to each path the workflow uses.
- Salesforce leads have no account lookup before conversion: Lead has no AccountId, only ConvertedAccountId (filled at conversion) and PartnerAccountId (partner-owned leads). Before assignment, match each new lead to an Account by normalized email domain and to an existing Contact by email. Write the matched account, its owner and its customer status to lead fields the assignment rules read, or use a lead-to-account matching tool. Otherwise an employee of a customer or an owned account routes as new business.
- Apollo: the CRM option on a CSV import overrides the integration's push settings, and updates to existing HubSpot contacts push regardless of those settings (checked 2026-11-06). Check the options used on the last import, not only the global settings.

## Clocks and escalation

- Keep `received_at`, `crm_seen_at`, `assigned_at` and `first_attempt_at`. Reassignment never resets `received_at`.
- Default target for explicit high-intent requests: first human attempt within 15 business minutes, backup rep at 30, team lead at 60. These are starting defaults to test, not a benchmark; propose the team's coverage hours for `decisions.md`.
- Fictional example: business hours 09:00 to 17:00 UTC, Monday to Friday. A request at 16:50 on Friday uses 10 business minutes on Friday, so the 15-minute deadline is 09:05 on Monday. Report the wall-clock wait as well.

## Fallback design

- A named duty owner (a person, not a shared inbox), a visible queue, an escalation deadline and a retry limit.
- Never cycle an unaccepted request through a pool where everyone is away.
- Fallback share above 10% of routed items is an alert (step 10); it means a rule or territory is missing.

## Policy file format

- Top level: `policy_id`, `version`, `fallback_owner`, `pools` (pool name to a list of owner names), `rules`. Keys starting with `_` are comments and are ignored.
- Rule: `id`, `when` (all conditions hold), optional `when_any` (at least one holds), `route`, and optional `owner_from` (a column), `cover_from` (a column used when `owner_available` is false), `pool` with `method` `round_robin`, `sequence`, `reason`. A rule with no conditions always matches; use it once, last, for the fallback.
- Condition: `{"field": column, "op": op, "value": v}` or `"value_field": other_column`. Ops: `eq`, `ne`, `in`, `not_in`, `empty`, `not_empty`, `gt`, `gte`, `lt`, `lte`. Numbers compare as numbers; booleans as the strings true and false, case-insensitive. A comparison against a blank number is false.
- Routes: `replay`, `no_action`, `service`, `cs_task`, `opp_owner_task`, `hold`, `owner_task`, `attach_existing`, `pool_task`, `sequence`, `research`, `nurture`, `fallback`.
- `_replay` is set by the script on later rows that repeat a `source_event_key`.
- Required episode columns: `episode_id`, `source_event_key`, `signal_class`, `identity_status`, `suppressed`, `customer`, `open_opportunity`, `opportunity_owner`, `account_owner`, `owner_available`, `covering_rep`, `active_sequence`, `last_human_reply_days`, `email_validation`, `direct_request`, `corroborated`, `evidence_age_days`, `expiry_days`, `message_version_approved`. Any other column (fit, engagement, territory) can be used in conditions. `owner_available` and `covering_rep` describe the account owner. Optional `opportunity_owner_available` and `opportunity_covering_rep` (both or neither) do the same for the opportunity owner; when present, rules with `owner_from` `opportunity_owner` and invariant I3 use them.
- Values: `signal_class` is direct_request, first_party, inferred or public_change; `identity_status` is matched, ambiguous, personal_domain or none; booleans are true or false; `email_validation` is the normalized validator status (valid, accept_all, unknown, invalid). The script exits 2 on any other signal class or boolean value. The template also reads `service_intent`, `territory`, `fit`, `engagement` and `fit_complete`.
- Build the episodes file from the CRM export joined to the signal exports: one row per signal delivery, with its `episode_id` and `source_event_key`, IDs only. Keep repeat deliveries; I1 needs them. The script reads UTF-8 with or without a byte-order mark.
- Owner columns (`account_owner`, `opportunity_owner`, `covering_rep` and the opportunity pair) use the same identifiers as the policy's `pools` and `fallback_owner`, either names or CRM owner IDs, or I3, I4 and I9 compare different labels.
- A later delivery on an open episode (same `episode_id`, different `source_event_key`) prints an I1 WARN until the policy attaches it. The script cannot see which items are open, so add a column such as `episode_open`, true on those rows, and a rule directly under R03 that routes it to `attach_existing` unless it is a direct request.
- Usage: `python3 scripts/route_dry_run.py --policy POLICY.json --records EPISODES.csv --capacity ae=22,sdr=40 --out routed.csv`. `--capacity` is per period covered by the file (weekly capacity x weeks). Exit 1 means at least one invariant failed. Exit 2 means bad input, for example a file that is not a UTF-8 CSV (save Excel files as CSV UTF-8) or an `--out` folder that does not exist yet.

## The outbound gate

- Re-check at enrollment time, not at list build: suppression in every scope and client, customer, open opportunity, active sequence, human reply window (30 days), prior hard bounce, validation policy, evidence age, approved message version, mailbox daily capacity.
- Approval covers one audience snapshot, one message version, one sender set and one schedule. It expires when any of them changes, or after 7 days. A later opt-out wins over any approval.
- Enrollment key = contact plus play plus episode. Store the destination lead and campaign IDs it returns.
- A timeout is an unknown outcome. Query the destination by the stored key before any retry. A unique key in your own database does not make the destination API idempotent.
- The line with lifecycle sending: lifecycle and marketing senders belong to lifecycle-marketing-audit, and this skill owns the sequencers and their gates. Suppression crosses it both ways: lifecycle unsubscribes, complaints and hard bounces reach the outbound suppression list, and outbound opt-outs reach every lifecycle sender (W15).
- Mailbox capacity: new enrollments per sending mailbox per day, plus the follow-ups earlier cohorts send that day, stay at or below the daily limit set for that mailbox in the sequencer. Count every campaign that uses the mailbox. Forecast follow-ups by day before approving a batch; when the forecast exceeds the limit, enroll fewer contacts or schedule later cohorts. A step delayed past the evidence expiry goes back through the gate. Adding or rotating mailboxes is email-sender-infrastructure's decision, never a way to absorb volume.
- HubSpot sequences enrolled by a workflow do not queue the excess: when the sender is over the daily send limit, the contact skips the enrollment action (help page updated 2026-08-03, checked 2026-11-06). Compare approved enrollments with actual ones and give skipped contacts an owner.

## Suppression that reaches live campaigns (checked 2026-11-01)

- Instantly: the Blocklist tab is checked at upload and across ongoing campaigns, but a connected Google Sheets blocklist is checked only at import or upload. Email and domain blocks are separate entries. Its help also explains that leads who replied before stop on reply was enabled are not stopped, and that moving or re-uploading replied leads can restart them.
- Smartlead: unassigned global blocklist entries only block campaigns with no client tagged; each client needs its own entry.
- Smartlead webhooks (checked 2026-11-06): a user-level webhook stops client- and campaign-level webhooks from firing, and within a level the last updated one is used. Smartlead's pages disagree on retries. The retry help article (modified 2026-11-03) says up to 8 retries over about 28 hours and none after a 400, 413 or 422 response. The webhooks API page lists retries at 1, 5 and 15 minutes, 1 hour and 6 hours, and disables the webhook after 5 failed attempts. Plan for the shorter one: a receiver outage can stop events until someone re-enables the webhook. Monitor webhook state, reconcile from the API after any outage, and keep the event ID so a retry or manual resend is not processed twice. Verify the HMAC SHA-256 signature as the webhooks API page shows; check the header name at run time.
- Apollo: sequence rulesets carry unsubscribe, connected call and bounce protections that cannot be changed, and a reply can stop the contact or delay the whole account (checked 2026-11-06). Check the ruleset each sequence uses.
- The test (controlled inboxes, explicit approval): queue a test contact for a later step, suppress it, confirm the step does not send; re-import it and confirm it stays suppressed; repeat in every client scope.

## Stops and replies

- Stop on any human reply, unsubscribe, hard bounce, booked meeting or created opportunity. Out-of-office with a return date pauses; a departure notice stops.
- Company-level stop on reply is decided per play and written in the spec.
- Reply classes: interested, referral, objection, out of office, departure, opt-out, unclear. Humans own every substantive reply. An opt-out acts at once, without waiting for a rep. A referral is not permission to enroll the colleague.
- A late "interested" classification never overrides an earlier opt-out.

## Sender checks per sending path (checked 2026-11-01)

- Gmail: a bulk sender sends close to 5,000 or more messages to personal Gmail accounts in 24 hours from one primary domain, and the status is permanent. Keep the Postmaster Tools spam rate below 0.10% and never at 0.30% or higher. Marketing and promotional mail needs one-click unsubscribe; unsubscribes should take effect within 48 hours. Enforcement since November 2025 includes temporary and permanent rejections.
- Yahoo: SPF and DKIM; DMARC of at least `p=none`, passing; spam rate below 0.3%, measured on mail delivered to the inbox; one-click unsubscribe (POST recommended); unsubscribes honoured within 2 days.
- Outlook.com consumer mailboxes: 5,000 or more messages a day from one 5322.From domain must pass SPF and DKIM and publish a valid DMARC policy aligned with at least one of them. Failures are rejected with 550 5.7.515.
- DMARC is now RFC 9989 (May 2026), which obsoletes RFC 7489 and RFC 9091. One-click unsubscribe is RFC 8058.
- Scope (checked 2026-11-02): Google's sender guidelines do not apply to mail sent to Google Workspace accounts. Postmaster Tools reports only mail to personal Gmail accounts (@gmail.com, @googlemail.com) and can be empty on low-volume days, and the Outlook.com rules cover consumer mailboxes. B2B cold email mostly reaches Workspace and Microsoft 365 business mailboxes, so these dashboards are often empty for it. Treat missing data as not checked, never as a 0% spam rate.
- Skill defaults, stricter than the providers. Pause new enrollments for a sending domain when any of these holds:
  - The Postmaster Tools spam rate reaches 0.1% on any day.
  - A batch's hard bounces exceed 2% of attempted sends. lifecycle-marketing-audit rates the same 2% as DL11 FAIL on lifecycle streams; here it is a pause trigger and a W17 WARN.
  - Opt-outs plus negative replies (remove me, stop, complaint) exceed 1% of delivered messages over 7 days. Apply this whenever Postmaster has no data for the domain or most recipients are business mailboxes. The 1% is a starting value to propose for `decisions.md`.
  - A placement test to controlled Workspace and Microsoft 365 mailboxes lands in spam.
- Resume only after the cause is fixed. Never rotate domains or mailboxes to escape complaints.
- Evidence per path: send a test to a controlled mailbox and read `Authentication-Results` (spf, dkim with `d=`, dmarc), `Return-Path`, `List-Unsubscribe` and `List-Unsubscribe-Post` on the received copy, after the provider has processed it.
- RFC 8058 test: the DKIM signature covers both unsubscribe headers; a POST to the URI works without cookies or login; no redirect; the subscription state changes; a queued message honours it; a re-import does not restore the contact.
- Read-only DNS lookups, with your own domain in place of the reserved name:

```bash
dig +short TXT example.com              # SPF record
dig +short TXT _dmarc.example.com       # DMARC policy
dig +short TXT s1._domainkey.example.com  # DKIM key for selector s1
```

## LinkedIn

- The LinkedIn User Agreement prohibits bots and other unauthorized automated methods. Automate research preparation and task creation only; a person sends.

## Region policy

- Permission to contact is the privacy owner's decision, recorded per region in `decisions.md`.
- CAN-SPAM covers business-to-business commercial email in the US. UK PECR treats corporate subscribers differently from sole traders and some partnerships. Under GDPR the lawful basis is the privacy owner's call.
- A region in the audience with no recorded decision stops that region. It does not default to allowed.
