Back Office · office.temerarii.xyz
OUTBOUND-SCALE.md

← all docs

Outbound at scale — sources, enrolment, the stage machine, the contact's clock, content as a library, capacity, and SIE as a provider

Outbound after OUTBOUND-IN-OFFICE.md: the same three rules (one record in Postgres with consent at the root ·

one clock — the contact's · one policy, cadence.yaml through can_send()), with the stage machine, the

enrolment rule, a capacity block, a content library, and SIE Data consumed as a provider behind a key. Nothing here

relitigates the Chairman's decisions:

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 · warm only until GO · GO = 2026-09-13 · the marketing calendar and cockpit [should]
be completely separate from the outbound … the only connection between inbound and outbound is somewhere within
SEO · [intent] should live within the outbound · we need SIE for API calls and the data. we can do the actual outbound

The separation, in one table

MarketingOutbound
unitasset · cell · cutcontact · account · touch
clockthe fiscal calendar (publish date)the contact's timeline (days since enrolment, capacity/day, a reply)
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

The only joins are registries: the record (with enroll_from, never a door), the segment and its door (UTM medium),

the intent registry inside outbound, and content as a library. Each is named below where it applies.

Sources — how a contact reaches the record, and who may be enrolled

SourceEnters asConsentStage on arrivalEnrolment source (plan_outbound.enroll_source_of)May be enrolled?
a door on temerarii.xyz (/go/<segment>, /inquire, /book/<event>)crm_contacts + inquiries / bookings + a crm_touches row (door · inquiry · booking)a versioned receipt, at the rootwarmdoornever — the inbound loop owns them (not_enrollable:door)
the archive (136 rows, 54 "met at an event")imported once, consent.receipt = nullnone until the re-consent sendimportedimports-without-receiptnot in a sequence; the ONE re-consent send is a scheduled batch (plan_sends)
the archive, after the re-consent answerthe same row, receipt now presentthe re-consent receiptwarmimports-with-receiptyes
a referral / a warm intro a seat adds, in writingcrm_contacts.source = manual + a note touchthe receipt the seat recordswarm once the receipt existsmanualyes
a SIE signal on an account (a company surging on a topic)crm_touches kind=signal on every contact of that domain in the segmentnone — a signal is an account fact, not permissionno changesignals (only on a contact with a receipt and no door)yes, in SIE-served segments only (intent.yaml enroll)
a partner / a channel listnever imported without a receipt————

policy.outbound.enroll_from = ["signals", "imports-with-receipt", "manual"], _never: ["door"], with the Chairman's

sentence as _who. The intent registry may narrow the list per segment (a segment SIE does not serve carries no

signals), never widen it. The lead-source is recorded, never inferred; a source that carries no receipt never

becomes sendable by being imported.

The stage machine

governance/outbound.json stages — a contact is in exactly one stage, derived from the record by

plan_outbound.stage_of() and never set by hand:

suppressed   a cadence.yaml suppress state on the consent root          no send, ever, until it changes
customer     a held booking, or crm_contacts.stage = customer           customer-<segment>: expand → referral
warm         a consent receipt                                          warm-<segment>: value → proof → book
imported     an archive row, receipt null                               the re-consent batch (plan_sends) — never a sequence step
cold         nothing                                                    cold-<segment>: declared, CLOSED (policy.outbound.cold_lane)

Exits: a receipt or a reply moves cold → warm; a booking or a won opportunity moves warm → customer; unsubscribed /

bounced / complained moves anything → suppressed; churn moves customer → warm. A reply closes the open sequence:

plan_outbound skips a contact whose latest touch on the sequence is a crm_touches kind=reply (open_reply()), and

the reply seat answers inside capacity.reply_seat_sla_hours. A step reaches its own stage only.

Sequences per segment × stage (governance/outbound.json v2, 40 sequences · 87 steps, built from gtm.yaml × funnel.yaml

× cadence.yaml, never edited by hand; no week, no placement, no mirror of any calendar):

stagesequencestepschannelcontent
importedreconsent-warm (segment *)1outbound_emailstatic — the receipt ask, the one link to the form; scheduled by policy.outbound.sends, not by a sequence clock
warm (the three funnel tracks)warm-compliance-exposed · warm-broken-attribution · warm-neutral-builder6 · 6 · 5outbound_email (the last neutral-builder step is a linkedin_dm)the funnel arc: pillar steps alternate value / proof, the last is the book step (static)
warm (the other ten)warm-<segment>3outbound_emailvalue (pillar) at +0 · proof (pillar) at +4 · book (static) at +8 — cadence.yaml lanes.nurture.interval_days
customercustomer-<segment>2outbound_emailexpand (pillar, the adjacent pillar) at +0 · referral (static) at +14
coldcold-<segment>1outbound_emailthe short ask; active: false while policy.outbound.cold_lane is closed

Every step carries `{n, offset_days, channel, template, purpose, purpose_kind, door, door_label, kind, content_from:

pillar | static}. The gate refuses a stage the machine does not know, a content_from` outside the two, a

purpose_kind pick_content cannot resolve, a segment with a missing stage sequence, and a cold sequence that is active.

The contact's clock

A step is due at enrolled_at + offset_days. enrolled_at is the contact's earliest held / draft / sent touch on the

sequence (crm_touches.payload.date, else at); a contact with none is a new enrolment whose day 0 is the

planning week's Tuesday. plan_outbound --week <key> walks every enrollable contact (ordered by intent_score, the

warmest first), finds the steps due inside that fiscal week, and runs each through decide(): the stage machine, the

open-reply rule, the receipt rule, the daily cap applied ahead of time, then cadence.can_send() with the contact's

real state at the send moment (10:00 local). The week_key on a row is bookkeeping for capacity and the queue — it is

not a placement on anything, and nothing in the calendar knows it exists.

2026W37 dry-run (the GO week, over the archive preview): 136 contacts, all imported → `not_enrollable:imports-without-

receipt 136 · not_enrollable:door 0` · 0 held. Honest: nobody has a receipt yet; the re-consent batches below ask for

one. The first warm enrolments come the week after the first answers.

Content as a library

crm_contacts.priorities holds the five Typeform multi-selects as `{marketing · automation · development · creative ·

relations: [choices]}. pick_content maps each choice to one of the eight content pillars (PRIORITY_PILLARS`: paid /

SEO / email / lead gen → performance · social media → social · branding → brand · content production → multimedia ·

CRM / workflows / integrations → it_dev · partnerships / PR → strategic_relations · "not sure" → emerging_tech) into an

ordered list, then for every content_from: pillar step of the contact's sequence picks a published piece from

the library on that pillar — newest first, a value step preferring a blog or long-form, a proof step a thread (a

receipt with a number), an expand step the next pillar; a piece is never repeated for the same contact.

The library is what the public can open, and nothing else:

sourcepublished whenURL
content/blog/*.md (frontmatter title · slug · date · pillar)date ≤ today and apps/lab/public/blog/<slug>/index.html existshttps://temerarii.xyz/blog/<slug>/
the content index (remotion/src/content-index.json cells + threads)status: published, or the week is in published-weeks.json and a CDN rendering existsthe CDN URL

The row is held with content_ref (blog:<slug> or the index id) and content_url; draft_touches composes the

letter from the piece's title and excerpt inside the channel spec's caps and the brand rails, the studio's name

rewritten out. The letter keeps one link — the door (the outbound_email spec); the piece's URL rides on the row

for the reviewer and the cockpit queue. A contact with no priorities takes the segment's own pillar (gtm.yaml) and the

row says pillar_source: segment. No calendar week is read, ever; verify_outbound greps for it.

Today the library holds 7 published articles (2026W27) and every one is unclassified — the frontmatter carries no

pillar: — so pick_content reports them and picks nothing (no_published_piece:<pillar> on a live pillar step).

Marketing adds the pillar line; outbound infers nothing. --include-drafts --assume-warm previews the mechanism over

the 1,428 draft index items: 54 pillar steps over 136 contacts resolve to 28 long-forms and 26 threads, 0 refusals.

Capacity — what scales with headcount, and what does not

governance/cadence.yaml capacity (mirrored into the registry; the gate holds them equal):

capacity:
  touches_per_seat_per_day: 25        # drafts a seat reviews (and, after GO, sends) in a day
  reply_seat_sla_hours: 24            # a reply is answered by a person inside this window
  max_active_sequences_per_seat: 150  # contacts mid-sequence one seat can hold
  seats: { outbound: [executive-director], reply: [executive-director] }
channels:
  linkedin: { lanes: [nurture], max_chars: 300, no_link_in_first_message: true, questions_per_message: 1,
              per_seat_per_day: 20, min_days_between_dms: 7, quiet_hours: inherit }

plan_outbound caps a day's held rows at touches_per_seat_per_day × outbound seats and a week's distinct contacts

mid-sequence at max_active_sequences_per_seat × seats; plan_sends caps each scheduled batch the same way. The

excess is refused as capacity_day / capacity_active and counted, never silently dropped. That is why the

re-consent send is five batches: 119 addressable contacts ÷ 5 = 24 · 24 · 24 · 24 · 23 a day under a 25/day cap on one

seat (plan_sends --all, 2026-09-15 … 19).

scales with headcount (a seat)scales with the machine (free)
reviewing drafts (25 / seat / day)planning: every candidate through can_send(), the stage machine, the clock
answering replies (the 24 h SLA)the library lookup, drafting, the rails, the caps
LinkedIn DMs (a person types each one; 20 / seat / day)the gates, the office pages, the metrics fold
the rationale for touching an archive rowenrichment: the crawler, SIE, provenance per field
opening the cold lane, in writing, with a datesignals: pulled, matched, scored

Adding a seat to capacity.seats.outbound doubles the daily ceiling and nothing else changes.

SIE — the boundary, the endpoints, the auth

Boundary (the Chairman, 2026-08-25): "we need SIE for API calls and the data. we can do the actual outbound."
SIE is data and API; the office does the outbound on its own Postgres record and email infra. Nothing in SIE
sends on our behalf, and nothing in SIE decides who is enrolled.

SIE Data (C:\Users\temer\sovereign-intent-engine, app.siedata.dev) stays its own product. The office reaches it over

HTTP from ONE module, engine/integrations/sie.py, and never ports its code. What the SIE repo actually exposes:

basehttps://api.siedata.dev/api/v1 — SIE_API_BASE overrides
authX-API-Key: <buyer key> → verify_api_key (signals · pricing · credits). Authorization: Bearer <jwt> → get_current_user — the /enrich routes are user-authenticated today; SIE_API_KEY carries the first, SIE_API_TOKEN the second when the Chairman issues one. The confirmed contract (docs/handoffs/sie-buyer-key.md): one Temerarii key with three scopes — enrichment · signals listing · scoring
enrichPOST /enrich/{field} — field ∈ email · find_email · phone · company · tech_stack · verify_email · intent → key path {provider, cost:{credits,usd}, fields:{field: value}, sources} · POST /enrich/batch · GET /enrich/providers
signalsGET /signals/surges?topic=&intensity=&min_confidence=&limit= (company topic surges) · GET /signals/batch?industry=&limit=&offset= · POST /signals/quote → POST /signals/purchase — the paid act; never called
intentPOST /scoring/score {record, industry, persist} → the office keeps intent_classification, discards the score
meteringenrichment 5 credits per field call (1 BYOK); tiers base 1 · contact 5 · deep 50; Pro $0.198/credit, agency $0.10; signal listing 0 credits, a surge purchase $3.00; /scoring/score declares no charge. VERIFIED against GET /pricing/tiers before any spend
the rulesignals, not scores — sie.FCRA_BLOCKED is the one place the eight names are written; assert_allowed() refuses a request, scrub() drops them from every response, and the gate proves no other enrichment file names one

Every call: refuses without SIE_API_KEY, prints the estimated cost first, returns `{value, source: "sie:<provider>",

at, cost: {credits, usd}} per field. python -m engine.integrations.sie --probe` prints exactly what a run would call.

Which segments SIE serves, and what it is asked — two registries, no code: gtm.yaml icp.segments[].engine == "sie"

names the served segments (compliance-exposed · publishers · data-brokers · directories · gtm-consolidators ·

agency-resellers); governance/outbound/intent.yaml segments.<id>.sie_topics names the topics sie.signals() lists

surges for (sie.topics_for(); there is no map in sie.py any more, and the gate refuses one). A served segment the

registry gives no topics is refused; an unserved segment is refused; pull_signals_sie asks served_segments() and

never carries a list of its own. The topic names are SIE's TOPIC_TAXONOMY keys (20, listed under taxonomy.names,

verified: false until GET /public/signals/taxonomy is read with the key).

A signal lands as crm_touches kind='signal' with payload `{signal_type, strength, source, observed_at, topic,

company_domain, sie_id, segment_id} on every contact of that domain in that segment; score_intent` weighs it by the

segment's signal_weight from the registry (default 15, capped at 2 signals); plan_outbound may enrol on it

(enroll_from: signals) only where the registry allows and only a contact with a receipt and no door.

The gap SIE would need to close (a spec, not built here)

  1. A buyer-key path to enrichment — accept X-API-Key on /enrich/{field} and /enrich/batch through the

existing verify_api_key, charging the buyer's credits (SIE Batch A, in progress).

  1. A since filter on signals — /signals/surges and /signals/batch page by limit/offset; the office filters

client-side on observed_at | detected_at | created_at until a server-side since exists.

  1. A plural topic listing — GET /signals/surges?topics=a,b would halve the calls per segment; the office's topics

per segment live in intent.yaml and are verified against GET /public/signals/taxonomy.

Provenance — where enrichment lands

crm_accounts.enrichment (20260831_contact_graph.sql: one row per domain, {<field>: {value, source, at, cost}},

"enrichment attaches here, never on the person"). enrich_contact writes the account row and links the contacts'

account_id. Order per domain: our crawler (free) → SIE (company, tech_stack) → other paid providers only when a

key exists. enrich_contact is consent-first: only a contact with a receipt or a source that is one of our own doors

is enriched, and an archive-only row is skipped unless a person types the rationale.

Run order

python -m engine.integrations.sie --probe                        # what SIE would be asked; no network
python -m engine.stages.import_airtable_archive [--live]         # the archive → the record, receipt null
python -m engine.stages.assign_segment [--live]                  # segment_rules.json via crm.segment_for
python -m engine.stages.enrich_contact [--limit N] [--live --yes]   # consent-first; crawl → SIE (cost printed) → others
python -m engine.stages.pull_signals_sie [--since ISO] [--live]  # SIE-served segments → crm_touches kind=signal
python -m engine.stages.score_intent [--live]                    # intent-v2, the formula printed
python -m engine.stages.plan_sends --all                         # the scheduled batches → sends-<date>.json, held
python -m engine.stages.plan_outbound --week 2026W37 [--live]    # enroll_from · the contact's clock · capacity · can_send() → held
python -m engine.stages.pick_content --week 2026W37 [--live]     # pillar steps → a published piece (content_ref + URL)
python -m engine.stages.draft_touches --week 2026W37 [--live]    # the letter, 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/*: overview · sequences · sends · signals · enrichment · intent · replies · policy
python -m engine.stages.pull_metrics_outbound --live             # metrics_daily channel outbound → /reports

Every stage is dry-run by default and refuses cleanly without SIE_API_KEY or the Postgres env; the send is not in

this repository.

The intent formula, v2

intent-v2: score = round( (min(door_hits,3)×10 + inquiry_answered×25 + booking_held×40 + replied×30
                          + min(sie_signals,2)×signal_weight + gsc_overlap×10) × 0.5^(days_since_last_signal/30) )
door_hits over 90 days · sie_signals = crm_touches kind signal over 90 days with payload.strength ≥ 0.5 (SIE-served
segments only) · signal_weight = governance/outbound/intent.yaml segments.<id>.signal_weight (default 15) · NULL when no
input · range 0–165 at the default weight (v1 scores are comparable up to 105)

What the Chairman provides

  1. The office's SIE buyer key → .env SIE_API_KEY (and a service token → SIE_API_TOKEN until SIE accepts the buyer

key on /enrich). Every SIE stage refuses until then and prints what it would have called.

  1. GO on 2026-09-13, after the re-consent draft is reviewed — the five batches are planned and held already.
  2. The short re-consent form (governance/intake/forms.json) — form:inquiry is the fallback door until then.