Lead Routing Simulator

Crescendo.ai Phase 1 — Salesforce routing engine. Edit any input on the left to see how the engine would classify the Contact, who would own it, and what side effects would fire. Mirrors ContactRouter.classifyOne on main as of the post-Plans-1-4 build.

Examples
Click an example above to load a scenario, or edit the record fields directly. The logic tree on the right updates live.

Routing logic tree

Live decision flow for the record on the left. Green = active routed path; red = active triage / HOLD terminal; dimmed = inactive branches. Click any expandable path node (T, A, D, E) to inspect its sub-tree; the sub-tree containing the active terminal auto-expands.

START — MQL on Contact Contact.On_Hold__c? HOLD (On_Hold) Account.Do_Not_Route__c? HOLD (Do_Not_Route) Contact_Status = Disqualified? HOLD (Disqualified) Email + AccountId + Status all set? UNROUTED_BAD_DATA Tagged Account? Path T (Tagged) Customer Account? Path A (Customer) Working/Recycled + active BDR? Path B (BDR continuity) Open Opp w/ active owner? Path C (Active deal) Owner = real active AE? Path D (AE-owned) Path E (round-robin) yes yes yes no yes yes yes yes yes else Path T — Tagged Account sub-tree Owner = real active AE? UNROUTED_TAGGED_NO_OWNER Target_Account_Status? T: AE + BDR Task (Active Target) T: AE-only, no BDR Task (Active Conversion) T: AE-only, no BDR Task (Active Velocity) Path A — Customer sub-tree Owner = real active AE? UNROUTED_NO_OWNER A: Contact.OwnerId = AE (no Task, no cadence) Path D — AE-owned sub-tree BDR_Owner__c set + active? D1: BDR works Contact (AE notification Task) Paired_BDR in roster? D2 self-heal (eligible — writes BDR) Stale Paired_BDR Task (→ fall through to E BDR-only) Paired_BDR blank (→ fall through to E BDR-only) Path E — round-robin sub-tree Full Path E or BDR-only? Region__c populated? UNROUTED_NO_REGION Segment populated? Eligible AEs > 0? Eligible BDRs > 0? E (full): rotate AE + BDR (flip Account to Working) UNROUTED_NO_AE UNROUTED_NO_BDR Eligible BDRs > 0? E (BDR-only): rotate BDR (status preserved)

Complete Logic Tree — v3 Routing Engine

Every node the router can visit, in evaluation order. No scenario highlighting — this is the full decision surface, derived from docs/v3-architecture-plan.md and ContactRouter.cls. Six paths: TAGGED_ACCOUNTCUSTOMER_ACCOUNTACTIVE_DEALACCOUNT_BDRROTATION_NEW_ACCOUNT / ROTATION_BDR_ONLY. First match wins; fall-through is explicit per gate.

Gate / decision node
Routed terminal — stamps Routing_Path__c
Unrouted terminal — inserts Routing_Exception__c
Hold / exit terminal
flowchart TD classDef gate fill:#f3f4f6,stroke:#d1d5db,color:#111827 classDef routed fill:#dcfce7,stroke:#15803d,color:#15803d,font-weight:bold classDef hold fill:#fef3c7,stroke:#b45309,color:#b45309,font-weight:bold classDef unrouted fill:#fee2e2,stroke:#b91c1c,color:#b91c1c,font-weight:bold classDef startnode fill:#111827,stroke:#111827,color:#ffffff,font-weight:bold START([START — MQL detected on Contact]):::startnode G0["$Permission.Trigger_Routing<br/>= false?"]:::gate EXIT_NO_PERM["EXIT — no routing<br/>Routing_Trigger_Users PSL missing"]:::hold G1["$Permission.Bypass_Automation<br/>= true?"]:::gate EXIT_BYPASS["EXIT — bypassed<br/>Routing_Bypass_All PSL held"]:::hold G2["Hold gate<br/>Contact.On_Hold__c OR Account.Do_Not_Route__c<br/>OR Contact.Contact_Status__c = Disqualified?"]:::gate T_HOLD["HOLD<br/>Routing_Path__c = HOLD"]:::hold G3["Data-quality gate<br/>Email blank OR AccountId blank?"]:::gate T_BAD_DATA["UNROUTED_BAD_DATA<br/>insert Routing_Exception__c"]:::unrouted subgraph CLASSIFY["Path classification — wrapped in per-Contact try/catch; any thrown exception → UNROUTED_ERROR"] G4["TAGGED_ACCOUNT?<br/>Account.Is_Tagged_Account__c = true"]:::gate G4a["Owner = active non-Pool user?<br/>Account.OwnerId check"]:::gate T_TAGGED_NO_OWNER["UNROUTED_TAGGED_NO_OWNER<br/>no fall-through; setup error"]:::unrouted T_TAGGED["TAGGED_ACCOUNT<br/>Contact.OwnerId = Account.OwnerId<br/>BDR Task if Active Target + BDR set"]:::routed G5["CUSTOMER_ACCOUNT?<br/>Account.Account_Status__c = Customer"]:::gate G5a["Owner = active non-Pool user?<br/>Account.OwnerId check"]:::gate T_CUSTOMER["CUSTOMER_ACCOUNT<br/>Contact.OwnerId = Account.OwnerId<br/>no cadence, no Task"]:::routed T_NO_OWNER["UNROUTED_NO_OWNER<br/>no fall-through; setup error"]:::unrouted G6["ACTIVE_DEAL?<br/>open Opp on Account<br/>with active non-Pool owner?"]:::gate T_ACTIVE_DEAL["ACTIVE_DEAL<br/>Contact.OwnerId = newest open Opp owner<br/>no cadence, no Task"]:::routed G7["ACCOUNT_BDR?<br/>Account.BDR_Owner__c populated<br/>+ active + not Pool"]:::gate T_ACCOUNT_BDR["ACCOUNT_BDR<br/>Contact.OwnerId = BDR_Owner__c<br/>cadence enrolled<br/>no BDR_Owner write; no BDR Task"]:::routed G8["ROTATION split<br/>Account.OwnerId = Pool OR inactive?"]:::gate subgraph RNA["ROTATION_NEW_ACCOUNT — Account was Pool / inactive AE"] RNA_G1["Region blank?<br/>Account.Region__c"]:::gate RNA_T_NO_REGION["UNROUTED_NO_REGION<br/>insert Routing_Exception__c"]:::unrouted RNA_G2["AE eligible?<br/>RotationService checkOnly AE"]:::gate RNA_T_NO_AE["UNROUTED_NO_AE<br/>insert Routing_Exception__c"]:::unrouted RNA_G3["BDR eligible?<br/>RotationService checkOnly BDR"]:::gate RNA_T_NO_BDR["UNROUTED_NO_BDR<br/>insert Routing_Exception__c"]:::unrouted T_RNA["ROTATION_NEW_ACCOUNT<br/>Account.OwnerId = rotated AE<br/>Contact.OwnerId = rotated BDR<br/>Account.BDR_Owner__c stamped<br/>cadence enrolled"]:::routed end subgraph RBO["ROTATION_BDR_ONLY — Account has active AE"] RBO_G1["Region blank?<br/>Account.Region__c"]:::gate RBO_T_NO_REGION["UNROUTED_NO_REGION<br/>insert Routing_Exception__c"]:::unrouted RBO_G2["BDR eligible?<br/>RotationService.nextRep BDR"]:::gate RBO_T_NO_BDR["UNROUTED_NO_BDR<br/>insert Routing_Exception__c"]:::unrouted T_RBO["ROTATION_BDR_ONLY<br/>Contact.OwnerId = rotated BDR<br/>BDR_Owner__c stamped<br/>Account.OwnerId unchanged<br/>cadence enrolled"]:::routed end end T_ERROR["UNROUTED_ERROR<br/>unhandled Apex exception inside the try/catch<br/>per-Contact isolation; other Contacts in batch route normally"]:::unrouted %% Flow entry START --> G0 G0 -->|no perm| EXIT_NO_PERM G0 -->|perm granted| G1 G1 -->|true| EXIT_BYPASS G1 -->|not bypassed| G2 %% Pre-route gates G2 -->|yes| T_HOLD G2 -->|no| G3 G3 -->|yes| T_BAD_DATA G3 -->|no| G4 %% Path classification — first match wins G4 -->|yes| G4a G4 -->|no| G5 G4a -->|yes| T_TAGGED G4a -->|no| T_TAGGED_NO_OWNER G5 -->|yes| G5a G5 -->|no| G6 G5a -->|yes| T_CUSTOMER G5a -->|no| T_NO_OWNER G6 -->|yes| T_ACTIVE_DEAL G6 -->|no qualifying Opp owner — fall through| G7 G7 -->|yes| T_ACCOUNT_BDR G7 -->|no| G8 %% Rotation split G8 -->|Pool / inactive| RNA_G1 G8 -->|active AE| RBO_G1 %% ROTATION_NEW_ACCOUNT sub-tree RNA_G1 -->|blank| RNA_T_NO_REGION RNA_G1 -->|populated| RNA_G2 RNA_G2 -->|none| RNA_T_NO_AE RNA_G2 -->|AE found| RNA_G3 RNA_G3 -->|none| RNA_T_NO_BDR RNA_G3 -->|BDR found| T_RNA %% ROTATION_BDR_ONLY sub-tree RBO_G1 -->|blank| RBO_T_NO_REGION RBO_G1 -->|populated| RBO_G2 RBO_G2 -->|none| RBO_T_NO_BDR RBO_G2 -->|BDR found| T_RBO %% Catch-all error — try/catch wraps the whole CLASSIFY block CLASSIFY -.->|unhandled exception| T_ERROR

Side-effect summary

Cadence enrollmentACCOUNT_BDR, ROTATION_NEW_ACCOUNT, ROTATION_BDR_ONLY paths only. Flow re-reads Contact after Apex returns, then calls assignTargetToSalesCadence with Routing_Config__mdt.Default.NonTarget_Cadence_Id.

BDR TaskTAGGED_ACCOUNT only, and only for Active Target accounts where BDR_Owner__c is populated and active. No other path creates a Task.

In-batch buying-group continuity — if ≥2 Contacts in the same route() batch share an Account, the first Contact classifies normally; subsequent Contacts on that Account copy the first Contact's Routing_Path__c, OwnerId, and cadence decision without re-running classification or rotation.

Account.BDR_Owner__c invariant — written only by ROTATION_NEW_ACCOUNT and ROTATION_BDR_ONLY. ACCOUNT_BDR does not write it (already aligned). No other path touches it.

Contact_Routed__e publish — fired on every successful routed terminal (TAGGED_ACCOUNT, CUSTOMER_ACCOUNT, ACTIVE_DEAL, ACCOUNT_BDR, ROTATION_NEW_ACCOUNT, ROTATION_BDR_ONLY). Clay subscribes; no per-BDR webhook fan-out. Not fired on HOLD/UNROUTED outcomes.

Salesforce Implementation & Migration Guide

End-to-end inventory of the routing engine. v3 architecture is live in routing-dev as of 2026-05-21. Phase 1 = "Flow detects, Apex decides". Staging is still empty — engine is ready for staging migration. Verified via sf apex run test and sf org list metadata diff.

All passing
ContactRouterTest + RotationServiceTest
≥85%
ContactRouter coverage
2 + 4
Production Apex classes + routing Flows
1
MDT records in source control
v3 architecture deployed in routing-dev (2026-05-21) Phase 1 "Flow detects, Apex decides" is complete. The engine is exactly two production Apex classes (ContactRouter + RotationService) and one thin record-triggered Flow (Contact_AfterSave_RouteOnMQL, ~8 elements). All v1 Apex classes — PoolClaimHandler, RoutingTriggerGate, RoutingMaintenanceBatch, ContactRouterFlow — are retired and removed from the codebase. Staging has not yet been touched.
Source-control hygiene: MDT records must ship from source control Routing_Config.Default is the only Custom Metadata record currently in the repo. Any Rep_Roster__mdt records added in routing-dev during testing must be retrieved before staging deploy: sf project retrieve start --metadata "CustomMetadata:Rep_Roster.*,CustomMetadata:Routing_Config.*" --target-org routing-dev. Never recreate MDT records manually in staging — always deploy from source.
Missing in dev, not in staging — needs migration
Partial exists in staging but needs change (formula, field cleanup)
In staging already deployed
Cleanup stale object/field in staging to remove
Config manual setup after deploy
Data production user-ids needed (currently dev placeholders)

All "Missing" items below are verified-complete in routing-dev (ContactRouterTest + RotationServiceTest passing, all routing Flows deployed, Routing_Config.Default MDT record loaded). "Missing" refers strictly to the staging org state.

Why Apex + Flow (and not all-Flow)

Architecture rationale

The engine is a deliberate hybrid: Flow detects events, Apex makes routing decisions. This isn't a Salesforce-versus-no-code preference — it's a response to four properties Flow cannot deliver at the volume and concurrency this engine sees.

Flow owns

Record-triggered detection (insert/update events), simple in-memory field stamps (e.g. Contact_BeforeSave_StampMQL), the trigger-gate decision, the $Permission.Bypass_Automation kill switch, and entry-filter expressions admins can read.

Apex owns

Path classification (TAGGED_ACCOUNTCUSTOMER_ACCOUNTACTIVE_DEALACCOUNT_BDRROTATION_NEW_ACCOUNT / ROTATION_BDR_ONLY), round-robin rotation with locking, bulk-safe counter math, per-record error handling, in-batch buying-group continuity, platform-event publish (Contact_Routed__e).

The four things Flow cannot do

1. SELECT ... FOR UPDATE row locking Flow has no equivalent. Two concurrent MQLs in the same Region would read the same Rep_Rotation_Counter__c value and assign the same rep — a guaranteed double-assignment race at any meaningful inbound volume. The mitigation (queueable serialization) is Apex-only.
2. Bulk-safe counter increments across a 200-record batch The Apex engine reads each counter once with FOR UPDATE, advances it in-memory across the whole batch, and writes once. Flow either updates each iteration (DML explosion against the 150-DML governor) or skips and recomputes (logic explosion). Neither survives a 200-record HubSpot sync burst.
3. Per-record continue-on-error in a batch One bad Contact in 200 must not roll back the other 199. Apex catches per-Contact, stamps Routing_Path__c="UNROUTED_ERROR" + Routing_Last_Error__c, and continues. Flow's fault model is per-element, not per-record-in-collection. Approximating it requires invoking a subflow per record — which destroys the bulk efficiency you just won back.
4. Deterministic automated tests Apex has unit tests with System.runAs, deterministic assertions, and a hard 85% coverage gate in the deploy pipeline. Tests like pathE_failedBdrCheck_doesNotBurnAESlot verify specific edge cases in concurrent rotation. Flow has no equivalent — change validation is manual debug runs in a sandbox.

What an all-Flow version would actually cost

  • ~15–20 Flows instead of 4 (each path becomes its own subflow tree).
  • Documented race condition in round-robin — accepted and patched with after-the-fact reconciliation jobs.
  • No automated regression suite — every change requires manual staging QA.
  • Slower change cadence — deeply branched Flow logic is harder to diff and review in a PR than Apex.
  • Higher governor-limit risk at peak inbound volume.
The deciding test Flow could route a Contact. Flow cannot reliably do round-robin assignment under concurrent load — which is the entire point of the engine. Everything Flow can do well (detection, stamps, kill-switch decisions) stayed in Flow. Everything Flow can't (rotation, locking, bulk-safe counters, per-record error envelope) moved to Apex. The split follows capability, not preference.

Apex Classes

2 production classes · 2 test classes · all passing in dev · 0 in staging

The v3 engine has exactly two production Apex classes: ContactRouter (main Invocable router, entry point for the thin Flow) and RotationService (locked round-robin utility, also exposed as an @InvocableMethod for Phase 2 Flow reuse). Retired v1 classes (PoolClaimHandler, RoutingTriggerGate, RoutingMaintenanceBatch, ContactRouterFlow) are removed from the repo. None of the v3 classes are in staging yet — coverage target ≥85% on ContactRouter.

ClassPurposeStatus
ContactRouterMain router — classifies MQL Contact into one of 13 Routing_Path__c values, assigns OwnerId, stamps Account.BDR_Owner__c on rotation paths, publishes Contact_Routed__e, inserts Routing_Exception__c on unrouted outcomes. @InvocableMethod entry point called by Contact_AfterSave_RouteOnMQL.Missing
RotationServiceLocked round-robin utility — SELECT ... FOR UPDATE on Rep_Rotation_Counter__c, bulk-safe counter math, checkOnly mode for pre-flight eligibility checks. @InvocableMethod also exposed for direct Phase 2 Flow calls.Missing
ContactRouterTestTest class — covers all 13 paths, bypass-permission run, in-batch buying-group continuity, concurrency pre-check (failed BDR check does not burn AE slot).Missing
RotationServiceTestTest class — round-robin sequencing, checkOnly mode, roster eligibility filtering.Missing

Record-Triggered Flows

4 routing engine · other dev-only Flows not listed

These four Flows are the Phase 1 routing engine surface. Contact_AfterSave_RouteOnMQL is the "thin Flow" (~8 elements): checks $Permission.Trigger_Routing → checks $Permission.Bypass_Automation → calls ContactRouter.route → branches on BDR paths → re-reads Contact → assignTargetToSalesCadence with fault path. No path classification inside the Flow. Full spec in docs/production-flows.md.

FlowTrigger / ActionStatus
Contact_AfterSave_RouteOnMQLContact after-save · $Permission.Trigger_Routing gate → $Permission.Bypass_Automation kill-switch → invokes ContactRouter.route → cadence enrollment for BDR paths (ACCOUNT_BDR, ROTATION_NEW_ACCOUNT, ROTATION_BDR_ONLY) with fault pathMissing
Contact_BeforeSave_StampMQLContact before-save · stamps MQL_Stamped_At__c + lifecyclestage__c on transition to "Marketing Qualified Lead"Missing
RoutingException_BeforeSave_StampResolutionRouting_Exception__c before-save · stamps Resolved_At__c / Resolved_By__c when Status__c transitions to ResolvedMissing
Task_AfterInsert_DetectPositiveSignalTask insert · detects positive engagement signal on Contact, stamps signal fields per docs/positive-signals.mdMissing
Non-routing Flows in the repo Several other Flows live in force-app/main/default/flows/ but are not part of the Phase 1 routing engine: Account_Before_Save, Contact_After_Save, Contact_AfterSave_DQFeedback, Task_Before_Save, Task_After_Save, Event_After_Save_Update_Last_Interaction_Date, Opportunity_Update_Contact_to_Sales_Qualified_Lead_Upon_Creation, and others. Decide per-flow whether they ship with the routing engine or are managed separately.

Custom Objects, Metadata Types & Platform Events

5 objects / events in repo
ObjectPurposeStatus
Rep_Roster__mdtCustom Metadata Type — AE/BDR roster keyed by Region + Segment.In repo
Routing_Config__mdtCustom Metadata Type — single Default record holds pool user-id, cadence id, triage owner, and config thresholds.In repo
Rep_Rotation_Counter__cCounter rows for round-robin rotation. Reads use SELECT … FOR UPDATE to serialize concurrent transactions.In repo
Routing_Exception__cTriage object (auto-number RX-{0000}) — replaces v1/v2 admin Tasks. Required fields: Contact__c, Account__c, Failure_Reason__c (picklist), Status__c, Error_Detail__c, Resolution_Notes__c, Resolved_At__c, Resolved_By__c, Routed_At__c. Owned by the Routing_Triage queue by default.In repo
Contact_Routed__ePlatform Event published by ContactRouter. Clay subscribes to this single endpoint instead of per-BDR webhooks.In repo

Custom Fields — Contact & Account

routing fields in repo

Routing-engine fields verified against force-app/main/default/objects/. The repo contains additional non-routing fields (lead-score, HubSpot sync state, etc.) not listed here.

Contact

FieldType / purposeStatus
lifecyclestage__cText (all lowercase — gotcha). Gate value: "Marketing Qualified Lead". HubSpot-owned; router reads, never writes.In repo
MQL_Stamped_At__cDateTime — set by Contact_BeforeSave_StampMQL, drives re-MQL window.In repo
Routing_Path__cText(40) — stable enum stamped on every routing pass. 13 values: HOLD, TAGGED_ACCOUNT, CUSTOMER_ACCOUNT, ACTIVE_DEAL, ACCOUNT_BDR, ROTATION_NEW_ACCOUNT, ROTATION_BDR_ONLY, UNROUTED_BAD_DATA, UNROUTED_TAGGED_NO_OWNER, UNROUTED_NO_OWNER, UNROUTED_NO_REGION, UNROUTED_NO_AE, UNROUTED_NO_BDR, UNROUTED_ERROR.In repo
Routing_Last_Run__cDateTime — timestamp of last router pass.In repo
Routing_Last_Error__cLong Text — preserved for org compatibility; triage now goes to Routing_Exception__c in v3.In repo
Routing_Cadence_Error__cText — cadence-enrollment fault captured by Flow.In repo
On_Hold__cCheckbox — pre-route gate; exits cleanly with path HOLD.In repo
Last_Signal_Date__cDateTime — positive-signal detection (Task_AfterInsert_DetectPositiveSignal).In repo
Signal_Type__cText — signal category from Task disposition.In repo
Record_source__cText (lowercase 's' — gotcha). Inbound source tag from HubSpot.In repo
Record_source_detail_1__cText (lowercase — gotcha). Source detail tier 1.In repo

Account

FieldType / purposeStatus
Account_Status__cPicklist — drives path selection (Tagged, Customer, Active Deal, BDR, Rotation).In repo
Is_Tagged_Account__cFormula — true for Active Target / Active Conversion / Active Velocity. Canonical flag for TAGGED_ACCOUNT path.In repo
Target_Account__cFormula — narrower: true only for "Active Target Account". Used to decide BDR manual-outreach Task; not the routing gate.In repo
Region__cPicklist — territory key; joins with Segment to Rep_Roster__mdt. Blank → UNROUTED_NO_REGION.In repo
Revenue_Range_Segments__cPicklist — values: "Velocity ($50M)" / "Enterprise ($50M-$500M)" / "Strategic ($500M+)".In repo
BDR_Owner__cLookup (User) — always equals the most recent BDR the router assigned to a Contact on this Account. Both ROTATION paths stamp it.In repo
Do_Not_Route__cCheckbox — Account-level routing block; exits with path HOLD.In repo
Cooldown_End_Date__cDate — set by maintenance sweep (Phase 2). Sweep-only; never blocks routing in Phase 1.In repo
AE_Meeting_Link__cFormula = Owner.Meeting_Link__c. Do not recreate.In repo

Permission Sets & Custom Permissions

3 perm sets · 2 custom perms · in repo
ComponentPurpose / kill-switch roleStatus
Routing_Engine_ServicePermission Set — engine runtime permissions. Grants object/field CRUD for Contact, Account, Rep_Rotation_Counter__c, Routing_Exception__c, Contact_Routed__e. Also grants Trigger_Routing. Assign to the integration user that runs routing.In repo
Routing_Trigger_UsersPermission Set — primary kill switch. Grants Trigger_Routing to trigger-context users. The Flow's element-zero gate checks this permission (default-deny). Unassigning from the trigger user halts routing immediately with zero blast radius.In repo
Routing_Bypass_AllPermission Set — emergency stop. Grants Bypass_Automation. Mass-assign to halt all record-triggered routing Flows within seconds; targeted unassignment to resume. No deploy required.In repo
Trigger_RoutingCustom Permission — checked by element-zero of every routing Flow ($Permission.Trigger_Routing). Default-deny: a user without this permission exits the Flow cleanly.In repo
Bypass_AutomationCustom Permission — checked immediately after the trigger gate in every routing Flow ($Permission.Bypass_Automation). Granted by Routing_Bypass_All.In repo

Custom Metadata Records

1 record in repo · Rep_Roster populated in dev org

Only Routing_Config.Default is checked into force-app/main/default/customMetadata/. Rep_Roster__mdt rows (AE/BDR dev users) exist in routing-dev but are not yet retrieved into source control — retrieve and commit before staging deploy.

RecordValues / notesStatus
Routing_Config.DefaultCooldown_Days=30, Inactive_Days=30, Signal_Expiration=30, AE_Dedupe=7. NonTarget_Cadence_Id__c, Prospecting_Pool_User_Id__c, and Unrouted_Triage_Owner_Id__c are null — populate before routing runs end-to-end. Note: legacy Routing_Trigger_User_Ids__c field is retired; do not reference it.In repo
Rep_Roster__mdt rowsAE and BDR dev-user records loaded in routing-dev via seed script (scripts/apex/seed_routing_dev.apex). Not yet in source control. Production roster must cover all expected Region × Segment combinations or missing combos route to UNROUTED_NO_AE / UNROUTED_NO_BDR.Dev only
Before staging deploy: run sf project retrieve start --metadata CustomMetadata to pull Rep_Roster rows into source control, review for PII (replace user Ids with prod values), then re-deploy. Production roster must fill all Region × Segment combinations expected in inbound traffic.

Pre-Deploy Checklist & RevOps Callouts

Read before merge
1. Target_Account__c formula narrowing — surface to RevOps BEFORE deploy The Path T PR narrowed Target_Account__c from OR(NOT(A), NOT(B)) (true for every non-blank status) to AND(non-blank, ISPICKVAL("Active Target Account")). Any existing report, list view, or dashboard filtered on Target_Account__c = true will return fewer rows after deploy. Inventory and notify owners.
2. Sales Engagement enablement Confirm Sales Engagement is enabled in staging (modern ActionCadenceTracker model). Build the production ActionCadence for non-Target outbound, then wire its Id into Routing_Config.Default.NonTarget_Cadence_Id. Assign the Sales Engagement Basic PSL (99 free seats) to the user context the routing Flow runs under.
3. Trigger-gate permission set Assign the Routing_Trigger_Users permission set (grants the Trigger_Routing custom permission) to the HubSpot sync user. The gate is default-deny — a user without this PSL gets no routing at all. This PSL is also the preferred rollback: unassigning it stops routing for that user immediately, no deploy required.
4. Pool user and triage owner Routing_Config.Default needs production Ids for Prospecting_Pool_User_Id__c (service account that holds unowned Accounts) and Unrouted_Triage_Owner_Id__c (the Id of the Routing_Triage queue — deployed from force-app/main/default/queues/Routing_Triage.queue-meta.xml; confirm the queue Id in staging after deploy before wiring it in).
5. RevOps: Routing_Exception__c queue, tab, and list views — required before go-live Triage in v3 uses Routing_Exception__c records (auto-number RX-{0000}) owned by the Routing_Triage queue — not admin Tasks. Before go-live: (a) confirm the Routing_Triage queue is deployed and RevOps team members are queue members; (b) verify the Routing_Exception__c App tab exists; (c) build list views for Open and Investigating statuses filtered to the queue. Sales reps must never see triage items in their Task lists.
6. Account_Status__c backfill Per project memory, Account_Status__c initial values ride the HubSpot → Salesforce production data load. There is no standalone Apex backfill. Confirm the data load includes this mapping before flipping the gate.
7. HubSpot → Contact.AccountId is required The pre-route data-quality gate rejects Contacts with null AccountId. HubSpot owns Contact → Account matching. Verify the integration is populating AccountId before routing goes live.
8. Tests must pass with ≥85% coverage 4 Apex classes in source: ContactRouter, ContactRouterTest, RotationService, RotationServiceTest. Run the full suite as part of the pipeline validation step (--test-level RunLocalTests). Tests must use System.runAs with a user whose Profile lacks Bypass_Automation; a dedicated bypass-path test must assert no routing occurred.

Deployment Sequence — staging

Run in order

All deploys go through the CI/CD pipeline (branch → PR → validation → merge). Claude Code does not deploy to staging. The order below is what the pipeline / human operator should follow.

0
Ensure CMT records are in source control
force-app/main/default/customMetadata/ must contain all Rep_Roster__mdt and Routing_Config__mdt records before the pipeline runs. If any records were added to routing-dev after the last retrieve, pull them now and commit. Production Ids (users, queue) are swapped into the files before pipeline deploy — do not hard-code dev sample values.
sf project retrieve start \ --metadata "CustomMetadata:Rep_Roster.*,CustomMetadata:Routing_Config.*" \ --target-org routing-dev git add force-app/main/default/customMetadata/ && git commit -m "Retrieve routing CMT records from dev"
1
Deploy custom fields and objects first
Schema must exist before Apex/Flows can reference it. Includes Rep_Roster__mdt, Routing_Config__mdt, Rep_Rotation_Counter__c, Contact_Routed__e, plus the new Contact and Account custom fields. Target_Account__c formula gets redeployed in this step.
sf project deploy validate \ --source-dir force-app/main/default/objects \ --target-org routing-staging
2
Deploy custom permissions and permission sets
Required before Flows can reference $Permission.Bypass_Automation / $Permission.Trigger_Routing.
sf project deploy validate \ --source-dir force-app/main/default/customPermissions,force-app/main/default/permissionsets \ --target-org routing-staging
3
Deploy Apex classes (production code + tests)
All 4 classes: ContactRouter, ContactRouterTest, RotationService, RotationServiceTest. Pipeline must run tests with --test-level RunLocalTests and verify ≥85% coverage on both production classes.
sf project deploy start \ --source-dir force-app/main/default/classes \ --test-level RunLocalTests \ --target-org routing-staging
4
Deploy Flows
Routing Flows depend on Apex Invocables. Activate after deploy — Flows default to Draft.
sf project deploy start \ --source-dir force-app/main/default/flows \ --target-org routing-staging
5
Load Custom Metadata records
Deploy all Rep_Roster__mdt and Routing_Config__mdt records with production user-ids substituted in, not dev sample values. At this point do not assign Routing_Trigger_Users to the sync user yet — that's the default-deny gate, flipped in step 6.
sf project deploy start \ --source-dir force-app/main/default/customMetadata \ --target-org routing-staging
6
Assign permission sets to the integration user
The HubSpot sync user (or whichever user posts MQLs) needs Routing_Engine_Service and Routing_Trigger_Users. Assigning Routing_Trigger_Users is the act that turns the gate on — do this only after data validation in step 7. The Sales Engagement Basic PSL must also be assigned for cadence enrollment to function.
sf org assign permset --name Routing_Engine_Service --on-behalf-of <integration_user> --target-org routing-staging sf org assign permset --name Routing_Trigger_Users --on-behalf-of <integration_user> --target-org routing-staging
7
Verify HubSpot → Salesforce data load populates routing fields
Account_Status__c, Region__c, AccountId on Contact. Spot-check a sample of records in staging before turning on the gate. Confirm Contact_Routed__e subscription is configured in Clay.
8
Confirm trigger gate is ON
The gate is driven by the $Permission.Trigger_Routing custom permission, granted via Routing_Trigger_Users PSL. If the PSL was assigned in step 6, the gate is live — no additional metadata deploy is needed. Verify in staging with a SOQL check that the PSL assignment exists.
sf data query \ --query "SELECT AssigneeId FROM PermissionSetAssignment WHERE PermissionSet.Name='Routing_Trigger_Users' AND AssigneeId='<integration_user_id>'" \ --target-org routing-staging
9
Smoke-test routing live
Create a test MQL Contact (matched to a known Account with Region + Segment + Status). Verify Routing_Path__c is set, owner is correct, Contact_Routed__e fires. Then test a Path E unowned Account to confirm round-robin AE selection.

Rollback Playbook

Two kill switches

1. Unassign Routing_Trigger_Users PSL (preferred, low blast-radius)

Remove the Routing_Trigger_Users permission set from the integration user. The Flow's element-zero check on $Permission.Trigger_Routing fails and routing exits cleanly — no routing for that user. No deploy required. Restore by reassigning the PSL. This is the standard rollback.

2. Mass-assign Routing_Bypass_All PSL

Assigns the Bypass_Automation custom permission. The Flow's second gate fires and halts routing for all holders within seconds — no deploy required. Use when something is firing wrong for multiple users and you need an immediate broad stop. Targeted PSL unassignment to resume.

Both kill switches are idempotent and reversible. Neither leaves the engine in a broken state. Re-enabling is a single action in each case.

Netlify + GitHub auto-deploy (this page)

5 min one-time setup

Set up once. Every push to the configured branch will rebuild and publish.

1
Push routing_simulator.html to a GitHub repo
If it's currently in lead-routing (which is private), consider a small public-ish or org-private repo just for the simulator HTML so Netlify can pull it.
2
In Netlify: Add new site → Import from Git
Authorize the GitHub app, pick the repo, pick the branch (likely main).
3
Build settings
Build command: leave blank. Publish directory: repo root (or the subfolder containing the HTML). No framework needed — static file.
4
(Optional) Rename the file to index.html
So the site loads at the root URL. Or set up a Netlify redirect from //routing_simulator.html in a _redirects file.
5
Custom domain (optional)
Netlify → Domain settings → Add custom domain. DNS via CNAME or Netlify nameservers.
Auto-redeploy verification Push any change to the configured branch. Netlify's deploy log should show "Build started" within ~30s. The site updates in 30–60s after build completes.