IMM-01 · Operator intake (start a pass application)
SOP:
SOP_Immigration_Applications.md§8 / Step 1Actors: IMM Operator —admin@spade.local(PLATFORM_ADMIN) in dev; productionIMM_OPERATOR. Pre-state: ALIVEemployer entity and an applicantPartyexist under the tenant. JurisdictionimmThresholdsconfigured (or fallback 5000 SGD applies). Post-state: OneIntakeRequest(channelPORTAL) + oneAgentRunofIMM.PREPARE_APPLICATIONqueued. The run advancesclassify → collectApplicationFormand pauses atAWAITING_CLIENTwith aFormInstance+ magic link issued.
0. Prerequisites
- Environment up (Docker: postgres on 5433, MinIO, Mailpit). DB is
breezycorp_p0. - Logged in as
admin@spade.local(dev seed:DevAdmin-Password-1!). - An employer entity (use the seed SG smoke group entity, or create one at
/dashboard/groups). Note itsentityId. - An applicant
partyId(aPartyrow with a linkedPerson). The intake form accepts a free-textparty_...id.
1. Steps
1.1 Start the application (Web — recommended)
- Navigate to
/dashboard/imm. - In the intake form card select the employer entity, enter the applicant partyId, choose a pass type (e.g.
EMPLOYMENT_PASS), enter an application reference, and optionally the monthly salary (SGD). - Submit. A toast confirms
Application run queued (intake …). Submit it in the review inbox.
This calls startImmApplicationIntake → POST /ops/imm/application-intake.
1.2 Start via API (equivalent)
POST /ops/imm/application-intake
Content-Type: application/json
{
"entityId": "<entityId>",
"partyId": "<partyId>",
"passType": "EMPLOYMENT_PASS",
"applicationRef": "EP-2026-001",
"monthlySalarySgd": 6500,
"renewalDueDate": "2028-06-01T00:00:00.000Z" // optional
}The handler (apps/api/src/routes/imm/index.ts):
- Guards
requireOperator— onlyIMM_OPERATOR/PLATFORM_ADMIN(403 otherwise; 401 if unauthenticated). - Resolves the entity (404 if not found), records an
IntakeRequest(channel = PORTAL,detectedIntent = IMM.PREPARE_APPLICATION,status = CLASSIFIED). - Enqueues
agent.run-startwithworkflowType = IMM.PREPARE_APPLICATIONand the run input. - Returns
202 { intakeRequestId, status: "queued" }.
1.3 What the worker does next
The agent.run-start handler builds the workflow with collectForm: true and runs:
classify— records pass type / applicant / employer.collectApplicationForm(FORM) — issues aFormInstance(prefilled applicant / pass type / employer / salary) + a 48-hour magic-link token (resourceType = IMM_FORM_INSTANCE), then pauses the run atAWAITING_CLIENT.
2. Verification
Database
SELECT id, status, detected_intent FROM intake_requests
WHERE raw_ref = 'EP-2026-001';
-- expect status = 'CLASSIFIED', detected_intent = 'IMM.PREPARE_APPLICATION'
SELECT id, status, module, workflow_type FROM agent_runs
WHERE workflow_type = 'IMM.PREPARE_APPLICATION' ORDER BY created_at DESC LIMIT 1;
-- expect status = 'AWAITING_CLIENT', module = 'IMM'
SELECT id, status, form_definition_id, expires_at, magic_token_hash IS NOT NULL AS has_hash
FROM form_instances WHERE run_id = '<runId>';
-- expect one row, status = 'ISSUED', expires_at ≈ now + 48h, has_hash = trueThe raw magic-link JWT is not stored — only its SHA-256 in form_instances.magic_token_hash.
Operator UI
/dashboard/imm/applicationslists the new run with its pass type andAWAITING_CLIENTstatus./dashboard/imm/applications/<runId>shows the steps so far (classify,collectApplicationForm).
3. Negative & edge cases
- Unknown entity → 404
Entity not found. - Wrong role → 403
Role <role> may not handle immigration applications. - Idempotent FORM step — a worker restart re-surfaces the existing
FormInstance(issuance is skipped if one already exists for the run). - Mock/dev mode — when the runtime is built without
collectForm, the FORM step auto-satisfies and the run does not pause (used by the reach-the-gate unit tests).
Next
Proceed to IMM-02 · Client form and documents.