---
title: "Passport-Authoritative Identity"
slug: passport-identity
section: Documents & Identity
status: stable
description: "How Globus establishes a single authoritative legal identity per applicant — sourced from the passport, versioned in an append-only spine, and gating SEVIS readiness."
last-updated: 2026-10-07
---

# Passport-Authoritative Identity

How Globus establishes a single authoritative legal identity per applicant — sourced from the passport, versioned in an append-only spine, and gating SEVIS readiness. Live in the `development` environment on Globus Hub (Portal ID 2).

## Overview

Before this feature, an applicant's legal identity existed in several competing places — the self-declared **Personal Information** step, **passport OCR** extractions, and **staff manual edits**. Staff manually reconciled conflicts, and name/DOB mismatches surfaced as compliance findings.

The Passport-Authoritative Identity spine replaces reconciliation with a single principle: **the passport IS the identity.** When an applicant's Passport workflow step is submitted (portal submit or staff step-complete), the passport's transcription of legal name and date of birth becomes the authoritative identity on the `applicants` row. Every change is recorded in an append-only identity version spine, an outbox event announces the change, and downstream systems react automatically.

The implementation is three coordinated layers:

1. **Write path (spine)** — a single-writer service applies passport identity atomically and idempotently, with material-change classification.
2. **Compliance model** — the old "declared vs. passport must match" rules are retired; a new rule set verifies the passport itself, aggregated by a **Verified Legal Identity** operational gate that **Ready for SEVIS** depends on.
3. **Read side (annotations)** — per-field annotations show staff and applicants where declared values differ from the authoritative passport values, plus a full identity history.

## The Core Design Decision — The Passport IS the Identity

The old model treated divergence between declaration and passport as a problem to fix. The new model treats it as expected reality to record:

| | Old model (reconciliation) | New model (passport-authoritative) |
| --- | --- | --- |
| Source of truth | Contested — declaration, OCR, staff edits all competed | The passport, applied on completed step submission |
| Name/DOB mismatch | Compliance failure requiring manual resolution | Expected, visible, and versioned — never a finding |
| Declared submission | Could be mutated to "fix" conflicts | Immutable — annotation over mutation |
| Provenance | Reconstructed from audit trail, if at all | Append-only version spine with who/what/when/before/after |

Suspicious or material changes (large DOB shift, low name similarity, duplicate passport number) route to a human **Identity Review** queue rather than silently blocking or silently applying wrong data downstream.

## Goals & Deliberate Scope Choices

**Goals**

- One authoritative legal identity per applicant, sourced from the passport, with full append-only provenance.
- Declared submissions stay immutable: annotation over mutation.
- Identity changes automatically re-evaluate the compliance picture and can force agreement re-signature.
- Material changes open a review work item instead of failing silently or blocking silently.

**Deliberate non-goals**

- **Nationality is confirmed, never replaced.** The passport sets `nationality_confirmed_by_passport = true` but never overwrites or clears the nationality column.
- **Backfill parked.** Existing applicants keep `identity_source_type = 'personal_information'` until their next passport submission — no retroactive rewrite.
- **Staff correction flows keep working.** `firstName` / `lastName` / `dateOfBirth` themselves are intentionally *not* single-writer-guarded — only the identity-source bookkeeping columns are.
- **Draft saves, OCR-only extraction, and previews never establish identity** — only a completed step submission does.
- **Search indexes authoritative names only.** An applicant who declared "Jon" but whose passport says "John" is findable by "John" only; indexing declared names is a stated follow-up.
- **Apply-vs-hold is a parked product decision.** `resolveApplyDecision()` is deliberately a single function so the policy can change in one place; the current default is **auto-apply for every classification** (material classes still open a review work item).

## Domain Model

Two schema surfaces (migration `20261228000000_passport_identity_spine.sql`):

**Identity-source columns on `applicants`:**

| Column | Role |
| --- | --- |
| `identity_source_type` | `'personal_information'` (default) → `'passport'` once applied |
| `identity_source_step_submission_id` | Deterministic submission key of the applying step submission (idempotency anchor) |
| `identity_applied_at` | When passport authority was established |
| `identity_version` | Monotonic counter, mirrors the latest version row |
| `nationality_confirmed_by_passport` | Boolean confirmation flag (confirm-only, never a column overwrite) |

**Append-only spine — `applicant_identity_versions`.** One row per applied identity change: snapshot fields (`first_name`, `last_name`, `date_of_birth`, `nationality_confirmed`), provenance (`source_type`, `source_step_submission_id`, `source_application_id`, `source_step_key`, `actor_user_id`), chain linkage (`version_number`, `supersedes_version_id`), and analysis (`change_set` JSONB of `{field: {before, after}}`, `material_classifications[]`, `applied_decision`, `reason`).

**Uniqueness invariants:** `(source_step_submission_id)` unique — duplicate submits collapse to one version; `(applicant_id, version_number)` unique — the chain is strictly ordered.

## Material Change Classification

Vocabulary lives in `server/services/applicantIdentity.ts`:

| Classification | Trigger |
| --- | --- |
| `dob_shift` | DOB change of more than **366 days** |
| `low_name_similarity` | Normalized name tokens barely overlap (possible wrong applicant / wrong passport) |
| `duplicate_passport_number` | Same passport number already present on another applicant |
| `eligibility_affecting` | The change flips eligibility-relevant facts |

## Write Path — Single Writer & Triggers

`applyPassportAuthoritativeIdentity` in `server/services/applicantIdentity.ts` is the **only sanctioned writer** of the identity-source columns — enforced at build time by a custom ESLint rule (`eslint-rules/no-direct-identity-source-write.js`) that fails on any Drizzle `.set({...})` touching a guarded column outside the owning service. In one lock-step pass it applies: the `applicants` row update, the append-only version insert, the audit event, and the outbox event.

**Two triggers, both on completed submissions only:**

1. **Portal:** passport step submit through `server/services/portalStepWrite.ts`.
2. **Staff:** step-complete on behalf of the applicant through `server/routes/integrations.portalStep.ts`.

**Passport DOB and OCR enrichment:** the passport step carries an applicant-editable **Date of birth shown on passport** field (`passport_date_of_birth`). When OCR reads the passport DOB the portal form pre-fills that field, and the applicant can correct it before submitting; the submitted, reviewed value is the authoritative DOB. The latest passport Document AI extraction (`loadLatestPassportOcr`) is only a fallback DOB for older submissions that predate the dedicated field — it never overrides a usable manual passport value. OCR also supplies the passport nationality code used for the confirmation flag and citizenship comparison, and the form pre-fills **Country of Issue** and **Nationality shown on passport** from the separate issuing-country and nationality ISO alpha-2 codes (one is never substituted for the other; a missing or invalid code leaves the applicant's manual value untouched). In the versioned `passport_information_v1_1` step the date-of-birth field is required (placeholder `MM/DD/YYYY`), and the upload is preceded by a four-step photo guide (open the passport to the details page and lay it flat, hold the phone directly above it, check the whole details page is visible and clear, upload) and followed by a note that the details are read from the image and can be reviewed before continuing.

## Idempotency & Race Safety

The submission key is a deterministic hash of applicant + application + step + **applied values**. It is claimed atomically:

```
UPDATE … WHERE identity_source_step_submission_id IS DISTINCT FROM <key> RETURNING
```

Consequences:

- Duplicate submits of the same values produce exactly one version.
- Concurrent submits cannot double-insert.
- A replacement passport creates a new version **only when values actually changed**.
- Non-applying calls return explicit skip reasons (e.g. `not_passport_step`) rather than failing.

## Identity Review Queue

When a version carries material classifications, the service opens an **`identity_review` work item** routed to the staff "Identity Review Required" queue — idempotent per identity version (existence check on `metadata.identityVersionId`; the block only runs on the call that actually inserted the version). Reason codes are derived from the classifications plus lifecycle context: if the application already holds a SEVIS ID, the post-SEVIS context is reflected in the review reason.

**Reason-code catalog entries.** The global (brand-agnostic) reason-code catalog in `server/seed/reasonCodeCatalog.ts` seeds these passport and identity codes. Severity is the catalog `severityHint`.

| Code | Severity | Meaning |
| --- | --- | --- |
| `PASSPORT_NOT_UPLOADED` | blocking | Required passport document has not been uploaded. |
| `PASSPORT_NAME_MISMATCH` | review | Passport name does not match the applicant record. |
| `PASSPORT_DOB_MISMATCH` | review | Passport date of birth does not match the applicant record. |
| `PASSPORT_CITIZENSHIP_MISMATCH` | review | Passport citizenship does not match the applicant record. |
| `DUPLICATE_PASSPORT_DETECTED` | review | A duplicate passport was detected across applicants. |
| `MATERIAL_NAME_DIFFERENCE` | review | The authoritative passport name differs materially (low similarity) from the declared name. The identity is already applied; the review adjudicates downstream impact. |
| `MATERIAL_DOB_DIFFERENCE` | review | The authoritative date of birth shifts materially from the declared date of birth. |
| `PASSPORT_POSSIBLE_WRONG_APPLICANT` | review | Name and date of birth both differ materially, so the passport may belong to a different person. |
| `PASSPORT_DUPLICATE_NUMBER` | review | The passport number is already recorded for another applicant. |
| `IDENTITY_CHANGE_AFFECTS_ELIGIBILITY` | review | The applied identity change (for example a corrected date of birth) moves the applicant across an eligibility boundary. |
| `IDENTITY_CHANGE_AFTER_SEVIS` | review | The identity changed after the application was already submitted to SEVIS, so the SEVIS record may need amendment. |
| `IDENTITY_CHANGE_REQUIRES_DOCUMENT_REISSUE` | review | The identity changed after identity-bearing documents (for example DS-2019) were issued; they likely need reissue. |
| `PASSPORT_DATA_INCOMPLETE` | review | A required passport field (given name, surname, passport number, country of issue) is blank; manual entry counts. Attached to the Passport Data Complete and Readable rule. |
| `IDENTITY_SYNC_INCOMPLETE` | review | A completed passport step submission exists but identity synchronization has not applied it (a sync anomaly). Attached to the Passport Identity Applied rule. |
| `IDENTITY_PREREQUISITES_NOT_MET` | blocking | The Verified Legal Identity gate is blocked: a dependency rule (passport upload, data completeness, expiry, citizenship resolution, identity application) failed, is pending or needs review. |
| `NAME_CHANGE_EVIDENCE_REQUIRED` | blocking | An open qualification-document name mismatch has no supporting evidence linked; the name-change supporting document requirement is active. Routed to Missing Compliance Documents. |
| `PASSPORT_VALIDITY_INSUFFICIENT` | blocking | The passport does not remain valid long enough for the program (nationality-conditional window, the six-month buffer). Replaces the deprecated `PASSPORT_EXPIRY_INSUFFICIENT`, which stays in the catalog so historical references resolve. |
| `CITIZENSHIP_REVIEW_REQUIRED` | review | Passport citizenship could not be resolved automatically against the declared nationality; staff must resolve it. |
| `AI_GENERATED_DOCUMENT_SUSPECTED` | review | Automated forgery detection flagged the passport as a possible AI-generated image; the reviewer verifies manually and the applicant is asked to re-upload a clear photo or scan of the original. |

## Downstream Consequences

Applying an identity emits **`applicant.identity.authoritative_changed`** (audit + ops-events outbox → n8n; dedupe-keyed, once per version). Three consumers:

1. **Compliance re-evaluation** (`server/services/identityReEvaluation.ts`) — in-process consumer running the same targeted single-application evaluation as manual triage: full rules-engine pass with persisted evaluations, then exception-queue and step-access reconciliation. Safety: per-(applicant, application) in-flight coalescing, per-identity-version process-lifetime dedupe, naturally convergent evaluations, best-effort post-commit.
2. **Agreement re-signature** (`server/services/agreementResignature.ts`) — an authoritative *name* change invalidates signed agreements and forces re-acceptance. This ties into the [Agreements Engine's](/docs/agreements-engine) signature-policy triggers.
3. **Facts / rules** — `server/services/factsResolver.ts` exposes facts such as `passportIdentityApplied` and `passportCitizenshipMatchStatus` to the rules engine.

## Compliance Model Shift

The old model treated declaration/passport divergence as findings. That is retired.

**Retired rules** (archived with audit rows by migration `20261229000000_authoritative_identity_rule_retirement.sql`):

- `identity.passport_name_match…` (REG-002)
- `identity.passport_dob_match…` (REG-PAS-004)
- `identity.passport_citizenship_match…` (REG-PAS-005)
- `documentation.passport_readable_extractable…`

**Replacement rule set** (authored on boot by `server/seed/rulesEnginePassportIdentityRules.ts`) — verifies the passport itself, not agreement with the declaration:

| Rule | Verifies |
| --- | --- |
| `documentation.passport_data_complete_readable` | Passport data present and machine-readable |
| `identity.passport_citizenship_resolved` | "Passport Citizenship Match (J-1)": the recorded citizenship matches the passport nationality (OCR or manually entered, compared as normalized country codes; country of issue is never treated as nationality). Fails on a genuine mismatch or when no citizenship is on record; while there is no passport nationality to compare yet it passes and surfaces as "Awaiting Evidence". Reads `passportCitizenshipMatchStatus` — no staff confirmation flag is required |
| `identity.passport_identity_applied` | The authoritative identity has actually been applied |

**Operational gates:** a **Verified Legal Identity** gate (`server/seed/rulesEngineVerifiedLegalIdentityGate.ts`) aggregates passport completeness / readability / expiry / identity-applied; the **Ready for SEVIS** gate (`server/seed/rulesEngineReadyForSevisGate.ts`) now depends on it — no SEVIS submission without a verified legal identity, unless a formally governed compliance exception is approved for the failing rule.

**Authored outcome copy.** The seeded outcome-copy catalogue file `server/seed/ruleOutcomeCopy/identityDocuments.ts` (filling only missing fields, never overwriting authored values) covers the passport-expiry rule `j1_primary.passport_expiry_meets_requirement` and its per-brand variants (`.brand:alliance_abroad`, `.brand:geovisions`, `.brand:stepwest`, `.brand:teacher_lounge`, `.brand:wild_packs` and `.brand:y`). A failure is an applicant self-service action: renew the passport and upload the new one, valid at least six months after programme end. A `needs_review` outcome routes to staff review because the expiry date or programme end date is missing or unclear, and the reviewer confirms both from source rather than passing the check on an assumption.

**Migration mechanics:** the retirement migration supersedes the published `j1-standard` regulatory bundle with a **new bundle version** that copies scopes and attachments minus the retired rules, archiving the prior version (history preserved; the live resolver picks up the new version immediately). Fully idempotent and portable — all rows resolved by business key; fresh databases converge to the identical end state.

## Read Side — Annotations & History

`server/services/identityAnnotations.ts` compares the **declared** Personal Information submission (byte-for-byte in `portal_step_snapshots`) against the **current authoritative** identity on the `applicants` row, producing per-field annotations for `firstName`, `lastName`, `dateOfBirth`. Comparison collapses case / whitespace / diacritics for the *is-different* decision while preserving exact values for display; each differing field carries source metadata.

**Endpoints** (`server/routes/applicantIdentityRoutes.ts`):

| Surface | Endpoint | Notes |
| --- | --- | --- |
| Portal (applicant) | `GET /api/portal/applications/:id/identity-annotations` | Token-fronted, **flat body, no internal ids** (portal SDK contract) |
| Staff | `GET /api/applicants/:id/identity-annotations` | Gated `applicant.view`; includes authoritative identity |
| Staff | `POST /api/applications/:id/identity-photo/override` | Gated `applicant.manage` (registered in `server/routes/applications/identityPhotoOverride.ts`, not in the file above); re-snapshots a locked application's identity photo from the applicant's current account photo, the only sanctioned way to change a locked photo. Body: `reasonCodeId` (an active, non-archived, non-deprecated reason code of the application's brand) and optional `message` (required when the reason has no default applicant message). 409 `CONFLICT` with `details.code` `IDENTITY_PHOTO_NOT_LOCKED` when the application's photo is not locked; 400 `VALIDATION_ERROR` for a bad reason code. Returns `applicationId`, `reasonCode`, `message`, `priorGcsPath` and `newGcsPath` |
| Staff | `GET /api/applicants/:id/identity-history` | Gated `applicant.identity_history.view`; append-only version list |
| Staff | `GET /api/applicants/:id/authoritative-identity` | Gated `applicant.view`; the current authoritative identity: `firstName`, `lastName`, `dateOfBirth`, `identityApplied` (true once `identity_source_type` is `passport`), `identitySourceType`, `identityAppliedAt`, `identityVersion`, `nationalityConfirmedByPassport` and a `source` block (null until applied). `404` for an unknown applicant |
| Staff | `GET /api/applicants/:id/contact-history` | Gated `applicant.identity_history.view` (not `applicant.manage`, because it exposes previous contact details); append-only email and phone change log behind the Overview "Contact history" card. Returns `entries` with `field`, `previousValue`, `newValue`, `changedByUserId`, `changedByName` (a deleted staff user shows as "Name (deleted)"), `source` and `createdAt`; 404 for an unknown applicant |

**UI surfaces:**

- `client/src/components/identity-annotations.tsx` — per-field discrepancy annotations; `IdentityAnnotationsPanel` renders inside the applicant workspace's Workflow Steps section.
- **Identity History dialog** in the applicant workspace — the append-only version trail.
- `client/src/pages/admin/passport-ocr-diagnostics.tsx` — passport OCR diagnostics for admins.

**Identity photo lock override.** An application's identity photo is locked when the applicant completes the Personal Information step. `POST /api/applications/:id/identity-photo/override` (permission `applicant.manage`) re-snapshots the locked photo from the applicant's current account-level photo; it is the only sanctioned way to change a locked photo (the automatic portal photo sync writes only the account-level photo). Body: `reasonCodeId` (required) and an optional `message` replacing the catalog reason's applicant message. The reason code must exist, be active (not archived or deprecated) and belong to the application's program brand, and must resolve to an applicant message or be given one, otherwise the request is a validation error. `404` for an unknown application; `409 IDENTITY_PHOTO_NOT_LOCKED` when the application's photo is not locked. Success returns `applicationId`, `reasonCode`, `message`, `priorGcsPath` and `newGcsPath`, with one audit entry recording actor, reason code, message and both photo pointers.

## Governance & Invariants

| Invariant | Enforcement |
| --- | --- |
| One writer for identity-source columns | ESLint `no-direct-identity-source-write` (build-fails any outside Drizzle `.set()`); reads deliberately unguarded |
| Declared snapshots never mutated | Service touches only the `applicants` row; annotations are a read-side overlay |
| Exactly one version per distinct submission | Unique index on `source_step_submission_id` + atomic `IS DISTINCT FROM` claim |
| Strictly ordered per-applicant chain | Unique `(applicant_id, version_number)` + `supersedes_version_id` linkage |
| Nationality confirm-only | Service invariant — boolean flag, never a column overwrite |
| ≤ 1 review item per identity version | Metadata-keyed existence check inside the version-inserting call |
| Identity application never fails on side effects | Event emit, review item, and re-evaluation are all best-effort post-commit |
| Every application audited + evented | Lock-step audit row + outbox `applicant.identity.authoritative_changed` |

## Ecosystem & Integration Notes

- **Event contract:** `applicant.identity.authoritative_changed` flows through the standard outbox → n8n pipeline with a per-version dedupe key; ecosystem consumers can treat it as once-per-identity-version.
- **Portal contract:** the portal annotation endpoint returns a flat body with no internal identifiers, per the portal SDK's no-unwrap convention. The Applicant Portal never writes identity — it only submits steps; authority is established Hub-side.
- **Applicant Portal (PPPToo) consumer:** PPPToo's BFF exposes `GET /applications/:id/identity-annotations`, which checks application ownership and then projects the Hub annotations into an applicant-safe flat shape for the client notice. On passport step submit it can also forward per-field capture provenance (`manual` / `ocr` / `unknown` hints coupled to the same-request values) as metadata to the Hub; forwarding is gated by the server switch `PASSPORT_HUB_CONTRACT_ENABLED` (default off). While the gate is off the provenance metadata is withheld and the step still saves normally, rather than aborting the submit.
- **Passport name-sync proof:** on the primary passport step, PPPToo attaches a `nameSyncProof` to the extraction result it returns to the applicant: an HMAC-SHA256 signature (secret `PASSPORT_NAME_SYNC_SECRET`) over the applicant subject, application id, step key, document reference, given names and surname. The proof expires exactly 10 minutes after issue, and a malformed, tampered, expired or wrongly shaped proof all verify as invalid without saying which. It is minted only when the secret is set. The BFF refuses to start when `PPP2_ENVIRONMENT` is `prod` and the secret is missing; other environments run without it. `POST /applications/:id/passport-name-sync` accepts only this proof (a body with raw name fields or anything besides `proof` is `400`; a foreign, invalid or expired proof is `400`). Even a valid proof never updates the profile: the route answers `409` `PASSPORT_FINAL_SAVE_REQUIRED`, because only saving and submitting the passport step applies passport details. The owned-media passport lane (`extract-passport` called with a `/step-media/` `fileUrl`) fails closed through `requirePassportHubContract()`: `503` while `PASSPORT_HUB_CONTRACT_ENABLED` is off (it is never retried through legacy base64 extraction), and `403` with a `PORTAL_TOKEN_REQUIRED` message when no forwardable portal token is present. Principal-applicant passport uploads use the owned-media upload only while the gate is on.
- **Identity semantics for downstream systems:** consumers should read legal name / DOB from the applicant record (authoritative once `identity_source_type = 'passport'`), treat declared values as historical context only, and expect SEVIS readiness to be gated on Verified Legal Identity.
- **Provenance guarantee:** every identity value on the wire is traceable to a version row with source step, actor, before/after diff, and classification — suitable for regulatory audit. In addition, every compliance fact derived from identity data is now bound to a revision-stable snapshot of its source database rows and evidence documents, ensuring that a compliance decision can be replayed with its exact original inputs.

## Open Items

- **Apply-vs-hold policy:** currently auto-apply for all classifications (review item opened in parallel). The hold-for-review decision point exists (`resolveApplyDecision`) awaiting a product call.
- **Declared-name search indexing:** staff search matches authoritative names only; indexing declared names is a stated follow-up.
- **Backfill:** existing applicants remain `personal_information`-sourced until their next passport submission (deliberately parked).
- **Source types beyond passport:** the spine is source-typed (`source_type`) and could admit future authoritative sources (e.g. government verification services) without schema change.
