Back Office · office.temerarii.xyz
OUTBOUND-IN-OFFICE.md

← all docs

Outbound in the office

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.

Where the desk lives

The Chairman's rule (2026-08-25): "outbound send should be outbound. the cockpit is the calendar." Three surfaces,

one record:

SurfaceWhereWhat it is
The desk/outbound/* on the app (apps/web/app/outbound/, Vercel project temerarii-office-app) — Queue · Contacts · Signals · Enrichment · Replies · Sendsthe 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 appthe 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 officeoffice.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.

The separation

MarketingOutbound
unitasset · cell · cutcontact · account · touch
clockthe fiscal calendar (publish date)the contact's timeline (days since enrolment, capacity/day, a reply) and the scheduled batches
editorauthoring workbench + the calendar twina queue: approve a draft, read a reply, move a stage
owner seatcreative / EMreply seat / ED
policybrand rails, likeness consentcadence, consent receipts, GO
resultper asset on the calendarper 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.

The only joins — all registries, none of them process

joinwhat it iswhere
1 · the recordboth 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 itgovernance/policy.json outbound.enroll_from · engine/stages/plan_outbound.py
2 · the segment and the dooroutbound 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 apartgtm.yaml icp.segments[].door · engine/lib/utm.py · draft_touches.door_link()
3 · intentONE 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 enrolgovernance/outbound/intent.yaml · engine/gates/verify_intent_registry.py · sie.topics_for()
4 · content as a librarya 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 touchengine/stages/pick_content.py

What lives where

ConcernLives inWritten byRead by
Recordcrm_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.sqlthe front door, import_airtable_archive, assign_segment, score_intentevery stage through engine/integrations/outbound_store.py; the desk (/outbound/*) under RLS
Sequencesgovernance/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.yamlthe build script (gtm × funnel × cadence)plan_outbound (the steps due on a contact's clock), render_outbound, verify_outbound
Scheduled batchesgovernance/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 decisionplan_sends (one batch against capacity and suppression → sends-<date>.json), the office Sends page, the desk's Sends page (/outbound/sends)
Enrolmentpolicy.outbound.enroll_from + intent.yaml segments.<id>.enrollthe Chairmanplan_outbound
Policygovernance/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 Chairmancadence.can_send(); touches_consent_guard; verify_outbound
Intentgovernance/outbound/intent.yaml (the registry) + crm_contacts.intent_score / intent_inputs (the formula's output — null means no signal)the registry by hand; score_intentsie.py, pull_signals_sie, score_intent, plan_outbound (ordering), the Intent page, the desk
Contentthe library: content/blog/*.md rendered under apps/lab/public/blog/<slug>/ (date ≤ today) + content-index items that are publishedmarketingpick_content → content_ref + content_url on the row; draft_touches
Resultcrm_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 outboundplan_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)
Ownerseats: executive-director runs the lane, reviews every draft, answers replies inside capacity.reply_seat_sla_hours; dom-davis owns GO—the board, /outbound/

The policy line

policy.outbound.cold_lane = "closed" since 2026-08-24 — "warm only until GO"; go.status = scheduled, `go.date =

2026-09-13`. Enforced three times:

  1. 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).

  1. 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).

  1. The database trigger 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.

Run order

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 scheduled batches — the re-consent send

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:

iddatebatchheld (dry-run)vs capacity
reconsent-archive-1Tue 2026-09-151/52425
reconsent-archive-2Wed 2026-09-162/52425
reconsent-archive-3Thu 2026-09-173/52425
reconsent-archive-4Fri 2026-09-184/52425
reconsent-archive-5Sat 2026-09-195/52325

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.

What changes in the sending job (its own repository)

  • It reads 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.

  • Before every send it calls outbound_store.contact_state(cur, ws, email) and cadence.can_send() again

(the state may have changed since planning); a refusal records nothing.

  • After a send it updates the row to sent — the trigger decides whether that is allowed; a raise is a

refusal to log and stop on, not to retry around.

  • Replies, bounces and unsubscribes are written back with 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.

  • The Airtable sync is retired; the archive is read-only. Cold sequences stay declared and inactive until

policy.outbound.cold_lane and outbound_policy.cold_lane say open.

What the Chairman provides

  1. GO, on 2026-09-13: set outbound_policy.go_opened_at and policy.json outbound.go.status = "open" once the

re-consent draft is reviewed. The first thing that goes is the five re-consent batches.

  1. The SIE buyer key → .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.

  1. A short re-consent form in 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.

  1. Cold lane stays closed until the Chairman says otherwise, in writing, with a date.

Blocks for the orchestrator

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.yaml

Nothing 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.