Skip to main content

Processing Application gate cutover

The gate is off by default when processingApplicationGate is absent — existing never-cutover locations stay ungated. Brand-new location installs are different: the signed marketplace INSTALL notification for a real sub-account requests provisioning of the gate (resolves the per-location FM Processing Application form id, writes an enabled gate with an empty grandfather list and a capture-evidence waiver under system:new-install). Admins do not need the cutover batch script or /api/admin/processing-application-cutover for a fresh install. The verified notification is stored in the location’s token-free installation history, including when it arrives without OAuth tokens or a company ID. Provisioning is scheduled atomically once both that event and the location’s credentials are present, regardless of which arrives first. Inserting a token row during lazy agency provisioning or the SaaS materializer is not an installation event and cannot request activation by itself. The event binds to one installation row; duplicate notifications and jobs from a removed row cannot activate a replacement row. Uninstall cancels pending intent even if credentials never arrived; duplicate notifications cannot revive it. The cancellation remains in token-free history. If the snapshot form or CRM token is not ready, it retries after 1, 5, 15, and 60 minutes, then every six hours until the form resolves or the location is uninstalled. Duplicate forms require a configuration fix; no arbitrary form is selected. Any existing gate configuration (including an explicit disable) is preserved. Reauthorization does not start a new job, and no fleet-wide sweep retroactively activates existing locations. The Labs rollout (#1552) removed the original new-install provisioning from #1544 while leaving these docs describing it. Automatic provisioning is now restored for verified marketplace installs; the Labs New agreements setup card reflects that same gate, so no separate Labs flag or manual opt-in is needed. Manual enable below remains the path for the pre-existing fleet. Deploying the capture, access, and cutover changes alone does not block any operator on those locations. Enable one legacy location only after completing the sequence below.

What enabling blocks

Submitting a contact to processing requires a captured Processing Application, an active grandfather override, a legacy agreement — a completed agreement sent before the location’s gate was enabled — or a Client App + Agreement upload — a manually verified uploaded agreement classified under a Client App
  • Agreement category. This covers every processing type, including term loans — submitting term loans to 7 Figures is a handoff, so it carries the client’s own attested answers like any other handoff. Term loans were exempt until Jul 28 2026.

The legacy-agreement exemption

Clients whose agreement went out before activation received old templates with no application link, so the requirement cannot apply to them retroactively. The submit gate exempts a contact automatically when the newest completed agreement was sent (createdAt) before the gate state’s verifiedAt. The contact page renders such contacts exactly like grandfathered ones, and each submission records how its gate passed in applicationGateReason (legacy-agreement marks these). Consequences worth knowing:
  • The in-flight review is no longer required for activation. Contacts mid-deal at enable time are exempted by their own agreement date. The candidates script and grandfather overrides remain available as an escape hatch (e.g. a contact whose paperwork must be re-sent after enable but who should not collect an application), not as a rollout step.
  • The exemption is permanent for old paperwork, unlike overrides, which expire after 90 days. A stalled old deal can still submit without an application months later; forcing it through the gate would demand paperwork the system never sent.
  • Re-signing new paperwork re-arms the gate. The newest completed agreement decides, so a client who completes a post-activation agreement is gated normally.
  • A disable→re-enable cycle re-exempts agreements sent while enforcement was off (verifiedAt moves forward), which mirrors what re-grandfathering did by hand.

The client-app-agreement exemption

The old “Client App + Agreement” templates carried the client’s application inside the agreement itself. A client can return such a signed copy outside the CRM after the location has moved to the linked Processing Application (for example, they signed the older agreement separately before the new one went out). The operator uploads that file on the contact and classifies it through Classify as Agreement with an agreement status of Complete and either checks This document also contains the client’s signed application (agreementIncludesApplication) or picks a Client App + Agreement (Personal | Business) category. The checkbox covers clients who return one combined agreement + application PDF under any category. Once that upload is the newest completed agreement, the gate treats the application as on file: the contact page renders it like a grandfathered contact, the Resend Processing Application action is withheld, signed-agreement opportunity conversion proceeds, and the submission records applicationGateReason: "client-app-agreement". Scope and limits:
  • Manually verified uploads only. The category on a CRM-sent row proves nothing — the legacy send form defaulted it and post-cutover sends record none — so CRM-synced agreements keep the normal gate, the override, and the legacy send-time exemption.
  • Agreement Only categories do not qualify on their own. They never carried an application; only the explicit checkbox exempts them. Reclassifying the category or toggling the checkbox flips the exemption in both directions on the next read.
  • The newest completed agreement decides, as with the legacy exemption. A client who later completes a post-activation CRM agreement is gated normally.
  • Marking the upload complete still requires Approved review status, a rate, a type, a signed date, and the operator’s confirmation checkbox (convex/lib/manualAgreementMetadata.ts).
Generating term-loan offers through Engine is not affected: pulling rates is not a handoff, so operators can still generate offers while an application is outstanding. The funding agreement is a separate gate that is always on and was never scoped by processing type. A contact needs both a completed agreement carrying a rate and an application before any processing submit succeeds.

What the operator sees

Enabling changes the funding-workflow checklist between the agreement send and the review:
  • Send client agreement & application replaces “Send client agreement”. The application link is a custom value inside the agreement template, so one send delivers both.
  • Once the agreement is out, that row nests a Waiting on client cluster with two rows — Awaiting signed agreement and Awaiting processing application — each carrying its own resend button. The rows are derived from CRM state and have no checkbox: the authoritative gate is the Convex check on the captured application, and a tickable row could claim a file was ready when a submit would still be rejected. Resend Application lives here rather than on the review step, so the cluster stays visible while either artifact is outstanding — even if the operator ticked the review early — and disappears only once both have landed and the review is cleared.
  • Review agreement & processing application replaces “Review signed agreement” and covers both artifacts in one review call. Unlike the gate-off agreement review, it does not auto-complete when the paperwork lands — the operator clears it by hand once both artifacts are in.
  • The Application tab keeps Resend Application available after a submission is captured under the prompt Missing or incorrect information? Use it when the client needs to correct an answer. It retriggers the existing CRM-tag workflow; Funding Machine does not send a direct SMS or email.
  • Beneath Resend Application (empty and captured states), operators can copy the Location’s FM Processing Application form link and send it manually (text / chat) when the CRM resend path isn’t the right channel. The link is the same branded share URL used by the contract-redirect walkthrough.
  • Its checkbox is disabled until both artifacts arrive, with a tooltip naming what is still missing. You cannot review paperwork that has not shown up, and ticking it early used to march the checklist onto a submit the server gate rejects. Un-ticking stays available, so a file whose review completed before the gate was switched on is never frozen. This is a UI guardrail only — the authoritative check is the Convex gate at submit time.
  • While the review is outstanding, the pinned banner names the missing artifact and offers Message Client instead of prompting a review of paperwork that has not arrived.
  • First-send walkthrough (contract redirect). The first time an operator opens the agreement send flow at a gate-enabled location that has not yet confirmed its post-signing redirect, a walkthrough dialog opens ahead of the send form. It explains the sign-first flow, shows the location’s own form URL with a copy button, and walks through configuring Enable redirection to custom URL on both Client Agreement templates. Enforcement is soft: Set up later always proceeds to the send, and the walkthrough returns on every send until the operator attests setup on both templates. The attestation is per location, recorded with who confirmed and when (locationSettings.featureFlags.contractRedirectSetup), and reachable afterwards via the “Redirect setup guide” link in the send modal footer.
  • On term-loan files the offers step stays reachable during the wait, because pulling rates from Engine is not a hand-off. Only Generate Offers is offered early; Submit Term Loans to 7 Figures Funding is withheld until the review is genuinely current, since that is the hand-off the gate exists to hold back.
  • CRM opportunity conversion waits for both artifacts. At a gate-enabled location, completing the agreement alone does not move the Applicants opportunity to Won - Signed or create the Clients opportunity. Conversion runs once the completed agreement and captured Processing Application are both present, regardless of which arrives first: Applicants becomes Won - Signed with status Won, then an idempotent Clients opportunity is created at Agreement Signed. A valid grandfather override also satisfies the application side. Pre-cutover agreements keep the legacy-agreement exemption, and gate-off locations keep signature-only conversion.
If an application is genuinely in hand but never synced, the documented override is grandfathering the contact, which returns the whole page to gate-off behavior — not ticking through the checklist. Gate-off locations see none of this; the checklist is unchanged. Built: at gate-enabled locations the Send Agreement popup offers only Capped / Non-Capped. The Business/Personal category and the upfront-payment section are hidden, and template matching selects the post-cutover names (Client Agreement - Capped / Client Agreement - Non-Capped) via findCutoverAgreementTemplate. Gate-off locations keep the legacy popup. Deploy this before enabling locations beyond the pilot. Established funding is exempt. It is a referral to a lending partner: the file never reaches 7 Figures, so the application it would collect is never read and the agreement isn’t required either. Established cards therefore render the plain gate-off spine — no relabelled send step, no waiting rows, no banner wait, and a freely tickable review — so the path is never parked on paperwork it has no use for. The exemption is display-only: completedWorkflowSteps is shared across tracks, so on a dual-track file the creative card still enforces everything above. An application may still be collected on an established file if the CRM template sends the link; stopping that is a template change, not an app change.

Preconditions

  1. The location installation has Forms read access and has been re-authorized if the scope was added after installation. Confirmed for every install as of Aug 6 2026. forms.readonly is in the requested scope list and every location has been re-authorized, so this is not an open question — verify it per location only through inspection.formId on the configuration check below.
  2. The location has exactly one form named FM Processing Application. The form itself is built.
  3. Capture requires no form-submission workflow. On an authorized contact read, the app resolves the location’s form and polls the Forms API for that exact contact. A successfully submitted, non-empty snapshot is the completion evidence. See How capture works below.
  4. A contact-tag workflow listens for fm-resend-processing-application and sends the client the Processing Application message using the Processing Application Form Link custom value. Built.
  5. The Processing Application Form Link custom value is created or refreshed by the app before every agreement send. Built. Delivering the application link inside the agreement email is no longer the plan — GHL provides no way to change the default email template used when an agreement is sent — but the custom value remains load-bearing for the resend workflow (precondition 4).
  6. The post-signing redirect is the delivery mechanism. Documents & Contracts supports Enable redirection to custom URL per template: when the client finishes signing, GHL sends them to the location’s Processing Application form. The setting lives in each operator’s own sub-account, on both Client Agreement templates (Capped and Non-Capped), takes a raw URL only (no custom values), and has no API to configure or verify it — so each operator sets it up once by hand, trained by the first-send walkthrough (see What the operator sees) and recorded as a per-location self-attestation. Template re-pushes from the snapshot do not clobber the setting (tested Aug 13 2026). Until a location’s redirect is set up, clients sign and stop there; the operator recovers with Resend Application, and the submit gate still enforces the captured application either way.
  7. (Optional since the legacy-agreement exemption.) Contacts already at or beyond the processing handoff are exempted automatically by their agreement date, so the per-contact review below is no longer an activation requirement. Run it when you want visibility into the population, or to grandfather a contact the exemption does not cover. Generate the candidate list with
    which replays the gates in the order the submit path runs them. Do not export by pipeline stage: the agreement gate runs first, so a contact in the right stage without a completed, rated agreement needs no override, while one in an unexpected stage with a signed agreement does. The script reports contacts still in flight separately from those whose processing has already finished, because the latter only meet the gate on a new submit. Processing here spans every type: a contact whose cards are done but whose term-loan handoff is still ahead counts as in flight. A third bucket, needs manual review, holds contacts whose submission history exceeded the per-contact scan limit with nothing open observed: the review cannot tell whether processing is still open, so those contacts are left out of the recommended override list (and out of the --out file’s recommendedContactIds) and listed under needsManualReviewContactIds instead. Check each one in the CRM and grandfather it only if it is genuinely still in flight. Activation reconciles the reviewed list against active overrides. To review the whole fleet in one pass instead of one location per run, use
    which iterates every installed location, resolves each form ID through the same admin configuration check, runs this same review, and writes a consolidated report of which locations are misconfigured, need overrides, need a live capture, or are ready to enable. With --apply-overrides it also creates the recommended overrides through the audited admin endpoint. See the script header for the required admin session token.

Scaling the capture trigger beyond the pilot

The per-sub-account workflow does not scale and is not the rollout plan. There are 150+ locations and several more are added every week. The bearer the webhook expects is derived per location, so a cloned workflow carries the pilot’s bearer, posts its own locationId, and is rejected with 401 Unauthorized. That rejection is deliberate — one shared bearer would let any sub-account record submissions against any other — and tests/processingApplicationCapture.test.ts covers it under rejects a token derived for a different location. The consequence is that per-location rollout means hand-entering a distinct secret into 150+ workflows and then into every new location on an ongoing basis. Treat that as unavailable.

There is no marketplace webhook for this

Checked in the marketplace dashboard on Aug 6 2026: the OAuth app’s webhook subscription list offers no FormSubmission event, and no Document events either. Do not plan around subscribing to one. More fundamentally, this marketplace app has a single webhook URL: /api/lb/webhook. Every subscribed event type — INSTALL, UNINSTALL, ContactCreate, ContactTagUpdate, Appointment*, Invoice* — arrives there and is dispatched on type. Any route expecting GHL to post marketplace events to its own dedicated path receives nothing. That makes app/api/webhooks/ghl-document-status/route.ts dead. It verifies marketplace ED25519 signatures and its header comment instructs you to register document.sent, document.signed, and friends against its own URL. No such events exist, and even if they did they would land on /api/lb/webhook. Production logs show no traffic to it. Its header comment is wrong; do not copy it as a pattern.

Document statuses are polled, not pushed

Agreement status works because of polling, not a webhook. PR #882 found the real cause of Sent→Signed never updating: GHL caps GET /proposals/document at limit 21 and returns 422 above it, so every status poll silently failed and the badge fell back to the send-time value. Fixing the page size and a string-vs-array status bug fixed the display. app/api/ghl/proposals/route.ts resolves live status on read, using each location’s own OAuth token. app/api/webhooks/ghl-workflow/document-status/route.ts does exist and uses a single shared bearer (GHL_WORKFLOW_WEBHOOK_TOKEN), but production logs show no meaningful traffic to it. It is not what makes document status work, and it is not evidence that the shared-bearer workflow pattern is proven at this scale.

How capture works

Capture is a read-triggered poll, mirroring how agreement status works. It requires no per-location setup of any kind. GET /api/processing-application returns the contact’s gate context. After the initial authorized read, the route calls pollProcessingApplicationCapture, which resolves that location’s own FM Processing Application form using that location’s own OAuth token, asks the Forms API for that contact’s submissions, and records the snapshot. If it records something, the route re-reads and returns the newest snapshot by submittedAt. This poll still runs when an older application is already captured, so corrected answers append a new immutable row instead of replacing history. Consequences worth knowing:
  • Every location is covered, including ones installed tomorrow. There is no workflow to clone, no bearer to distribute, and no snapshot to update.
  • The delivery-relative window does not apply. A read has no event to correlate against, so submittedAfter is omitted and applications older than 10 minutes are captured normally. The ambiguity failure mode and the localStorage._ud retry trap only ever applied to the webhook path.
  • Already-captured submissions are still excluded, so re-reads dedupe rather than writing duplicate snapshots.
  • A poll failure never breaks the read. It is logged and the existing context is returned, including field-label repair when needed.
  • Capture runs regardless of gate state, so the 7-day capture evidence that activation requires accumulates on its own before cutover.
The cost is latency and API traffic: capture happens when something looks at the contact rather than the instant the client submits. The Forms lookup is filtered server-side by contact and excludes captured submission IDs, so a poll is normally a single small API call. Repeat reads are damped by PROCESSING_APPLICATION_POLL_TTL_MS (5 minutes), but be precise about what that buys: the throttle is in-process, so each serverless instance keeps its own window and the real floor is roughly one poll per contact per 5 minutes per warm instance, not per contact globally. The resolved form ID is cached per location for an hour — and, since Aug 2026, a missing form is cached for 5 minutes too, so locations that have not built the form yet do not rescan every form page on each read. If Forms rate limiting ever becomes the constraint, move the throttle into Convex rather than lengthening the TTL. The pilot’s webhook (/api/webhooks/ghl-workflow/processing-application) still works and shares the same capture core. It is now redundant. Leave it if you like; do not build more of them.

Answers are named by the form that was submitted

The Forms API keys an answer by form field id, and those ids belong to one form: rebuilding a form, or building the same form in another sub-account, mints new ones. A hardcoded id map therefore labels exactly one form until it is rebuilt, which is why the Application tab rendered rows of raw ids — including at the pilot, whose form was rebuilt after FORM_FIELD_KEY_ALIASES was written. The form is the only place that bridge exists. GET /forms/ returns just { id, locationId, name }; GET /forms/{formId} is rejected by the CRM’s IAM service; and the location’s custom fields are keyed by custom field id, which is not the form field id an answer carries (checked live on Aug 20 2026: none of the pilot’s 52 unnamed answer keys appear among that location’s custom fields). So labels are read off the rendered form at /widget/form/{formId} — the same page the client filled in — cached an hour per form. Each field is wrapped in id="el_{formId}_{fieldId}_{position}" and holds its own label; the id comes from that wrapper rather than the label’s for, because a date field’s label points at its date picker (<label for="98dd-d444-NativeDatePicker">) while the answer is keyed by the field id. A label takes the first of: our curated label for that key, the form’s own label, then the raw key. FORM_FIELD_KEY_ALIASES stays as an offline fallback. When a form label uniquely matches one of PROCESSING_APPLICATION_FIELD_LABELS after punctuation/case normalization, the resolver also carries that canonical key in memory. Captures use it for canonical ordering and duplicate checks, and stored snapshots use their repaired label as the same bridge on later reads. This keeps consumers such as the HELOC Prescreen’s application-address signal working after a form rebuild without changing the immutable stored field id. See docs/plans/heloc-process.md; Eligibility Confirmation now owns the definitive property attestation. Naming never blocks a capture — the gate depends on the snapshot existing — so a failed lookup still records the snapshot. Any snapshot whose answers still read as ids is repaired on the next authorized read of that contact, which means snapshots captured before this shipped fix themselves the first time someone opens the Application tab. The repair rewrites labels only: keys and values, on both the working copy and the preserved original, are untouched, and it is not recorded as a processor edit. buildProcessingApplicationFormUrl builds every client-facing form link on the location’s own branded domain (location.domain from GET /locations/{id}, e.g. connect.veloce-inc.com), falling back to api.leadconnectorhq.com when the location has no domain or the lookup fails. This covers the synced custom value (and therefore the resend workflow message) and the redirect URL the walkthrough displays. Branded serving of /widget/form/ was verified live on Aug 13 2026. A domain change propagates on the next agreement send — the form-link sync cache invalidates when the resolved URL changes, not only when the form ID does. This is deliberately scoped to Processing Application links: Partner Module signup links stay pinned to the unbranded host per the Brock invariant in lib/crm-form-widget-url.ts (branded domains intermittently failed to serve the signup form there, Aug 6 2026).

Per-location token mechanics (pilot and debugging only)

Each location’s token is HMAC-SHA256 over processing-application:<locationId>, keyed by GHL_PROCESSING_APPLICATION_WEBHOOK_SECRET, encoded base64url. The app never hands it out; derive one only when maintaining the optional pilot webhook.
Treat these tokens as secrets: never commit one and never paste one into a shared doc or ticket. Rotating the shared secret invalidates every location’s token at once.

Check configuration

From Locations → App cutover controls in the FM Admin dashboard (/admin/locations?cutover=1), turn on cutover mode on the sub-account list, select one or more locations, and click Check forms. The table shows stored cutover state on load and live form resolution (including duplicate detection) after a check. An enabled location whose live FM Processing Application form ID no longer matches the gate shows Stale form ID — that is the same mismatch that hides a captured application on the Application tab. The Overrides column and Has active overrides filter list locations with non-expired grandfather overrides from Convex (refreshed on load and when you click Refresh gate state). A Vercel cron (/api/cron/processing-application-form-audit) repeats that live form check twice a day (05:00 and 17:00 UTC) for enabled locations only. It stages inspections in batches of 100 and publishes the run only after every batch succeeds, so the admin table never mixes inspection state from a partial run. It posts an ops Slack alert when any gate form ID has drifted, gone missing, or become duplicated. Findings are computed before those writes, so a later persist failure still alerts and then returns 500 while the prior completed inspection remains visible. It does not rewrite processingApplicationGate.formId; re-enable from this page after reviewing. Kill switch: MFM_PROCESSING_APPLICATION_FORM_AUDIT_ENABLED=false. For a single location from an authenticated admin browser session, the API still works directly:
Run this from the Funding Machine frame using an internal admin session. Do not continue unless inspection.formId is populated and inspection.error is absent.

Grandfather in-flight contacts

Create one explicit override for each reviewed in-flight contact:
Overrides are idempotent and remain separate from captured application data. They record the verified admin identity and reason, are bound to the currently verified form, and expire after 90 days. Recreate an expired override only after a new review. reason is trimmed before the 5–500 character check, so whitespace-only or padded-short input is a 400 and never reaches the mutation. renew: true is only valid against an active override for the current live form; a missing, expired, or replaced-form override returns 409 { code: "renewal_not_active" } with nothing written — renewal never falls through to a first-time insert. Without renew, the action never restarts the clock on an active override: the server returns { deduped: true, expiresAt } and leaves the stored expiry untouched. Renewal is a separate, explicit intent — send renew: true and the server resets the 90-day window and reason, returning { deduped: false, renewed: true, expiresAt }. Every successful response includes expiresAt so the caller can read the expiry back. “Active” means the same thing everywhere — the gate, the cutover list, the per-contact readback, and the write all use isActiveProcessingApplicationOverride and effectiveOverrideExpiresAt (expiresAt, or createdAt + 90 days for rows written before form/expiry scoping). Preserving an active legacy row also writes its explicit formId/expiresAt without moving the expiry; that is a normalization, not a renewal. Compare-and-set. Interactive callers send expectedExpiresAt: the active expiry they last read back, or null if none was shown. If the stored active expiry no longer matches, the server writes nothing and returns 409 { code: "override_changed" }. This is what makes a retry after a timed out request safe — if the first attempt committed late, the retry is refused instead of renewing a second time. Callers that omit expectedExpiresAt (the batch script) skip the check and rely on deduped.

Per-contact admin UI

Admins can grandfather (or renew) a single contact from the Application tab of /admin/internal/processing and from /admin/internal/application. The panel shows the exact locationId and contactId it will act on, reads the stored override on load (GET …?locationId=…&contactId=… returns override, overrideActive, and the live form inspection), and never writes on page load.
  • First-time grandfather: shown when there is no override, or the override is expired or bound to a replaced form. Requires a 5–500 character reason and a confirmation, then posts action: "grandfather" without renew.
  • Renewal: shown only when an active override exists. The existing expiry, reason, and form ID are displayed; the button and confirmation copy say “renew”, and the post includes renew: true. Cancelling keeps the current override as is.
  • Unknown outcome (network failure, 5xx, unreadable response): the panel blocks resubmission until it has re-read the override for that exact contact. A readback showing the expected result (active; for a renewal, a later expiry than before) confirms success. A readback that does not show it is reported as not confirmed — it is not treated as proof the write failed, because the request may still commit. The panel pins the original operation (intent, the expectedExpiresAt observed before the write, and the audit reason as confirmed). “Retry” re-sends exactly that request — never an expiry read afterwards or an edited reason — so a late first attempt turns the retry into a 409 rather than a second renewal, and the audit trail matches what the operator confirmed. The reason field is read-only while pinned; Dismiss releases it. A later readback that shows the expected result resolves the state to success; only an explicit dismiss releases the pinned operation.
  • Switching contacts while a request is in flight drops that request’s completion — even when the operator returns to the same contact before it finishes. Each visit is a new epoch; completions from an older epoch never update the panel or trigger a readback. The current view reads the server’s truth on its own.
Grandfathering waives the Processing Application workflow requirement only. The completed-agreement requirement is unchanged: a “viewed” agreement or an opportunity in a Won stage still does not satisfy it. Overrides created before form/expiry scoping remain valid only for 90 days from their original creation time. Re-grandfather those contacts to write the explicit form and expiry fields before the compatibility window closes. Activation reconciles at most 1000 active overrides in one call and rejects a list that does not match them exactly. A location with more in-flight contacts than that cannot be activated until the population is narrowed.

Prove polling capture

Submit the live Processing Application once for a designated test contact and confirm in the CRM that the submission belongs to that exact contact. Then open or reload the contact in Funding Machine. The read polls the Forms API and the snapshot should appear in the Application tab, even while the gate is off. Activation requires a successful authenticated capture for the verified location and form within the preceding 7 days. Reopening an already-captured contact does not refresh that evidence, so use a new uncaptured test contact if the window expires. Freshness only proves the pipeline works with the current form — evidence is bound to the exact form ID, so a replaced form voids it regardless of age.

Waiving capture evidence

For locations whose form is a snapshot-managed clone of one already proven end-to-end, an admin may skip the evidence check by adding captureEvidenceWaived: true and a waiverReason (10–500 characters) to the enable call. The waiver is per-activation, never a standing setting: it is recorded on the gate state (captureEvidenceWaivedAt, captureEvidenceWaiverReason) and on the immutable enabled audit event, and an evidence-backed re-enable clears it. Every other activation safeguard — live form access, override reconciliation, form-link sync, final revalidation — still runs. Waiving accepts the risk that a location-specific capture failure (e.g. a form whose fields do not parse) surfaces only after operators are blocked; the recovery is the disable action or a per-contact grandfather.

Troubleshoot a failed capture

First confirm that the CRM activity shows the form submission on the same contact ID Funding Machine is reading. If the first read happened before the Forms API exposed the submission, wait five minutes for the in-process poll throttle to expire and reload once. A poll failure is logged but does not fail the contact read. The optional pilot webhook is not part of rollout. If debugging that legacy path, its CRM workflow execution log shows the response body:
  • 400 Invalid payload — the body must omit submissionId entirely when the workflow cannot supply one. An empty string is rejected.
  • 401 Valid session required — the request never reached the route. The path must be exempt from the session middleware (lib/proxy/isExemptPath.ts).
  • 401 Unauthorized — the bearer does not match the token derived for the locationId in the body. On a newly rolled-out location this is almost always a workflow copied from the pilot with the pilot’s bearer left in place.
  • 409 form_mismatch — the body’s formId is not the location’s own FM Processing Application form, re-checked against a forced refresh. Same cause: a copied workflow still pointing at the pilot’s form.
  • 409 ambiguous_submission — the contact has more than one uncaptured submission, or an uncaptured one older than the fallback window.
  • 409 configuration_error — Forms read access is missing, or the location does not have exactly one FM Processing Application form.
  • 422 invalid_submission — the submission could not be turned into a field snapshot. The logged message names the offending key.

Enable

After polling capture has been checked (or deliberately waived):
grandfatheredContactIds must exactly match the location’s active overrides — an empty list when none were created, which is the normal case now that in-flight contacts are exempted by agreement date. Activation bypasses the form-ID cache and re-checks live form access. It also requires a captured form from the preceding 7 days (unless waived) and an exact match between the reviewed grandfather list and active overrides. To activate many locations in one pass, use the batch script’s --enable (and, when accepted, --waive-evidence "<reason>") flags — the server re-runs every safeguard per call either way. Smoke-check one pilot location first. The stored gate state records the resolved form ID, verification timestamps, and verified admin identity. Enable and disable transitions also append immutable audit events instead of relying only on the current settings row.

Smoke checks

Verify all three paths before widening rollout:
  • A newly captured application can submit to Processing.
  • A contact whose completed agreement predates activation can submit to Processing without an application (legacy-agreement exemption), and its submission records applicationGateReason: "legacy-agreement".
  • A contact whose newest completed agreement is a manually verified upload classified under a Client App + Agreement category, or marked as containing the application, can submit to Processing without an application, and its submission records applicationGateReason: "client-app-agreement". The same upload under an Agreement Only category without the checkbox stays blocked.
  • A grandfathered in-flight contact can submit to Processing.
  • An expired override or an override from a replaced form does not bypass the gate.
  • A contact whose newest completed agreement was sent after activation and who has neither a captured application nor an override is blocked with the Processing Application requirement.
  • Resend Application, on the Awaiting processing application row, removes and re-adds the workflow tag and produces a new client message with a working form link.
  • Resend Application remains available in the Application tab after capture; a corrected submission appends to history and the newest submittedAt snapshot is displayed.
  • The review step stays open after both artifacts land and only clears when the operator checks it.
  • Operators can read the captured snapshot and processors can edit only the audited working copy from the Application tab.

Roll back

Disable enforcement without deleting snapshots or audit records: