---
title: "Interviews & Screening"
slug: interviews-screening
section: Placement & Matching
status: stable
description: "How Globus models sponsor and host interviews as staff-executed back-office work items, with a versioned screening bounded context and append-only evidence — and zero applicant-portal exposure by construction."
last-updated: 2026-10-07
---

# Interviews & Screening

How Globus models sponsor and host interviews as staff-executed back-office work items, with a versioned screening bounded context and append-only evidence — and zero applicant-portal exposure by construction. Live in the `development` environment on Globus Hub (Portal ID 2).

## Overview

Interviews in Globus are **staff-executed back-office screening work**. They are deliberately modeled on the generic **`work_items`** engine (one row per interview, discriminated by `work_type_key`) rather than on dedicated tables, dedicated endpoints, or applicant-facing workflow steps.

Around the work-item core sits the **Screening bounded context**: versioned per-program screening blueprints (what screening a program requires), per-application screening activity projections, and an append-only `screening_attempts` evidence log with staff override and evidence-upload capabilities.

Interview outcomes never write applicant lifecycle state directly. They reach the journey through defined **consumers**: the eligibility rules engine (`interview_requirement` rule), readiness resolution (work-item signals), the next-required-action recommender, exception queues, and placement/SEVIS readiness gates.

## The Core Design Decision — Work Items, Not Steps

The single decision to run interviews as `work_items` rather than `workflow_steps` drives everything else:

| Consequence | Mechanism |
| --- | --- |
| One vocabulary and one transition ruleset per interview type | A single transition module per type owns its outcome tuple |
| One staff API surface | `PATCH /api/work-items/:id` for scheduling, rescheduling, and outcome recording — no parallel interview routes |
| Zero portal exposure by construction | Interviews run entirely as `work_items`, never `workflow_steps`, so they can never leak onto the applicant portal |

## Goals & Deliberate Scope Choices

**Goals**

- Uniform interview lifecycle (schedule → conduct → outcome) across interview types (sponsor, host, phone, in-person) on shared infrastructure.
- Deterministic, idempotent auto-generation of required interviews — entering the interview phase with an active requirement rule yields exactly one work item, whether or not staff already created one manually.
- Forensic screening evidence: every attempt logged append-only with disposition and assessment axes; overrides are explicit, permissioned operations, never silent edits.
- Outcome consequences flow through declared consumers, keeping interview logic out of lifecycle writes.
- Config-driven staff UI so sponsor/host outcome capture cannot drift apart.

**Deliberate non-goals**

- **No applicant-portal exposure.** Applicants see at most derived phase status ("Screening in Progress"), never interview details, scheduling slots, interviewer identity, or notes.
- **No calendar/video integration** (Outlook, Google, Zoom) in the current scope — `scheduled_at` is a manually entered timestamp.
- **No host access to the staff work-item API.** External host roles hold zero staff permissions and cannot call `PATCH /api/work-items/:id`; hosts schedule and record host-interview outcomes only through the host-portal route `POST /api/partner/host/work-items/:id/interview` (gated by `host.positions.manage` and the `job_orders.write` scope, with job-order ownership checked), which runs the same screening outcome engine.
- Transition modules never write `applications.current_stage_id` / `journey_position` — a build-time guard (`scripts/checkers/stage-write-guard.cjs`) fails CI if they do.

## Domain Model

**Execution — `work_items`** (`shared/models/rules-eligibility.ts`), one row per interview instance:

| Column | Role |
| --- | --- |
| `work_type_key` | Discriminator: `sponsor_interview`, `host_interview`, `phone_interview`, `in_person_interview` |
| `application_id` / applicant linkage | The application the interview belongs to |
| `interview_type` | Stamped at scheduling (`host`, `sponsor`, …) |
| `interviewer_id` | FK → `users` (staff interviewer) |
| `job_order_id` | FK → `job_orders` (host interviews) |
| `scheduled_at` / `completed_at` / `due_at` | Timing; `scheduled_at` cleared on reschedule |
| `status` | `pending` → `in_progress` → `completed` |
| `outcome` | Persistent wire outcome (see outcome vocabularies) |

**Idempotency invariant:** a partial unique index (`idx_work_items_sponsor_interview_unique ON work_items(application_id) WHERE work_type_key = 'sponsor_interview'`) enforces **exactly one sponsor interview per application** at the database level — auto-generation and manual creation cannot double up.

**Screening bounded context** (`shared/models/screening.ts`):

| Entity | Table | Role |
| --- | --- | --- |
| Screening Blueprint | `program_screening_blueprints` | Versioned per-program definition of required screening activities |
| Blueprint Activity | `program_screening_activities` | The activities a blueprint requires, incl. `outcomeOptions` metadata (`terminal`, `completionSatisfying` flags) and a `completion_policy` (`mandatory` / `recommended` / `optional`) |
| Application Activity | `application_screening_activities` | Per-application projection; `latest_valid_attempt_id` pins the current authoritative attempt |
| Screening Attempt | `screening_attempts` | **Append-only** evidence log on two axes: `attempt_disposition` (`completed` / `no_show` / `cancelled` / `invalid`) and `assessment_outcome` (`passed` / `failed` / `needs_review`) |

**Vocabulary & configuration:**

- `shared/workItemVocabulary.ts` — canonical work-type vocabulary.
- `shared/screeningDefaults.ts` — V1 defaults: legacy outcome vocabulary and scheduling rules (`allowReschedule: true`).
- `server/seed/workTypes.ts` — per-work-type `config.outcomeOptions` (the staff-facing outcome choices).
- `server/services/screening/outcomeEngine.ts` — the single generic, config-driven transition builder. It returns a partial `work_items` column map and never writes applicant lifecycle status. Its behaviour is driven by a `ScreeningTransitionConfig` produced by `server/services/screening/configResolver.ts`.
- The per-type transition modules (`hostInterviewTransition.ts`, `sponsorInterviewTransition.ts`, `modalityInterviewTransition.ts`) were **deleted on 2026-07-13 (#1444)** and replaced by the generic engine above (Task #1626 Phase 2). The work-type key constants (`SPONSOR_INTERVIEW_WORK_TYPE`, `HOST_INTERVIEW_WORK_TYPE`, `WORK_TYPE_KEYS`) live in `shared/workItemVocabulary.ts`; the `OUTCOMES` tuples and `build<X>InterviewUpdates()` builders are gone.

## Lifecycle & State Machine

```
pending ──(schedule: stamps interview_type, scheduled_at, interviewer_id)──▶ in_progress
in_progress ──(terminal outcome)──▶ completed (+completed_at)
in_progress ──(outcome = reschedule)──▶ pending (clears scheduled_at / completed_at)
```

- **No-show:** recorded as a completed work item with a `no_show` disposition in `screening_attempts`.
- **Invalid outcome values** are rejected with HTTP 400 `VALIDATION_ERROR` — the config-driven outcome engine validates against the program's configured outcome vocabulary.

## Outcome Vocabularies

Per type, intentionally different:

| Interview type | Outcomes | UX behavior |
| --- | --- | --- |
| Sponsor | `passed` / `failed` / `reschedule` | `passed` is one-click; others confirm-gated |
| Host | `accepted` / `rejected` / `reschedule` | `accepted` is one-click; others confirm-gated |

## Creation Paths

1. **Auto-generation (sponsor):** `server/services/sponsorInterviewAutoGenerate.ts` (`ensureSponsorInterviewWorkItem`) runs on **every persist pass** of lifecycle reconciliation. It gates on the applicant's effective journey phase being `interview` **and** an enabled `interview_requirement` rule with `required: true` (resolved through the rules engine), then inserts with `onConflictDoNothing` — the partial unique index is the idempotency mechanism, and a pre-existing manual interview satisfies the requirement.
2. **Blueprint activation:** program screening blueprints generate required activities on enrollment per their activation policies.
3. **Manual:** staff create ad-hoc interviews from the Screening tab.

## Outcome Consumers

The transition modules write **only** the `work_items` row. Consequences flow through four declared consumers:

1. **Eligibility rules engine** — the `interview_requirement` rule type gates both interview kinds, selected by an `interviewer` parameter (`"host"` / `"sponsor"`, default `"host"` for back-compat). Host branch: `accepted` → pass, `rejected` → fail (with legacy `interviewCompleted` boolean fallback). Sponsor branch: `passed` → pass, `failed` → fail, `reschedule`/none → **pending** — an unrecorded sponsor outcome never silently passes on a host completion. The sponsor fact is sourced from the **latest effective (non-superseded) `screening_attempts` assessment** (`passed` / `failed`; any other assessment, or broken correction lineage, yields no outcome and never restores a legacy Pass). The legacy `work_items.outcome` is used only when no attempt has ever been recorded (`pickEffectiveSponsorInterviewOutcome` in `eligibilityRulesEngine.ts`), so an older Passed work item cannot overrule a later Failed correction.
2. **Readiness resolution** — lifecycle reconciliation gathers `workItemSignals` from the sponsor interview row (`completed = status==='completed'`, `passed = outcome==='passed'`); a completed-but-not-passed sponsor interview resolves readiness to **blocked**. When no sponsor interview exists, the signal is *absent* (not all-false), so applicants without interviews are unaffected. Journey position itself remains stage-authoritative — this affects readiness and next-action only.
3. **Next required action** — a completed non-pass interview becomes a blocking recommendation ("Resolve failed sponsor interview"); awaiting-outcome items surface "Record `{label}` outcome" actions with deep links. The recommendation is persisted to `applications.next_action_deep_link`.
4. **Queues & gates** — recording an outcome for any configured screening activity triggers exception-queue reconciliation (e.g. the `interview-pending` queue empties); `placementReadinessGate` and SEVIS readiness checks consult completed mandatory interviews; the `HOOK_HOST_INTERVIEW_ACCEPTED_AUTO_CONFIRM_PLACEMENT` hook auto-confirms a placement when a host interview is accepted. Sponsor outcome writes also revoke the stored Market Ready grant (see API Surface).

**Action-required queue membership (optional activities excluded).** Membership in the `screening-action-required` queue is determined by a predicate (`server/services/queueRuleEvaluation.ts`) that identifies outstanding work items based on their work type definition's metadata (`activity_category = 'screening'`, `journey_contribution_key = 'screening'`), not a hardcoded list of keys. An outstanding screening activity counts as action-required only if its completion policy is `mandatory`, **or** it is *engaged*: it has a `scheduled_at`, status `scheduled`/`in_progress`, a `no_show` outcome, or it is staff-created rather than lifecycle-reconciliation-generated. Untouched auto-generated `optional` / `recommended` rows are excluded. The policy resolves from the application's pinned projection `config_snapshot`, then the published blueprint activity, then v1 defaults (`sponsor_interview` → mandatory, other keys → optional). A `no_show` outcome always needs follow-up. The queue also provides specific reasons for membership by describing the required action for each outstanding activity (e.g., "Phone Interview needs an outcome").

## API Surface

**Staff — work items** (`server/routes/workItems.ts`):

| Method | Path | Purpose |
| --- | --- | --- |
| PATCH | `/api/work-items/:id` | **The single transition door**: schedule, reschedule, record outcome. Per-`work_type` branches delegate to the type's transition module. Gated by `applications.manage`. |
| GET | `/api/applicants/:id/work-items` | Lists the work items of applicant `:id`, newest first. Filters `applicationId`, `workTypeKey`, `status`; `limit` (default 50) and `offset`; returns `items` and `total`. Gated by `dashboard.view`. |
| POST | `/api/applicants/:id/work-items` | Creates a work item for the applicant in the path (the path id wins over any body `applicantId`); same handler as `POST /api/work-items`. Body needs `workTypeKey` and `applicationId`, with optional `priority`, `assignedToUserId`, `scheduledAt`, `dueAt`, `notes`, `metadata`. 201 with `item`; 400 for a missing field or unknown work type; 409 `SPONSOR_INTERVIEW_ALREADY_EXISTS` when the application already has a sponsor interview (pre-check plus unique-index race backstop). Gated by `applications.manage`. |

Notes:

- Host users can never call this: external host roles hold zero permissions in the role map, so the gate rejects them with 403 — enforced at the data level, no parallel route.
- An optional opt-in marker (`invokedNextAction`) on the PATCH body records that the transition was driven by a "Recommended action" chip; the route then emits a second, additive audit (`applicant.next_action.invoked`) alongside the transition audit.
- **Sponsor outcomes are atomic.** A sponsor-interview outcome (any non-empty outcome other than `reschedule`) is written by `recordSponsorInterviewOutcomeAtomically` in one transaction under a per-application screening lock: the work-item update, the immutable `screening_attempts` row, the correction stamp (when applicable) and a fail-closed deletion of the stored Market Ready grant commit together, and a Market Ready refresh is scheduled after commit. If the work item changed concurrently, the PATCH returns 409 `SPONSOR_INTERVIEW_OUTCOME_CONFLICT`; a failed attempt write does not acknowledge the outcome change. Other screening activities still append their attempt best-effort after the work-item update. A one-time migration (`20290203000000_sponsor_interview_market_ready_reconcile.sql`) deleted existing `ready` `market_ready` gate-status rows whose latest non-superseded sponsor-interview attempt was not `passed` (or, where there is no attempt, whose application has a sponsor-interview work item without a `passed` outcome); the gate-status sweeper repopulates them. Host discovery (recommended and participant search) lists only applications with a `ready` `market_ready` gate-status row, so a removed grant hides the applicant until the gate is re-evaluated (see Rules Engine, Host Portal enforcement).
- **Derived wave status and single sponsor interview.** The wave 1 document work item (`doc_upload_wave1`) is derived: its `completed` status is derived from document verification. `PATCH /api/work-items/:id` with `status` `completed`, or `POST /api/work-items/:id/complete` with any outcome other than `reschedule`, returns `422` with the body `{ error, code }` and `code` `WAVE_STATUS_DERIVED`. Creating a second sponsor interview for an application (`POST /api/work-items` or `POST /api/applicants/:id/work-items`, permission `applications.manage`) returns `409` with `code` `SPONSOR_INTERVIEW_ALREADY_EXISTS`, from a pre-check and again from the unique-index violation when two creates race.

**Staff — screening context** (`server/routes/screening.ts`):

| Method | Path | Purpose |
| --- | --- | --- |
| GET | `/api/applications/:id/screening` | Composed screening picture: rollup, summary, attempts, evidence |
| POST | `/api/screening/attempts/:id/override` | Correct/override an outcome (permission: `screening.override_outcome`) |
| PATCH | `/api/screening/library/:definitionId` | Edit a custom screening activity type (API key scope `applications.write`, permission `screening.create_custom_type`); the key is immutable. An empty body returns 400, an unknown id 404, and a system-defined type returns 409 `SYSTEM_TYPE_LOCKED` |
| POST | `/api/screening/attempts/:id/evidence` | Attach interview notes / recordings |
| GET | `/api/applications/:applicationId/screening/:activityKey/form` | Operational screening form for one screening activity (API key scope `dashboard.read`, permission `operational_forms.view`). Returns `attached`, `definition`, `version`, `currentSubmission` and `submissions`; `attached` is false with the rest empty when no form is bound to the activity, and an unknown activity key returns 404. The version is the one pinned on the current in-progress or submitted sheet, otherwise the published version |

Override notes: a correction appends a new attempt and stamps the old one as superseded; an already-superseded attempt returns 409 `ATTEMPT_ALREADY_SUPERSEDED`. For the sponsor interview the override runs through the same atomic path as above, updates the work item, and only the *current* (latest valid) attempt can be corrected — otherwise 409.

**Attempt attribution.** Each attempt carries two actors: `recordedBy` (who entered the outcome) and `conductedBy` (who ran the interview, snapshotted when the outcome is recorded because the scheduled interviewer on the work item changes on reschedule; null on attempts recorded before the column existed and never backfilled). `GET /api/applications/:applicationId/screening` adds display fields for both (`recordedByName`, `recordedByEmail`, `recordedByIsDeleted`, `recordedByIsPortalActor` and the matching `conductedBy*` fields) so the Screening tab can show who conducted and who recorded without a second call. Deleted staff resolve through the users tombstone, and host-portal attempts resolve to the host organization.

**Screening permission keys** (seeded in `server/seed/rbac.ts`, category `applicants` unless noted): `screening.view` (Screening tab, activity statuses, attempt history; also the evidence file download route), `screening.upload_evidence` (attach files, text or scores to an attempt; enforced on `POST /api/screening/attempts/:attemptId/evidence`), `screening.view_sensitive_evidence` (restricted; additionally required to download evidence in the `recording` and `transcript` categories, otherwise 403), `screening.override_outcome` (restricted, reason required). Four further keys are seeded with these descriptions but no route under server/ checks them: `screening.execute` (work screening activities: claim, progress, complete), `screening.schedule` (schedule or reschedule activities), `screening.record_outcome` (record attempt dispositions and assessment outcomes) and `screening.review` (review outcomes flagged needs-review). Authoring keys (category `programs`): `screening.author`, `screening.create_custom_type` and `screening.publish_blueprint` (reason required).

**Screening conflict codes.** Conflicts return `409 CONFLICT` with the code in `details.code`: `ATTEMPT_NOT_LINKED` (the attempt has no linked work item), `OVERRIDE_NOT_RECORDED` (the engine recorded no new attempt for the correction), `DUPLICATE_WORK_TYPE_KEY` (`POST /api/screening/library` with a key that already exists; details `key`), `SYSTEM_TYPE_LOCKED` (editing a system-defined activity type; details `key`), `DRAFT_ALREADY_EXISTS` (`POST /api/programs/:programId/screening/blueprints` while a draft version exists; details `blueprintId`), `BLUEPRINT_NOT_DRAFT` (`PATCH`, `DELETE` or `POST .../publish` on `/api/screening/blueprints/:blueprintId` for a version that is not a draft; details `lifecycleStatus`) and `BLUEPRINT_NOT_PUBLISHED` (`POST /api/screening/blueprints/:blueprintId/migrate-applications` on a version that is not the published one; details `lifecycleStatus`). Blueprint authoring (create, edit, discard) uses permission `screening.author`; publish and the migrate route use `screening.publish_blueprint`, and creating a custom type uses `screening.create_custom_type`.

**Audit events:** `applicant.work_item.created` · `applicant.work_item.scheduled` · `applicant.work_item.rescheduled` · `applicant.work_item.outcome_recorded` · `applicant.next_action.invoked` — all through the platform's six-dimension audit capture.

## UI Surfaces

**Screening tab — the single home.** `client/src/pages/applicant-profile/screening-section.tsx`, rendered **only** on the applicant workspace's dedicated Screening tab (`section=screening`, RBAC `screening.view`). The Workflow Steps tab shows portal-surfaced published-workflow steps only — screening is never embedded there (a past cutover leaked it in and was reverted; this boundary is deliberate).

**Outcome capture — one shared hook.** Interview scheduling and outcome capture live in the `ScreeningSection` component, which `screening-section.tsx` re-exports from `screening-section.attempt-row`, with the shared `useInterviewOutcomeMutation` hook in `use-interview-outcome.ts` (which stamps the `record_interview_outcome` invocation marker); the former `interview-outcome-section.tsx`, `sponsor-interview-section.tsx` and `host-interview-section.tsx` panels were retired.

**Deep links — two producers in lock-step.** Interview deep links (`?wt=sponsor_interview|host_interview` on the Screening tab, which highlights the matching card) are produced in **two** places that must move together:

1. **Client:** `applicant-action-drawer.tsx` derives the target section and work type from the attention reason (which carries the live work-item label).
2. **Server:** `nextRequiredAction.ts` `buildDeepLink` — persisted to `applications.next_action_deep_link` and navigated raw by the "Recommended" quick-action chip and applicant list rows. Persisted links can lag until the next recalculation (accepted drift).

**Elsewhere:** interview work items appear in queues/worklists like any work item (SLA via `queue_sla_policies` when the item lands in an audited queue; staleness pills from canonical applicant activity timestamps). The right-rail "next required action" recommendation surfaces interview scheduling/outcome tasks.

## Portal Side

**None, by construction.** Interviews run as `work_items`, never `workflow_steps`, so no portal serving path exists. The applicant portal sees only derived phase-level status. This is a stated invariant in the schema module (`shared/models/screening.ts`), not just a convention.

## Governance & Invariants

| Invariant | Enforcement |
| --- | --- |
| Staff transition surface is `PATCH /api/work-items/:id`; the only other interview route is the host-portal `POST /api/partner/host/work-items/:id/interview`, which runs the same outcome engine | Convention + review; recommendation analytics piggyback on the same PATCH |
| ≤ 1 sponsor interview per application | Partial unique DB index (race-safe; makes auto-gen + manual creation converge) |
| Transition modules never write applicant lifecycle columns | `stage-write-guard` build checker |
| Hosts cannot transition interviews | `applications.manage` gate + zero-permission host roles (asserted in tests at the data level) |
| Attempts are append-only; corrections are explicit overrides | `screening_attempts` model + `screening.override_outcome` permission |
| Sponsor/host outcome UIs cannot drift | Single config-driven `InterviewOutcomeSection` |
| Every mutation audited | Platform audit-capture + audit-coverage CI checker |

## Ecosystem & Integration Notes

- **No portal contract:** there are no `/api/portal/*` interview endpoints; the Applicant Portal has nothing to integrate against for interviews. Ecosystem consumers should read screening state only through staff-scoped APIs or derived journey/readiness state.
- **Eventing:** interview activity reaches the ecosystem as `applicant.work_item.*` audit/domain events through the standard outbox → n8n dispatch pipeline (subscription-dependent; unrouted events persist as `skipped`).
- **Host interaction is indirect:** a host interview `accepted` outcome can auto-confirm a placement (hook), which then flows to partner-facing surfaces through the placement contract — hosts schedule and record host-interview outcomes only through `POST /api/partner/host/work-items/:id/interview`.
- **Vocabulary stability:** outcome vocabularies are per-type constants owned by transition modules and mirrored in seed `outcomeOptions`; changing them requires the transition module, the seed, and an idempotent data migration for existing databases, in lock-step.

## Open Items

- Readiness signals currently consume **sponsor** interviews only; host interview and wave-document signals are wired but default-off (deliberate scope).
- No calendar/video-conference integration; scheduling is a manual timestamp.
- Persisted next-action deep links tolerate staleness between recalculations (accepted drift; recompute on reconciliation).
- Phone / in-person interview work types share the infrastructure, but the richest tooling (auto-generation, readiness signals, rule gating) is centered on sponsor/host today.
