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.
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 | |
|---|---|---|
| unit | asset · cell · cut | contact · account · touch |
| clock | the fiscal calendar (publish date) | the contact's timeline (days since enrolment, capacity/day, a reply) |
| 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 |
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.
| entity | table | from Airtable | from the form | notes | |
|---|---|---|---|---|---|
| Contact | crm_contacts (+ columns) | Contacts (147 rows, 47 columns) | identity + qualification roles | consent 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 | |
| Account | crm_accounts (new) | Company / Website / Opportunities.Companies | company field | one per domain, else per company name; contacts link by account_id; enrichment attaches here with per-field provenance {value, source, at, cost} | |
| Consent receipt | crm_contacts.consent (exists) | Opt-in (4 checked) | consent field | a checked box is not a receipt: imports carry `{receipt: null, legacy_opt_in: true | false, imported_from} and stay unsendable until a receipt exists (the one re-consent send asks — a scheduled batch, policy.outbound.sends`) |
| Touch / signal | crm_touches (kinds _v3) | Interaction (9) | door visit · submit · booking | kinds 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} | |
| Opportunity | crm_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 enrolment | outbound_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) | |
| Task | crm_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 | |
| Location | crm_contacts.address.metro | Location (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 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.
Contacts, 147 rows (2 are entirely empty Airtable rows; 128 carry an email, 119 distinct — 9 pairs share one):
| column | filled | reading |
|---|---|---|
| Stage | 70 | 77 blank; 1. New Lead 35 · Lead 14 · 10. Contact in Future 8 · 9. Unqualified 4 (suppressed by cadence.yaml) |
| Lead Owner | 11 | all one person (→ dom-davis); 136 unowned |
| Lead Source | 70 | Met someone from your team at an event 54 · Referral 13 · Was referred / introduced 2 · Event 1 |
| ICP | 25 | Municipality 10 · Small Business 5 · Agency 5 · Startup 4 · Enterprise 1 → icp_bucket |
| Email / Phone / Website / Role / Company | 129 / 115 / 89 / 104 / 107 | the contact facts; 19 rows have no email (8 of them a phone, 16 a company) |
| Address / City / State / Zip | 40 / 52 / 52 / 38 | every State is TX; 51 of 52 Location links resolve to a contact |
| Opt-in | 4 | checked — recorded as consent.legacy_opt_in, never a receipt |
| Authority / Decision Maker / Budget / Timeline – Start | 3 / 4 / 4 / 4 | the qualification questions were answered by 4 people — the form was the intake, the table was the event list |
| Service Interest / the five priority multi-selects | 4 / 2 · 3 · 2 · 0 · 0 | Creative and Relations priorities empty on all rows |
| Main Concern | 61 | used as the meeting-notes field far more than as the question |
| Contact Status · Booking Status · Timeline – Deadline · Proposal Sent Date | 0 | empty on all 147 — retired or kept as typed empty columns |
| Interaction · Tasks (links) · Most Recent Interaction · Attachments | 0 | the links were never used; the lookups over them are empty |
| Log Interactions · Today's Date | 147 | a 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.
base; nothing replaces it. Sends happen only under the consent guard after GO.
keeps no calendar: its dated things are the contact's timeline and policy.outbound.sends.
src/airtable_orchestration in the SIE repo) — retired in the tools map; the officeis the record, SIE is a provider behind a key (data and API only; the sends are ours).
has an explicit {table: null, reason} in the field map.
crm_contacts.stage and bookings.status are the facts.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.
crm_contacts.email is now nullable for the archive only (crm_contacts_email_or_archive_check): 19archived 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_legacy / external_ids.airtable_owner; no seat is invented.
outbound_store.TOUCH_KINDS should gain sms · meeting · signal to match _v3.imports-without-receipt until it answers the re-consent send; then it is imports-with-receipt and may be enrolled. The door never enrols.
supabase/migrations/20260831_contact_graph.sql — applied.python -m engine.stages.import_airtable_archive --dir C:\Users\temer\Downloads — dry-run, read the counts.python -m engine.stages.import_airtable_archive --dir C:\Users\temer\Downloads --live.python engine/gates/verify_crm_parity.py → CRM-PARITY PASS.python -m engine.stages.assign_segment --live (reads the confirmed segment_rules.json).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).