Outbound is its own surface — its own nav entry and pages in the static office, and its own working seat, the desk, at
/outbound/* on the app — and it is
completely separate from the marketing calendar. It runs on the office's three rules: one record (Postgres
crm_contacts, consent at the root), one clock — the contact's (a step lands at enrolled_at + offset_days; a
scheduled batch lands on its own date; nothing is a calendar cell), one policy (governance/cadence.yaml, applied
through engine/lib/cadence.py can_send()). The static office shows seats and counts, never people.
The Chairman's decisions, verbatim, which this document does not relitigate:
keep airtable in the archive for what it currently has but all its content and moving forward should
be custom in the office with the postgres (2026-08-24)
warm only until GO (2026-08-24) · GO = 2026-09-13 (2026-08-25)
the marketing calendar and cockpit [should] be completely separate from the outbound … the only connection
between inbound and outbound is somewhere within SEO but that needs to [be] dialed in (2026-08-25)
[intent] should live within the outbound. intent part of the outreaching process (2026-08-25)
we need SIE for API calls and the data. we can do the actual outbound (2026-08-25)
One record. The contact's clock. One policy. Nothing sends until the GO gate opens. No compensation figures on
office pages. The 136 archive contacts import unsendable (consent.receipt = null) and get **one re-consent
send** as a scheduled batch in GO week.
The Chairman's rule (2026-08-25): "outbound send should be outbound. the cockpit is the calendar." Three surfaces,
one record:
| Surface | Where | What it is |
|---|---|---|
| The desk | /outbound/* on the app (apps/web/app/outbound/, Vercel project temerarii-office-app) — Queue · Contacts · Signals · Enrichment · Replies · Sends | the working seat: approve a draft touch, hold it, mark a reply handled, approve a scheduled batch. Its own frame (app/outbound/layout.tsx: header, the GO banner, the six tabs, "← Office", sign-out) — not the dashboard's, no calendar, no marketing link. Gated exactly like /dashboard (middleware.ts; login carries next=/outbound/…). /dashboard/outbound/ 308s here. The API under /api/outbound/ writes status only — there is no send path and there must never be one. |
| The cockpit | /dashboard/* on the same app | the marketing calendar's editable twin — assets, cells, cuts, authoring, publish, the front door, the roster. Its nav carries no Outbound group and nothing in it links to /outbound. |
| The office | office.temerarii.xyz/outbound/* (static, apps/office/render_outbound.py) | counts and rules only — seats, never names, no PII. Every one of the eight pages carries an "Open the desk ↗" link into the matching desk tab: overview → /outbound, sequences → /outbound/contacts, sends → /outbound/sends, signals → /outbound/signals, enrichment → /outbound/enrichment, intent → /outbound/contacts, replies → /outbound/replies, policy → /outbound. The base is edit_links.cockpit_base() (COCKPIT_BASE), falling back to the app's own host. |
| Marketing | Outbound | |
|---|---|---|
| unit | asset · cell · cut | contact · account · touch |
| clock | the fiscal calendar (publish date) | the contact's timeline (days since enrolment, capacity/day, a reply) and the scheduled batches |
| editor | authoring workbench + the calendar twin | a queue: approve a draft, read a reply, move a stage |
| owner seat | creative / EM | reply seat / ED |
| policy | brand rails, likeness consent | cadence, consent receipts, GO |
| result | per asset on the calendar | per contact per sequence |
No outbound lane on the calendar, no outbound cell in the content index, no week in governance/outbound.json.
engine/gates/verify_outbound.py refuses a calendar/outbound.yaml, any placement key in the registry, and any
stage that reads a calendar file.
| join | what it is | where |
|---|---|---|
| 1 · the record | both sides write crm_contacts. An inbound lead never auto-enrols in a sequence: policy.outbound.enroll_from = signals · imports-with-receipt · manual, never door. plan_outbound.enroll_source_of() classes every contact; a door-sourced one is refused and counted as not_enrollable:door — the inbound loop (inquiry → booking → the reply seat) owns it | governance/policy.json outbound.enroll_from · engine/stages/plan_outbound.py |
| 2 · the segment and the door | outbound links to the segment's door (/go/<segment>, a form, a booking) exactly as marketing does; utm_medium splits the result column so a booking from a letter and a booking from a post are told apart | gtm.yaml icp.segments[].door · engine/lib/utm.py · draft_touches.door_link() |
| 3 · intent | ONE registry, inside outbound: per segment the search themes (the SEO join — held equal to governance/seo/keywords.yaml by a gate that prints the diff for the orchestrator), the SIE topics (what SIE is asked for), the door, the pillar, the signal weight and which sources may enrol | governance/outbound/intent.yaml · engine/gates/verify_intent_registry.py · sie.topics_for() |
| 4 · content as a library | a content_from: pillar step references a published piece by the contact's pillar priority — a lookup in the library (the static blog + the content index's published items), never a lane on the calendar and never a cell turned into a touch | engine/stages/pick_content.py |
| Concern | Lives in | Written by | Read by |
|---|---|---|---|
| Record | crm_contacts (consent at the root; segment_id, door, intent_score, intent_inputs, stage, source, priorities) — 20260827_front_door.sql + 20260830_outbound.sql + 20260831_contact_graph.sql | the front door, import_airtable_archive, assign_segment, score_intent | every stage through engine/integrations/outbound_store.py; the desk (/outbound/*) under RLS |
| Sequences | governance/outbound.json — 40 sequences × 87 steps (segment × stage), each step {n, offset_days, channel, template, purpose_kind, door, content_from}; the stage machine; capacity mirrored from cadence.yaml | the build script (gtm × funnel × cadence) | plan_outbound (the steps due on a contact's clock), render_outbound, verify_outbound |
| Scheduled batches | governance/policy.json outbound.sends[] — the re-consent send as five fifths over GO week (2026-09-15 … 19, Tue–Sat), each {id, date, day, week, batch n/5, rule, status held, template, channel, door} | the Chairman's GO decision | plan_sends (one batch against capacity and suppression → sends-<date>.json), the office Sends page, the desk's Sends page (/outbound/sends) |
| Enrolment | policy.outbound.enroll_from + intent.yaml segments.<id>.enroll | the Chairman | plan_outbound |
| Policy | governance/cadence.yaml (suppress states, caps, quiet hours, lanes, capacity, linkedin) + policy.json outbound (cold lane closed, GO scheduled 2026-09-13) + outbound_policy row (what the trigger reads) | the Chairman | cadence.can_send(); touches_consent_guard; verify_outbound |
| Intent | governance/outbound/intent.yaml (the registry) + crm_contacts.intent_score / intent_inputs (the formula's output — null means no signal) | the registry by hand; score_intent | sie.py, pull_signals_sie, score_intent, plan_outbound (ordering), the Intent page, the desk |
| Content | the library: content/blog/*.md rendered under apps/lab/public/blog/<slug>/ (date ≤ today) + content-index items that are published | marketing | pick_content → content_ref + content_url on the row; draft_touches |
| Result | crm_touches rows: sequence_id, step, status (held · draft · approved · sent · replied · bounced · suppressed), week_key (bookkeeping), payload.enrolled_at, enroll_source, content_ref; folded to metrics_daily channel outbound | plan_outbound (held), pick_content, draft_touches (draft), the desk (approved), a send made elsewhere under the guard (sent) | pull_metrics_outbound → /reports; the Outbound pages (counts only) |
| Owner | seats: executive-director runs the lane, reviews every draft, answers replies inside capacity.reply_seat_sla_hours; dom-davis owns GO | — | the board, /outbound/ |
policy.outbound.cold_lane = "closed" since 2026-08-24 — "warm only until GO"; go.status = scheduled, `go.date =
2026-09-13`. Enforced three times:
engine/gates/verify_outbound.py refuses any sequence with lane: cold that is active, an enroll_from that names door, a scheduled batch without a date / batch / rule / status or over capacity, a registry that differs
from cadence.yaml, a door that does not resolve, any calendar coupling, and any send path in the stages or
the store. Prints OUTBOUND PASS / OUTBOUND FAIL (n).
engine/gates/verify_intent_registry.py holds the intent registry complete and consistent (every segment, topics ⊆ taxonomy, doors resolve, themes == keywords.yaml or the diff is printed, no door in any enrol list). Prints
INTENT-REGISTRY PASS / FAIL (n).
touches_consent_guard raises on any crm_touches row moving to sent while outbound_policy.go_opened_at is null, while the contact has no consent receipt (except the single
reconsent send), or while the sequence's lane is cold and outbound_policy.cold_lane is not open.
The receipt test is the codebase's existing one: consent.given_at present and consent.receipt not JSON null.
An imported row carries {"receipt": null, "imported_from": "airtable-archive", "legacy_opt_in": <the box>} — the
box is kept for the record and grants nothing.
Every stage is dry-run by default (preview files under content/_generated/outbound/, gitignored); --live needs
the Postgres env. Run from the repo root.
python -m engine.integrations.sie --probe # what SIE would be asked (topics from intent.yaml); no network
python -m engine.stages.import_airtable_archive --dir <folder> [--live] # receipt null, stage imported
python -m engine.stages.assign_segment [--live] # segment + door from segment_rules.json; never from a name
python -m engine.stages.pull_signals_sie [--live] # SIE-served segments → crm_touches kind=signal (refuses without the key)
python -m engine.stages.score_intent [--live] # intent-v2, the formula printed; signal_weight per segment from intent.yaml
python -m engine.stages.plan_sends --all # the scheduled batches (re-consent, GO week) → sends-<date>.json, held
python -m engine.stages.plan_outbound --week 2026W37 [--live] # enrolment by enroll_from · the contact's clock · can_send() → held
python -m engine.stages.pick_content --week 2026W37 [--live] # pillar steps → a published piece from the library (content_ref + URL)
python -m engine.stages.draft_touches --week 2026W37 [--live] # the letter (one link: the door), the caps, the rails → draft
python engine/gates/verify_outbound.py # OUTBOUND PASS
python engine/gates/verify_intent_registry.py # INTENT-REGISTRY PASS
python apps/office/render_outbound.py # /outbound/* (counts only)
python -m engine.stages.pull_metrics_outbound --live # metrics_daily channel outbound → /reports
A draft is reviewed by the Executive Director on the desk's Queue (/outbound on the app; approve · hold; status writes only).
The send itself is not in this repository: when GO opens, the sending job reads rows in approved whose date has
come, sends through the warm rail, and records sent — the trigger is the last word.
The 136 archive contacts: 119 addressable (an email on the row), 17 keyed by name and company with no address.
policy.outbound.sends schedules the ONE re-consent send as five fifths over GO week so the 25/seat/day capacity
covers every addressable contact:
| id | date | batch | held (dry-run) | vs capacity |
|---|---|---|---|---|
| reconsent-archive-1 | Tue 2026-09-15 | 1/5 | 24 | 25 |
| reconsent-archive-2 | Wed 2026-09-16 | 2/5 | 24 | 25 |
| reconsent-archive-3 | Thu 2026-09-17 | 3/5 | 24 | 25 |
| reconsent-archive-4 | Fri 2026-09-18 | 4/5 | 24 | 25 |
| reconsent-archive-5 | Sat 2026-09-19 | 5/5 | 23 | 25 |
Rule, applied literally by plan_sends: `source=airtable-archive and email present and not suppressed; one fifth by
created order — plus no receipt yet and not already sent. draft_touches --week 2026W37` drafts the 119 letters
(0 refused by the rails). DB hand-off (Stream W's 20260901_outbound_workspace.sql): one outbound_sends row per
batch (name = id, send_date, rule, status held, planned = held) and one crm_touches row per contact (kind
reconsent, status held, sequence_id reconsent-warm, step 1); plan_sends --live refuses until the table exists.
crm_touches where status = 'approved' and the payload date has come, through outbound_store.held_touches(cur, ws, week, status="approved") — never held, never draft.
outbound_store.contact_state(cur, ws, email) and cadence.can_send() again(the state may have changed since planning); a refusal records nothing.
sent — the trigger decides whether that is allowed; a raise is arefusal to log and stop on, not to retry around.
outbound_store.record_touch(... status='replied'|'bounced') and outbound_store.mark_suppressed(cur, ws, email, reason) — the consent root is the one place every lane reads.
policy.outbound.cold_lane and outbound_policy.cold_lane say open.
outbound_policy.go_opened_at and policy.json outbound.go.status = "open" once there-consent draft is reviewed. The first thing that goes is the five re-consent batches.
.env SIE_API_KEY (three scopes: enrichment · signals listing · scoring). Until then pull_signals_sie and enrich_contact refuse and print what they would have called; the taxonomy in
intent.yaml stays verified: false.
governance/intake/forms.json (email + versioned consent, nothing else). Until it exists the re-consent door is form:inquiry, which records a versioned receipt but asks twenty questions to do it.
engine/goal.py — GATES ("intent registry (every segment · topics ⊆ taxonomy · doors resolve · themes == keywords.yaml or the diff is printed · no door enrols)",
[PY, "engine/gates/verify_intent_registry.py"], "INTENT-REGISTRY PASS"),
governance/seo/keywords.yamlNothing today — verify_intent_registry reports the registry and keywords.yaml equal (13 segments, 64 seed terms).
When they drift the gate prints the exact themes: block to paste; the registry is the source.
content/blog/*.md (marketing)The seven published articles carry no pillar: in their frontmatter, so the library holds them as unclassified and
pick_content never picks them. Marketing adds pillar: <one of the eight> to each; nothing in outbound infers it.