> ## Documentation Index
> Fetch the complete documentation index at: https://docs.myfundingmachine.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Onboarding review 2026 09 29

# Onboarding code review — September 29, 2026

Findings from a read-only review of the new onboarding work (commits `90777411`
FM-2620 through `d61ceac8`), for agents picking up onboarding fixes. Read
[AGENTS.md](../../AGENTS.md) and [VISION.md](../../VISION.md) first.

**How to use this file**

* Line numbers are anchored to `origin/main` at `d61ceac8`. Re-verify each finding
  against current main before acting; code has likely moved.
* **Verified** = traced in code during this review by the reviewer who wrote this.
  **Confirmed** = traced by a sub-reviewer with a concrete path, not independently
  re-checked. **Suspected** = plausible, not traced end to end or needs real CRM data.
* When you fix a finding, strike it through with the PR link rather than deleting it,
  and update the status table. When everything here is resolved or superseded,
  delete this file — do not let it become a competing "current" handoff.
* Nothing here authorizes production writes, flag changes, or merges.

## Verdict

The intent is right and the risky paths are handled with care: server-derived actor
and Location, claim-then-readback CRM writes, uncertain outcomes recorded instead of
retried, fail-closed gates, identity session fencing. The problem is size and
tangling: \~43k lines in six days, 24 new Convex tables, 14 `ONBOARDING_*` settings,
\~11 hand-rolled provider clients, six overlapping status notions, and \~3.6k lines of
onboarding docs. Tests pass (399 app, 167 engine) but mostly against fixtures written
by the same author, so they confirm the code's assumptions rather than real CRM
behavior. Recommendation: fix the safety bugs, then simplify before enabling more
flags. Not a rewrite.

## Status

| # | Finding | Severity | Status |
| - | - | - | - |
| ~~S1~~ | ~~Slack resolver can attach another Location's channel~~ | High (tenant) | Fixed in [#1791](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1791) |
| S2 | Forged admin-Location session reaches onboarding admin writes | High (pre-existing FM-2218) | Open — needs design decision |
| ~~S3~~ | ~~Setup-panel appointment writes bypass the CRM write gate~~ | High | Fixed in [#1790](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1790) |
| ~~S4~~ | ~~Staff draft save overwrites concurrent edits~~ | Medium | Fixed in [#1797](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1797) |
| ~~S5~~ | ~~Selecting a client in the queue writes to the CRM~~ | Medium | Fixed in [#1793](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1793) |
| ~~S6~~ | ~~A stuck pending edit can lock a client indefinitely~~ | Medium | Fixed in [#1795](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1795) |
| ~~S7~~ | ~~Identity webhook 503s permanent failures~~ | Medium | Fixed in [#1789](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1789) |
| ~~S8~~ | ~~Location-links refresh always bumps revision~~ | Medium | Fixed in [#1788](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1788) |
| ~~S9~~ | ~~Account-link error aborts the whole setup refresh~~ | Medium | Fixed in [#1788](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1788) |
| E1 | No single-writer guard between comms runtimes | High before cutover | Runbook merged in [#1812](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1812) — no code guard; open until the cutover revokes the mini's CRM token |
| ~~E2~~ | ~~Cloud live sender readback will likely self-block~~ | High before cutover | Fixed in [#1796](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1796) |

Batch 3 (architecture and cleanup), by section below:

| Item | Status |
| - | - |
| Provider clients | Fixed in [#1804](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1804), [#1809](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1809) |
| Constants | Operations Location ID fixed in [#1809](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1809); agency ID partly open |
| Four progress models | Fixed in [#1811](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1811) |
| Refresh load | Fixed in [#1811](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1811) |
| Copy | Code fixed in [#1811](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1811), carrying [#1810](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1810); guide follow-up below remains open |
| Dead code | Batch-3 removals merged in [#1811](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1811), carrying [#1810](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1810); remaining items below |
| Agency token refresh | Fixed in [#1805](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1805) (with [#1813](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1813)); Next.js refresh path still open |
| Docs | Fixed in [#1806](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1806) |
| Consent gate, stage ownership, run status | Fixed in [#1807](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1807) |
| Engine minor items (SMS, template comments, shadow page) | Fixed in [#1808](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1808) |
| Standalone executor | Fixed in [#1812](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1812) |
| E2 and QA task skipped when the progression job moves first | Fixed in [#1814](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1814); failed-delivery case is a follow-up (FM-2691) |
| Live runner reads custom values from the imported snapshot | Fixed in [#1814](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1814) (with [#1815](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1815)) |

Batch 4, wave 1 (September 30, 2026):

| Item | Status |
| - | - |
| Recorded CRM contracts and fixture shapes | In review ([#1821](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1821)); successful phone responses and write contracts still need evidence |
| Recorded CRM search-parameter regression | In review ([#1823](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1823)) |
| Recorded CRM terminal-page regression | In review ([#1824](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1824)) |
| Missing route-auth and outside-agency refresh tests | In review ([#1825](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1825)) |
| Compute-only cohort mode | Fixed in [#1818](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1818) (merged by Brock) |
| Shadow runner and snapshot-import removal | In review ([#1820](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1820), originally stacked on merged #1818) |
| Status sprawl: admin KPIs | In review ([#1819](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1819)) |
| Status sprawl: legacy onboarding and Launchpad retirement | In review ([#1822](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1822), stacked on #1819); tables and legacy reference stay |

Still open: S2 (Brock), reverse-state code and then readability splits in wave 2,
subscriber sync, the calendar 200-events cap, and the legacy table drop. The E1
operational cutover, manual provider write checks and other follow-ups below remain
open. These PRs do not enable CRM writes or auto-progression.

## Safety and correctness findings

### ~~S1 — Slack resolver accepts unmapped opportunities (Verified)~~ — fixed in [#1791](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1791)

`lib/onboardingSlackSync.ts:114-117` only rejects an opportunity when its Product
Location ID is **set and different**. A blank Product Location ID passes, and if the
field lookup doesn't return exactly one field, no filter runs at all. Scenario: one
contact owns Locations A and B; A's opportunity has no channel, B's opportunity is
unmapped (account linking deliberately refuses multi-subscription contacts,
`lib/onboardingAccountLink.ts:45-48`) but has a channel. A's refresh resolves B's
channel and `convex/onboardingSlack.ts:278-288` saves it for A; Slack events then
route to A.
**Fix:** require Product Location ID to equal `locationId` (same rule as
`identityCrmTarget`), and fail closed when the field can't be resolved. Add a test
for the unmapped-opportunity case.

### S2 — Forged admin-Location session (Verified; pre-existing FM-2218)

`app/api/auth/location-session/route.ts` mints a session for any posted `location_id`
(documented in the file as a knowingly restored vulnerability). `isAdminActor`
(`convex/lib/adminActor.ts:98-108`) accepts `isAdminLocation(actor.locationId)`, so a
session for the FM admin Location is treated as staff. The onboarding workspace
raises the blast radius: opportunity edits, appointment cancel/reschedule/status,
notes attributed to staff user IDs (`convex/onboardingNotes.ts:284,340`, Confirmed).
**Not an onboarding-only fix.** Options to raise with Brock: stop treating
`isAdminLocation` alone as admin for onboarding writes (require `verifiedAgencyAdmin`
or managed Super Admin), or close FM-2218 properly with a verifiable iframe
credential. Do not re-apply PR #1358's shape (see the route's comment).

### ~~S3 — Appointment writes bypass the write gate (Confirmed)~~ — fixed in [#1790](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1790)

`convex/onboardingAppointments.ts:85` only enforces `ONBOARDING_ADMIN_CRM_WRITES_ENABLED`
when `queueWrite` is set. The Setup panel path (`app/api/prospecting/portal/route.ts`
\~583 → `AppointmentControls` "Team controls", `components/admin/onboarding/OnboardingSetupAdmin.tsx:99`)
never sets it, so staff can mark Showed/No-show or cancel in the Operations CRM with
the gate off, and that path does not sync milestone dates. Two attendance controls
now behave differently.
**Fix:** one gated appointment/attendance path; remove the duplicate admin control.

### ~~S4 — Draft save defeats optimistic locking (Verified)~~ — fixed in [#1797](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1797)

`components/admin/onboarding/useSetupDrafts.ts:21-23` freezes form defaults once a
draft is dirty, but `OnboardingSetupAdmin.tsx:30` sends
`expectedRevision: setup.data?.revision` (the live revision). The stale check always
passes, so the multi-field `staff` command (`SetupAdminControls.tsx:180`) can revert
a colleague's blocker/owner change.
**Fix:** send the snapshot's revision. That will then surface S8 and the
whole-row-revision problem — consider field-level patches instead of one row counter.

### ~~S5 — Reads write (Confirmed)~~ — fixed in [#1793](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1793)

Selecting a client fires `onboardingQueueLinks` (`components/admin/onboarding/OnboardingQueue.tsx:300-330`)
→ `refreshLinks` → `save({syncLinks:true})` (`convex/onboardingAdmin.ts:1153-1166`),
which can run `reconcile` (`:968-977`) and advance the stage when auto-progression is
on for that Location.
**Fix:** make viewing pure; sync only on explicit action or the cron.

### ~~S6 — Unreleasable pending edit (Confirmed)~~ — fixed in [#1795](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1795)

`check` in `convex/onboardingAdmin.ts:1034,1051` releases a pending edit only if every
value equals `after`, or after 5 minutes every value equals `before`. A concurrent CRM
edit to the same field satisfies neither; all writes for that client stay blocked.
**Fix:** audited staff force-release with a reason.

### ~~S7 — Identity webhook retries forever (Confirmed)~~ — fixed in [#1789](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1789)

`convex/onboardingIdentityWebhook.ts:19-28` returns 503 for all failures, including
permanent ones (Labs off, mapping changed, CRM write gate off, unknown status).
Stripe retries for days and may disable the shared endpoint.
**Fix:** 503 only for transient failures (lock held, provider 5xx); acknowledge the
rest with an audit record. Signature verification itself is sound
(`convex/lib/stripeWebhookVerify.ts`).

### ~~S8 — Location-links churn (Verified)~~ — fixed in [#1788](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1788)

`convex/onboardingLocationLinks.ts:36-37` rebuilds the `application` task object on
every run when there's no affiliate URL; the `changed` check at `:45-46` compares by
identity, so it is always true → revision bump + audit row every refresh, which also
makes the Slack persist drop its result (`convex/onboardingSlack.ts:241`). `:61`
doesn't null-check the token.
**Fix:** only replace the task when `complete` actually changes.

### ~~S9 — Account-link error kills setup refresh (Confirmed)~~ — fixed in [#1788](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1788)

`claim` runs outside the `try` (`convex/onboardingAccountLink.ts:21`); its
"not Operations agency" error propagates through `convex/onboardingSetup.ts:679`,
so Slack/calls/phone refresh fail for any Labs Location outside the MFM agency.
`identityCrmTarget` throws plain `Error`, so actionable messages surface as generic
retryable errors (`:89-92`).

### Other admin/progression findings (Confirmed unless marked)

* Staff-editable `blocked` (`lib/onboardingAdminCommands.ts:21`) is recomputed from
  Missing Info on every sync (`lib/onboardingProgression.ts:252`) — manual tags get wiped.
  Fixed in [#1795](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1795).
* KYC: the CRM "KYC Verified" field is the single truth, and the Compliance gate reads
  it (`lib/onboardingAdmin.ts:500`). The defect was the unguarded staff `kycStatus` edit
  in the queue, which bypassed the identity module so the Operator view and the gate
  drifted apart. [#1795](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1795) (merged) removed the queue edit; [#1798](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1798) (merged) adds linking an
  existing Stripe verification and makes the Operator view follow the CRM field.
* Auto-advance needs bare `YYYY-MM-DD` dates (`lib/onboardingProgression.ts:158`);
  manual advance accepts any date (`lib/onboardingAdmin.ts:464`). Numeric CRM dates
  would make `readOnboardingValues` throw (`:440`) and take down the queue. (Suspected —
  fixture only uses bare strings.) Fixed in [#1796](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1796).
* One malformed record throws for the entire queue page (`convex/onboardingAdmin.ts:363,376`)
  instead of flagging the row. Fixed in [#1795](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1795).
* Calendar matching: cancelled/no-show appointments still count as candidates
  (`lib/onboardingSetup.ts:312`), so a kickoff rebook goes needs-attention; FM1/FM2 on
  one calendar require manual pins for every client (`:324-334`); a pinned FM2 that's
  rebooked stays pinned to the cancelled one; discovery gives up at 200 events/window
  (`lib/onboardingCalendar.ts:12`, Suspected in practice). Cancelled/rebooked matching in
  review ([#1794](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1794)). FM1/FM2: decided without a code change; ops assigns FM2 a
  separate FM Follow-up calendar in onboarding settings.
* Reverse states: a Live client can't be moved back from the app
  (`convex/onboardingAdmin.ts:695`); notes can't be edited.
  **Decided 2026-09-30 (Kevin):** any staff member can move a Live client back;
  the client returns to the stage it came from. A note edit overwrites the original,
  as CRM notes do. Code remains open for wave 2, before the readability splits.
* Subscriber sync (disabled): pages every Operations opportunity once per account
  (`convex/lib/subscriberSyncCrm.ts:195-219`, `convex/admin/subscriberSyncNode.ts:170`),
  hard-fails past 10k; stuck `opportunityCreateAttemptedAt`/contact reservations have
  no clear function.
* Agency token refresh has no single-flight guard (`convex/admin/saasLookup.ts:54-75`,
  pre-existing, Suspected race) and no fetch timeout (`:104`); account linking now
  calls it on every setup refresh. Fixed in [#1805](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1805), which
  adds a per-agency lease and timeouts and now carries [#1813](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1813), which routes the 6-hour token sweep
  (`refreshExpiringTokens`) through the same lease. The CRM rotates refresh tokens, so
  an unleased sweep could spend the token first, get `invalid_grant`, and delete the
  agency install. #1813 also keeps a rejected agency row for reauthorization instead
  of deleting it; Location rows are unchanged. After an ambiguous refresh (timeout or unreadable response),
  \#1805 refuses to reuse the stored token until newer credentials arrive, so account
  linking can wait for the next sweep or a reauthorization. Still open: the Next.js
  agency refresh (`getValidGHLAgencyAccessToken` in `lib/ghl-auth.ts`) doesn't use
  the lease. It refreshes and writes the shared agency row outside the lease's fenced
  write, so it is a second, uncoordinated writer. It never deletes the row. In a race
  one side gets `invalid_grant`: if that's the lease path, the fence re-checks the
  stored refresh token first and a later write clears it; if it's the Next.js path,
  that request fails. Route it through the lease.

## Communications engine

**Decided 2026-09-29 (Kevin):**

* **Runtime:** the cloud runner (Convex, this repo) is the only sender. The Mac mini's
  engine job retires; its Slack support gateway stays. This repo's standalone executor
  goes. Shared rules and templates stay.

* **Stage ownership:** the progression job (`convex/crons.ts`, `reconcilePage`) is the
  only automated mover of onboarding stages. The engine owns messages, tasks, field
  stamps, retention creation and the retention pipeline. It keeps `mark_won`, the
  handoff to retention, because the progression job never marks won. Native CRM
  workflows stop moving onboarding stages. A card moved by hand is never moved back.

* **DND:** consent is collected before onboarding, so a send is allowed unless the
  CRM says DND is on. Block when `dnd === true` or `dndSettings[channel].status` is
  `active` or `permanent`, or when the contact has no address for that channel.
  Missing DND data is not a block. Same rule for email and SMS.

* **E1 — No single-writer guard (Confirmed).** Mac mini SQLite ledger and cloud
  action ledger are independent; "exactly one sender" is a runbook step only.
  Add a guard both runtimes check (e.g. a Convex flag the mini's wrapper reads).
  Runbook merged in [#1812](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1812); open until the token step is done. With the mini's sender retiring, there's no cross-repo
  guard. `services/onboarding-engine/docs/RUNTIME.md` is the cloud cutover runbook: stop
  the mini's engine job, then revoke or rotate its CRM token so it can't send even
  if restarted.

* **E2 — Live readback will likely self-block (Suspected, high likelihood).** Fixed in [#1796](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1796).
  `matchesWrite` (`lib/onboardingEngineLive.ts:200-204`) requires the CRM to echo the
  exact string written (`YYYY-MM-DD`, `"true"`). A mismatch marks the action `unknown`,
  blocking the opportunity until a manual `resolve`, which requires pausing the whole
  sender (`convex/onboardingEngineLiveStore.ts:126`). Compare normalized values.

* ~~**Consent gate blocks nearly all sends (Confirmed).** Requires `dnd === false` and
  `dndSettings[channel].status === "inactive"` (`lib/onboardingEngineLive.ts:35`); the
  pilot contact returned neither.~~ Fixed in [#1807](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1807) with the rule decided above.

* **Three runtimes.** The mini runs `fundingmachine-agents@dc91b87`, not
  `services/onboarding-engine` (never deployed); the cloud has its own executor
  (`lib/onboardingEngineLive.ts:207-231` re-implements `services/onboarding-engine/src/execute.ts`).
  Rules and templates are shared and byte-tested, so they won't drift; executors,
  ledgers and signal adapters will. Fixed in [#1812](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1812): this repo's standalone
  executor is removed; the shared rules, templates and the read-only client the
  shadow runner uses stay.

* Standalone client retries non-idempotent POSTs (tasks, notes, `POST /opportunities/`)
  on 429/5xx (`services/onboarding-engine/src/ghl/client.ts:40-41`); `create_task` has
  no pre-dispatch claim (`src/execute.ts:121-129`) → duplicate tasks. Fixed
  ([#1812](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1812)): the executor and the client's write methods go with the standalone
  runtime.

* ~~Up to four actors move the same cards (native CRM workflow, mini, cloud W5/W8,
  progression cron at `convex/crons.ts:12` / `convex/onboardingAdmin.ts:861-890`).~~
  The cloud runner no longer moves onboarding stages ([#1807](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1807)); the mini retires and
  the native CRM workflows are turned off by the cutover runbook
  ([#1812](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1812)).

* **E2 and the QA task can be skipped (Verified, found in batch 3).** W5 sends E2 only
  while the card is in Compliance and W8 creates the QA task only in Setup. If the
  progression job moves the card first, neither fires. The rules also disagreed on
  Compliance: W5 wanted four checklist items and the progression job three. Fixed
  in [#1814](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1814): W5 also sends E2 from Setup In Progress and W8 also
  creates the QA task from Final QA; each proposes its stage move only from its
  original stage. The shared checklist is the progression job's three items, which
  also stops a spurious "finish checklist" reminder from W4b. Still open (FM-2691):
  if E2 delivery fails and the card reaches Final QA before a retry, E2 is never sent.
  W5 deliberately doesn't send the roadmap email from Final QA; surfacing the
  missing receipt to staff needs a product decision.

* **Live runner custom values are frozen (Verified, found in batch 3).** The cloud
  runner reads custom values such as `compliance_calendar_url` from the imported
  snapshot, which can't be replaced, so fixing a value in the CRM never unblocks the
  SMS. Fixed in [#1814](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1814) (with [#1815](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1815)): each pass reads them from the CRM, with no
  fallback to the snapshot. A missing or non-HTTPS value blocks only that message and
  names it; a failed read stops the pass before any claim.

* ~~Run status: any `blocked` member marks the run `failed`
  (`convex/onboardingEngineLive.ts:33`), and `blocked` includes the normal
  "not yet permitted" case — status is mostly noise.~~ Fixed in [#1807](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1807):
  not-yet-permitted members report `waiting`.

* ~~Minor: SMS text isn't validated (missing `compliance_calendar_url` yields a message
  ending in a colon, `src/jobs/onboarding.ts:47,224`); shadow transport rejects exactly
  20 conversations (`lib/onboardingEngineShadow.ts:74` vs `limit: 20`); template
  leading HTML comments with internal notes ship in the email body.~~ Fixed in
  [#1808](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1808).

* Gates are genuinely inert when flags are unset (shadow is GET-only with path and
  Location checks; live re-checks flag, state, revision and lease on every claim).
  Template audience and "CRM" copy pass.

* **Shadow and snapshot retirement — In review**
  ([#1820](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1820)).
  Compute mode merged in [#1818](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1818).
  Per-member `compute` uses the live fetch, signals and rules, records each proposal
  and its reason in the staff-readable run ledger, and never claims an action write
  lease or sends. Missing mode preserves `send`. The separate shadow runner, cron,
  comparison and snapshot importer retire; new configuration explicitly selects E9
  mode. Existing imported receipts and uncertain claims remain readable and binding,
  including imports that previously ran only in shadow. Every table and schema field
  stays. Removing unused tables and the mini's operational token cutover are separate.
  **Decided 2026-09-30 (Kevin):** an incomplete historical import stays fail-closed.
  It is a cutover blocker requiring a separate reviewed recovery; partial delivery
  evidence must not be bypassed. Production occurrence has not been checked.

## Operator setup surface

* **Four progress models.** `readiness` (`lib/onboardingSetup.ts:227`),
  `sharedReadiness` (`lib/onboardingReadiness.ts:13`), `operatorOnboardingProgress`
  (`lib/operatorOnboardingProgress.ts:3`), `nextSetupAction`
  (`lib/onboardingNavigation.ts:71`) disagree — banner "3 of 7" vs header "3 / 10"
  on the same screen; stage 3 shows "0 of 2 required" when both tasks are hidden
  (`components/onboarding/SetupWorkspace.tsx:675`). Fixed in
  ([#1811](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1811)): one model in `lib/onboardingSetup.ts`; the other three become
  projections of it.
* **Dropped requests (Confirmed).** `components/onboarding/useSetup.ts:56-59` drops any
  non-refresh request while one is in flight; refresh fires on window focus
  (`:109-113`), which inside the iframe fires on the first click back — that click is
  lost. This is why Phone/Appointment keep shadow `result` state and why identity
  status can go stale. Fixed in [#1797](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1797).
* **Refresh load (Suspected, unmeasured).** Focus refresh + 30s identity poll + 60s
  provider poll/focus/visibility/page change (`components/onboarding/OnboardingProgressProvider.tsx:58-69`),
  all ultimately on the one Operations CRM token (`convex/onboardingSetup.ts:712`);
  phone inventory isn't throttled. Will hit CRM rate limits at scale. Fixed in
  ([#1811](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1811)): one shared setup source for the shell and the Setup page.
  Idle route requests per Operator tab drop from about 1 to 0.2 per minute on an
  ordinary page, 1.2 to 0.2 on the Setup page, and 5.2 to 0.2 while identity is
  pending. Automatic refreshes skip phone inventory when it was checked under five
  minutes ago; the Refresh status button always checks it.
* **Copy.** ~~GoHighLevel help-center links on the Operator page
  (`SetupWorkspace.tsx:756,779`) — `tests/userFacingCopy.test.ts` doesn't scan URLs;
  extend it.~~ Kept by decision; [#1792](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1792) closed. Each stage has two names (`STAGES[i].title` vs `SETUP_GROUPS[i]`). Website
  task is `owner: "staff"` but the copy tells the Operator to fix it. CRM base URL
  hardcoded twice (`SetupWorkspace.tsx:261`, `OnboardingSetupAdmin.tsx:60`) instead of
  `getAgencyLoginUrl`. Fixed in [#1811](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1811), which carries [#1810](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1810): `SETUP_GROUPS` is the only stage
  name, the website copy says the onboarding team verifies it, and both CRM links use
  `getAgencyLoginUrl`. The Operator guides (`docs/operators/getting-started/onboarding-setup.mdx`,
  `shared-readiness.mdx`) still tell Operators to fix the website themselves; they
  need a follow-up edit.
* **Dead code.** Operator reads throw when Labs is off (`convex/onboardingSetup.ts:69`),
  so every operator-side `!data.enabled` branch is unreachable (e.g.
  `SetupWorkspace.tsx:500`); route `search` op / `searchNumbers` unused
  (`app/api/onboarding/setup/route.ts:80-85,147`, `convex/onboardingSetup.ts:790`);
  `/onboarding/fm` wizard has no inbound links. The review's assumption that legacy
  `components/launchpad/*` was live for non-Labs Locations was incorrect: Kevin
  confirmed on 2026-09-30 that Launchpad and the legacy onboarding tables were never
  used. [#1811](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1811), carrying [#1810](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1810), removed the unreachable Operator
  `!enabled` branches and route `search` op. Launchpad retirement is **In review**
  ([#1822](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1822)): current setup moves to `/onboarding/setup`, with the existing
  Labs gate, session handling and iframe policy. `/onboarding/fm` stays: it is a
  separate public survey flow, does not call the legacy onboarding functions and
  ends at external tech-call scheduling. Public provisioning enters through the CRM
  sign-in link, then the authenticated setup navigation. Still open: delete unused
  `searchNumbers`.

## Cross-cutting architecture

* ~~**Provider clients.** \~11 hand-rolled `fetch` wrappers to
  `services.leadconnectorhq.com` / `api.stripe.com` across `convex/onboarding*.ts`
  with differing timeouts, errors and no 429 handling, bypassing
  `convex/lib/ghlApi/client.ts` (has 429/5xx retry) and `convex/lib/stripeClient.ts`.
  Stripe key fallback chain duplicated (`convex/onboardingAccountLink.ts:45`,
  `convex/onboardingIdentity.ts:14`). Consolidate to one `crm(ctx, locationId, version)`
  and one `stripe()` helper with typed `{ retryable }` errors; keep Version a
  parameter (versions legitimately differ per endpoint).~~ Fixed in
  [#1804](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1804) (one CRM custom-field reader) and [#1809](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1809)
  (`convex/lib/onboardingProviders.ts`: only GETs retry on 429/5xx, honoring
  `Retry-After`; PUT and POST are sent once; each caller keeps its error mapping).
  Review follow-ups that fixed CRM read shapes before this:
  [#1801](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1801) (checklist multi-select) and
  [#1802](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1802) (queue linked-account read).
* **Status sprawl — In review**
  ([#1819](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1819),
  [#1822](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1822)). **Decided 2026-09-30 (Kevin):** the legacy
  `onboardingProgress`/`onboardingCalls` tables and Launchpad were never used in any
  capacity. This corrects the review's assumption that they served non-Labs Locations;
  no fallback, migration or backfill is needed. Client health, Location 360 and both
  admin dashboard readers use batch 3's `setupProgress` for every Location. No setup
  shows "Not started" even for a paid subscriber. Output fields/types stay; the old
  completion timestamp is null because setup has no equivalent completion event.
  Each Location uses 1–6 indexed reads (setup plus the existing identity projection),
  with no per-Location table scan. Legacy functions, callers and count retire with
  Launchpad. The two tables and `provisioning.onboardingProgressId` remain, marked
  unused; dropping them is a separate step Kevin approves later.
  KYC still has the CRM field and a Convex `onboardingIdentity` projection/cache after
  merged [#1798](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1798).
  The view follows the observed CRM value and can lag until refresh; no KYC redesign
  is included. CRM queue/progression fields and engine ledgers also remain.
* **Constants.** Operations Location ID defined three times (`lib/onboardingSetup.ts:3`,
  `lib/admin/subscriberOpportunitySync.ts:2`, `convex/admin/agencyMigrationPlan.ts:22`);
  agency ID likewise. Routing IDs in source are fine; centralize them. The Operations
  Location ID is fixed in [#1809](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1809) (both copies point at `OPERATIONS_LOCATION_ID`).
  The agency ID is open: `lib/crm-dashboard-urls.ts`, `lib/ghl-saas-materialization.ts`,
  `lib/mfm-processing-fee.ts` and `convex/lib/partnerAgencyCredential.ts` still
  define their own copies.
* **Readability.** `convex/onboardingAdmin.ts` (1,589 lines, `save` \~340 lines with
  three modes), `components/admin/onboarding/OnboardingQueue.tsx` (1,839),
  `components/onboarding/SetupWorkspace.tsx` (1,356). Code-golf density (e.g.
  `convex/onboardingAccountLink.ts` \~92 chars/line, nested ternaries at `:41-43`).
  Correctness lives in long guard chains a reviewer can't reliably check — the biggest
  long-term risk. Extract named predicates; split by responsibility.
* **Tests.** Convex tests for permissions, concurrency, lost responses and identity
  reopen are good. Weakness: fake CRM (`scripts/onboarding/admin-fixture.ts`) mirrors
  the code's assumptions (date format, PUT merge, note `userId`); no contract tests from
  recorded real CRM payloads at review time. Contract tests are **In review**
  ([#1821](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1821)):
  26 GET responses from Operations and one explicitly mapped pilot Operator Location,
  redacted with stable fake IDs and shape checks. Queue values, identity, Slack,
  account linking, calendar/readiness inputs and engine readers consume those shapes;
  the admin fake uses recorded date, multi-select and note-author shapes. Recorded
  notes have `userId: null`; synthetic staff-note examples do not verify POST echoes.
  The pilot's phone GETs returned 404/422, so successful phone payloads remain a gap.
  PUT merge/replace behavior and other write-side assumptions remain manual checks
  listed in #1821; none was exercised by this read-only recording. Recorded 422s also
  exposed obsolete snake-case opportunity-search parameters in identity/account
  linking; that fixture-backed fix is **In review** ([#1823](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1823)).
  A recorded terminal response also has a nonempty `nextPageUrl` with an empty
  `nextPage`; stopping queue pagination at that terminal page is **In review**
  ([#1824](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1824)).
  Missing route-auth and outside-agency/transferred-out setup refresh coverage is
  **In review** ([#1825](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1825)); this does not close S2's session-minting design issue.
  Copy-string snapshot tests (`tests/onboardingWorkspace.test.ts`) still lock wording,
  not behavior. Earlier regression coverage remains for `useSetup` concurrency,
  shared progress, draft revision, Slack unmapped opportunities, permanent webhook
  failures and 429 handling; the new route tests do not replace those tests.
* **Docs.** \~3.6k lines across 15 onboarding docs, several reading as session diaries
  (and one names customers and staff). Consolidate to one integration map/runbook plus
  the Mintlify staff and Operator guides. Fixed in
  [#1806](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1806): `docs/internal/onboarding-integrations.md` (map) and
  `docs/internal/onboarding-runbook.md`, with roles instead of names, the Mac mini
  cutover path marked superseded, and cloud cutover linked to
  `services/onboarding-engine/docs/RUNTIME.md`.

## What to keep

* Server-derived actor/Location on the Operator route with a second Convex check
  (`convex/onboardingSetup.ts:62`); staff-only fields projected away (`:178-206`).
* Opportunity ID is the only client input on admin writes; Location derived from CRM
  and cross-checked.
* Claim-then-readback writes, no POST/PUT retry, uncertain outcomes recorded.
* Phone purchase claim serialization (no double billable POST), live re-check,
  audited release.
* Identity: 35-minute lock > 10-minute action limit, generation fence, idempotency
  keys, old-session webhooks ignored after reopen, `verified_outputs` never stored.
* Engine rules as pure functions shared by both runtimes; E2-before-leave-Compliance,
  never move a human-moved card back, Live requires QA.

## Recommended sequence

1. Safety PRs, each small with a regression test. Done: S1, S3, S4, S5, S6, S7, S8,
   S9. Still open: take S2 to Brock as a design decision.
2. One CRM client + one Stripe client; centralize IDs. Done
   ([#1804](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1804), [#1809](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1809)) apart from the agency ID copies.
3. One progress model: extend `SETUP_TASKS` into the single source of truth; delete the
   other three computations. The data hook that queues rather than drops, and the switch
   from focus to visibility refresh, are done ([#1797](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1797)). The model and
   the polling cut are merged ([#1811](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1811)); admin KPI adoption and legacy retirement are in review above.
4. Engine: the cloud runtime, stage ownership and the DND rule are decided (see
   Communications engine) and applied ([#1807](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1807)). The standalone
   executor strip and the cutover runbook that replaces the single-writer guard are
   merged ([#1812](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1812)). Compute mode merged in [#1818](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1818); removal of the snapshot-import machinery and
   separate shadow runner is in review ([#1820](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1820)). The
   `matchesWrite` fix (E2) is done ([#1796](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1796)).
5. Recorded payload contracts are in review (#1821); complete the remaining provider
   evidence/manual checks before enabling `ONBOARDING_ADMIN_CRM_WRITES_ENABLED` or
   auto-progression. Wave 2 implements the reverse-state decisions, then splits the
   large files. Legacy table deletion stays a separate approved step. Docs consolidation is done
   ([#1806](https://github.com/Funding-Machine/ghl-leadbuilt-marketplace-app-fundingmachine/pull/1806)).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.