IMM-02 · Client form and documents (portal)
SOP:
SOP_Immigration_Applications.md§4 / §8 Step 3Actors: Applicant / Client Submitter — opens a magic link, no login. Pre-state: Run isAWAITING_CLIENTwith anISSUED/OPENEDFormInstance(see IMM-01). Post-state: Required documents uploaded asKycDocumentrows;FormInstanceflipped toSUBMITTED;agent.run-form-resumeenqueued so the paused run continues to the next steps.
0. Prerequisites
- A
FormInstancemagic link for the run. In dev, mint or read the token — the JWT is not persisted, so capture it from the worker logs / form-issue path, or use theresolveFormflow against a known token. - The portal page is
/portal/imm/[token](web); the API prefix is/portal/imm.
1. Steps
1.1 Open the form
Navigate to http://localhost:3000/portal/imm/<token>. The page calls GET /portal/imm/:token, which:
- Verifies the token (
resourceType = IMM_FORM_INSTANCE, carriesrunIdslot = formInstanceId,filingIdslot = tenantId). - Resolves the
FormInstanceby id underwithTenantContext, checksmagic_token_hash, and on first open flipsISSUED → OPENED. - Returns the form
title,passType,prefilledData,submittedData, thefields(withprefillOnlyflags), and therequiredDocschecklist (each withuploaded+fileId).
Prefilled, locked fields (pass type, employer, salary) are read-only; the applicant fills the rest (passport number, nationality, DOB, and pass-type-specific fields — see SOP §4).
1.2 Upload each required document
For each checklist slot, choose a file. The page POSTs multipart to POST /portal/imm/:token/files with a slotKey field:
POST /portal/imm/:token/files
Content-Type: multipart/form-data
file=<binary>
slotKey=passport_bio_pageThe handler:
- Rejects an unknown
slotKey(400) or a missing file/slotKey (400); 413 on a file > 25 MB. - PUTs the bytes to S3 at
imm/<runId>/docs/<ts>_<name>. - Records a
Filerow (bucket, objectKey, sha-256 checksum) and aKycDocumenton the run'sKycCase(created if absent), withverification_status = UPLOADED:<slotKey>. Re-uploading the same slot replaces the prior file. - Returns
201 { fileId, kycDocumentId, slotKey }.
1.3 Save a draft (optional, save-and-resume)
PATCH /portal/imm/:token
{ "submittedData": { "passportNumber": "...", "nationality": "..." } }Saves the partial form data and keeps status OPENED; the applicant can return to the same link later.
1.4 Submit
POST /portal/imm/:token/submit
{ "submittedData": { ...all fields... } }The handler validates every required-doc slot has a file (400 listing the missing labels otherwise), flips the FormInstance to SUBMITTED, then enqueues agent.run-form-resume with { tenantId, runId, formInstanceId }. Returns 200 { status: "submitted" }.
2. Verification
Database
SELECT id, status, submitted_data IS NOT NULL AS has_data
FROM form_instances WHERE run_id = '<runId>';
-- after submit: status = 'SUBMITTED'
SELECT id, doc_type, verification_status, file_id IS NOT NULL AS has_file
FROM kyc_documents kd
JOIN kyc_cases kc ON kc.id = kd.kyc_case_id
WHERE kc.source_run_id = '<runId>';
-- one row per uploaded slot, verification_status = 'UPLOADED:<slotKey>'S3 / MinIO (http://localhost:9001)
- Objects under
imm/<runId>/docs/— one per uploaded document.
Worker
- Within seconds,
agent.run-form-resumefires: the run transitionsAWAITING_CLIENT → RUNNINGand stepscollectDocuments … submitexecute. Watch foragent.run.form_resumedin worker logs.
3. Negative & edge cases
- Expired link (> 48h) →
GETflips the form toEXPIREDand returns 404Form link not found or expired. - Submit with missing docs → 400
Please upload the required documents before submitting: <labels>. Status stays unsubmitted. - Upload after submit → 400
Form already submitted. - Duplicate submit / job retry → the resume handler is a defensive no-op if the run is no longer
AWAITING_CLIENT(agent.run.form_resume.skipped).
Next
Proceed to IMM-03 · Eligibility and review.