---
title: "Job Orders"
slug: job-orders
section: Placement & Matching
status: stable
description: "Engineering reference for the job_orders aggregate — the unified demand-side record."
last-updated: 2026-10-07
---

# Job Orders

Engineering reference for the job_orders aggregate — the unified demand-side record. State machine, three independent approval gates, clone/carry-forward tiers, capacity counters with over-recruitment math, and the host-verification + site-visit prerequisites that gate final approval. Source of truth: attached_assets/hosts-and-job-orders.md §3.

See also: Host Portal &rarr; Job Order Lifecycle for the host-facing prose, and Sequence Diagrams &rarr; Job Order Lifecycle for the cross-system flow.

## Overview

A job order is the aggregate root for one host's request for participants on one program / season. Almost every host-side workflow (matching, reservations, placements, compliance, capacity dashboards, finance) hangs off a job order. Mutations are funnelled through one transition endpoint so the state machine can never be bypassed by direct status writes.

## Data Model

| Table | Purpose |
| --- | --- |
| job_orders | Aggregate root. One row per request from a host for participants. |
| job_order_amendments | Audit trail of changes after the first approval. |
| job_order_housing_leads | Many-to-many link of a job order to candidate housing options. |
| job_order_matches | Per-applicant eligibility/score record for a job order. |
| housing_leads | Property-level housing options the matches can point at. |
| site_visits | Worksite inspections required before final approval. |
| host_verifications | Internet-presence vetting (per host, not per job order). |
| commute_assessments | Distance/transit feasibility from housing to worksite. |
| participant_recommendations | Suggestions of specific applicants to a host, created by staff (`source = 'staff'`) or by the host itself (`source = 'host'`). Only active staff-created rows populate the host Recommended list. |
| organization_program_associations | Which programs a host org operates in. `addOrgProgramAssociation` runs in one transaction and delegates the write to `writeOrgProgramAssociation` (from `./orgProgramAssociationWriter`); `syncOrgProgramAssociations` locks the organization and calls `refuseHostProgramSync`, and runs on the caller's executor when one is passed, otherwise in its own transaction. |
| us_state_minimum_wages | Effective-dated statutory hourly minimum wage per US state: `state_code`, `hourly_cents` (integer cents), `effective_from` and optional `notes`, unique per state and effective date. The rate in effect is the newest row whose date has arrived, so a scheduled increase is a second row with a later date. Staff maintain it through `GET` and `POST /api/admin/state-minimum-wages` and `PATCH /api/admin/state-minimum-wages/:id` (permission `system_settings.manage`); a second rate for the same state and date returns 409 with `details.code` `DUPLICATE_STATE_EFFECTIVE_DATE`. It feeds the rules fact `jobOrderStateMinimumHourlyWageCents`, which is null for a state with no recorded rate; the code itself enforces no wage floor (any comparison is a rule authored in the Rules Library). |

### Requirements Namespacing

A job order's substantive content lives in the JSONB column job_orders.requirements, split into three namespaces (helpers in server/routes/jobOrders/helpers.ts, packing in shared/jobOrderRequirements.ts):

Per-section completion is tracked in job_orders.sectionProgress (JobOrderSectionProgressMap) and drives the stepper UI. Progress is written with `PATCH /api/admin/job-orders/:id/section-progress` (API key scope `job_orders.read`; permission `job_orders.create` or `job_orders.manage`; job order scope enforced). The body is `sectionId` (one of the known section ids), `completedFields` and `totalFields`; `completedFields` above `totalFields` returns `400 VALIDATION_ERROR`, an unknown job order returns `404`, and the response is the updated job order.

> **Uniform and Required Payment are dual-stored, tri-state answers**
>
> Uniform (`uniformProvided`, `uniformCost`, `uniformCostCurrency`, `uniformCostDue`) and Required Payment (`employeePaysForAdditional`, `employeePaymentDetails`) live in two places in `requirements`: staff create/edit packs them nested under `grooming` and `compensation`, while Host Portal section writes persist them flat at the requirements root. `resolveUniformRequirement` and `resolveRequiredPaymentRequirement` (shared/jobOrderRequirements.ts) give every read surface one tri-state answer — yes, no, or unknown (never answered, shown as "Not provided") — and a nested staff-packed value wins over a flat one. Packing never invents an answer: a Required Payment "No" is written only when an actual boolean was supplied. A uniform cost with no currency resolves to `USD`, and a stored currency is upper-cased. When a host section write addresses one of these keys the matching nested copy is removed so the host's latest answer wins, and a dependent field whose controlling answer flips to No (hidden by its `visibleWhen` rule) is cleared rather than persisted. In the seeded `job_order` workflow step library, Uniform is collected on the `jo_grooming` step (`uniformProvided`, with `uniformCost` and `uniformCostDue` shown only when `uniformProvided` is true) and Required Payment on the `jo_payment` step (`employeePaysForAdditional`, with `employeePaymentDetails` shown only when it is true); these `visibleWhen` rules are what the clearing above keys on.

> **cultureHobbyTags facet (requirements.matching)**
>
> The requirements.matching namespace carries a cultureHobbyTags facet: the tagged culture/hobby interests the matching engine fuzzy-matches against the applicant's skills/hobbies data. This feeds the Hobbies Fit scoring dimension (Aug 11 2026 release capsule). See Matching Engine &rarr; Hobbies Fit for the scoring behaviour and weighting.

The staff completion wizard groups these namespaces into stepper sections: Host Entity, Employment Position Information, Role, Recruitment Details, Salary/Stipend, Grooming, Arrival Instructions, Housing, and Interviews. Each section's percentage in job_orders.sectionProgress is derived from its own required-field set — filling every visible field is not sufficient if a section has a hidden required field with a non-obvious default.

> **Arrival Instructions caps at 50% until Reporting To is set**
>
> The Arrival Instructions section defaults its "Reporting To" field to "None." Because "None" is not a valid contact, the section's completion percentage is capped at 50% even when every other visible field is filled — there is no visual indicator that "Reporting To" is the blocking field. Setting it to an actual contact is required to reach 100% and clear this section.

## State Machine

Status column: job_orders.status. State transitions are evaluated by server/services/jobOrderWorkflowService.ts.

### State Diagram

draft ──submit──► submitted ──start_review──► in_review

│

├──request_revision──► revision_requested ──resubmit──► submitted

│

├──reject──► rejected

│

├──auto──► partially_approved ──host confirms──► approved ──activate──► active

│ │

│ ├──pause──► paused ──resume──► active

│ └──cancel──► cancelled

### Transition Endpoint

All status changes go through POST /api/admin/job-orders/:id/transition. Direct writes to job_orders.status are forbidden — the workflow service is the only writer. Routes live in server/routes/jobOrders/workflow.ts.

### revert_to_draft Reset

> **revert_to_draft is a first-class transition**
>
> revert_to_draft moves a job order back to draft and resets all three approval gates to pending while preserving history (gate audit log and amendments are not deleted). Used after a host substantively edits an in-review or revision-requested order so reviewers re-evaluate every gate from scratch.

### Host Status Filter Mapping

The host-facing job-order list accepts a **canonical status-filter vocabulary** that translates server-side into the internal status sets above — hosts filter in their own vocabulary and never see raw internal statuses leak through. Fixed Aug 11 2026 (Task #2179): the filter translation previously mapped some canonical filters to the wrong internal set, which stranded approved job orders out of every host view. Previously stranded approved orders were repaired via an idempotent migration, the demo seeder that re-stranded rows was fixed, and the reservation flow was verified end-to-end against the corrected mapping.

### Host Portal Job-Order Endpoints

The Host Portal reads and authors job orders through `/api/partner/host/*` routes (server/routes/hostPortal/jobOrders.ts and positions.ts). Reads need the `host.positions.view` capability and the `job_orders.read` partner scope; writes need `job_orders.write` and, for submit, `host.positions.manage`.

| Method | Path | Behaviour |
| --- | --- | --- |
| GET | /api/partner/host/job-orders | The host's "My job orders" list. Rows are snake_case (`status` is the canonical host badge value, `internal_status` the raw workflow status, `program_name`, `season_name`, `positions_total`, `filled_count`, transition timestamps, `cancellation_reason` / `cancellation_reason_key`, `is_archived`, and an `offer` block from the canonical offer projection with an `is_complete` flag). Sorted by `last_touched_at` descending, ties broken by id. Archived rows are hidden unless `include_archived=true`. Filters: `status` (canonical value; an unknown value is a validation error), `program_id`, `season_id` (camelCase aliases accepted) and `limit` (1–200, default 50). A closed job order reports `closed` whatever its workflow status. |
| GET | /api/partner/host/positions | Reservable positions for the host with placed and available counts. With no `status` it returns only `active` and `partially_approved` job orders; an explicit `status` must be exactly one valid job-order status, otherwise it is a validation error. The Host Portal's active-positions query sends `status=active` and keeps only rows whose status is `active` (it previously asked for `approved`). |
| PATCH | /api/partner/host/job-orders/:id | Draft-only autosave. Accepts any job-order field except identity and state (host org, program, brand, source, status, submit idempotency key, rejection reason); an empty payload is rejected. A row that has left draft returns 409 `JOB_ORDER_LOCKED_FOR_REVIEW`. The effective season must sit inside the host's agreement scope. |
| POST | /api/partner/host/job-orders/:id/sections/:sectionId/next | Validates the section's fields against the published workflow step schema, merges them into `requirements` (applying the Uniform / Required Payment canonicalisation above), and marks the section complete. Draft only: a row that has left draft returns `409 CONFLICT` with `details.code` `JOB_ORDER_NOT_IN_DRAFT` (unlike autosave, which returns `JOB_ORDER_LOCKED_FOR_REVIEW`). |
| GET | /api/partner/host/job-orders/:id/workflow-schema | The published Host Job Order workflow for the job order's program with each step's library schema inlined, plus the resolved `hostProgramModelType`. |
| POST | /api/partner/host/job-orders/:id/submit | Submit for review. Re-validates every visible `jo_*` step schema and returns 422 with `<sectionId>.<fieldKey>` errors when a required field is missing. |
| POST | /api/partner/host/job-orders/:id/restore | Un-archives a draft. |
| GET | /api/partner/host/job-orders/:id/decision-notes | Whole-order review decisions only (approve, reject, revision requested, and the gate-driven automatic outcomes as note-less events), each with the reviewer's role and time but never the reviewer's identity or per-gate notes. |

`POST /api/partner/host/job-orders/:id/restore` needs the `admin` or `recruiter` host role and partner scope `job_orders.write`. It refuses a job order of another host organization with `403`, one that is not in `draft` with `409 JOB_ORDER_LOCKED_FOR_REVIEW`, and one that is not archived with `409 CONFLICT` whose `details.code` is `JOB_ORDER_NOT_ARCHIVED`; if the row changes between those checks and the update it returns `409 CONFLICT` with `details.code` `JOB_ORDER_RESTORE_FAILED`. Staff have a matching `POST /api/admin/job-orders/:id/restore` (`job_orders.create` or `job_orders.manage`) that returns `409 CONFLICT` with `details.code` `JOB_ORDER_NOT_ARCHIVED` when the job order is not archived.

The Host Portal client deliberately writes only the job-order fields it has confirmed the Hub stores through autosave: title, description, positionsTotal, overRecruitmentPercent, seasonIds, advertisingChannels, wageAmount and wageFrequency (its deny-by-default allowlist, probed against the development Hub on 2026-09-08). Other published questions, including the per-position Uniform and Required Payment answers, are shown read-only there; the portal has no create-draft call because it has no host-surface create route to use.

Staff read a program's active position types with `GET /api/programs/:id/position-types` (permission `dashboard.view`, entity scope enforced, `404` for an unknown program). It returns a page of `id`, `code`, `label` and `description` (null when unset) and an empty list for a program without a catalogue. It backs the structured-offer-default editor and the job-order position-type picker; hosts use the separate offer-details lookup.

### Immutable Fields

Host Company, Program, and Season are write-once for an ordinary edit: `job_orders.hostOrgId`, `programId`, and `seasonId` cannot be changed through `PATCH`. A job order created without a season attached (for example, through a quick-create flow that does not force the season picker) cannot be repaired by an ordinary edit either, except that a draft whose program or seasons are still empty may fill them in. Otherwise the supported fix is the audited reassign endpoint described below, not a new job order, unless the job order is already `approved` or `rejected` or has active placements or reservation holds, in which case reassignment is refused.

In practice the write-once rule is enforced by a guard with two exceptions rather than by the absence of any route. An ordinary `PATCH /api/admin/job-orders/:id` that changes the host, program or seasons of an existing job order is refused with `409 CONFLICT` whose `details.code` is `JOB_ORDER_SCOPE_EDIT_BLOCKED`; a draft that has no program or seasons yet may fill them in, which counts as resuming the draft. A deliberate, audited change goes through `POST /api/admin/job-orders/:id/reassign` (`job_orders.create` or `job_orders.manage`; body `hostOrgId`, `programId` and/or `seasonIds`, at least one of which must change, plus a mandatory `reason`), and `GET /api/admin/job-orders/:id/commitments` previews the blockers. A job order already `approved` or `rejected` cannot be reassigned. Reassignment is refused with `409 CONFLICT` and `details.code` `JOB_ORDER_REASSIGN_BLOCKED` (details `activePlacements`, `activeReservations`) while the job order has active placements or reservation holds; the target host must be allowed to author job orders and hold an active association with the target program, and every season must belong to the target program, otherwise it is a validation error. Separately, a `request_revision` transition that finds the job order no longer in `in_review` or `partially_approved` returns `409` with `details.reasonCode` `JOB_ORDER_REVISION_STATE_CONFLICT` and `details.currentStatus`.

### Staff Job Order List Pagination

`GET /api/admin/job-orders` parses numbered pagination from the query with `parseJobOrderNumberedPagination`. When it is present the list is read by offset, and the response `page` object carries the parsed numbered-pagination values plus `totalRecords`, `totalPages` and `hasNextPage` (true while the page number is below `totalPages`). In this mode `status=pending` selects the job orders whose status is one of the pending statuses from `getPendingJobOrderStatuses`. Without numbered pagination the route keeps using `parsePaginationParams`. In numbered mode `totalRecords` comes from `storage.getJobOrderCount` and `totalPages` is that count divided by the page size, rounded up. The staff Job Orders page renders a `JobOrderPagination` control below the list, hidden when the list query has failed. When the list query has failed, the page shows an alert in place of the list: "You do not have access to these job orders." for a 403, otherwise "Unable to load job orders. Please try again." with a Retry button that refetches the list (the Retry button is not shown for a 403). Opening a job order from a list card navigates to `/job-orders/<id>` with the current `window.location.search` appended, and the back-to-list actions on the job order detail page navigate to `/job-orders` with the same query string appended, so the list's query string is kept. The list's filters, sort, search text and page are held in that query string by `useJobOrderList`: each change is written to the URL by replacing the current history entry, and changing a filter sets `page` back to 1. `pageSize` accepts 10, 25, 50 or 100, and any other value falls back to 25. Search text is debounced by 300 ms. When the requested `page` is past the last page, the hook rewrites `page` to the last page once the list query is no longer fetching.

## Three Approval Gates

job_orders.approvalGates is a JSONB object holding three independent gates. Each gate stores . Endpoints in server/routes/jobOrders/approvalGates.ts.

### job_order Gate

Compliance/regulatory review of wages, hours, EV category, supervisor info, and the rest of the compliance namespace. Reviewed by Globus compliance staff.

### housing Gate

Logistics review of the attached job_order_housing_leads &rarr; housing_leads , commute_assessments, and cost-to-wage ratio. Has a special carry-forward tier when cloning.

### host_confirmation Gate

Direct sign-off by the host. Acceptable proof methods:

- portal_approval — host clicks confirm in the portal.
- uploaded_approval — signed PDF uploaded to the order.
- email_confirmation — staff records the host's email confirmation against the gate.

> **Camp job orders hide this gate (QA, 2026-07 — Task #1937)**
>
> Host Confirmation does not apply to camp-program job orders. `GET /api/admin/job-orders/:id/progress` now returns `programModelType` (additive field); the staff job order detail page uses the host-portal workflow-schema value when available and falls back to this admin value otherwise (staff/admin viewers get a 403 from the host-portal endpoint). When the resolved model type is `camp`, the `host_confirmation` gate is hidden from the Approval Gates panel; `standard` and unknown/null model types keep the three-gate rendering. UI + additive API field only — the gate data model is unchanged.

`POST /api/admin/job-orders/:id/approval-gate` (permission `job_orders.manage`) decides one gate with body `gate`, `action` (`approve` or `reject`), optional `notes` and, for `host_confirmation` approvals, `confirmationMethod`. Refusals: `400 INVALID_CONFIRMATION_METHOD` when `confirmationMethod` is sent for any other gate, `400 MISSING_CONFIRMATION_METHOD` when approving `host_confirmation` without it, `409 GATE_NOT_APPLICABLE` when the gate does not apply to the program's model type (`host_confirmation` on a camp job order), `409 GATE_ALREADY_DECIDED` when the gate is no longer `pending` (reset it first), and `409 GATE_EDIT_NOT_ALLOWED` when the job order has left a gate-editable status, for example after a concurrent `request_revision` moved it to `revision_requested`; the revision wins.
### Auto-Transition Rules

| Gate State | Auto-Transition |
| --- | --- |
| All three approved + host-verification passed | approved |
| job_order + housing approved while host_confirmation pending | partially_approved |
| Any gate rejected | rejected |

### Approval Auto-Activation

When a job order reaches approved, activation is applied automatically with a race-safe dedupe guard (Aug 2026 — #2173): concurrent approval paths cannot double-activate the order, and stranded approved-but-never-activated orders no longer occur. The explicit activate transition remains in the state machine for staff-driven cases; auto-activation is layered on top and goes through the same workflow service, never a direct status write. This also fixed the host-portal offer-details surface — approved job orders are no longer treated as terminal there (see Host Offer Details).

### Automated Camp Job-Order Population

> **Automated Camp orders (approved-terms seeding is a design, not built)**
>
> *Status: only host-creation auto-creation is built. When staff create a camp-family host organization, the Hub auto-creates one active order per camp-family program (Camp Counselor for camp-model programs, Support Staff for SWT-Camp programs) with env-configured position counts (`CAMP_COUNSELOR_JOB_ORDER_POSITIONS` default 50, `SUPPORT_STAFF_JOB_ORDER_POSITIONS` default 1). No approved-terms revision, `sourceRevision` metadata or refresh-in-place of drafts was found in code; the paragraph below is the intended design.*
>
> Newly automated Camp job orders are populated deterministically from the host's effective approved-terms revision (seasonal salary, contract dates, EIN snapshotted with sourceRevision metadata), the Host Offer Details row, and references to the canonical arrivals and housing data. Creation is idempotent per (host_org_id, program_id, season, position_type) and fails explicitly on missing prerequisites — no partial orders. Later approved revisions refresh drafts in place and raise amendments (this page's amendment flow) for reviewed/active orders. Automated orders enter the same approval gates and prerequisites documented on this page. Full contract: Host Verification & Approved Terms → Automated Camp Job-Order Population.

## Approval Prerequisites

Even with all three gates approved, the workflow service will not transition a job order to approved unless the following prerequisites hold.

### Site Visits

A site_visits row tied to this job order must satisfy status = completed AND outcome = pass. Endpoints under /api/admin/site-visits/*.

| Field | Allowed Values |
| --- | --- |
| status | pending, scheduled, in_progress, completed, failed |
| outcome | pass, fail, conditional |

### Host Verification

A passing host_verifications row must exist for the host (see Host Portal &rarr; Onboarding ). Verification is per-host, not per-job-order — so once a host is verified, every future job order benefits from the same row.

> **Re-checked at SEVIS batch confirmation, not just at job-order approval**
>
> Host verification is not a one-time gate cleared at job-order approval. The same host_verifications check runs again when a SEVIS batch is confirmed (see SEVIS & Government &rarr; DS-2019 Lifecycle ): confirming a batch fails with 403 HOST_VERIFICATION_REQUIRED — "DS-2019 issuance blocked: N placement(s) belong to Camp hosts that have not passed host verification" — if any placement in the batch belongs to a host that has since lost, or never had, a passing verification, even when the job order itself was approved while the host was verified. Treat host verification as a standing precondition for DS-2019 issuance, not a one-time checkbox.

### Repeat-Host Exemption

> **Repeat hosts can skip a fresh site visit**
>
> getCompletedSiteVisitForHostOrg in server/storage/jobOrderStorage.ts looks up any prior completed-pass site visit for the same host + program. If one exists, the prerequisite is satisfied without scheduling a new visit. New hosts (or hosts onto a program they have not run before) must complete a fresh visit per job order.

### Matching Requirements

Independent of the three named approval gates and the site-visit/host-verification prerequisites above, Submit for review also requires at least one must-have entry in the job order's Matching Requirements panel — Skills, Certifications, Languages, or Age Range. Submitting without one fails with 422 POLICY_VIOLATION: "At least one must-have requirement is required."

## Clone & Carry-Forward Tiers

POST /api/admin/job-orders/:id/clone (handler: cloneJobOrder in server/storage/jobOrderStorage.ts) creates a new draft job order seeded from a source. The job_order and host_confirmation gates always reset to pending. The housing gate gets a tiered carry-forward based on how recently it was approved on the source order. Linked job_order_housing_leads rows are duplicated to the clone.

### Housing Carry-Forward Tiers

| Source housing approval age | Cloned housing gate | Behavior |
| --- | --- | --- |
| ≤ 6 months ago | auto_approved | Gate cloned as approved; no logistics re-review needed. |
| 6–12 months ago | potential_review | Gate reset to pending with a soft review flag — reviewer is prompted but not forced to re-evaluate from scratch. |
| > 12 months ago | compliance_re_evaluation | Gate reset with a hard flag — full housing re-evaluation required (treat as new). |

## Capacity Counters & Over-Recruitment

### Counters

| Counter | Meaning |
| --- | --- |
| positionsTotal | Hard cap of confirmed placements (the contract figure agreed with the host). |
| positionsReserved | Currently held by active reservations against this job order. |
| positionsPlaced | Currently held by confirmed/active placements. |

> **Counters update only via storage methods**
>
> Reservations and placements update these counters through storage methods, never via ad-hoc UPDATEs. A partial unique index on active reservations/placements (fix #1894) prevents a single applicant from being held against two active reservations or placements simultaneously. See also: Reservation Concurrency Contract for the row-level lock, the 409 RESERVATION_POSITION_TAKEN response shape, and the nightly duplicate-placement integrity scan that backs this index. For the participant-facing view of reservations and placements — which of these transitions trigger (or should trigger) participant communications — see [Participant Placement Journey & Notifications](/docs/participant-placement-notifications).

**Resizing `positionsTotal`.** `PATCH /api/admin/job-orders/:id/capacity` (body `positionsTotal`, an integer of at least 1; permission `job_orders.create` or `job_orders.manage`) changes the position count and its capacity slots in one transaction. Growing adds open slots. Shrinking needs at least as many open slots as it removes; otherwise nothing is written and the response is `409 CONFLICT` with `details` `jobOrderId`, `previousTotal`, `newTotal`, `openSlots` and `requiredOpenSlots` (previous minus new total), and a message telling staff to cancel confirmed placements first. The storage layer names this failure `JOB_ORDER_CAPACITY_SHRINK_BLOCKED`, but that string is not returned as the response `error.code`.

### Over-Recruitment Math

overRecruitmentPercent and originalRequest allow recruitment above positionsTotal to absorb attrition. The effective recruitment target is:

recruitmentTarget = Math.ceil(originalRequest * (1 + overRecruitmentPercent / 100))

Logic lives in server/services/overRecruitment.ts. positionsTotal remains the hard placement cap; the recruitment target only widens the funnel of reservations and matches.

### Reservation Ceiling vs Hire Ceiling (Task 2487)

A job order enforces two distinct capacity ceilings, resolved in one module (`server/services/jobOrderCapacityCeilings.ts`) so every gate, picker and availability figure reads the same numbers. The seat counters and their accounting are unchanged; only the comparisons moved.

| Ceiling | Value | Governs |
| --- | --- | --- |
| Reservation ceiling | `max(positionsTotal, candidatesAllowed)`, where `candidatesAllowed` is the recorded over-recruitment allowance (a missing `positionsTotal` counts as 1, a missing allowance as 0) | How many seats may be committed at once (active holds plus confirmed placements), compared against `committedSeatsCount` |
| Hire ceiling | `positionsTotal` | How many placements may be confirmed, compared against `confirmedPlacementsCount` |

The allowance is floored at the position count, so a stale allowance left behind by a later positions increase cannot shrink reservation capacity. Over-recruitment therefore governs who a host may hold while it decides, never how many people it may employ.

- **Reserve.** The reservation gate in `partnerReservationLock` compares committed seats against the reservation ceiling; a capacity refusal now also reports `reservationCeiling` in its details. The staff reservable-job-order picker (`listReservableJobOrdersForHost`) lists only orders with reservation head-room and returns `seatsAvailable` as reservation ceiling minus committed seats. Staff can also reserve a participant straight onto one of the holding host's job orders (`POST /api/applicants/:applicationId/reserve` with `jobOrderId`), or attach an existing host-level earmark to one (`POST /api/applicants/:applicationId/reserve/attach`); both go through the same `createReservation` writer as the Host Portal, so Placement Ready, Market Ready, quota and seat capacity keep their authority. The target job order must belong to the holding host (409 `JOB_ORDER_NOT_OWNED_BY_HOLDING_HOST`), run the participant's own programme (422 `JOB_ORDER_PROGRAM_MISMATCH`) and list the participant's operational cycle (422 `JOB_ORDER_SEASON_MISMATCH`). Staff do not supply an agreement id: the operating agreement is resolved server-side from host, programme, season and brand, and a miss is refused with 422 `HOST_AGREEMENT_NOT_FOUND` (no active operating or standard agreement; a trial agreement cannot authorise a reservation) or 422 `HOST_AGREEMENT_SCOPE_MISMATCH` (an agreement exists but does not cover the participant's brand or cycle). An attach carries over the earmark's expiry when it falls within the job order's hold-duration bounds (otherwise the resolver's default applies) and supersedes the earmark in the same transaction.
- **Staff release and hire.** `POST /api/applicants/:applicationId/reserve/release` (`applicant.manage`) cancels the application's active staff host-level hold (`staff_host` only; host job-order holds keep their own cancel path). `POST /api/applicants/:applicationId/hire` (`placements.manage`) hires the participant onto a host job order on the host's behalf through the same confirmation writer as the Host Portal hire, so the placement, seat counters and notifications match a host hire. The body is `jobOrderId`, `startDate`, `endDate` (ISO dates or timestamps), `salary` (a non-negative integer) and an optional `positionTitle`. The participant and the job order are resolved through the caller's scope first, so an out-of-scope job order answers `404` before any policy refusal. Success is `201` with audit action `staff_hire_participant_on_behalf_of_host`; policy refusals such as `RESERVATION_HELD_BY_OTHER_HOST` and `RESERVATION_ON_OTHER_JOB_ORDER` are listed on the Error Handling page.
- **Hold window.** Reservation writes that set an `expiresAt` (including host `POST` and `PATCH /api/partner/host/reservations`, `POST /api/partner/host/reservations/:id/convert`, `POST /api/admin/reservations` and `POST /api/admin/partners/reservations`) are checked against the effective hold window: the published Rule Library window for the scope, otherwise the canonical 7 days. A timestamp that is unparseable or at or before now is refused with `422 RESERVATION_PERIOD_INVALID`; one later than now plus the window with `422 RESERVATION_PERIOD_EXCEEDED`, whose message points to `POST /api/partner/host/extension-requests` for longer holds. Both carry `details` `maxBaseDurationDays`, `requestedExpiresAt`, `maxAllowedExpiresAt`, `serverNow` and `source`. When the host create call omits `expiresAt`, the hold defaults to the full window.
- **Hire.** Every confirm path (staff hold confirm, staff and host placement create in `confirmed` status, transition of a placement into `confirmed`, and the host hire route) asserts the hire cap through `assertJobOrderHireCapacity`. It locks the `job_orders` row inside the caller's transaction, after the hold or placement row is already taken (lock order), and refuses with 409 `JOB_ORDER_POSITIONS_FILLED` (details `jobOrderId`, `positionsTotal`, `confirmedPlacements`, `openPositions: 0`) when every position is filled. The thrown error rolls back the caller's earlier writes. Without the cap an over-recruited order would only be stopped by the capacity-slot helper's internal error, surfacing as a 500.
- **Hire.** Every confirm path (staff hold confirm, staff and host placement create in `confirmed` status, transition of a placement into `confirmed`, and the host hire route) asserts the hire cap through `assertJobOrderHireCapacity`. It locks the `job_orders` row inside the caller's transaction, after the hold or placement row is already taken (lock order), and refuses with 409 `JOB_ORDER_POSITIONS_FILLED` (details `jobOrderId`, `positionsTotal`, `confirmedPlacements`, `openPositions: 0`) when every position is filled. The thrown error rolls back the caller's earlier writes. Without the cap an over-recruited order would only be stopped by the capacity-slot helper's internal error, surfacing as a 500. If the job order id does not exist, the same assertion throws `JOB_ORDER_NOT_FOUND` with status 404 and `details.jobOrderId` instead.
- **Hire picker.** `GET /api/admin/job-orders` with `hireableOnly=true` now keeps orders with `confirmedPlacementsCount < positionsTotal` (active, not archived), and the per-row `seatsAvailable` is `positionsTotal` minus `confirmedPlacementsCount`, floored at 0. An over-recruited order that holds more candidates than positions is therefore not hidden from the picker.
- **Reservation hireability.** Every host-facing reservation, and the staff read `GET /api/applicants/:applicationId/reservation` (which returns null when the application has no active hold), carries a server-decided verdict: `hireable` (boolean), `hireableReasonCode` (string or null) and `hireableReason` (display text, null exactly when `hireable` is true). Clients render the reason verbatim and branch on the code rather than inferring availability from an empty position list. Codes: `HOST_LEVEL_HOLD_NO_JOB_ORDER` (a staff host-level earmark with no job order; the host converts it with `POST /api/partner/host/reservations/:id/convert`), `RESERVATION_NOT_ACTIVE`, `RESERVATION_EXPIRED` and `RESERVATION_MISSING_JOB_ORDER`. `PLACEMENT_NOT_READY` belongs to the same vocabulary but is never returned by the resolver: the host reservation routes (reserve, hire, convert) return it as a 422 refusal with code `PLACEMENT_NOT_READY` and `details.reasonCode` of the same value when the participant has not cleared Placement Ready. The staff read uses staff wording for the same codes. `hireable: true` only means the reservation is in a hireable shape; the hire route still runs its own placement validation. A second hold on a participant who already has an active hold is refused with 409 `PARTICIPANT_ALREADY_RESERVED` (see Host Governance).

### Reservation Hold Extension and Host Decline

`POST /api/admin/reservations/:id/extend` (staff session or API key with `placements.read` plus `admin.write`; permission `reservations.manage`) moves an active hold's expiry later. Body: `expiresAt` (required), optional `expectedExpiresAt` (the expiry the caller last saw) and optional `reason` (1 to 500 characters). A hold outside the caller's application scope is a 404, the same as an unknown hold. Rejections: 422 `RESERVATION_NOT_EXTENDABLE` when the hold is not active or has already expired; 409 `RESERVATION_EXTENSION_CONFLICT` when `expectedExpiresAt` no longer matches the hold's current expiry, or when the hold changed concurrently (the guarded update also fails if the hold is no longer active or unexpired, or the applicant now has a live placement); 409 `RESERVATION_EXTENSION_LIMIT_REACHED` once `extensionCount` reaches the rule-library `maxHoldExtensions` cap (details carry `maxExtensions`, `source` and `ruleDefinitionId`); 409 `RESERVATION_SCOPE_UNAVAILABLE` (top-level `error.code`, no details) for a staff host-level hold whose application, program or brand cannot be resolved. The cap is resolved through the cascade (brand, program, season) from the job order for an ordinary hold and from the applicant's application for a staff host-level hold; when no published rule or valid parameter exists the fail-safe default of 2 applies and `source` is `fail_safe_default`. The window is measured from the hold's current expiry, not from now: the new expiry must be later than the current one, else 422 `RESERVATION_EXTENSION_NOT_FORWARD`, and no more than 4 days later (`RESERVATION_RULES.MAX_EXTENSION_DAYS`), else 422 `RESERVATION_PERIOD_EXCEEDED` with `maxExtensionDays` and `maxAllowedExpiresAt` in the details; an unparseable `expiresAt` is 422 `RESERVATION_PERIOD_INVALID`. The limit check (409) runs before these bounds checks. Staff host-level holds (`holdType` `staff_host`) can be extended through the same endpoint: the earlier `409 STAFF_HOLD_NOT_EXTENDABLE` rejection (a fixed 7-day window) was removed on 2026-09-30 in BackOfficePrototype. Success returns the updated hold and writes a `reservation.hold_extended` audit entry.

`POST /api/partner/host/reservations/:id/decline` (host roles admin and recruiter, partner scope `reservations.write`) lets a host decline an active hold on its own org. Body needs `reason`, one of the shared cancellation reason codes (`participant_interviewed_poorly`, `unsatisfactory_skill_level`, `participant_declined`, `participant_non_responsive`, `no_longer_need_skill`, `no_show_to_interview`, `other`), plus an optional `note` (also read from `cancellationNote` or `reasonNote`, max 2000 characters); otherwise 422 `VALIDATION_ERROR` with `details.field` and `details.reasonCodes`. An unknown or foreign hold is 404 and a hold that is not active is a validation error. The hold becomes `declined`, its seat is released, a `partner.reservation.declined` event is emitted, and the applicant's placement stage is released back toward Sponsor Interview when no live hold remains.

## Amendments

job_order_amendments records changes made to a job order after its first approval. Each row captures the amendment number, what changed, the actor, and the reason. The workflow service sets resubmissionOfId on resubmissions so reviewers see the delta against the previously rejected/approved order. Migration: 20260324100000_job_order_amendments.sql.

Amendment requests are stored in `job_order_amendment_requests` (states `submitted`, `in_review`, `approved`, `rejected`) and can be opened only against job orders in `approved`, `partially_approved` or `active` status (otherwise `409 AMENDMENT_NOT_ALLOWED_IN_CURRENT_STATE`). A job order can have one open request (`409 AMENDMENT_ALREADY_OPEN`), the patch must change at least one editable field (validation error otherwise), and hosts may edit a narrower field set than staff. Approving applies every field of the stored diff to the job order in one transaction.

| Method | Path | Behaviour |
| --- | --- | --- |
| POST | `/api/partner/host/job-orders/:jobOrderId/amendments` | Host opens a request with body `patch`; `host.positions.manage` and partner scope `job_orders.write`; `201`. A job order of another host is `404`. |
| GET | `/api/partner/host/job-orders/:jobOrderId/amendments` | Host lists the job order's requests; `host.positions.view` and `job_orders.read`. |
| POST | `/api/admin/job-orders/:id/amendments` | Staff opens a request into the same queue; `job_orders.manage` or `admin.access`, entity scope enforced. |
| GET | `/api/admin/job-order-amendments` | Review queue with an optional `state` filter; `job_orders.view` or `job_orders.manage`. |
| GET | `/api/admin/job-order-amendments/:id` | One request with its diff; same permissions. |
| PATCH | `/api/admin/job-order-amendments/:id` | Body `state` (`in_review`, `approved` or `rejected`) and optional `notes` (up to 2000 characters); `job_orders.manage` or `admin.access`. An illegal move is `422 INVALID_AMENDMENT_TRANSITION`. |

### Queue Buckets — Revision Requested & Amendment Requests

The staff job-order review queue surfaces two additional buckets (Aug 11 2026 — Task #2180):

- **Revision Requested** — job orders in revision_requested appear in the queue with the revision reason displayed, aged by the revision-requested timestamp (not the original submission time) so long-ignored revision requests rise to the top.
- **Amendment Requests** — a dedicated bucket lists job orders with pending amendment requests, showing the pending amendment diff for each. The job-order detail page renders a matching alert while an amendment request is pending. Staff can review an amendment without leaving Queues: a dialog loads the open amendment (`GET /api/admin/job-order-amendments/:id`), shows the field-by-field before/after diff, and offers the transitions that fit its state (submitted: start review, approve or reject; in review: approve or reject) through the same decision contract as the Job Order Amendments administration page. Approving applies the requested changes immediately; rejecting requires a reason the host can see.

## Housing Vocabulary on a Job Order

> **host_housing vs housing_leads / job_order_housing_leads**
>
> Two distinct housing concepts hang off the host. Do not conflate them. host_housing — host-managed inventory. Properties the host has registered with Globus and manages over time (assignments, inspections, occupancy). Lives behind /api/partner/host/housing. housing_leads + job_order_housing_leads — per-job-order candidate properties reviewed under the housing approval gate. housing_leads is the property-level row; job_order_housing_leads is the many-to-many join to a specific job order. Lives behind /api/partner/host/housing-leads.

See Host Portal &rarr; Housing Management for the host-facing operational view of host_housing, and the housing gate above for how housing_leads drive the housing approval gate.

### Housing Lead Deletion

Housing leads can be deleted by staff (QA, 2026-07 — Task #1948). Contract:

- `DELETE /api/admin/housing-leads/:id` — gated on `housing.read` **plus** `job_orders.create` or `job_orders.manage`.
- A successful delete removes the lead and its `commute_assessments` rows, writes an audit log entry with a `previousValue` snapshot, and emits the `housing_lead.deleted` domain event.
- Leads still linked to any job order via `job_order_housing_leads` are refused with **409**, `details.code: "HOUSING_LEAD_LINKED"`, and a `details.linkedJobOrders` list. Unknown ids return 404.
- Delete controls (with confirmation dialogs) exist on the staff Housing page and the Host Management Housing tab, permission-gated, with a friendly toast on the 409.

Deliberately out of scope: host-portal withdraw, soft delete, and any change to unlink behavior.

### Free-Housing Flag Alias

`POST /api/admin/housing-leads` and `PATCH /api/admin/housing-leads/:id` accept boolean `isFreeHousing` as a compatibility alias for `costIsFree` (used by the Host Management client). The alias is normalized to `costIsFree` before the cost-clearing rule or storage sees the payload, `true` clears `costPerBed` and `costFrequency`, and a request that sends both fields with different values is rejected as a validation error. Responses expose only the persisted `costIsFree`.

### Housing Lead Score

`GET /api/admin/housing-leads/:id/score` (permissions `housing.read` plus `job_orders.view`, `job_orders.create` or `job_orders.manage`) returns a hybrid score. The deterministic part is completeness (10 points), cost (35) and commute (35); an optional AI qualitative assessment adds 20, for a maximum of 100. Completeness weighs eleven key fields. Cost compares the lead's weekly housing cost with the weekly wage derived from its linked job orders: 15% of wage or less earns the full 35, tiers step down through 25%, 35% and the 40% ceiling, and no cost or no linked wage scores 0. Commute uses the best completed transit assessment (15 minutes or less earns the full 35); an off-site lead with no completed assessment falls back to its stored distance to the site, and with neither it scores 0.

The response carries `totalScore`, `maxScore`, `percentage`, a letter `grade` (A at 85% and above, B 70%, C 55%, D 40%, otherwise F), a per-component `breakdown`, `aiStatus` and a derived `recommendedConclusion`: A or B `suitable`, C or D `needs_review`, F `reject`. The conclusion is a recommendation only; it never approves or rejects a lead. The AI part is computed out of band: the endpoint returns the deterministic score immediately with `aiStatus: "pending"` (AI contributes 0) and includes the assessment on a later fetch (`ready`), or reports `unavailable` when AI is not configured. Scores are cached per lead against a fingerprint of every input, with a 10-minute freshness window for a ready or unavailable result and a 1-minute retry window after a failed AI attempt.

**Commute assessment.** `POST /api/admin/housing-leads/:housingLeadId/assess-commute` (body `destinationOrgId`) and its `/bulk` variant (permission `job_orders.create` or `job_orders.manage`) call the Google Distance Matrix API for transit and driving times between the lead and the worksite. The key is read from `GOOGLE_MAPS_API_KEY`, falling back to `VITE_GOOGLE_MAPS_API_KEY`. Without either, the request does not error: the route stores a `commute_assessments` row with `status` `failed` and an `errorMessage` naming the missing configuration, and returns it with `201`. An address that matches the worksite is recorded as on-site without calling the API.

## Talent Pool Endpoint Contract

Hosts browse candidate participants for their approved job orders through the host talent pool endpoint. The route is registered in server/routes/hostPortal/talentPool.ts and surfaced under the host portal API. This section is the authoritative contract for every filter the endpoint accepts — the age range, nationality, language, gender, skills, availability, and maxDaysRemaining filters — including how each one composes with the corresponding field on the job order's requirements.matching namespace.

> **One validation envelope for all filters**
>
> Every filter below validates server-side and rejects with HTTP 422 using the same field-error envelope documented in Validation & 422 Error Shape. Each filter section names only the rules and field keys that are specific to it; the wrapper shape (), the “only-failing-fields” rule, and the “no silent coercion” rule are inherited and never repeated.

### Endpoint Surface

The talent pool search is registered on two paths that are served by the same handler and the same middleware (authentication, host resolution and the admin, recruiter or readonly host role check). Both accept the same query parameters and return identically-shaped result sets. They are registered with the same handler and middleware, so treat the two paths as interchangeable; the rows below label the bare path canonical and the `/talent-pool/` path an alias for readability only:

| Method | Path | Description |
| --- | --- | --- |
| GET | /api/partner/host/search-participants | Canonical host talent-pool search. Returns the eligible-and-filtered candidate set for the authenticated host org, scoped (when supplied) to a specific job order. |
| GET | /api/partner/host/talent-pool/search-participants | Alias of the canonical endpoint (same handler and middleware). Same query parameters, same response shape. |
| POST | /api/partner/host/talent-pool/:id/recommend | Action endpoint — host recommends a specific participant for review. Not a search endpoint; the age filter does not apply here. |
| GET | /api/partner/host/talent-pool/recommended | Staff-recommended participants for the authenticated host org, also served at the alias `/api/partner/host/recommended`. Sourced only from active (not dismissed) staff-created `participant_recommendations` rows addressed to the host (`source = 'staff'`). Algorithm matches are internal and never surface here, and a host's own suggestions (`source = 'host'`) do not qualify. `?source=algorithm` is accepted only as a legacy alias and runs the same staff-only query. A participant the host has already reserved under a job-order hold is excluded until the hold releases, but a staff host-level hold (`staff_host`, no job order) owned by the calling host is an earmark for that host and stays listed, labelled held-for-you, and only applicants whose Market Ready projection passes are listed (fail-closed). The participant filter parameters apply. Dismiss, list-dismissed and restore actions live at `/api/partner/host/recommended/dismiss`, `/recommended/dismissed` and `/recommended/restore`. Matching recompute and single-job-order evaluate never create a recommendation: for a job order with a host, each eligible algorithmic match emits only `host.participant_matched` (one event per applicant) and an `application.matched` audit row, and when at least one match is eligible the host gets a single `host.participant_matched_digest` notification per job order evaluated. Recompute no longer emits `host.participant_recommended` or `host.participant_recommended_digest` and no longer writes an `application.recommended` audit row. A participant is recommended to a host only by an explicit staff recommendation (`POST /api/applicants/:applicationId/recommend`), which creates the `participant_recommendations` row and logs the audit action `create_participant_recommendation`; the daily participant-digest scanner builds `host.participant_recommended_digest` from those staff rows. Each participant on the recommended list carries `recommendationId`, `source` (always `staff`), `recommendedBy` (the recommending staff member name), `note` and `recommendedAt`, plus `heldForYou` and `hostLevelHold` (null, or `reservationId`, `holdType`, `expiresAt`, `createdAt` and `convertible`). |
| GET | /api/partner/host/participants/:applicationId | Participant detail for one application. Submitted skills and hobbies from the applicant's skills_hobbies step are flattened onto it: top-level `hobbies` (array, empty when the step was never completed) and `hobbiesReflection` (string or null). `skillsDetail` (`[{ name, level, description }]`) is present only when the step carries skills, and in that case the flat `skills` array is back-filled from those names when the applicant column is empty. An applicant who never completed the step gets empty arrays or null, never an error. |
| GET | /api/applicants/:applicationId/recommendations | Staff read, not a Host Portal route. Requires `applicant.view` plus an in-handler program and season scope check (401 without a session, 403 `FORBIDDEN` outside scope). Returns the active (not dismissed) recommendations for the application, newest first, each as `{ id, hostOrgId, hostName, note, recommendedAt }`. |

> **Both endpoints accept the same filters**
>
> Every filter rule in this section — age, nationality, language, gender, skills, availability, and maxDaysRemaining — applies identically to GET /api/partner/host/search-participants and to its /api/partner/host/talent-pool/search-participants alias. Implementations must not diverge between the two paths.

### Applied Program Filter (appliedProgram)

Both GET /api/partner/host/search-participants and its /api/partner/host/talent-pool/search-participants alias accept an optional appliedProgram query parameter that narrows the pool to applicants who applied to one programme family. The filter is applied to the applications before pagination, so page, pageSize and total reflect only the matching family.

| Value | Matches programme keys |
| --- | --- |
| support | summer_work_travel, summer_work_travel_wildpacks (the SWT – Camp programmes, i.e. Support Staff) |
| counselor | camp_counselor_wildpacks (Camp Counselor) |

- The public value is a fixed enum (support or counselor), not a programme name or caller-supplied programme key; Globus owns the mapping to exact programme keys and resolves them to programme IDs server-side.
- The resolved programmes are intersected with the host's agreement scope. If the intersection is empty the endpoint returns HTTP 200 with an empty page (not an error).
- A value that is not exactly support or counselor (or is not a single string) is rejected with HTTP 400 INVALID_QUERY_PARAM, with details { field: "appliedProgram", reason: "not_string" | "invalid_enum" }. The rejected value is not echoed back, and there is no silent coercion. Validation runs before any database lookup, on both the canonical path and the alias.
- Omitting the parameter leaves the pool unfiltered by programme family.

### Age Range Filter (min_age / max_age)

The age filter is expressed using the same parameter shape that the Rules Engine uses for its threshold_range age condition, so a host filtering the talent pool reasons in the same vocabulary as the eligibility rule that runs against each candidate (see the Matching Engine &rarr; Age Requirement hard filter).

| Parameter | Type | Unit | Required? | Meaning |
| --- | --- | --- | --- | --- |
| min_age | integer | whole years | optional | Inclusive lower bound. Candidates whose computed age is strictly less than min_age are excluded. |
| max_age | integer | whole years | optional | Inclusive upper bound. Candidates whose computed age is strictly greater than max_age are excluded. |

- Inclusivity. Both bounds are inclusive — a candidate matches when min_age &le; computed_age &le; max_age.
- Omission. Either bound may be omitted independently. Omitting min_age means “no lower bound”; omitting max_age means “no upper bound”; omitting both means the age filter is not applied at all.
- Age computation. Age is the number of whole years between the participant's date_of_birth and today (UTC) at request time. It is not computed against season start, job-order start, program start, or any other reference date — a candidate who turns 18 the day before the request becomes 18 in the engine's eyes the day before the request, regardless of when their placement would actually start.
- Eligibility-strict exclusion. Candidates whose computed age falls outside [min_age, max_age] are excluded from the result set entirely. They are not returned with an ineligible flag, are not down-ranked, and do not appear in pagination totals for the filtered query.
### Validation & 422 Error Shape

Both bounds are validated server-side before the search runs. A request that violates any of the rules below is rejected with HTTP 422 and the standard portal field-error envelope — no partial results, no silent coercion.

Validation rules

- min_age and max_age must each be integers (no floats, no numeric strings with decimals).
- 0 &le; min_age when supplied.
- max_age &le; 120 when supplied.
- When both are supplied, min_age &le; max_age.
- Negative values, non-integer values, and values above 120 are all rejected at the same gate; the engine never coerces them to the boundary.
HTTP/1.1 422 Unprocessable Entity

Content-Type: application/json

"error.code": "INVALID_AGE_FILTER",

"error.message": "min_age must be less than or equal to max_age",

"error.details.field": "min_age",

"error.details.reason": "min_greater_than_max",

"error.traceId": null

Only the first failing parameter is reported. error.details.field is min_age or max_age and error.details.reason is one of not_integer, negative, above_max or min_greater_than_max; error.message is the matching plain sentence (for example max_age must be less than or equal to 120).

### Composition with the Job Order Age Window

The talent-pool age filter is not the only age constraint that runs over a candidate. Each job order also carries its own min_age / max_age rule in requirements.matching.requiredAgeRange, evaluated by the Rules Engine's threshold_range condition (see Matching Engine &rarr; Age Requirement ). Both windows must be satisfied simultaneously, so the engine composes them as the intersection of the two ranges:

effective_min = max(host_filter.min_age ?? 0, job_order.min_age ?? 0)

effective_max = min(host_filter.max_age ?? 120, job_order.max_age ?? 120)

candidate matches ⇔ effective_min ≤ computed_age ≤ effective_max

> **Host filters can only narrow, never widen**
>
> A host filter cannot override the job order's own age window — it can only further restrict it. Submitting min_age=16 against a job order whose Rules Engine window is [18, 25] still excludes 16- and 17-year-olds, because effective_min = max(16, 18) = 18. This preserves the rules-engine ↔ talent-pool parity and prevents the host UI from accidentally surfacing candidates that would later fail the matching hard filter.

> **Empty intersection returns 200 OK with []**
>
> If effective_min > effective_max — for example a host requesting min_age=30 on a job order capped at max_age=25 — the engine returns HTTP 200 with an empty result list, not a 422 or 400. An empty intersection is a legitimate “no candidates fit your filter” outcome, not a request validation error.

### Worked Examples

Two short scenarios make the behaviour concrete. Both assume today (UTC) is 2026-04-28 and a candidate A with date_of_birth = 2007-09-01 (computed age = 18).

Example 1 — Host filter narrows the job order window

Job order requirements.matching.requiredAgeRange:

Host request:

GET /api/partner/host/search-participants?job_order_id=42&min_age=21&max_age=28

effective_min = max(21, 18) = 21

effective_max = min(28, 30) = 28

Candidate A (age 18) is excluded — 18 falls inside the job order's [18, 30] but outside the narrowed effective window [21, 28]. The response is HTTP 200 with a candidate list that contains only 21–28-year-olds.

Example 2 — Empty intersection returns HTTP 200 with []

Job order requirements.matching.requiredAgeRange:

Host request:

GET /api/partner/host/talent-pool/search-participants?job_order_id=42&min_age=30

effective_min = max(30, 18) = 30

effective_max = min(120, 25) = 25 → effective_min > effective_max

The intersection is empty. The engine still validates the request (min_age=30 alone is a legal value), so this is not a 422. The response is HTTP 200 with — a clear “no candidates fit” signal that the host UI can render as an empty state rather than as an error.

Historical note: this filter first shipped under Task #31 (changelog April 8–21, “Age filter added to search-participants”). That entry documents the ship event; this section is the authoritative behavioural contract.

### Nationality Filter (nationality)

The nationality filter narrows the candidate set to participants whose citizenship matches one of a host-supplied list of country codes. It is the host's view of the same data the matcher reads as a soft preference (see Matching Engine &rarr; Soft Preferences ) on the job order side.

| Parameter | Type | Required? | Meaning |
| --- | --- | --- | --- |
| country | string (comma-separated country values) | optional | Candidates whose nationality equals one of the supplied values are returned: a single value is an equality match and several are matched with an IN list. |

- Set semantics. Multiple values are unioned (logical OR). ?country=BR,AR admits Brazilian or Argentine candidates. Only a single string is read, so a repeated query parameter is not supported.
- Eligibility-strict. Candidates whose citizenship is outside the requested set are excluded from the result list and do not appear in pagination totals. They are not returned with a soft “not preferred” flag.
- Validation. Values are sanitised and compared as given against applicants.nationality. There is no ISO 3166-1 validation, no 1 to 50 entry limit and no 422 for unknown codes; an unknown code simply matches nobody.
> **Host filter is a plain country match; job order nationality fields differ**
>
> On the job order, nationality constraints live in requirements.matching.allowedNationalities and disallowedNationalities; there is no preferredNationalities ranking signal. The host's talent-pool country filter is a plain equality / IN match on applicants.nationality, and the search handler reads the job order only to look up its age-range rule.

### Language Filter (language / min_proficiency)

*Status: not implemented. The participant search handler reads no `language` or `min_proficiency` parameter, and `requiredLanguages` on the job order is a plain list of language strings with no per-language proficiency level; the contract below is a design.*

The language filter narrows the candidate set to participants whose self-reported language profile meets a minimum proficiency in a named language. It mirrors the matcher's Language Minimum hard filter so a host filtering the talent pool reasons in the same vocabulary as the eligibility rule that runs against each candidate.

| Parameter | Type | Required? | Meaning |
| --- | --- | --- | --- |
| language | string (ISO 639-1, lower-case) | optional | Language code the candidate must report. Single value only — multi-language requirements are expressed by repeating the call client-side or by upgrading the job order's requiredLanguages. |
| min_proficiency | enum: a1 \| a2 \| b1 \| b2 \| c1 \| c2 \| native | optional (defaults to b1 when language is present) | CEFR-style minimum proficiency the candidate must meet or exceed. Ignored if language is absent. |

- Pair semantics. The two parameters are evaluated as a single rule. Submitting min_proficiency without language is rejected (fields.min_proficiency: "requires language"); submitting language alone is treated as min_proficiency=b1.
- Ordering. Proficiency is ordered a1 < a2 < b1 < b2 < c1 < c2 < native. A candidate matches when their reported level for language is greater-than-or-equal to min_proficiency.
- Eligibility-strict. Candidates who do not report the language at all, or who report it below the threshold, are excluded from the result list. They are not returned with a “language gap” flag.
- Validation. language must match /^[a-z]$/ and resolve to a known ISO 639-1 code; min_proficiency must be one of the seven enum values. Unknown languages and out-of-enum proficiencies are 422-rejected, never coerced.
> **Composes by raising the threshold, never by lowering it**
>
> On the job order, requirements.matching.requiredLanguages is an array of entries that the Rules Engine runs as hard filters. When the host also supplies a language parameter for the same language code, the engine takes the maximum of the two thresholds — effective_min = max(host_filter.min_proficiency, job_order.min_proficiency). A host can therefore demand stricter language than the job order asks for, but cannot relax the job order's own minimum. When the host filters on a language the job order does not require, that filter is added on top and remains eligibility-strict.

### Gender Filter (gender)

*Status: partly built. The search accepts a single `gender` value (a sanitised string) matched by equality on `applicants.gender`. Enum validation, repeated or comma-separated values, `all` / empty-string rejection and the 422 responses below are not implemented, and the job order has no `genderBalance` field (see `JobOrderMatchingRequirementsSchema`).*

The gender filter narrows the candidate set to participants whose self-reported gender matches one of a host-supplied set. It exists to support sex-segregated housing and program rosters; it is not a generic preference signal — the matcher's job-order side carries that as a soft balance target only (see Matching Engine &rarr; Soft Preferences ).

| Parameter | Type | Required? | Meaning |
| --- | --- | --- | --- |
| gender | enum \| enum[]: female \| male \| non_binary \| prefer_not_to_say | optional | Repeatable query parameter or a comma-separated string. Candidates whose gender is not in the resulting set are excluded. |

- Set semantics. Multiple values are unioned (logical OR). The empty string and all are 422-rejected — “no filter” is expressed by omitting the parameter, not by sending a wildcard.
- Eligibility-strict. Off-set candidates are excluded from the result list and pagination totals. The engine never returns a candidate marked “wrong gender”.
- prefer_not_to_say is opt-in. Candidates who declined to report a gender are only returned when prefer_not_to_say is explicitly present in the filter set. They are not implicitly included by an unfiltered request — that case is governed by the job order's policy (see callout below).
- Validation. Values must come from the four-element enum after lower-casing. Out-of-enum values, all, and the empty string are 422-rejected with a fields.gender entry.
> **Host narrows; job order's gender_balance is ranking-only**
>
> On the job order, gender is carried as requirements.matching.genderBalance — a soft target (e.g., “~50/50”) that the matcher uses to rank candidates within the eligible set. It does not exclude anyone. The talent-pool gender filter runs after eligibility and may exclude candidates the matcher would still rank. It can only narrow the eligible set; it never reintroduces a candidate the Rules Engine ruled out for unrelated reasons.

### Skills Filter (skills)

The skills filter narrows the candidate set to participants whose recorded skills include at least one of a host-supplied list of skill keys. The matcher already weighs skills as a ranking dimension on the job order side (see Matching Engine &rarr; Skills Match ); this filter is the eligibility-strict counterpart for hosts browsing the pool.

| Parameter | Type | Required? | Meaning |
| --- | --- | --- | --- |
| skills | string (comma-separated; send the `value` of an option from `GET /api/partner/host/talent-pool/filters`) | optional | Candidates holding at least one listed skill are kept, whether it is recorded on the applicant `skills` array or on a completed Skills & Hobbies submission. |

- Set semantics — OR within the host filter. Multiple values are unioned (logical OR). A candidate matches when candidate.skills &cap; host_filter.skills &ne; &empty;. This matches the OR semantics shipped under Fix #1988 (“skills OR logic”).
- Eligibility-strict. Candidates with no overlap are excluded from the result list and pagination totals. The endpoint never returns a candidate flagged “skills mismatch”.
- Normalisation. Values are compared on a normalised form: case-insensitive, with underscores, hyphens and spaces treated as equal and `&` read as `and`, so `first_aid`, `First Aid` and `FIRST-AID` select the same participants. Both the applicant `skills` array and every completed Skills & Hobbies snapshot of the application are searched. The handler does not check values against a catalogue and does not reject unknown ones: an unknown value matches nobody, and a request with no usable value adds no skills condition. Picker options come from `GET /api/partner/host/talent-pool/filters`, which returns `skills` as de-duplicated `{ value, label }` pairs (slug and label spellings collapsed) alongside `positions`, `countries`, `genders` and `ageRange`.
> **Host OR composes with job order AND**
>
> On the job order, requirements.matching.requiredSkills is an array of skill keys the candidate must possess all of (logical AND, evaluated by the Rules Engine as a hard filter). The host's talent-pool skills filter is OR among itself and runs after the Rules Engine, so the effective rule is: candidate.skills &supseteq; job_order.requiredSkills &and; candidate.skills &cap; host_filter.skills &ne; &empty; A host filter can therefore only narrow the eligible set within the job order's required-skills floor. Submitting a host filter that has no overlap with any candidate's skills returns HTTP 200 with an empty list, not a 422.

### Availability Filter (available_from / available_to)

*Status: not implemented as a filter. The search handler reads no `available_from` or `available_to` parameter; availability start and end dates are only projected onto the result rows. The job order matching requirements carry `startDateMin` and `startDateMax`; there is no `placementWindow`, `job_order.start_date` or `job_order.end_date` in `requirements.matching`.*

The availability filter narrows the candidate set to participants whose declared availability window fully contains a host-supplied date range. Availability lives on the participant as availability.start_date / availability.end_date and represents the outer bounds of the season they can be placed for; the matcher requires full containment, not just partial overlap, because the host needs coverage for the entire requested window (see the “Containment, not just overlap” rule below).

| Parameter | Type | Required? | Meaning |
| --- | --- | --- | --- |
| available_from | ISO-8601 date (YYYY-MM-DD, UTC-anchored) | optional | Earliest date the host needs the candidate available. Inclusive lower bound on the host's required window. |
| available_to | ISO-8601 date (YYYY-MM-DD, UTC-anchored) | optional | Latest date the host needs the candidate available. Inclusive upper bound on the host's required window. |

- Containment, not just overlap. A candidate matches when their declared availability fully contains the host's required window: candidate.start_date &le; available_from &and; available_to &le; candidate.end_date. A partial overlap is not a match — the host needs coverage for the whole window, not just part of it.
- Omission. Either bound may be omitted independently. Omitting available_from means “no lower bound”; omitting available_to means “no upper bound”; omitting both means the availability filter is not applied.
- Eligibility-strict. Candidates whose declared window does not contain the host's window are excluded from the result list and pagination totals.
- Validation. Each bound must parse as a valid ISO-8601 date (no time component, no timezone suffix). When both are supplied, available_from &le; available_to. Reversed ranges, malformed dates, and timestamps with time-of-day components are 422-rejected.
> **Host window must contain the job order's program window**
>
> Each job order carries its program window in requirements.matching.placementWindow as ; the Rules Engine already filters out candidates whose declared availability does not contain that window. When the host also supplies an availability filter scoped to the same job order, the effective required window is the union of the two ranges: required_window = [ min(host_filter.available_from, job_order.start_date), max(host_filter.available_to, job_order.end_date) ] because the candidate must cover both at once. A host can therefore demand a longer window than the job order requires (e.g. early arrival or late departure) but cannot shrink the job order's own program window. When the host filter is not anchored to a job order (job_order_id omitted), only the host filter applies.

### Remaining-Window Filter (maxDaysRemaining)

*Status: not implemented on the talent-pool search endpoint. The search handler reads no `maxDaysRemaining` parameter; that name is only handled in the host reservations route (`server/routes/hostPortal/reservations.ts`).*

The remaining-window filter narrows the candidate set to participants whose remaining declared availability is short enough to fit a host operating on a tight calendar — for example a host backfilling late-season slots who only wants candidates whose visa or program window leaves them at most N days of runway. It is the only filter that has no direct counterpart in requirements.matching: the matcher does not derive a remaining-window cap from a job order today.

| Parameter | Type | Unit | Required? | Meaning |
| --- | --- | --- | --- | --- |
| maxDaysRemaining | integer | whole days | optional | Inclusive upper bound on candidate.availability.end_date − today (UTC) in whole days. Candidates with more remaining runway are excluded. |

- Computation. days_remaining = floor((candidate.availability.end_date − today_utc) / 1 day). Today is evaluated at request time in UTC, identically to the age filter's computed_age rule. A candidate matches when days_remaining &le; maxDaysRemaining.
- Negative values are matches, not errors. A candidate whose end_date is already in the past (i.e. days_remaining < 0) trivially satisfies any non-negative maxDaysRemaining and so is returned. Hosts who want to exclude expired candidates should layer the availability filter on top.
- Eligibility-strict. Candidates whose remaining-window exceeds the cap are excluded from the result list and pagination totals. The engine never returns a candidate marked “too much runway”.
- Validation. maxDaysRemaining must be an integer (no floats, no numeric strings with decimals) with 0 &le; maxDaysRemaining &le; 365. Negative values, non-integers, and values above 365 are 422-rejected with a fields.maxDaysRemaining entry — never coerced to the boundary.
> **No job-order-side counterpart today**
>
> Unlike the other filters in this section, the talent-pool maxDaysRemaining filter does not intersect with any field in requirements.matching. Job orders may shrink the required window via placementWindow (see the availability filter), but they do not declare a runway cap. Future work that introduces such a field will be specified here as an intersection rule; until then, maxDaysRemaining applies on its own and is composable with every other filter as a plain logical AND.

### Response Envelope & Per-Item Shape

A successful talent-pool query, against either the canonical /search-participants endpoint or its /talent-pool/search-participants alias, returns HTTP 200 with the standard success envelope. Its data object holds data (the page of participants) and pagination (page, pageSize, total, totalPages), plus the agreement-scope metadata fields (scopeEmpty, scopeEmptyReason, activeAgreementCount, inheritedFromParentOrgId, unscopedAgreementCount). Items carry no ranking block.

HTTP/1.1 200 OK

Content-Type: application/json

"data": TalentPoolItem[], // post-filter, eligibility-applied page slice

"pagination": page, pageSize, total, totalPages // total is the post-filter, eligibility-applied count

Each entry of items is a TalentPoolItem with the fields below. Fields marked scoped are only emitted when job_order_id is supplied; without a job-order context there is no ranking surface to project.

| Field | Type | Always present? | Meaning |
| --- | --- | --- | --- |
| id | string (UUID) | yes | Stable participant identifier; safe to use as a React key and for /talent-pool/:id/recommend. |
| displayName | string | yes | Pre-composed "First L." form, redacted per the host-org PII contract. Never the raw legal name. |
| dateOfBirth | string (ISO date, UTC) | yes | Used by the host UI to render computed age locally if needed; matches the value the age filter evaluates against. |
| computedAge | integer | yes | today_utc − date_of_birth in whole years, identical to the value the age filter uses (#talent-pool-age-filter). |
| nationality | string (ISO 3166-1 alpha-2) | yes | Single canonical country code. Multi-citizenship participants are projected through their primary citizenship only. |
| gender | enum (female\|male\|non_binary\|prefer_not_to_say) | yes | Self-declared; mirrors the gender filter enum (#talent-pool-gender-filter). |
| languages | Array< code: string; proficiency: 'a1'\|'a2'\|'b1'\|'b2'\|'c1'\|'c2'\|'native' > | yes | ISO 639-1 codes with CEFR level. Sorted by proficiency desc, then code asc, for stable rendering. |
| skills | string[] | yes | Canonical snake_case skill keys, deduplicated and sorted alphabetically. Matches the universe the skills filter accepts. |
| availability | start_date: string; end_date: string; days_remaining: number | yes | ISO-date window plus the floor((end_date − today_utc) / 1 day) value used by maxDaysRemaining. days_remaining can be negative. |
| ranking | score: number; rank: number; signals: Array< key: string; weight: number; matched: boolean > \| null | scoped to job_order_id | Matcher output for this candidate against the supplied job order. score is in [0, 1]; rank is 1-indexed within the post-filter result set; signals enumerates the ranking dimensions that contributed (preferredNationalities, genderBalance, requiredSkills bonus, etc.). Omitted (null) when job_order_id is not supplied. |

- Eligibility-strict alignment. items and total are computed against the same post-filter set. A candidate excluded by any filter in this section is excluded from both — there is no “total eligible vs total filtered” split. This matches the eligibility-strict semantics documented for every individual filter (e.g. skills, availability).
- No PII spillage. TalentPoolItem is the full surface area — emails, phone numbers, raw legal names, passport numbers, and document URLs are never included. Hosts that need a deeper view must traverse the explicit /talent-pool/:id/recommend → reservation → placement chain, which gates further disclosure on the host org's operating-agreement scope.
- Empty result is HTTP 200. A query whose post-filter set is empty returns items / total / page / pageSize, never HTTP 404. Empty intersections (e.g. host filter narrower than the job-order window) are a valid “no candidates fit” outcome, identical to the rule called out for the age intersection.
- 422 envelope. Any malformed parameter — whether a filter, page, pageSize, or sort — produces the same field-error envelope shared with Validation & 422 Error Shape. The offending key's name appears under fields; no partial result is ever returned.
> **ranking is null without a job_order_id**
>
> The matcher needs a job order to produce a score, so callers that omit job_order_id receive ranking: null on every item. Client code that wants to render rank chips should branch on item.ranking !== null rather than relying on default-zero scores. Submitting sort=score_desc in this mode is a validation error — see the Sort Contract.

### Pagination (page / pageSize)

The talent-pool endpoint paginates with offset-style page / pageSize parameters; limit / offset are also accepted and take precedence when supplied (limit is capped at 100, a negative offset is treated as 0, and page is derived as floor(offset / limit) + 1). Both the canonical endpoint and the /talent-pool/search-participants alias accept the identical pair, and both echo the resolved values back in the response envelope so clients can render “page X of Y” controls without re-deriving them. There is no cursor parameter today; pagination is stable enough for the host UI because the sort contract below guarantees deterministic ordering.

| Parameter | Type | Default | Range | Meaning |
| --- | --- | --- | --- | --- |
| page | integer | 1 | 1 or greater | 1-indexed page number. A missing, non-numeric or sub-1 value is treated as 1 and never rejected. Pages past the last populated page return an empty page with the real total still set. |
| pageSize | integer | 20 | 1 to 100 | Number of items per page. A missing, non-numeric or zero value falls back to 20, a negative value is raised to 1, and a value above 100 is clamped to 100 (never 422-rejected); the cap protects the matcher from full-table scoring runs. |

- total is the post-filter, eligibility-applied count. It reflects the exact set the engine would return if you walked all pages with the same filters — never the “all participants before filtering” cardinality. This is consistent with every filter in the section being eligibility-strict, and lets the host UI render an accurate “X candidates match” counter.
- Last-page semantics. page = ceil(total / pageSize) is the last populated page. Requesting a higher page returns HTTP 200 with items: [] and the same total; clients should detect end-of-results from items.length === 0 rather than from a 404.
- No validation errors. page and pageSize are never rejected: out-of-range values are clamped as described in the table above, so there is no fields.page or fields.pageSize 422.
- No cursor today. Cursor-based pagination is deliberately deferred — the matcher's sort keys are stable (see below) and the dataset size per host org is bounded enough that offset paging is performant. A future cursor parameter would be added here as an additive contract change.
### Sort Contract (sort)

*Status: not implemented. The endpoint has no `sort` parameter and always orders by application creation time descending, then application id descending.*

The sort parameter selects one of a fixed enum of ordering keys. The engine always applies a deterministic tie-breaker on id ascending so that repeated requests with the same filters return the same order, page over page. Sort keys that depend on a job-order ranking signal require job_order_id to be supplied; otherwise the request is 422-rejected with fields.sort.

| Key | Requires job_order_id? | Ordering |
| --- | --- | --- |
| score_desc | yes | Matcher score (item.ranking.score) descending. Default when job_order_id is supplied. Respects every job-order-side ranking signal: requirements.matching.preferredNationalities, requirements.matching.genderBalance, and the requiredSkills weight bonus from the Matching Engine. |
| score_asc | yes | Same signals as score_desc, ascending. Useful for diagnostic sweeps; not surfaced in the host UI by default. |
| available_from_asc | no | availability.start_date ascending. Earliest-available candidates first. |
| available_from_desc | no | availability.start_date descending. Late-window candidates first. |
| days_remaining_asc | no | availability.days_remaining ascending (shortest runway first). Mirrors the maxDaysRemaining filter (#talent-pool-max-days-remaining-filter). |
| created_desc | no | Participant record creation timestamp descending. Default when job_order_id is not supplied — it gives a reproducible "newest first" feed. |
| created_asc | no | Participant record creation timestamp ascending. |

- Defaults. When sort is omitted: the engine uses score_desc if job_order_id is supplied, and created_desc otherwise. The resolved key is not echoed in the response envelope today; clients that need to display the active sort should track the request value themselves.
- Stable tie-break. Every key is paired with a tertiary ascending sort on id. Two candidates with the same primary value will always appear in the same order across pages and across requests — pagination cannot duplicate or skip an item because of an unstable sort.
- Validation. Values outside the documented enum, or score_* without job_order_id, are rejected with the shared 422 envelope at Validation & 422 Error Shape, fields.sort set to a human-readable reason. The engine never falls back to the default on an invalid key.
- Sort runs after eligibility. Filters narrow the set; sort only orders what survives. Switching sort never changes total or which candidates are returned — it only changes their order within the page slice.
> **score_* mirrors the matcher, not just raw fit**
>
> score_desc is the same scalar the Matching Engine assigns when scoring an applicant against a job order (see Matching Engine &rarr; Ranking ). That means it already incorporates the job order's ranking-only signals — preferred nationalities, gender balance, and the bonus weight on requiredSkills overlap — on top of the eligibility-strict filters in this section. A host who sorts by score_desc and a recruiter looking at the same job order in the partner portal therefore see the candidates in the same order, which is the parity guarantee Tasks #302 / #303 established for the input side.

> **Where the operator sees this**
>
> Once a job order moves an applicant through to acceptance, the resulting placement appears on the People & Placements dashboard. For the live-app build spec for that read-only operator surface (endpoints, row shape, RBAC, audit, performance), see Placements Overview &rarr; Engineering Brief . The roll-up KPI surface (filled / reserved / available counts and fill-rate %) that the same job order rolls into — with the 30 s polling contract and program-manager-gated recompute action — is specified in the Placement Fill-Rate Dashboard .

### Pool Visibility Diagnostic (Staff)

Shipped Sep 4 2026. When a host's *unfiltered* talent pool is empty or smaller than expected, staff (permission `partners.view` plus organisation scope for the host — never Host Portal users) open **Host Management → host → Pool visibility** to see the live funnel and drill into the two stages that remove participants without changing any reservation, placement or applicant data:

- **Not under an active reservation hold** — participants removed by an active hold and not already covered by a live placement. Any active hold hides the participant, including the host's own job-order holds, with one exception: a staff host-level hold (`staff_host`, no job order) owned by this same host is an earmark for that host and does not remove the participant. Holds owned by other hosts still do.
- **Not already placed with a host** — participants removed by a confirmed or active placement and not already removed by a hold. Terminal placements are excluded.

A participant appears at most once across the two lists (same per-participant ranking as the live pool, even with multiple applications). The read uses the same funnel as the pool itself (agreement scope, market-ready status, hold and placement checks), so the aggregate count and the dialog's own total are both point-in-time reads that can legitimately differ if a hold or placement changes between them; the dialog reports its own total. A forbidden or failed read is a scope/permission problem to resolve with the caller's organisation scope, not evidence that the pool is empty. The stage drilldown endpoint (`GET /api/admin/partners/organizations/:orgId/talent-pool-visibility/:stage`, `stage` is `reservation_holds` or `placements`) returns a typed failure: a 403 `FORBIDDEN` when the caller is outside the organisation's scope, or a 500 `POOL_VISIBILITY_DETAIL_READ_FAILED` ("The participant details could not be read.") if the read itself throws; both carry the request correlation id as `error.traceId`. When the read fails for a reason other than authorization, the dialog shows a "Support code" line (the error code), plus a "Reference" line when a trace id is present, under the retry message. One row per participant is enforced in the query itself (one distinct row per applicant; the hold with the latest expiry, or the placement with the latest start date, represents the applicant, with the record id as a tiebreaker) rather than by filtering rows afterwards, and the hold expiry and placement dates are returned as ISO timestamps.

## Host Defaults → Automatic Camp Orders (Sep 2026)

Shipped Sep 7 2026 (host-management contract true-up). Host Management **Staff Info** now carries validated per-host defaults for **Uniform** (`{ provided, cost, currency, costDue }`) and **Required Payment** (`{ required, details }`), alongside the housing-cost defaults, editable by staff holding `partners.manage` via `PATCH /api/admin/partners/organizations/:id` (change-registry effective version `2029-01-05`):

- An unanswered value is *incomplete*; an explicit `false` answer is *complete*. Dependent cost / currency / due / payment details are cleared when the controlling answer is No. Values are trimmed, capped at 2,000 characters and validated before save. When a Uniform cost is supplied without a currency the stored currency is `USD`; an explicit currency must be a three-letter ISO 4217 code and is stored upper-case (anything else is rejected). On automatic orders the currency is carried as `grooming.uniformCostCurrency`.
- **Structured offer defaults are validated against published compensation rules.** When a staff create or update of a host's structured offer default (Host Offer Details, `PUT /api/admin/partners/organizations/:orgId/offer-details?programId=...`) supplies `salary` or `salaryFrequency`, the proposed wage is evaluated in candidate mode against the currently published `job_order_compensation` rule, using the program's brand (not the host's cached brand), the host's associated seasons (or the program's active seasons for a program-level association), the shared parameter cascade and the rule's authored reason-code message. When only one of the two fields is sent, the stored value of the other is used. A blocking violation returns the standard 400 validation envelope with `details.fields.salary` and leaves the default unchanged. A `season` frequency additionally requires `salary_for_season` (and `salary_for_season` is rejected for any other frequency), reported under `details.fields.salary_for_season`.
- **Structured offer defaults are keyed by position type.** When the program has an active position-type catalogue, the same `PUT` requires `positionType` (an active catalogue code) in the query and each host, program and position-type pair holds its own record; a missing or inactive code is a validation error on `details.fields.positionType`, with `allowedPositionTypes` listed. A program without a catalogue keeps one untyped record and rejects `positionType`. Updating an existing record requires its current `editStatus` in the body, otherwise `409 CONFLICT` with `details.code` `EDIT_STATUS_REQUIRED`; a stale `editStatus` returns `409 CONFLICT` with `details.code` `EDIT_STATUS_CONFLICT`, and a concurrent create of the same pair returns `409 CONFLICT` with `details.code` `DUPLICATE_OFFER_DEFAULT`, each carrying `currentEditStatus`.
- **Synchronisation to automatic orders is atomic.** Saving a host default updates every existing non-cancelled automatic **Camp Counselor** or **Support Staff** order for that host, merging only the `grooming` uniform keys or the `compensation` payment keys — manual orders and unrelated requirement keys are untouched. Future automatic orders inherit the current defaults at creation. If the synchronisation fails, the host save rolls back rather than leaving host and orders out of step.
- Related contract corrections in the same window: `camperAgeRange` on `/api/partner/host/overview` is canonicalised to `{ min, max }` on write and read (legacy strings such as "7-16" are normalised; malformed or inverted values reject with 422), and the automatic-order route tests were corrected to the shipped behaviour (`b028a5de2`) — no behavioural change to auto-order creation beyond the defaults inheritance above.
- **Automatic order capacity (Task 2487).** The automatic Camp Counselor and Support Staff orders created at camp host organization creation no longer share a position count. Every number follows the environment-variable-first, hardcoded-default convention (an invalid value is logged and falls back rather than failing host creation):

| Order | Position count | Reservation head-room |
| --- | --- | --- |
| Camp Counselor | `CAMP_COUNSELOR_JOB_ORDER_POSITIONS` (default 50), unchanged | none, and no quota row |
| Support Staff | `SUPPORT_STAFF_JOB_ORDER_POSITIONS` (default 1) | `SUPPORT_STAFF_JOB_ORDER_RESERVATION_QUOTA` (default 3) candidates held at once |

  The Support Staff order gets over-recruitment head-room sized to the configured candidate count (`ceil((candidates - positions) / positions * 100)` percent; one position with three candidates is 200 percent) and its own active, job-order-scoped `reservation` quota row, created idempotently and never overwriting a staff-adjusted limit, so the ceiling is visible and editable on the admin partner-quota surface. Hiring stays capped at the position count (see Reservation Ceiling vs Hire Ceiling above). The quota row records the host's auto trial agreement only so the agreement-keyed admin list shows it; it binds by host plus job order (see Quota Model). Creating the quota never rolls back the host or the order: a failure leaves an active but uncapped order, is logged, and writes a `job_order.auto_create_quota_failed` audit entry; the `job_order.auto_created` audit entry now carries `reservationQuotaId` and `reservationQuotaLimit`. A paired idempotent migration (`20271125000000_task_2487_support_staff_auto_order_capacity`) moves existing auto-created Support Staff orders (title Support Staff, source internal, camp-family program) to one position, 200 percent over-recruitment and a quota of 3 using the hardcoded defaults; orders already holding more than 3 committed seats or more than 1 confirmed placement are left untouched.

> **Unconfirmed for this window: "cancellation history"**
>
> The Sep 15 2026 30-day report lists "cancellation-history tracking" under host and job-order operations, but none of the referenced commits (`74c6df437`, `b028a5de2`, `85c6daf8d`) add a cancellation-history surface, and the only cancellation-related change-registry entry in the repository is the earlier reservation-decline reason-code change. Treat cancellation history as **not documented as shipped** until a commit or contract can be cited.
