Skip to content

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; production IMM_OPERATOR. Pre-state: A LIVE employer entity and an applicant Party exist under the tenant. Jurisdiction immThresholds configured (or fallback 5000 SGD applies). Post-state: One IntakeRequest (channel PORTAL) + one AgentRun of IMM.PREPARE_APPLICATION queued. The run advances classify → collectApplicationForm and pauses at AWAITING_CLIENT with a FormInstance + 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 its entityId.
  • An applicant partyId (a Party row with a linked Person). The intake form accepts a free-text party_... id.

1. Steps

  1. Navigate to /dashboard/imm.
  2. 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).
  3. Submit. A toast confirms Application run queued (intake …). Submit it in the review inbox.

This calls startImmApplicationIntakePOST /ops/imm/application-intake.

1.2 Start via API (equivalent)

http
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):

  1. Guards requireOperator — only IMM_OPERATOR / PLATFORM_ADMIN (403 otherwise; 401 if unauthenticated).
  2. Resolves the entity (404 if not found), records an IntakeRequest (channel = PORTAL, detectedIntent = IMM.PREPARE_APPLICATION, status = CLASSIFIED).
  3. Enqueues agent.run-start with workflowType = IMM.PREPARE_APPLICATION and the run input.
  4. 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 a FormInstance (prefilled applicant / pass type / employer / salary) + a 48-hour magic-link token (resourceType = IMM_FORM_INSTANCE), then pauses the run at AWAITING_CLIENT.

2. Verification

Database

sql
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 = true

The raw magic-link JWT is not stored — only its SHA-256 in form_instances.magic_token_hash.

Operator UI

  • /dashboard/imm/applications lists the new run with its pass type and AWAITING_CLIENT status.
  • /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.

Internal use only — BreezyCorp