Back Office · office.temerarii.xyz
CONTACT-GRAPH.md

← all docs

The contact graph — one record from the door to the deal

Status: schema written (supabase/migrations/20260831_contact_graph.sql, applied 2026-08-25) · field map and

confirmed segment rules in governance/intake/ · import stage proven over the real export · parity gate

engine/gates/verify_crm_parity.py (CRM-PARITY PASS, live).

The Chairman (2026-08-24): *"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" · "nothing should be lost in translation"*.

This document is the translation: what the Airtable "Temerarii Sales CRM" held, where each column now lives,

what the Typeform asked and where each answer lands, and what was retired on purpose.

One record, two loops

The record is the ONE join between marketing and outbound — and it is a join of data, not of process. The

Chairman (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."*

Marketing (inbound)Outbound
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

Both write crm_contacts. A contact who came through a door (a form, a booking, a /go page) belongs to the

inbound loop — inquiry → booking → the reply seat — and is never auto-enrolled in a sequence:

governance/policy.json outbound.enroll_from = signals · imports-with-receipt · manual, never door

(plan_outbound counts the refusal as not_enrollable:door). The other three joins are registries, not

process: the segment and its door (UTM medium splits the result column), the intent registry inside outbound

(governance/outbound/intent.yaml), and content as a library (engine/stages/pick_content.py). See

OUTBOUND-IN-OFFICE.md.

The entities

entitytablefrom Airtablefrom the formnotes
Contactcrm_contacts (+ columns)Contacts (147 rows, 47 columns)identity + qualification rolesconsent at the root. icp_bucket = the 5 legacy buckets · segment_id = the 13 gtm.yaml segments · service_interest[] · priorities jsonb {marketing, automation, development, creative, relations}: string[] · budget_band · authority · decision_maker · procurement · timeline_start · timeline_deadline · main_concern · lead_source · owner_seat · role (reused) · middle_name · website · phones · address jsonb · account_id · stage_legacy · proposal_sent_at · airtable_created_at · airtable_updated_at · source: airtable-archive for imports
Accountcrm_accounts (new)Company / Website / Opportunities.Companiescompany fieldone per domain, else per company name; contacts link by account_id; enrichment attaches here with per-field provenance {value, source, at, cost}
Consent receiptcrm_contacts.consent (exists)Opt-in (4 checked)consent fielda checked box is not a receipt: imports carry `{receipt: null, legacy_opt_in: truefalse, imported_from} and stay unsendable until a receipt exists (the one re-consent send asks — a scheduled batch, policy.outbound.sends`)
Touch / signalcrm_touches (kinds _v3)Interaction (9)door visit · submit · bookingkinds now: inquiry · booking · booking_cancelled · email · call · note · import · outbound_email · linkedin_dm · reconsent · visit · door · reply · sms · meeting · signal. A signal row's payload is {signal_type, strength, source, observed_at, topic, company_domain, sie_id, segment_id}
Opportunitycrm_opportunities (new)Opportunities (16)—stage_legacy verbatim; stage = the offer-ladder tier (free · tripwire · core · product, funnel.yaml) placed by a human; outcome open/won/lost read only where the legacy stage says so; amount · target_close · rep_seat (+ rep_legacy) · contact_ids[] · account_id
Sequence enrolmentoutbound_sequences + crm_touches.sequence_id/step (exist)Outbound Sequences (7) · Pipeline Runs (1)—rows active: false, lane cold; one touch each, status: held, no week_key — no week, no drafting: a legacy send is never resumed. The run → engine_jobs (kind outbound_pipeline, id = the archive's Run ID)
Taskcrm_tasks (new, small)Tasks (9)—rep → seat where the name is a seat; category; due date; surfaces on the board's EOD view, never a new app
Locationcrm_contacts.address.metroLocation (7)—a rollup, not a table
Campaign calendar—0 rows, 12 columns—retired: the fiscal calendar (2026W40 keys) supersedes it for marketing; outbound has no calendar at all. The headers are still validated so a future export with rows is refused loudly

Field map — governance/intake/field_map.json (version 2026-08-31.1): all 30 fields of the inquiry form

(21 Typeform questions + consent + 5 UTM + source_page + segment + the honeypot) and all 113 Airtable columns

across the 8 CSVs (47 + 10 + 10 + 11 + 11 + 9 + 3 + 12) → exactly one destination {table, column | jsonb_key, transform, pii, required, note}, or

{table: null, reason}. The five pillar-priority answers land in `priorities.{marketing, automation, development,

creative, relations}[] — the vector pick_content` reads to choose a published piece from the content library

by pillar for a warm contact's content_from: pillar steps (a lookup, never a calendar week).

Segment rules — governance/intake/segment_rules.json (v1, confirmed by the Chairman 2026-08-25): 12 ordered

rules from org_type × need × priorities × lead_source × the door's hidden segment → a gtm.yaml segment, each with a

why quoting the segment's who/pain; default = the free consult door with segment_id: null (unassigned is

honest, a wrong segment is not); 5 unmapped combinations stated with reasons. engine/lib/crm.segment_for()

applies it; assign_segment.py reads it.

The import key

The export carries no Airtable record id. A contact's key is its email (lower-cased) when the row has

one, else the "Name & Company" cell; external_ids.airtable_key + airtable_key_kind say which, and a

partial unique index (source = 'airtable-archive') makes the key unique per source. Rows sharing an email

merge into one contact (first row wins, later rows fill blanks; every folded key is kept in

external_ids.airtable_keys[], counted in airtable_rows), so parity is a sum, not a row count:

contacts imported + duplicate-email rows merged + empty rows = 147 — the dry-run over the real export:
136 + 9 + 2 = 147 (key: email 119 · Name & Company 17); 75 accounts (59 by domain); 14 opportunities
(+2 empty); 8 interaction touches (+1 error row); 7 sequences + 7 held touches; 1 job; 8 tasks (+1 empty);
51 contacts stamped with a metro from 4 Location rows (+3 empty); 1 Location link name unmatched.

Fill-rate findings (read from the CSV, not remembered)

Contacts, 147 rows (2 are entirely empty Airtable rows; 128 carry an email, 119 distinct — 9 pairs share one):

columnfilledreading
Stage7077 blank; 1. New Lead 35 · Lead 14 · 10. Contact in Future 8 · 9. Unqualified 4 (suppressed by cadence.yaml)
Lead Owner11all one person (→ dom-davis); 136 unowned
Lead Source70Met someone from your team at an event 54 · Referral 13 · Was referred / introduced 2 · Event 1
ICP25Municipality 10 · Small Business 5 · Agency 5 · Startup 4 · Enterprise 1 → icp_bucket
Email / Phone / Website / Role / Company129 / 115 / 89 / 104 / 107the contact facts; 19 rows have no email (8 of them a phone, 16 a company)
Address / City / State / Zip40 / 52 / 52 / 38every State is TX; 51 of 52 Location links resolve to a contact
Opt-in4checked — recorded as consent.legacy_opt_in, never a receipt
Authority / Decision Maker / Budget / Timeline – Start3 / 4 / 4 / 4the qualification questions were answered by 4 people — the form was the intake, the table was the event list
Service Interest / the five priority multi-selects4 / 2 · 3 · 2 · 0 · 0Creative and Relations priorities empty on all rows
Main Concern61used as the meeting-notes field far more than as the question
Contact Status · Booking Status · Timeline – Deadline · Proposal Sent Date0empty on all 147 — retired or kept as typed empty columns
Interaction · Tasks (links) · Most Recent Interaction · Attachments0the links were never used; the lookups over them are empty
Log Interactions · Today's Date147a button URL and a TODAY() formula — no data

The other tables: Opportunities 16 (14 named, 2 empty; 6 amounts — one eight-digit amount is imported as

written and should be corrected by hand; 4 contact links, all resolve; 1 company; 2 legacy stages).

Interaction 9 (8 real, 1 #ERROR! row; Call 3 · Email 2 · In Person 1 · untyped 2; **no row is linked to a

contact** — they import as archive facts with contact_id null, allowed by crm_touches_orphan_check for the

archive only). Outbound Sequences 7 (one cold "PE IaaS Q1" campaign; step 1 of 3 on every row; nothing was

ever sent — Next/Last Send blank; every contact resolves). Pipeline Runs 1 (completed, 20 leads → 7

qualified). Tasks 9 (8 named; due dates in February 2019 with categories Personal / Wellness / Social —

these are the Airtable template's sample rows, imported for parity and safe to delete). Location 7 (4 with

contacts, 3 without). Campaign Calendar 0.

Retired, on purpose

  • The one automation ("when a record is created, send an email") — switched off and flagged broken in the

base; nothing replaces it. Sends happen only under the consent guard after GO.

  • Campaign Calendar — 0 rows; the fiscal calendar and the content index are marketing's calendar. Outbound

keeps no calendar: its dated things are the contact's timeline and policy.outbound.sends.

  • SIE's Airtable sync (src/airtable_orchestration in the SIE repo) — retired in the tools map; the office

is the record, SIE is a provider behind a key (data and API only; the sends are ours).

  • Helper columns — button URLs, TODAY() formulas, lookups over empty links, AI attachment summaries: each

has an explicit {table: null, reason} in the field map.

  • Contact Status / Booking Status — empty on every row; crm_contacts.stage and bookings.status are the facts.

The FCRA line

Signals, not scores. No column in this schema holds credit, eligibility, employment, rental, criminal or

payment-history data, and none may be added: enrichment requests only firmographics with provenance, and

crm_touches.kind = 'signal' records an intent signal from SIE (a topic surge on a company's domain, weighted by

the segment's signal_weight in governance/outbound/intent.yaml), never a score of a person.

Decisions worth knowing

  • crm_contacts.email is now nullable for the archive only (crm_contacts_email_or_archive_check): 19

archived people have no email; the front door still requires one.

  • crm_touches.contact_id is nullable for the archive only (crm_touches_orphan_check).
  • role is reused; no role_title. owner_seat and org_type already existed and are reused.
  • Rep names that are not a seat (three first names in the archive) stay verbatim in rep_legacy /

external_ids.airtable_owner; no seat is invented.

  • outbound_store.TOUCH_KINDS should gain sms · meeting · signal to match _v3.
  • An archive row is imports-without-receipt until it answers the re-consent send; then it is

imports-with-receipt and may be enrolled. The door never enrols.

Run order

  1. supabase/migrations/20260831_contact_graph.sql — applied.
  2. python -m engine.stages.import_airtable_archive --dir C:\Users\temer\Downloads — dry-run, read the counts.
  3. python -m engine.stages.import_airtable_archive --dir C:\Users\temer\Downloads --live.
  4. python engine/gates/verify_crm_parity.py → CRM-PARITY PASS.
  5. python -m engine.stages.assign_segment --live (reads the confirmed segment_rules.json).
  6. python -m engine.stages.plan_sends --all — the five re-consent batches of GO week, held; the DB hand-off is

in plan_sends.py (the outbound_sends table from Stream W's migration).