Onboarding — Technical Mapping

The decision tree made buildable: data model, state machine, the swarm as services, event catalog, flows, routing logic, and the production rigor. Grounded in the real precon_web schema — this extends the app, it doesn't reinvent it.

Perch · CRO cockpit · Jun 25 2026 · real exists in code · extend add fields to real · new greenfield

Architecture Data model Enums State machine Swarm services Event catalog Flows Routing logic Cross-cutting Maps to app Build order

Architecture — capture to close

1 · Capture
Channel → Lead. PLG link / referral / search / outbound / bid-portal. Warm carries identity; cold carries only an email (or nothing yet). → Lead
2 · Enrichment swarm
6 agents fan out. Cold = on the critical path; warm = pre-fetched before arrival. Each field confidence-scored. → Profile
3 · Router (decision tree G0–G7)
Pure function of Profile. Picks track · persona · surface · next question · close motion. → RouteDecision
4 · Onboarding session
State machine instance. One per lead/org. Drives confirm → value → activate, branching self-serve vs white-glove. → OnboardingSession
5 · Surfaces + close
Real app surfaces (Jobs · Chat · Sarah · Team · Plan), seeded with the Profile's pursuits/network. Close = in-flow card or AE+MEDDPICC.
Cross-cutting (every layer)
  • Event bus + audit — every transition + agent action logged
  • Idempotency + retry — keyed per (lead, stage); backoff
  • Feature flags — ships dark; defaults to today's 6-step form
  • Kill switch — disable swarm → graceful manual fallback
  • Confidence gate — assert if high, ask if low (~100% bar)
  • Consent / PII — email + inbox access gated

Data model

TS-ish types. real already in precon_web · extend add columns to an existing table · new greenfield table.

Leadnew
iduuid
emailstring?null only in fully-anonymous cold pre-capture
namestring?
sourceLeadSource
warmthWarmth
identityKnownboolean
consent{inbox,marketing}
idempotencyKeystring
capturedAttimestamp
→ has one EnrichmentRun, one OnboardingSession; becomes an Org + User on activate
EnrichmentRunnew
iduuid
leadIduuid
modesync_critical | async_prefetchcold = sync; warm = prefetch before arrival
statusEnrichmentStatus
attemptsint
startedAt / completedAttimestamp
→ has many EnrichmentSignal; emits enrichment.*
EnrichmentSignalnew
iduuid
runIduuid
fieldProfileFieldcompany|orgType|sizeBand|revenue|acv|role|trade|sectors|serviceArea
valuejson
confidencefloat 0–1
agentAgentIdprovenance — which swarm agent
resolves into the materialized Profile
Profilenew view
leadIduuid
companystring
orgTypeOrgTypecontractor|sub_contractor|consumer — REAL enum
sizeBandSizeBand
revenueEst / acvEstmoney
role / personaPersona
tradeTradeValue?one of 34 (trades-data.ts) — subs only
sectors / serviceAreastring[]
confidenceRecord<field,float>
consumed by the Router (G0–G7) — read-only projection of signals
RouteDecisionnew
iduuid
sessionIduuid
trackTrack
personaPersona
primarySurfaceSurface
closeMotionCloseMotion
nextQuestionstring
gateTracejsonG0–G7 outcomes — auditable why
output of the pure router; recomputed if a signal changes
OnboardingSessionnew
iduuid
leadId / orgIduuid
stateSessionState
warmth / trackenum
versionintoptimistic lock — concurrent transitions
lastEventIduuid
the FSM instance; replaces the FE OnboardingStep store real
Pursuitnew → Job
iduuid
orgIduuid
name / value / bidDatemixed
sourcepermit|public_bid|news
statusfound|scoped|watching
seeds the real Job kanban; from ProjectFinder agent
TeamInvitereal extend
emailstringalready exists
rolestringalready exists — stop hardcoding "member"
surfacechat | field_opsEXTEND: field roles route to Field Ops
statuspending|accepted
extends the real TeamInvite{email,role}
TradeConfignew seed
tradeTradeValueTRADES[].value (trades-data.ts)
landerstring/{trade} → "{Label} OS"
takeoffFamilyTakeoffFamilycount|area|linear|volume|weight|systems
takeoffRecipestring[]what Sarah extracts
valueLeverstring
moneyFlagstringthe change-order / lead-time gotcha
seed data = the 34-row trade-library; composeQuestion() reads it. Position overrides live in the white-glove BattlePack, not here.
Deal / BattlePacknew
id / orgIduuid
meddpiccMeddpiccRecordseeded by research, AE confirms
dossierjson
draftedOutreachstring
preRunTakeoffIduuidSarah, on their real pursuit
aeOwner / stageenum
white-glove only; reuses the Zwick MEDDPICC scorecard
Eventnew
iduuid
sessionIduuid
typeEventTypesee catalog
actoragent|user|ae|system
payloadjson
idempotencyKeystring
attimestamp
append-only log = audit trail + analytics + retry source-of-truth
Org / User / Subscriptionreal
Org.orgTypeOrgTypedrives AccountProfile features
User.roleOwner|Admin|Member
SubscriptionStripe21-day trial, plan_selection
existing tables; Lead → Org+User on activation

Enums

OrgType = contractor | sub_contractor | consumer | platform · REAL (account-profiles.ts)
BusinessType = general_contractor | sub_contractor | homeowner · REAL (onboarding-store.ts) — the onboarding-facing label of OrgType
Warmth = cold | warm | warmest · cold=anonymous · warm=email+name · warmest=+role
Track = self_serve | wg_mid | wg_enterprise
Persona = estimator | owner_exec | pm | field · field → Field Ops, not chat
CloseMotion = in_flow_card | ae_meddpicc
Surface = jobs | chat | sarah | company_brain | field_ops | analytics | network · REAL surfaces
SizeBand = micro | small | mid | large | enterprise · thresholds differ GC vs sub (see Routing)
TakeoffFamily = count | area | linear | volume | weight | systems · the 34 trades collapse into 6 recipes — see trade-library
LeadSource = plg_invite | referral | search | ad | outbound | bid_portal | event
AgentId = domain_site | classifier | size_estimator | role_resolver | project_finder | network_seeder
EnrichmentStatus = queued | running | partial | complete | failed

State machine — OnboardingSession

Happy paths first, then the full transition table (incl. fallbacks/terminals). States map 1:1 to decision-tree gates.

Shared front-end

S0ENTRY
S1AWAIT_EMAIL
cold only
S2ENRICHING
S3CONFIRM
warm may skip
S4ROUTED

Self-serve tail

S5VALUE
S6NEXT_Q
S7VALUE_PROOF
Sarah
S8ACTIVATE
S9TRIAL
S10CLOSED_WON
card on file

White-glove tail 🤝

W5BATTLEPACK
agents
W6OUTREACH
SDR
W7DISCOVERY
AE
W8DEMO
W9SEAT
W10CLOSED_WON
MEDDPICC

Transition table

FromEventGuardTo
S0 ENTRYlead.capturedwarmth==coldS1 AWAIT_EMAIL
S0 ENTRYlead.identifiedwarmth>=warmS2 ENRICHING (prefetched)
S1 AWAIT_EMAILemail.submittedS2 ENRICHING
S2 ENRICHINGenrichment.completeconf>=θ all gating fieldsS3 CONFIRM
S2 ENRICHINGenrichment.partialsome fields low-confS3 CONFIRM (ask only those)
S2 ENRICHINGenrichment.failedretries exhausted / killed→ FALLBACK_FORM
S3 CONFIRMonboarding.confirmedwarm & role knownS4 ROUTED (skip ask)
S3 CONFIRMonboarding.role_capturedrole was unknownS4 ROUTED
S4 ROUTEDroute.decidedtrack==self_serveS5 VALUE
S4 ROUTEDroute.decidedtrack==wg_*W5 BATTLEPACK
S4 ROUTEDroute.decidedorgType==consumer→ DISQUALIFIED (route to HO product)
S5→S8value.revealed · takeoff.completedS9 TRIAL
S9 TRIALcard.capturedpost-wow triggerS10 CLOSED_WON
S5..S9 (self-serve)expansion.detectedseats/ACV cross threshold→ W5 (hand to white-glove)
W7 DISCOVERYmeddpicc.qualifiedchampion+EB confirmedW8 DEMO
W9 SEATdeal.closed_woncontract signedW10 CLOSED_WON
any activesession.timeoutno event > TTL→ ABANDONED (re-engage queue)
FALLBACK_FORMmanual steps done→ today's 6-step flow → activate

The swarm — six agents as services

AgentInputOutput (signals)SourcesMode / budget
domain_siteemail domaincompany, website, HQwebsync ~1s
classifierwebsite textorgType, trade, sectors, project mixsite, LLMsync ~2s
size_estimatorcompanysizeBand, revenueEst, acvEstaggregators, bondingsync ~2s
role_resolveremail, namerole, personasignature, LinkedInsync ~2s
project_findercompany, trade, geopursuits[]permits, public bids, newsasync — can lag
network_seedercompanynetwork[] (subs/GCs)CRM, bid invites (consent)async — can lag

Orchestration: fan-out in parallel; the 4 sync agents gate S2→S3 (the confirm needs them), the 2 async agents can resolve after the user is already in the workspace (pipeline fills in live). Warm = all six run as async_prefetch before arrival, so none are on the critical path.

Event catalog

EventEmitted whenConsumers
lead.captured / lead.identifiedentry (cold / warm)enrichment, session, analytics
enrichment.requestedemail known (or pre-capture)swarm orchestrator
enrichment.agent.completedeach agent returnssignal store, confidence gate
profile.field_resolveda field crosses confidence θrouter (may recompute)
enrichment.complete / .partial / .failedrun terminalsession FSM, retry engine
onboarding.confirmed / .role_captureduser taps confirmsession, router
route.decidedrouter runssession, surfaces, sales
value.revealed / next_question.asked / takeoff.completedself-serve value loopanalytics, close trigger
team.invitedinvite sentspawns a new warm Lead per invitee
trial.started / card.captured / account.activatedself-serve closebilling, CRM
deal.battlepack_ready / outreach.sent / meeting.booked / demo.delivered / deal.closed_wonwhite-gloveAE tooling, CRM, MEDDPICC
session.abandoned / session.handed_offtimeout / expansionre-engage, sales queue

Flows

Cold · self-serve (Squires, cold)

Visitorlands on GC lander → submits email email.submitted
Enrichment4 sync agents fan out; pipeline agents async enrichment.complete
Webrenders confirm from Profile (chips) → one tap onboarding.confirmed
RouterG0–G7 → {self_serve, owner_exec, jobs, in_flow_card} route.decided
SurfacesJobs seeded w/ pursuits + subs; Tom alert; the next question fires
Sarahscopes their real bid takeoff.completed → post-wow card ask card.captured

Warm · self-serve (Squires, warm)

Enrichmentpre-ran at PLG capture enrichment.complete (before arrival)
Visitorclicks personalized link lead.identified — no email/name ask
Routerrole known? → skip confirm → route.decided; role unknown → one tap
Surfaceslands directly on the pre-filled board → same value loop → close

White-glove (CVE)

Agentsdeep swarm → dossier + Sarah pre-takeoff + drafted outreach + MEDDPICC deal.battlepack_ready
SDRsends generated, personalized outbound outreach.sentmeeting.booked
AEinsight call (not discovery); confirms amber MEDDPICC cells meddpicc.qualified
Demopre-loaded with CVE's real pursuit demo.delivered
AEFDE seats the dept + migrates jobs → annual close deal.closed_won

Routing logic — the pure function

// Router: Profile -> RouteDecision. Pure, deterministic, re-runnable.
const θ = 0.85; // confidence threshold to assert vs ask

function route(p: Profile): RouteDecision {
  // G2 — type (homeowner exits the B2B funnel)
  if (p.orgType === "consumer") return disqualify("route_to_homeowner_product");

  // G3 — track by size, ACV wins ties (open Q). Thresholds differ GC vs sub.
  const band = sizeBand(p);              // GC: <10M | 10–500M | 500M+   sub: <10M | 10–100M | 100M+
  const track =
    band === "micro_small"      ? "self_serve"
    : band === "mid"             ? "wg_mid"
    :                              "wg_enterprise";
  if (disagree(p.revenueBand, p.acvBand)) prefer(p.acvBand); // ACV gate

  // G4 — persona -> surface; field roles leave chat entirely
  const persona = p.role ?? "ask";     // unknown -> the one allowed ask
  const surface = { estimator:"jobs", owner_exec:"analytics",
                    pm:"network", field:"field_ops" }[persona];

  // G5 — trade lander + Sarah recipe (subs only)
  const trade = p.orgType === "sub_contractor" ? tradeConfig(p.trade) : null;

  // G6/G7 — compose the question + pick the close
  return {
    track, persona, primarySurface: surface,
    nextQuestion: composeQuestion(persona, band, trade),
    closeMotion: track === "self_serve" ? "in_flow_card" : "ae_meddpicc",
    gateTrace: trace(),
  };
}

// The "smart" rule, applied everywhere we read a field:
const use = (f) => p.confidence[f] >= θ ? assert(p[f]) : ask(f); // never re-ask what's known

Cross-cutting concerns

Idempotency

Every Event + EnrichmentRun carries an idempotencyKey = (leadId, stage). Replays and at-least-once delivery are safe; the FSM uses an optimistic version lock so two concurrent transitions can't corrupt state.

Retry + backoff

Failed agents / outreach retry on 2m → 10m → 1h → 6h → abandoned. The Event log is the source of truth, so a retry cron replays from the last good event — same pattern as the Loops integration.

Feature flags — ship dark

Every gate + agent sits behind a flag, default off. With all off, onboarding is today's 6-step form. Cutover flips one flag at a time (e.g. classifier first), per cohort.

Kill switch + fallback

A global SWARM_ENABLED kill drops the whole flow to FALLBACK_FORM (the real organization_type → … → invitations steps). Onboarding never hard-depends on greenfield.

Confidence / human-in-loop

The ~100% bar: assert only at conf ≥ θ, else ask that one field. A wrong pre-fill is worse than a blank — and in white-glove a human stakes credibility on it, so the AE reviews before send.

Consent + PII

Email enrichment is fair game; inbox access (bid-invite seeding) is explicit opt-in. Retention + delete honored on the Lead before it converts to an Org.

Maps to the real app

New conceptReal precon_web anchorChange
Profile.orgTypeOrgType (account-profiles.ts)real reuse enum + AccountProfile features
Confirm steporganization_type · service_area · trades · market_sectorscollapse 4 backend steps → 1 confirm
OnboardingSession FSMOnboardingStep store (zustand)replace FE store w/ server FSM
route.persona → surfaceAccountProfile.features + sidebar gatingnew capture role; field → fieldOps
TeamInvite.surfaceTeamInvite{email,role}extend add surface; stop hardcoding "member"
PursuitJob (kanban)new seeds real Jobs pre-signup
TradeConfig (×34)trades-data.ts TRADES[]seed the trade-library — 6 families, per-trade levers
Value proofSarah (precon_agents)real tune recipe per type/trade
Close (self-serve)plan_selection + Stripe 21-day trialtrigger move ask to post-wow
Deal / MEDDPICCZwick MEDDPICC scorecardseed from research vs by hand
context_notesCompany Brainauto pre-fill from enrichment

Build order — ship dark, cut over one flag at a time

Phase 0
Spine, dark. Event log + OnboardingSession FSM behind a flag, defaulting to today's 6-step form. No user-visible change — just instrumentation + the state shell.
Phase 1
Cold MVP swarm. domain_site + classifier + size_estimator + role_resolver on the cold path → confirm-don't-ask replaces organization_type/service_area/trades/market_sectors. Biggest UX win; lowest risk (4 agents, all sync).
Phase 2
The value lever. project_finder + network_seeder → pre-filled Jobs pipeline + subs. Wire the role-conditioned next question + post-wow card trigger. This is the stickiness jump.
Phase 3
Warm / PLG. Identity passing on PLG links + async_prefetch enrichment before arrival. Each team invite becomes a warm lead.
Phase 4
White-glove. Battle pack + MEDDPICC seeding + AE tooling (outreach draft, pre-run takeoff, demo seeding). Enterprise contracting + security-review path.

This is the implementation spec for the decision tree; the Squires & CVE walkthroughs are its acceptance tests. Closes Brock's technical-mapping ask.