---
title: "Section Locking & Unlocking"
slug: section-locking
section: Rules & Compliance
status: stable
description: "How Globus lets staff and admin-authored policies control when applicants can and cannot edit specific sections (steps) of their application — with fail-closed enforcement, authored explanations, scheduled auto-release, and full observab…"
last-updated: 2026-10-07
---

# Section Locking & Unlocking

# Section Locking & Unlocking

How Globus lets staff and admin-authored policies control when applicants can and cannot edit specific sections (steps) of their application — with fail-closed enforcement, authored explanations, scheduled auto-release, and full observability. Shipped and in production.

## Overview

Section Locking lets sponsor staff control **when applicants can and cannot edit specific sections (steps) of their application**, in two ways:

1. **Manually** — a case manager locks one or more sections of a specific application, with a note explaining why.
2. **Automatically** — program admins author **per-program policies** (presented in the admin UI as **applicant editing controls**) that lock sections when a lifecycle trigger fires (phase reached, stage entered, step completed, step approved by staff, phase completed).

Once a lock is active, the applicant sees the section as read-only with a staff-authored explanation banner, and every write path (portal and staff-assisted) rejects edits with `STEP_LOCKED`. Locks can be released manually, auto-release at a scheduled time, step aside automatically when a rejected step needs resubmission, or be temporarily suspended for a bounded correction window (see **Suspension** below).

Section Locking is one layer of a broader, fail-closed **Step Access** system: a single resolver decides, for every step of every application, whether it is `editable`, `view_only`, `locked`, or `hidden` — and every consumer (portal, write guards, webhooks) reads that one decision so they can never disagree.

## Problem & Design Position

J-1 program operations require freezing parts of an application at specific points in the applicant journey:

- **Regulatory integrity** — once data has been submitted to SEVIS or embedded in issued documents (DS-2019, insurance, placement agreements), applicant edits to the underlying sections create compliance drift between what was filed and what the record says.
- **Operational review windows** — while staff review a submitted section, concurrent applicant edits invalidate the review.
- **Milestone-based freezes** — after key milestones (placement confirmed, visa issued), earlier sections must become immutable except through a controlled staff process.

Before Section Locking, the platform had only *derived* read-only states (upstream-dependency gating, SEVIS/DS-2019 gating). There was no way for an operator to say: *"When an applicant reaches phase X, freeze sections A and B and show them this message."*

Deliberate **non-goals**:

- **Sequential step gating** — steps are deliberately *not* gated by completion order (removed by product decision, June 2026). Section Locking must not be used to re-introduce it.
- **Portal-side lock computation** — the portal is a pure renderer of the server's step-access contract; it never derives lock state itself.
- **Document/legal locks** — "legally locked" step semantics (driven by step-type metadata) are adjacent but separate.
- **Replacing derived locks** — SEVIS-track gating, DS-2019 gating, and explicit-prerequisite gating remain independent layers beneath Section Locks.

## Step Access Resolver & Precedence

A single resolver (`server/services/stepAccessResolver.ts`) produces the access decision for every step of every application, resolved against the application's **pinned workflow version**. Precedence, highest first:

| Priority | Layer | Result |
| --- | --- | --- |
| 1 | Conditional-visibility logic hides the step | `hidden` |
| 2 | Persisted Section Lock (active lock row) | `locked` — authored banner + variant; `unlock_at` surfaced for time locks; skipped if the reject-clearable bypass applies or the lock is currently suspended |
| 3 | Derived locks — DS-2019 not yet issued (`DS2019_NOT_ISSUED` — steps at the `visa_sevis` / `pre_departure` journey positions until the application has an issued DS-2019), the four pre-SEVIS track gates (`SEVIS_*_TRACK_INCOMPLETE`), upstream dependency (`UPSTREAM_STEP_INCOMPLETE` — explicit workflow prerequisite edges only, satisfied only by a staff `approved` status) | `locked` per layer |
| 4 | Otherwise | `editable` |

> **Fail-closed contract.** Any uncertainty produces a *more* restrictive access. If the pinned workflow version cannot be resolved, everything is treated as locked (`WORKFLOW_VERSION_UNRESOLVED`) — writes are blocked, never silently allowed.

### Compliance Block (`COMPLIANCE_BLOCKED`) — not a resolver layer at HEAD

The `COMPLIANCE_BLOCKED` reason code still exists in the shared vocabulary (`shared/stepAccess.ts`), but the step-access resolver on `development` no longer produces it: there is no compliance/readiness layer in the precedence stack above, so an application's readiness state (including `blocked`) does not itself lock any step. Compliance results surface to staff as needs-attention rather than as applicant-facing step locks. The earlier intake-step exemption and awaiting-submission handling for this layer (Aug 11 2026 release capsule) are therefore moot.

Every step write path (portal submission and staff-assisted writes) is intended to consult the same resolver and reject locked writes with error code `STEP_LOCKED`. The portal learns access exclusively from the published Step Access & Lock Contract endpoint (`GET /api/portal/applications/:applicationId/step-access`).

> **Enforcement status (Aug 2026):** the *portal* write path is enforced — the applicant-portal BFF re-checks step-access before forwarding any submit and refuses non-editable steps with `423 STEP_LOCKED` (it also refuses when the record carries `can_applicant_submit: false`), fail-closed if the step-access read fails (live on dev, PPPToo PR #204). The Hub's own submit door (`POST /api/portal/steps/:stepKey/submit`) does **not** yet enforce the contract, so callers hitting the Hub directly bypass locks; it also has no validation schemas registered, so arbitrary payloads pass `valid:true`. Both gaps are escalated (Asana 1217435439544074) and tracked under **Open Items** below.

Staff have a second, richer read of the same decision: `GET /api/applications/:id/step-access-explanation` returns every applicable lock layer per step — winner and suppressed alike, including an active-but-not-enforcing Section Lock (suspended or reject-clearable-released) — for diagnosing why a step is (or isn't) locked without guessing.

Shared contract vocabulary lives in `shared/stepAccess.ts`: `STEP_ACCESS_LEVELS`, `LOCK_REASON_CODES`, `LOCK_TRIGGER_TYPES`, `POLICY_TRIGGER_TYPES`, `BANNER_VARIANTS`, `StepAccessRecord` / `StepAccessSnapshot`, `STEP_LOCKED_ERROR_CODE`.

## Submitted and Approved Steps Stay Editable Unless Locked

Submitting a step no longer freezes it, and neither does staff approval or completion (Aug 11 2026 release capsule, extended since). Previously, once a step was submitted the applicant could no longer amend it — a derived `STEP_ALREADY_SUBMITTED` layer flipped the step to `view_only` the moment it was submitted. That behavior is superseded:

- **No derived terminal-status layer.** The resolver has no already-submitted / already-approved layer any more (`STEP_ALREADY_SUBMITTED` survives only as a legacy code retained for backwards-compatible parsing). A step's tracked status — `submitted`, `approved`, `completed` — does not by itself make it read-only; staff sign-off is status/history evidence, not a lock. To freeze a step after review, author a Section Lock or policy (for example an approval-triggered policy — see **Section Lock Policies**).
- **A submitted step stays `editable`.** Applicants can correct a submitted step without a support ticket unless a persisted Section Lock (or a SEVIS / DS-2019 / upstream layer) applies.
- **Returned-for-revision reverts to editable immediately.** A step returned for revision becomes `editable` again the moment it is returned — the applicant does not wait for any sweep.

Status still matters in two places: it selects the applicant banner variant on a persisted lock (`completed` only for an `approved` or `completed` step), and only a staff `approved` status satisfies an upstream prerequisite.

This eliminates the back-channel correction requests that were previously handled via support tickets. The derived floor still applies: a step gated by SEVIS, DS-2019 or an unmet prerequisite is not made editable by this behavior.

> **Every application status transition is audited with its rule evidence (Aug 11 2026 release capsule).** Every application status transition is now recorded with the rule-evidence snapshot that triggered it — which rules passed, which failed, and what the evaluated facts were at the moment of the transition. Staff can replay exactly why an application moved from one state to another. This snapshot is captured alongside the standard 6-dimension audit capture described under **Eventing & Audit**.

## Lock Objects

A **Section Lock** is a concrete row (`application_section_locks`) on one application + one step key.

| Aspect | Contract |
| --- | --- |
| Trigger types | `manual` (applied directly by staff), `status` / `completion` (materialized from policies), `time` (carries a scheduled auto-release `unlockAt`) |
| Reason codes | Machine-readable shared vocabulary: `MANUAL_SECTION_LOCK`, `STATUS_TRIGGER_LOCK`, `COMPLETION_TRIGGER_LOCK`, `TIME_WINDOW_LOCK` |
| States | `active` → `released` (staff action) or `expired` (time-based auto-release); an `active` lock can also be temporarily `suspended` without changing state (see **Suspension**) |
| Invariant | At most **one active lock per (application, step)** — enforced by a partial unique index — so banner selection is unambiguous and re-materialization is idempotent |
| Provenance | Records who locked (`lockedBy` / `lockedByName`), the internal reason, the source policy (if any), the workflow version evaluated against (`evaluatedAgainstVersion`), and release metadata (`releasedBy`, `releaseReason`, `releasedAt`) |

## Applicant-Facing Messaging

Every lock (and policy) carries **two** applicant-facing banner messages:

- `messageIncomplete` — shown when the locked step has not yet been approved or completed.
- `messageCompleted` — shown when the step's status is `approved` or `completed`.

The variant is chosen automatically server-side from the step's status. Authored messaging is exclusive to persisted Section Locks; derived locks (DS-2019, SEVIS, upstream) show no authored banner.

## Section Lock Policies

A **Section Lock Policy** (`workflow_section_lock_policies`) is an admin-authored template on a program, optionally narrowed to one workflow type. In the admin UI these are presented as **applicant editing controls**; the workflow type shown while authoring is context, not an enforced scope boundary (materialization resolves targets against each application's pinned workflow and drops any that do not exist there, so a matching step key in another workflow version used by the program is also affected).

**Policy trigger types:**

| Trigger | Fires when | Notes |
| --- | --- | --- |
| `status` | Applicant's journey enters a chosen lifecycle phase | `triggerKey` = journey position key |
| `stage_entry` | Applicant reaches a stage from the Program Lifecycle Builder | `triggerKey` = stage definition id; materializes as a `status`-reason lock |
| `completion` | A specific step is completed | `triggerKeys` may hold several step keys; fires when ANY completes |
| `approval` | Staff transition a specific step to `approved` | `triggerKey` = one step key (the create route rejects more than one trigger key). Fires only on a staff-attributed transition into `approved` on the principal lane — applicant submission and automatic completion do not fire it. To lock the approved step itself, select the same step as both trigger and target. Materializes as a `completion`-reason lock |
| `phase_completion` | Every step in a chosen phase is completed | Materializes as a `completion`-reason lock |

`stage_entry`, `approval` and `phase_completion` are **authoring-only** trigger types — they materialize down to `status` / `completion` reason codes so the resolver vocabulary stays small.

**Targets** (`section_lock_policy_targets`): a policy locks either **specific steps** or **entire phases** (`targetType` = `step` | `phase`). Phase targets resolve to concrete step keys against each application's **pinned workflow version** at trigger time — never the latest published version, including for applications pinned to older versions.

Policies can be created, edited, deactivated (`isActive`), and deleted; each stores author identity. Policy CRUD and per-trigger materialization live in `server/services/sectionLockPolicies.ts`.

## Backfill & Idempotency

- **Backfill** — creating or (re)activating a policy fires a best-effort backfill that finds existing non-terminal applications whose current state already matches the trigger and materializes their locks — no gap cohort. The match is against current state (journey position, current stage, current step status; for `approval`, a step currently `approved` with a recorded staff `updatedBy`), not historical traversal: the system does not infer that an applicant previously passed a milestone from where they are now. Policies never fire retroactively on their own; backfill is an explicit (automatic-on-create/activate) sweep, so behavior is deterministic.
- **Idempotency** — materialization skips steps that already hold an active lock (unless an explicit `override` is requested on manual creation). A `status` policy fires exactly once per application when the journey enters the trigger phase; re-entry does not duplicate locks (partial unique index + idempotent materialization). Later policy fires against an already-locked step are no-ops — the first lock's messaging wins.

## Unlock & Release Paths

1. **Manual release** — staff release a single lock with an optional `releaseReason` (max 2,000 chars), recorded with releaser identity and timestamp. There is no bulk applicant-facing "release all"; the staff panel multi-select issues per-lock calls.
2. **Scheduled auto-release** — `unlockAt` is required for `time` trigger locks. Read paths treat a past-due time lock as released immediately; the scheduler flips due locks to `expired` and emits events.
3. **Reject-clearable bypass** — if a lock is flagged `rejectClearable` and the step's status is `rejected`, the lock is skipped at read time so the applicant can resubmit; it re-applies once the step leaves `rejected`. In the admin UI this is the "Allow corrections after rejection" option.
4. **Staff suspension** — a bounded, deadline-enforced correction window that leaves the lock row `active` but non-enforcing until the deadline or resubmission, without a full release/re-lock cycle (see **Suspension** below).

> **Derived locks are not releasable.** Staff can only release persisted Section Locks; SEVIS / DS-2019 / upstream states clear only when their underlying condition clears. Releasing a Section Lock restores the underlying derived access — a step with no derived lock beneath it becomes `editable` again regardless of its submitted or approved status (see **Submitted and Approved Steps Stay Editable Unless Locked**).

## Suspension — Temporary Correction Windows

Permanently releasing a lock and re-creating it later is the wrong tool for a small applicant correction (a passport typo, a wrong date) — nothing guarantees the section actually gets re-locked afterward. **Suspension** gives staff a bounded, deadline-enforced alternative that leaves the lock row itself intact:

- `POST /api/applications/:id/section-locks/:lockId/suspend` — suspends an active lock. Body: `suspendUntil` (ISO timestamp; defaults to now + 72h; must be in the future), `reason` (optional staff note), `reopenStep` (boolean — when true and the step's *derived* status is terminal, the step is flipped to `rejected` via the canonical step-status writer so the portal form actually reopens). Returns `{ lock, stepReopened }`; `409 LOCK_ALREADY_SUSPENDED` if the lock is already suspended.
- `POST /api/applications/:id/section-locks/:lockId/resume` — early re-engage ("Re-engage now"): clears the suspension immediately. `404` if the lock is not an active, currently-suspended lock.

Semantics: a suspended lock's row stays `state: "active"` — it still occupies the one-active-lock-per-step slot, so a lock policy re-materialization cannot race in and re-lock the step mid-correction — but the resolver stops enforcing its own persisted-lock decision until the applicant resubmits the step (the tracked status leaves `rejected`) **or** `suspendUntil` passes, whichever comes first. Like the reject-clearable bypass, a suspension never loosens the derived floor beneath the lock — an unmet SEVIS/DS-2019/upstream gate still applies even while a lock is suspended.

Deadline enforcement is **fail-closed at read time**: the resolver re-checks `suspendUntil` on every read, so a past-deadline suspension enforces again immediately even before any background sweep runs. The same section-locking scheduler that expires time locks also runs a parallel sweep for suspensions (5-minute coarse poll + precise per-lock timers) that clears lapsed suspension fields and emits `step.lock_changed` for any suspension the read-time check has already treated as expired.

Every suspend and resume writes an audit row (`section_lock_suspended` / `section_lock_reengaged`); an applicant-triggered auto-resume (resubmission) is tagged accordingly, distinct from a staff-initiated "Re-engage now".

Shipped 2026-07-21 as an API + resolver capability. The Section Locks staff panel does not yet expose a manual "Suspend" / "Resume" button — staff currently call the API directly, and the read-only step-access-explanation view (see **Step Access Resolver & Precedence**) is how staff can confirm a suspension is in effect (see **Open Items**).

## Time-Lock Scheduler

`server/services/sectionLockingScheduler.ts` combines a **5-minute coarse poll** with **precise per-lock timers**, and is restart-safe — a boot tick sweeps anything already past due. A time lock with `unlockAt` in the past is treated as released on read immediately, and flipped to `expired` (with a `step.lock_changed` event) within one poll interval. Success signal: time locks expire within one poll interval of `unlockAt`.

## API Surface & Permissions

| Endpoint | Method | Permission |
| --- | --- | --- |
| `/api/applications/:id/section-locks` | GET (list, incl. history) | `applicant.profile.view` |
| `/api/applications/:id/section-locks` | POST (create manual/time locks across a scope) | `applicant.journey.update` |
| `/api/applications/:id/section-locks/:lockId/release` | POST | `applicant.journey.update` |
| `/api/applications/:id/section-locks/:lockId/suspend` | POST (temporary correction window) | `applicant.journey.update` |
| `/api/applications/:id/section-locks/:lockId/resume` | POST (early re-engage) | `applicant.journey.update` |
| `/api/applications/:id/step-access-explanation` | GET (per-layer access breakdown) | `applicant.profile.view` |
| `/api/admin/programs/:programId/section-lock-policies` | GET | `programs.view` |
| `/api/admin/programs/:programId/section-lock-policies` | POST (`triggerType`: `status` \| `completion` \| `approval` \| `phase_completion` \| `stage_entry`) | `programs.manage` |
| `/api/admin/section-lock-policies/:policyId` | PATCH / DELETE | `programs.manage` |
| `/api/portal/applications/:applicationId/step-access` | GET (portal contract read) | portal auth |

All staff endpoints additionally enforce entity scope (program / season / brand / partner) via the RBAC scope layer. Locked writes are rejected with `STEP_LOCKED`.

## Eventing & Audit

- **`step.lock_changed`** — every lock mutation (create, release, expiry, suspend, resume) diffs the before/after step-access snapshot and emits one event per flipped step into the `ops_events` outbox (best-effort, deduped on application + step + access + version), delivered via the managed n8n subscription. This feeds webhooks and notifications.
- **RBAC audit** — staff mutations flow through the standard 6-dimension audit capture (who / what / when / where / before-after / source). Every create, release, expiry, suspend, and resume is auditable with actor, reason, and timestamps.
- **Application status transitions carry their rule evidence (Aug 11 2026 release capsule).** Every application status transition is recorded with the rule-evidence snapshot that triggered it — which rules passed, which failed, and the evaluated facts at the moment of the transition — so staff can replay exactly why an application moved from one state to another (see **Submitted and Approved Steps Stay Editable Unless Locked**).

## UX Surfaces

**Admin — Applicant editing controls panel** (Workflow Studio / program config; formerly "Section Lock Policies"): framed in operational language ("Choose when completed application sections should become read-only"), with an **Add editing control** button and a four-question form — what should applicants stop editing ("Choose a whole phase" — the default — or "Choose individual sections"), when editing should stop, whether applicants may make corrections after rejection, and what the applicant should see. The standard "when" options are "Applicant reaches a milestone", "A section is completed" and "A workflow step is approved" (staff approval of the selected step; applicant submission and automatic completion do not trigger it); "Applicant reaches a stage" and "All steps in a phase are completed" sit behind **Advanced settings**, together with the optional internal name and the separate in-progress applicant message. A live **portal preview** of the applicant-facing banner is shown as the admin types. Creating a control requires ticking "Apply this control to existing applicants" (existing non-terminal applications that currently match are updated after save; submit button reads "Create and apply"). Rows read as "Protect … when …" with an Active / Paused badge and the internal name; reactivating a paused control opens a confirmation dialog because it starts an asynchronous review of existing non-terminal applications.

**Staff — Section Locks panel** (Applicant Workspace, deep-linkable tab): lists every lock on the application — active and historical — with trigger type, reason, who/when, and messages. Staff create a manual lock across a scope (one or more step keys and/or phase keys, at least one required; optional `unlockAt`, required for time locks; optional internal reason; optional reject-clearable and override flags) and release active locks with an optional reason. A separate read-only **Explain step access** view surfaces every applicable lock layer per step, including a non-enforcing (suspended or reject-clearable-released) Section Lock — today this is the only staff-facing way to see that a lock is suspended, since the panel has no manual Suspend/Resume control yet.

**Applicant portal**: locked steps render read-only with the authored banner (correct variant chosen server-side). Time-window locks surface `unlock_at`, letting the portal render a scheduled-unlock / countdown affordance — the portal never computes the unlock moment itself.

## Governance & Invariants

- **One active lock per (application, step)** — partial unique index; later fires are no-ops absent explicit override.
- **Fail-closed everywhere** — unresolvable workflow version blocks all writes (`WORKFLOW_VERSION_UNRESOLVED`); uncertainty always yields more restrictive access.
- **Single resolver** — portal, write guards, and webhooks all read one decision; no consumer computes lock state independently.
- **Pinned-version resolution** — phase targets resolve against each application's pinned workflow version at trigger time, never the latest published version.
- **No sequential gating** — by decision of record (June 2026), neither upstream-dependency semantics nor lock policies may be used to force step order.
- **Derived locks are a separate layer** — not releasable through the Section Lock release path.

Success is measured by reduction in post-filing data-drift incidents, falling "why can't I edit this?" support volume (authored banners), the share of releases with a recorded reason, and scheduler health.

## Open Items

Roadmap items from the decision record (not yet built), except the first which is a landed backend capability still missing its staff-facing control:

1. **Suspend/Resume staff UI** — the `/suspend` and `/resume` API endpoints, resolver support, and audit trail are live; the Section Locks staff panel doesn't yet expose a manual trigger for either action, so staff must call the API directly today.
2. **Policy simulation/preview for new policies** — the create dialog already shows advisory overlap warnings, and edits have a dry-run impact preview and an active-lock count, but there is no preview of which current applications a brand-new policy would lock before saving (today: backfill applies immediately on create).
3. **Bulk release UX** — first-class multi-lock release with a single audit reason (today: per-lock calls from the staff panel).
4. **Hub submit-door lock enforcement (F3)** — the Hub door must consult the same resolver and reject locked/hidden/view-only writes with the `STEP_LOCKED` envelope. Today only the applicant-portal BFF enforces this (interim mitigation, live on dev); direct Hub callers are unprotected. Asana 1217435439544074.
5. **Submit-door validation schemas (F4)** — published workflow steps need validation schemas registered on the Hub door; arbitrary payloads currently pass `valid:true` and persist as completed step data. Same escalation ticket.
6. **Applicant notifications** — applicant-facing notification on lock/unlock transitions; the `step.lock_changed` event already exists as the hook.
