---
title: "Seasons"
slug: seasons
section: Program Configuration
status: stable
description: "Operational cohorts — where intake windows, capacity, lifecycle state, and per-season configuration live"
last-updated: 2026-10-07
---

# Seasons

Operational cohorts — where intake windows, capacity, lifecycle state, and per-season configuration live

> **Capability Layer 2**
>
> Seasons correspond to Layer 2 of the Globus Capability Layers. They cleanly separate program definition from operational cohorts — no annual rebuild required.

## What It Enables

Operational instances like Summer 2026 or School Year 2026–27. Seasons are where intake windows, dates, capacity windows, and operational status live. This cleanly separates program definition (Layer 1) from operational cohorts (Layer 2) — no annual rebuild required.

Layer 1 (Program) owns identity, brand, visa type, category, regulatory lifecycle defaults, and default operational behavior. Layer 2 (Season) owns the operational cohort fields: operational dates, intake open/close windows, capacity, workflow bindings and version locks, renewal & extension availability per cohort, recurring check-in schedule, quota/capacity allocations, and season-level rule overrides.

## Season Configuration

Each season captures the operational parameters for a specific cohort. Seasons inherit baseline configuration from their parent program but can override any setting at the season level:

**Cycle type.** The season create/edit form requires a cycle type of `seasonal`, `annual` or `rolling`. Program End Date is required for Seasonal and Annual cycles and optional for Rolling. For Rolling cycles the form hides the intake open/close date inputs and submits them as null.

### Season Identity & Uniqueness

A season's name and season_key are unique within their parent program — two different programs can both have a “Summer 2026” season, but a single program cannot. The season_key is derived server-side from the name by deriveSeasonKey(): lowercase the input, replace runs of non-alphanumeric characters with a single underscore, then trim leading and trailing underscores. Example: “Summer 2026 — Pilot” becomes summer_2026_pilot.

On a duplicate, the create endpoint returns:

409 Conflict

"code": "CONFLICT",

"field": "name" (also used when the season key collides),

"seasonKey": "…",

"conflictingSeasonId": "season_…",

"conflictingSeasonName": "…"

See Program Register → Identity & Uniqueness Guarantees for the full Brand / Program / Season uniqueness contract, normalization rules, and the defense-in-depth pattern that backs the 409 above.

### Listing Seasons — Query Filters

GET /api/seasons (also reachable as /api/operational-cycles, which is rewritten onto the same handler) applies its query parameters in SQL. The supported parameters are programId, programCode, status and limit; results are ordered by creation recency, newest first.

- programCode — resolved against the program's public code (for example CAMP_WP); returns 404 if no program carries that code. Supplying both programId and programCode is a 400.
- status — one of planned | open | active | closing | closed | archived | cancelled; any other value is a 400.
- limit — a whole number, clamped to 1–200; a non-numeric value is a 400. The handler over-fetches by one so page.hasMore reports whether the limit truncated the result.
- Unknown parameters are not rejected (the endpoint is reachable with a portal.read API key); they are ignored, and the response carries an additive warnings string array naming them.

> **A status filter is not an intake-eligibility guarantee**
>
> status=open does not mean a cycle will accept an application: an open cycle outside its intake window still refuses new applications, and rolling cycles pass the intake check on open or active. Callers picking a cycle to provision against should not treat the first row of an unfiltered list, or a status filter alone, as proof of eligibility.

**Dev seed intake-window refresh.** After the program seeder runs, `open` seasons whose intake window has expired or not yet started are rolled to a window from 30 days ago to 180 days ahead, but only outside production: `INTAKE_WINDOW_AUTOREFRESH` set to `true`, `1` or `yes` forces it on, `false`, `0` or `no` forces it off, and when unset it runs unless `NODE_ENV` is `production`. `tsx scripts/seed-update-intake-windows.ts --update-windows` runs the same refresh on demand (a dry run without the flag); in production the script refuses to run without `--force`, which sets the variable to `true` for that run.

## Lifecycle State Machine

Seasons follow a formal lifecycle state machine. State transitions are system-enforced — manual overrides require appropriate RBAC permissions and produce audit entries.

The transition matrix is enforced by `assertSeasonTransitionAllowed` on `PATCH /api/seasons/:id` when both the current and target status are lifecycle states. Forward edges (planned to open, open to active, active to closing, closing to closed, closed to archived) need a role with `seasons.manage`. Backward override edges (open to planned, active to open, closing to active, closed to closing, archived to closed) need `system.admin` and a non-empty `reason`. Every other transition is illegal. Errors: 422 `SEASON_TRANSITION_ILLEGAL` (edge not in the matrix, regardless of role; the message lists the allowed targets), 403 `SEASON_TRANSITION_FORBIDDEN` (the actor lacks the role the edge needs: `seasons.manage` for a forward edge, the system administrator role group for an override edge; raised before any state change) and 422 `SEASON_TRANSITION_REASON_REQUIRED` (override edge without a reason). Forbidden and missing-reason attempts are audit-logged before the error is returned.

> **Implementation status — current contract**
>
> The 6 states above are the current canonical contract for the Season lifecycle. Active and Closing are part of that contract alongside Planned, Open, Closed, and Archived. Wind-down behavior is not represented as a separate lifecycle state. Instead, it is expressed as Active with intake closed plus operation-specific policy gates (renewals, extensions, recurring check-ins) that may continue to allow valid work where appropriate.

> **Lifecycle state ≠ all behavior**
>
> Season-level behavior is governed by operation-specific gates in policy code, not only by lifecycle state. Two seasons in the same state can behave differently based on per-operation policy (e.g. renewals open, extensions closed, recurring check-ins still scheduled).

**Season check-in binding.** `season_check_in_bindings` holds at most one row per season (unique on `season_id`) naming the check-in questionnaire the season uses: `step_key`, `step_version`, `cadence` (`monthly`, `quarterly` or `annual`), `period_offset_days` (default 0) and `grace_window_days` (default 7). It is read, written and deleted through `GET`, `PUT` and `DELETE /api/seasons/:seasonId/check-in-binding`. The recurring check-in reminder job iterates only seasons that have a binding. For `open` and `active` seasons it resolves the open periods, creates a `recurring_check_ins` row per on-program applicant and period where none exists, and emits `checkin.reminder_due`. `closing` seasons get no new reminders. For `closed` and `archived` seasons it marks in-flight `pending` and `submitted` check-ins `closed_season_ended` and sends nothing.

### Planned → Open Gate

Creating a season only requires a published enrollment workflow; capability workflows (extension, renewal, …) may still be unauthored. The blocking enforcement point is the transition into open, implemented once in assertSeasonOpenPrerequisites and called from all three paths that can open a season: the canonical PATCH /api/seasons/:id, the admin PATCH /api/admin/seasons/:seasonId, and the date-triggered season lifecycle worker.

- Capacity must be greater than 0 (“Cannot open a season with 0 sponsor capacity”).
- Every application type the program requires must be bound to a published workflow definition of the matching workflow type.
- A required type with no binding is auto-bound by inheriting the program's single published definition, so a workflow published after the season was created binds automatically. An enrollment season that already carries a locked definition keeps that locked definition rather than being upgraded to the latest published one.
- A binding that points at an unpublished or wrongly-typed definition is never silently rebound; the transition fails with 409 Conflict, details.code SEASON_OPEN_BINDINGS_UNSATISFIED, plus missingApplicationTypes and unpublishedApplicationTypes arrays.
- The lifecycle worker (polls every 5 minutes) auto-opens only non-rolling seasons whose intake open date has arrived. If the gate rejects a season it stays planned, a warning is logged, and it is re-checked on the next tick — so publishing the missing workflow heals it, and 0-capacity seasons no longer auto-open. After a successful auto-open the worker locks the season's enrollment workflow definition if none is set.

## Workflow Version Locking

Workflow version locking at the season level is resolved through the publish & resolve pipeline and the season's workflow bindings:

Workflows are pinned per (program, workflow type) at publish time — exactly one version is the active published version for that pair. / Season workflow bindings (season_workflow_bindings, keyed by season and application type) point each season at the published definition for each application type it requires; staff never pick versions, and the open gate above inherits any definition published in the meantime. / When the season opens, the enrollment definition is also mirrored into the legacy seasons.workflow_definition_id column (written only when it is still empty), so existing readers see a consistent lock. / When an application is created, it captures workflowVersionId on the application row and is bound to that snapshot for its lifetime. / Per-season tuning is done via season_step_overrides (keyed by seasonId + stepKey) — overrides ride on top of the published workflow without forking the definition. / Compliance-locked steps and rules cannot be overridden at the season level.
> **Trust the per-application version for in-flight work**
>
> Consumers should resolve the active workflow via the program + workflow-type lookup (or the season's binding for the application type), and trust the per-application workflowVersionId for in-flight binding. Binding mutation and delete routes on already-open seasons are not yet locked down.

## Ownership Model

Seasons have clear ownership boundaries that define what they control versus what they inherit:

Owns

Decides

Does NOT own

## Relationship to Program Register

Every season belongs to a parent program . The program defines what is offered; the season defines when and how it operates. Seasons inherit baseline configuration from their program (structured rule definitions via program_rule_configs, eligibility parameters, workflow type) but can override business rule parameters for their specific cohort via season_rule_overrides. Compliance-locked rules cannot be overridden at the season level.

> **Design Principle**
>
> Seasons are operational instances — they should never require rebuilding the program definition. Configuration inheritance with per-season overrides keeps the system maintainable while allowing cohort-specific flexibility.

**Program sunset and its seasons.** Moving a program from `active` to `sunsetting` cancels its `planned` seasons, and the program goes straight to `inactive` when every remaining season is closed or cancelled; moving a program to `inactive` closes its `open` and `recruiting` seasons. Moving a `sunsetting` program back to `active` (cancel sunset) restores to `planned` only the seasons cancelled by the most recent sunset that are still `cancelled`. `GET /api/admin/programs/:programId/sunset-restorable-seasons` (`programs.manage`, API keys need `admin.write`; program scope enforced; `404` for an unknown program) previews that set and returns `{ seasons }` with the full season records, read from the `cancelledSeasonIds` stored on the program's latest `admin_program.sunsetting` audit event.

## Settings & Feature Flags

Globus uses a centralized settings and feature flag system to control platform behavior without code deployments. Settings govern system-wide defaults; feature flags enable progressive rollout of new capabilities per brand, program, or season.

System Settings

Global defaults for all brands and programs / SEVIS configuration thresholds (check-in due days, critical escalation day) / Queue SLA default targets and business hours / Notification delivery preferences (email, SMS, in-app) / Capacity management policies (auto-waitlist, hard cap) / Audit retention periods
Feature Flags

Toggle new features on/off without deployments / Scoped by brand, program, or season / Supports percentage-based rollout for gradual release / Override hierarchy: system → brand → program → season / Audit-logged — who enabled/disabled and when / Kill switch — instant disable for production incidents

System setting writes (not feature flags) are gated by tier: `settings.global.manage` for global settings (create and update), `settings.program.manage` for program-scope overrides, `settings.season.manage` for season-scope overrides and `settings.brand.manage` for brand-scope overrides (brand-tier writes must also fall inside the brand scope of the caller). All four are restricted, reason-required permissions; only `settings.brand.manage` is delegated to Brand Admin, while the global, program and season keys are held by System Administrator. Reading settings, their scope stack and the audit history needs any one of the four. Scope overrides are written with `PUT /api/admin/settings/:key/scope` with `scopeType` and `scopeId` in the body. Feature flags use different keys: `feature_flags.manage` for the global flag and `feature_flags.override` for scoped overrides.
### Documented Program-Scope Settings

The settings below are explicitly documented elsewhere in this site as overridable on the program scope. They follow the same scope inheritance chain (system &rarr; brand &rarr; program &rarr; season) as feature flags.

| Setting key | Type | Default | Owner / spec |
| --- | --- | --- | --- |
| holdDurationDays (Rule Library parameter) | integer (days) | 7 | Reservation hold window — cascade-resolved Brand &rarr; Program &rarr; Season from the published Rule Library rule, with a fail-safe default of 7 days. No reservationTimeoutHours setting exists in code. |

> **Adding new entries here**
>
> This table is the registry of program-scope settings that have a contract documented on another page. Any new tunable that ships with a contract (default value, type, scope rules, edit RBAC) should be added here so the Settings admin page stays in sync with the rest of the docs.

### Feature Flag Scoping

Feature flags follow the same inheritance model as season configuration. A flag can be set at any scope level, with narrower scopes overriding broader ones:

| Scope | Priority | Example |
| --- | --- | --- |
| System | Lowest | enable_travel_requests = false (globally off) |
| Brand | Medium | Alliance Abroad: enable_travel_requests = true |
| Program | High | Camp Counselor: enable_travel_requests = false (override brand) |
| Season | Highest | Summer 2026: enable_travel_requests = true (override program) |

### Feature Flag Lifecycle

> **Settings Admin**
>
> System settings and feature flags are managed by Super Admin and System Admin roles through the Settings admin page. All changes are audit-logged with the previous value, new value, scope, and the staff member who made the change.
