---
title: "Program Register"
slug: program-register
section: Program Configuration
status: stable
description: "The stable program catalogue — long-lived program identities that define what Globus offers, independent of any particular season or cohort"
last-updated: 2026-10-07
---

# Program Register

The stable program catalogue — long-lived program identities that define what Globus offers, independent of any particular season or cohort

> **Capability Layer 1**
>
> The Program Register corresponds to Layer 1 of the Globus Capability Layers. It provides the foundational "product list" that all downstream layers depend on.

## What It Enables

A stable program catalogue ("product list"). Programs are long-lived identities (not recreated annually). The register stores baseline program metadata including visa type, program model, Program Extension / Program Renewal eligibility flags, and default workflow type.

## Program Identity & Metadata

Each program in the register captures the essential identity and configuration that remains constant across seasons:

**SEVIS sponsor identity.** Each program carries a SEVIS Program Number (sevis_program_number, the sponsor program number / OrgID that batch records are filed under) and, next to it, a SEVIS exchange-visitor category (sevis_category_code, e.g. 12 Summer Work Travel, 11 Camp Counselor). SEVIS designates a sponsor program number for specific categories and rejects any other pairing with S2163 (per record, after submission), so the two are edited together in the program form (Program Number input plus a SEVIS Category selector) and resolved together when batch records are built. The category is operator-configured, not derived from a hardcoded map keyed by program code. The category labels follow the official Department of State table (for example 11 is Camp Counselor, 12 is Summer Work/Travel, 13 is Au Pair, 14 is Trainee, 15 is Intern, 03 is Teacher, and 1A is Student Secondary). Every code is selectable in the SEVIS Category picker on the program form; the five codes that were once labelled Reserved (09, 11, 13, 14 and 15) are no longer hidden. A migration moved existing programs off the wrong values: Camp Counselor programs from 1A to 11 and Teach USA programs from 1C to 03 (only where the old value was still in place), and cleared the Intern and Trainee programs to not configured rather than guess. The write boundary rejects any value that is not a valid exchange-visitor category code, and a blank value is stored as NULL ("not configured"). A program with no category configured is never defaulted to a real category: SEVIS batch validation fails with REQ_CATEGORY_CODE before any send. The column was introduced by a migration that backfilled it once from the retired code-to-category map (filling NULLs only). Per the resolver's source comment, the Intern and Trainee programs are deliberately left unconfigured while their category is pending confirmation from the sponsor's Department of State contact.

Camp Counselor for Wild Packs (`CAMP_WP`) carries its own sponsor program number, `P-4-38637`, paired with category `11`, and never borrows Summer Work Travel's `P-2-17835`. The two halves of a seeded program's pairing are written differently on boot: `sevis_program_number` is overwritten by the seed whenever it differs, while `sevis_category_code` is fill-only (seeded only while it is NULL) because staff can change it in Admin, Programs. Correcting an already-populated category therefore needs a data migration narrowed to the exact wrong value, so a deliberate staff choice is never overwritten. On the SEVIS beta sandbox account, camp sends fail with S2163 by design until that account designates `P-4-38637`; this is expected, not a fault.

SEVIS program numbers are also mapped to a program type in `program_number_mappings`: `sevis_program_number`, `program_category_id` (canonical program category), `effective_date`, `notes` and retirement fields (`retired_at`, `retired_by_user_id`, `retired_reason`). A remap is a new row with a later effective date; the resolver picks the latest non-retired mapping whose effective date is on or before the as-of date, so a renumbering never reclassifies past records, and at most one non-retired mapping exists per program number and effective date. `GET /api/program-number-mappings` and `GET /api/program-number-mappings/resolve` (`programNumber` required, optional `asOf`) need `compliance.program_mapping.view`. `POST /api/program-number-mappings`, `PATCH /:id` and `POST /:id/retire` need `compliance.program_mapping.manage`, which system-admin god mode does not satisfy and API keys are refused (403); a duplicate active mapping, editing a retired mapping or retiring one twice returns 409.

## Ownership Model

The Program Register has clear ownership boundaries that separate it from downstream layers:

Owns

Decides

Does NOT own

## Relationship to Seasons

Programs and seasons have a clean parent–child relationship. A program defines what is offered; a season defines when and how it operates for a specific cohort. This separation means programs are never rebuilt annually — only new seasons are created.

> **Design Principle**
>
> Programs are stable catalogue entries. Seasons are operational instances. This separation eliminates the need for annual program rebuilds and allows configuration inheritance with per-season overrides.

**Inactive programs are read-only.** While the status of a program is `inactive`, `PATCH /api/programs/:id` and `PATCH /api/admin/programs/:id` reject every change with HTTP 409 `PROGRAM_INACTIVE_READ_ONLY`, except a pure reactivation (a body that only sets `status` to `active`). A system administrator can edit an inactive program by adding an `override` object with non-empty `confirmedBy` and `reason`, which is stamped on the program audit-log row; a non-admin sending `override`, or an invalid envelope, gets the same 409. On a program that is not inactive the field is ignored.

## Auto-Attach: Regulatory Rule Bundles

When a new program is created, the Program Register transaction calls the platform's bundle resolver against the program's metadata (visa_type, program_model_type, and allows_dependents). Every matching, published Regulatory Rule Bundle is attached in the same transaction as the program insert — brand operators never hand-pick compliance rules. The platform ships with a seeded j1_standard v1 bundle that attaches to every J-1 program.

- One program_rule_configs row is written per (rule × bundle) match, with bundle_id and bundle_version stamped for provenance and compliance_locked = true when the bundle declared lock_required.
- The Program Register's create response includes the resolved attached_bundles array so the UI can show "This program inherited 14 compliance rules from j1_standard v1" immediately on success.
- A "Preview attached bundles" read-only call (POST /api/regulatory-bundles/resolve with body `{ programId }`, implemented in the Globus Hub) is available so brand admins can see which bundles attach to a program. It requires the compliance.bundles.read permission and returns the matched bundles (key, version, precedence band) and, per rule, the winning bundle with its lock state.
- Auto-attach is idempotent: if a brand admin re-runs program create with the same metadata, the upsert never duplicates rows and never downgrades a compliance lock.
> **See the Rules Engine spec for the full bundle model**
>
> Bundle shape, resolver semantics, immutability rules, version-update migration flow, the Compliance Console Bundle Author surface, and the bundle API are documented on the Rules Engine — Regulatory Rule Bundles section. The Program Rules page covers how the lock surfaces in the cascade.

> **What the Program Manager sees next — the Rules tab**
>
> Once a program is created, the brand admin lands on the program detail page. The Rules tab is the surface that makes the auto-attached bundle visible and explains what was applied, what they can change, and where workflow coverage stands. See Program Setup — Rules Tab UX for the full specification of the four-card layout (bundle banner, locked compliance rules, operational rules, season overrides) and the five-stage flow that ties program creation through to workflow publish.

## Legacy Import Path (Retired)

> **POST /api/admin/programs/import has been removed**
>
> The legacy bulk-import endpoint POST /api/admin/programs/import no longer exists. It bypassed the bundle resolver, the brand-scoped uniqueness checks, and the position-types lookup, leaving imported programs in a state the rest of the platform could not reason about. There is no replacement bulk endpoint.

All program creation now flows through the same Program Register transaction used by the admin console — one program at a time, through POST /api/admin/programs, with the auto-attach resolver, uniqueness guarantees, and audit trail described above. Any external tooling that previously posted to the import endpoint must call the per-program endpoint in a loop and surface per-row errors to its own operator.

## Position Type Codes — Admin Screen

### Purpose & Scope

The Host Offer Details contract freezes position_type as a code from a Program Register-owned lookup (program_position_types). The Program Register admin screen is the only surface where those codes are managed — adding a new program role or retiring an obsolete one is a data change made by Program Register admins, not a code change shipped by engineering. The screen is scoped per program: each program in the catalogue has its own list of allowed codes.

> **Data, not code**
>
> No hardcoded position-type enum lives in the Host Portal, the Globus staff UI, or the database schema. The Program Register screen is the single write surface; the lookup endpoint at /api/partner/host/offer-details/position-types?program_id=… is the single read surface.

> **Spec only — implementation lives in Globus**
>
> This page is a contract spec. The admin surface itself (table, CRUD, audit, RBAC) is owned and built inside the Globus Program Register, not in this architecture/spec repo. The sections below describe the screen Globus must build; nothing here is a working application.

### Screen Wireframe (reference)

The screen lives under Program Register → [program] → Position Types. Program selection happens in the page header; the body shows a single sortable table with inline create / edit / deactivate / reactivate actions.

Program Register

Camp Counselor (J-1) — Position Types

program_id: prog_camp_2026 
+ Add position type 
| | Code | Label | Description | Status | Actions |
| --- | --- | --- | --- | --- | --- |
| ⋮⋮ | counselor | Camp Counselor | Direct camper supervision | | Edit Deactivate |
| ⋮⋮ | support | Support Staff | Kitchen, maintenance, admin | | Edit Deactivate |
| ⋮⋮ | lifeguard | Lifeguard | Waterfront safety; certification required | | Edit Deactivate |
| ⋮⋮ | kitchen_lead | Kitchen Lead (retired) | Replaced by support in 2025 | | Edit Reactivate |

Edit opens an inline drawer with label, description, and sort_order editable. code is read-only after create.

### Field Rules

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| field | type | required | notes |

_(row above is a template, repeated for each data row)_

### Activation Lifecycle

Codes have a binary status — Active or Inactive — and never delete. This preserves the readability of historical offers while preventing accidental selection of retired codes on new writes.

selectable on new offers • included in lookup with active: true 
historical offers still render the label • new PATCH referencing the code returns 422 • included in lookup with active: false so consumers can show greyed-out items 
> **Renames are deactivate + create**
>
> Because code is the persisted FK on every offer row, it is immutable after create. To rename a role, deactivate the old code and create a new one with the desired identifier; the label can be edited freely.

### RBAC & Permissions

Access to this screen is gated by the program_register.position_types.manage permission, granted to the Program Register Admin role. See RBAC for the full role/permission map.

| Role | View list | Create / Edit | Deactivate / Reactivate |
| --- | --- | --- | --- |
| Program Register Admin | ✓ | ✓ | ✓ |
| Sponsor Ops (read) | ✓ | — | — |
| Partner Ops / Host user | via lookup endpoint only | — | — |

### Audit Logging

Every create, edit, deactivate, and reactivate emits one audit event with the staff actor, the program scope, and a before/after diff. No-op edits do not emit. Events feed the standard Globus audit trail and are filterable by program in the audit viewer.

| Event | Payload fields |
| --- | --- |
| event | fields |

_(row above is a template, repeated for each data row)_

### Admin Endpoints

The screen calls the following Globus-internal endpoints. The host-facing read endpoint (/api/partner/host/offer-details/position-types) reads from the same program_position_types table — the admin write surface and the host read surface stay in lock-step automatically.

| Method | Path | Description |
| --- | --- | --- |
| method | path | desc |

_(row above is a template, repeated for each data row)_

## Identity & Uniqueness Guarantees

Globus prevents duplicate brands, programs, and seasons through a single, layered pattern: a Zod-validated request body is normalized, a pre-flight SELECT looks for an existing row, a partial unique index in PostgreSQL is the last line of defense, the 23505 constraint-violation is caught, and the endpoint returns a structured 409 Conflict that always includes the conflicting row's id so portals can deep-link to it.

Each entity is unique within a different scope. Brand identifiers are global. Program names are scoped to the brand. Season names and keys are scoped to the program.

### Brands — Global Uniqueness

Both name and slug on a brand row are unique across the entire platform — there is no brand-of-brand container to scope against. The slug is the URL-safe form; the name is the human-readable form. Both sides are checked.

**Deleting a brand.** `GET /api/brands/:slug/delete-preview` (`programs.manage`; the segment may be the slug or the brand id, 404 when no brand matches) reports what would block `DELETE /api/brands/:slug` without deleting anything. It returns `brandId`, `brandSlug`, `canDelete` (true only when every count is zero) and `references` with counts for `programs`, `sevisBatches`, `notificationTemplates`, `jobOrders`, `partnerAgreements`, `brandRuleDefaults`, `emailSenderIdentities`, `emailTemplateRegistry` and `history` (audit history that also blocks deletion). The delete itself (`brands.manage`) answers 409 `BRAND_HAS_REFERENCES` when references exist.

| Layer | Guarantee |
| --- | --- |
| Database | Unique constraints on brands.name and brands.slug — the last-line-of-defense check that races cannot bypass. |
| Application | Zod validates shape, normalizeBrandName(name) and normalizeSlug(slug) run before a pre-flight SELECT checks for an existing row. The insert is wrapped in a 23505 catcher. |
| API response | On conflict the endpoint returns 409 with error code `CONFLICT`; `details.field` is `name` or `slug` and carries `attemptedName` / `attemptedSlug` (plus `conflictingBrandId` on update). |

### Programs — Brand-Scoped Uniqueness

A program's name is unique within its brand — two brands can each own a “Camp Counselor” program, but a single brand cannot. The slug-style identifiers program_key and code are globally unique (used in URLs, integrations, and reporting).

| Layer | Guarantee |
| --- | --- |
| Database | Unique index on programs(brand_id, lower(trim(name))) from migration 20260526000000_programs_brand_name_unique.sql, plus global unique constraints on programs(program_key) and programs(code). None of the three is partial (programs has no deleted_at column). |
| Application | Zod → normalizeProgramName(name) (trim, collapse internal whitespace, case-fold for the index comparison) and normalizeSlug for program_key / code. Pre-flight SELECT by (brand_id, lower(trim(name))); 23505 catcher on insert. |
| API response | On conflict the endpoint returns 409 with error code `CONFLICT`; details carry `field` (`name`), `conflictingProgramId`, `conflictingProgramName` and `brandId`. |

The position-type lookup table documented above (program_position_types) uses its own per-(program_id, code) uniqueness rule — see the Field Rules table for that contract.

**Moving a program to another brand.** `PATCH /api/programs/:id` (`programs.manage`) that changes `brandId` is refused with 409 `CONFLICT` while branded email templates are registered against the program's current brand (`details.code` `PROGRAM_HAS_TEMPLATE_BINDINGS`, with `templateIds` and `lifecycleStates`), or while a campaign narrows its audience to the program (`details.code` `PROGRAM_HAS_CAMPAIGN_AUDIENCE`, with `campaignKeys`). Retire or replace the template registrations, or remove the program from the campaign audience or move the campaign, first. The check runs inside the update transaction against the locked program row, after brand-scope authorisation on both the source and the destination brand; a payload that names the brand the program is already in is not a move.

### Seasons — Program-Scoped Uniqueness

A season's name and season_key are unique within their parent program. The season_key is derived from the name by deriveSeasonKey(): lowercase, replace runs of non-alphanumeric characters with a single underscore, then trim leading and trailing underscores. Example: “Summer 2026 — Pilot” → summer_2026_pilot.

| Layer | Guarantee |
| --- | --- |
| Database | Unique index on seasons(program_id, season_key) — because season_key is derived from the normalized name, a single composite index covers both the “same name” and “same key” race conditions. |
| Application | Zod → normalizeSeasonName(name) → deriveSeasonKey(name) → pre-flight SELECT on (program_id, season_key) → insert wrapped in a 23505 catcher. |
| API response | On conflict the endpoint returns 409 with error code `CONFLICT`; details carry `field` (`name`), `conflictingSeasonId`, `conflictingSeasonName` (and `seasonKey` when the derived season key collides). |

### Normalization Rules

Normalization happens in application code before the pre-flight check, and the matching lower(trim(...)) expression sits in the database index so the two stay in lock-step. Names are compared insensitively to whitespace and case; slug-style identifiers (slug, program_key, code, season_key) are compared exactly but are required to already be in their canonical lowercase, snake/dash form.

| Field kind | Normalization applied | Comparison |
| --- | --- | --- |
| Name (brand, program, season) | Trim leading/trailing whitespace, collapse runs of internal whitespace to a single space, case-fold for the index lookup. | Case- and whitespace-insensitive. |
| Brand slug | Must already be lowercase, hyphenated, URL-safe. Zod rejects anything else. | Exact, case-sensitive. |
| program_key / code | Must already be lowercase snake_case ASCII. Zod rejects anything else. | Exact, case-sensitive. |
| season_key | When not supplied, derived server-side from the name via deriveSeasonKey(name). When the client does supply one, it must already satisfy the same canonical form (lowercase, snake_case ASCII) or Zod rejects it. | Exact, case-sensitive. |

### Scope Comparison

| Field | Unique within | Case-sensitive? | Index expression |
| --- | --- | --- | --- |
| Brand name / slug | Global | No / Yes | brands.name · brands.slug |
| Program name | Brand | No | (brand_id, lower(trim(name))) |
| Program program_key / code | Global | Yes | program_key · code |
| Season name / season_key | Program | No / Yes | (program_id, season_key) — name maps to key via deriveSeasonKey |

### Defense-in-Depth Pattern

Every create endpoint that protects an identity field follows the same five-stage pattern. Each stage exists to catch a failure mode the previous stage cannot.

- Zod validates request shape, types, and basic format rules (e.g. slug regex). Rejects malformed input with 400 VALIDATION_ERROR.
- Normalizer applies trim, whitespace collapse, and case-fold to name fields; rejects illegal characters in slug fields.
- Pre-flight SELECT looks up the canonical key inside the scope and returns a friendly 409 with the conflicting row's id before the insert runs.
- Partial unique index enforces the rule at the database level for any race that slips past the pre-flight check.
- 23505 catcher converts the constraint violation into the same structured 409 Conflict payload so portals see one consistent error shape regardless of which layer caught the duplicate.
> **One shape, every endpoint**
>
> Brand, program, and season create endpoints all return the conflicting row's id (conflictingBrandId, conflictingProgramId, conflictingSeasonId) so portals can deep-link the user to the existing record instead of forcing a re-search.
