Skip to main content

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 and 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

Batch 3 (architecture and cleanup), by section below: Batch 4, wave 1 (September 30, 2026): 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

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

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

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

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

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

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). 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. 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.
  • 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 (merged) removed the queue edit; #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.
  • One malformed record throws for the entire queue page (convex/onboardingAdmin.ts:363,376) instead of flagging the row. Fixed in #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). 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, which adds a per-agency lease and timeouts and now carries #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; 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. 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 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: 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): 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); the mini retires and the native CRM workflows are turned off by the cutover runbook (#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: 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 (with #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: 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.
  • 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). Compute mode merged in #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): 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.
  • 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): 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 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, which carries #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, carrying #1810, removed the unreachable Operator !enabled branches and route search op. Launchpad retirement is In review (#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 (one CRM custom-field reader) and #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 (checklist multi-select) and #1802 (queue linked-account read).
  • Status sprawl — In review (#1819, #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. 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 (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): 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). A recorded terminal response also has a nonempty nextPageUrl with an empty nextPage; stopping queue pagination at that terminal page is In review (#1824). Missing route-auth and outside-agency/transferred-out setup refresh coverage is In review (#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: 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.
  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, #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). The model and the polling cut are merged (#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). The standalone executor strip and the cutover runbook that replaces the single-writer guard are merged (#1812). Compute mode merged in #1818; removal of the snapshot-import machinery and separate shadow runner is in review (#1820). The matchesWrite fix (E2) is done (#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).