Processing Application gate cutover
The gate is off by default whenprocessingApplicationGate 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 (
verifiedAtmoves 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).
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.
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
-
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.readonlyis in the requested scope list and every location has been re-authorized, so this is not an open question — verify it per location only throughinspection.formIdon the configuration check below. -
The location has exactly one form named
FM Processing Application. The form itself is built. - 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.
-
A contact-tag workflow listens for
fm-resend-processing-applicationand sends the client the Processing Application message using theProcessing Application Form Linkcustom value. Built. -
The
Processing Application Form Linkcustom 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). - 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.
-
(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
--outfile’srecommendedContactIds) and listed underneedsManualReviewContactIdsinstead. 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, usewhich 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-overridesit 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 ownlocationId, 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 noFormSubmission 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 capsGET /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
submittedAfteris omitted and applications older than 10 minutes are captured normally. The ambiguity failure mode and thelocalStorage._udretry 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.
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 afterFORM_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.
Form links use the location’s branded domain
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 isHMAC-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.
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:
inspection.formId is populated and inspection.error is
absent.
Grandfather in-flight contacts
Create one explicit override for each reviewed in-flight contact: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"withoutrenew. - 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
expectedExpiresAtobserved 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.
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 addingcaptureEvidenceWaived: 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 omitsubmissionIdentirely 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 thelocationIdin 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’sformIdis not the location’s ownFM Processing Applicationform, 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 oneFM Processing Applicationform.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
submittedAtsnapshot 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.

