---
title: "Quota & Capacity"
slug: quota-model
section: Placement & Matching
status: stable
description: "What quotas are, where they live, what types exist, and how they are enforced"
last-updated: 2026-10-07
---

# Quota & Capacity

What quotas are, where they live, what types exist, and how they are enforced

> **Quotas Are Governance Controls**
>
> Quotas are governance controls that limit how many applicants, reservations, or placements a partner (or internal office) may create within a defined scope. They are not advisory. They are enforced at the system level.

Quotas protect:

Sponsor risk exposure / Host capacity / Compliance thresholds / Fair distribution between partners
## Where Quotas Live

In the future-state architecture, quotas are defined and enforced by Globus. During the transitional phase, quota limits may be operationally tracked but are not yet system-enforced across all programs.

Quotas are always scoped by:

Brand / Program / Season / Partner (or internal office)
For details on how these scoping dimensions are structured, see Globus Capability Layers (Layer 1: Program Register, Layer 2: Season/Period).

## Types of Quotas

### 1. Candidate Intake Quota

Maximum number of new applicants a partner may create for a given program + season.

Example

Partner A may submit up to 200 applicants for Camp Counselor — Summer 2026.

Purpose

Prevents uncontrolled intake volume.

### 2. Active Pipeline Quota

Maximum number of applicants a partner may have “in progress” at one time.

Example

No more than 75 applicants simultaneously in Under Review / Approved state.

Purpose

Prevents partners from flooding the system with unfinished candidates.

### 3. Reservation Quota

Maximum number of positions a partner may reserve at once.

Example

Partner may hold 10 open job reservations at any given time.

Purpose

Protects host demand allocation and prevents job hoarding.

### 4. Reservation Hold Policy & Auto-Expire

*Status: the 72-hour default and the per-program `reservationTimeoutHours` override described in this subsection are not built. In code the base hold window is 7 days (`RESERVATION_RULES.BASE_DURATION_DAYS`) or the Rule Library `holdDurationDays` for the scope.*

**Effective hold-duration read.** `GET /api/admin/partners/agreements/:agreementId/effective-hold-duration` (API key scope `partners.read`, no additional permission, so any authenticated staff member, with the organisation scope check) returns the hold window the Rule Library cascade resolves for the optional `brandId`, `programId` and `seasonId` query parameters. The body is `durationDays`, `source` (`rule_library` or `fail_safe_default`), `ruleDefinitionId`, `effectiveRuleVersionId` and `fallbackReason`. Without a `brandId`, or when the brand's rule is missing or unpublished, the result is the 7-day fail-safe with a `fallbackReason`. An unknown agreement returns `404` and an agreement outside the caller's organisation scope returns `403` in the envelope `{ error: { code, message, details, traceId } }`. The agreement id is used only for that scope check; the per-agreement override was removed.

Every reservation hold carries a TTL recorded at creation time in `reservation_holds.expires_at` (referred to elsewhere on this page as holdExpiresAt). When the TTL passes without a confirmation, the hold automatically transitions to Expired and the seat returns to the job order’s available pool. This subsection is the canonical Layer 7 specification for the reservation timer, the auto-expire transition, the per-program timeout setting, the sweeper that performs the transition, the audit entry it writes, and the countdown contract both portals consume.

> **72 hours is the system default — not a hard-coded value**
>
> The system default reservation timeout is 72 hours, applied when no narrower scope overrides it. Programs may override the default by setting reservationTimeoutHours on the program scope — see Settings & Feature Flags for the setting contract and scope inheritance for how brand / program / season overrides resolve. Per-reservation-type variations (Standard 72h, Priority 120h, Bulk 48h) and the Reservation Extension workflow are recruiter-side knobs documented in Partner Portal &rarr; Reservation Rules . They sit on top of this base timer; they do not replace it.

#### State Machine: Active &rarr; Confirmed | Expired | Cancelled

Every reservation row moves through exactly one of three terminal outcomes. The transition from Active to Expired is the only one performed by the system on its own — the other two are user-initiated.

Active

Hold counts against capacity. Countdown ticking toward holdExpiresAt.

&rarr; 
Confirmed

Partner / staff confirms before TTL. Converts to placement.

Expired

TTL elapsed without confirmation. Sweeper writes the transition.

Cancelled

Partner / staff explicitly releases the hold before TTL.

Notes on the three terminal transitions:

s positionsPlaced and decrements positionsReserved in a single transaction. Surfaced as
> **Out of scope here**
>
> Reservation Extensions (which reset holdExpiresAt forward before the timer fires) and the hold-age escalation ladder (hold_warn_hours / hold_cut_hours / hold_freeze_hours) are agreement-level mechanics owned by the partner-side spec. They modify or react to holdExpiresAt but do not change the state machine above — see Partner Portal &rarr; Reservation Extension Workflow and Partner Organizations &rarr; Policy Knobs .

#### Extension Approval Expiry Flooring

When a Reservation Extension is approved, the new holdExpiresAt is **floored at the current time** (Aug 10 2026 fix) — an approved extension can never produce a hold that is already expired at approval time. The extension-approval hold update is pinned to active status and fails loudly if it races the expiry sweeper (no silent lost update), and the nightly reconciler reports any approved-but-expired holds it finds so they can be repaired rather than silently swept.

#### Capacity Release on Expiry (Atomic)

When a reservation transitions to Expired, the same database transaction that flips the status must also release the held seat back to the job order’s available pool. The two writes are inseparable: a status flip without a counter decrement, or vice versa, is a release-blocking integrity break.

Decrement positionsReserved on the parent job order by exactly 1, in the same transaction as the reservation status update. / Hold a row-level lock on the job order row (SELECT … FOR UPDATE) for the duration of the transaction so the release composes safely with concurrent reservation writes. / Re-derived counters from raw rows must continue to satisfy positionsReserved + positionsPlaced ≤ positionsTotal after every sweep run — verified nightly by the reservation-placement-integrity-scan. / If the transaction fails for any reason, neither write is committed; the sweeper retries the batch on the next tick (see Sweeper subsection below).
> **Cross-references**
>
> Capacity counter contract: Job Orders &rarr; Capacity Counters Concurrency / row-lock spec for the same transaction: Layer 7 &rarr; Reservation Concurrency & Placement Integrity

#### Per-Program reservationTimeoutHours

The reservation timer is configurable per program. The setting is registered on the Settings & Feature Flags page and resolved through the standard scope inheritance chain (system &rarr; brand &rarr; program &rarr; season). The narrowest set value wins; absent any override, the system default applies.

| Field | Value |
| --- | --- |
| Setting key | reservationTimeoutHours |
| Type | integer (hours) |
| System default | 72 |
| Allowed range | 1 – 720 (1 hour minimum to enforce a meaningful hold; 30 days maximum to prevent indefinite job-order locking) |
| Scope | System / Brand / Program / Season — narrower scope overrides broader (see Feature Flag Scoping) |
| Edit permission | Super Admin and System Admin via the Settings admin page; no portal-side edit surface |
| Resolved at | Reservation creation — the value in effect at insert time is captured in holdExpiresAt and does not change retroactively if the setting is later edited |
| Audit | Every change is audit-logged with the previous value, new value, scope, and acting staff member |

> **Worked example**
>
> A sponsor wants Camp Counselor reservations to hold for 96 hours instead of the default 72. A System Admin sets reservationTimeoutHours = 96 at Program scope on Camp Counselor. New Camp Counselor reservations now compute holdExpiresAt = createdAt + 96h; all other programs remain on 72h. Reservations created before the change keep their original 72h holdExpiresAt — the resolved value is frozen at insert time.

#### Reservation Expiry Sweeper

Auto-expiry is performed by a dedicated batch job that runs every 10 minutes. It is the only writer that performs the Active &rarr; Expired transition. The full job spec — cadence, scope, idempotency, retry policy, and alert routing — is registered in the central job catalog:

Batch Jobs &rarr; reservation-expiry-sweeper &rarr;
> **Two distinct scanners — do not conflate**
>
> The expiry sweeper is intentionally separate from the Reservation Countdown Scanner that emits the host reservation digest notifications (host.reservation_three_day_digest to the host, host.reservation_expiring_am to the account manager). The sweeper writes status transitions, capacity counters, and audit rows; the countdown scanner emits warning events into the notification pipeline. The two have different cadences (10 min vs. once daily, 06:00 UTC by default), different failure modes, and different RBAC owners — see Data Flows &rarr; Scheduled Background Scanners for the side-by-side comparison.

#### Expiration Audit Entry Shape

Every auto-expiry writes one row into the generic audit_logs table (entityType = "reservation_hold", action = "reservation.expired"). The write happens after the status flip and capacity decrement, in its own try/catch: a failure is logged and does not abort the sweep. The field table below is the target shape; the row written today has no reason or correlationId field and records status, expiredAt, expiredBy, jobOrderId, applicationId, partnerOrgId, expiresAt and createdAt inside newValue. The original reservation timestamp must be preserved on the audit row so downstream readers can reconstruct “how long the hold lived before it expired” without needing to join back to the (now-expired) reservation row.

| Field | Type | Required content |
| --- | --- | --- |
| entityType | string | Literal 'reservation' |
| entityId | uuid | The reservation row's id |
| jobOrderId | uuid | Parent job order id (denormalised so the row is queryable without a join) |
| applicantId | uuid | Applicant the reservation was held for |
| previousStatus | string | Literal 'active' (or 'extended' if Reservation Extension had been applied) |
| newStatus | string | Literal 'expired' |
| reason | string | Literal 'timeout' |
| actor | string | Literal 'system:reservation-sweeper' — distinguishes auto-expiry from user-initiated cancellations |
| reservationCreatedAt | timestamp | The original reservation createdAt — preserved verbatim from the reservation row before deletion / archival. Required by the acceptance criteria. |
| holdExpiresAt | timestamp | The resolved TTL boundary, including any Reservation Extensions that pushed it forward |
| occurredAt | timestamp | Sweeper transaction commit time — the moment auto-expiry actually happened |
| correlationId | uuid | Sweeper batch run id — every row in the same sweep shares this id for traceability |

> **Append-only**
>
> Auto-expiry audit rows follow the same Layer 7 audit guarantees as placement history: insert-only, never updated or deleted. A correction (e.g. a reservation that should not have expired) is recorded by appending a new audit row describing the corrective action, not by editing the original. See Layer 7 &rarr; Append-Only / Immutability Contract .

The host-portal Reservation Statuses table reads from this audit row when a reservation is shown in its terminal expired state — the timestamp displayed alongside the status is occurredAt, and the “held for” duration is derived from occurredAt &minus; reservationCreatedAt.

#### Countdown Display Contract

Both the partner portal and the host portal render a per-reservation countdown alongside every active hold. The countdown is a thin presentational component — it reads only from holdExpiresAt and the client clock, and never re-computes the timer itself. This subsection is the canonical contract that both portals consume so the UX stays consistent.

| Aspect | Contract |
| --- | --- |
| Source field | reservation.holdExpiresAt — the resolved TTL boundary written at reservation insert (and re-written if a Reservation Extension is approved). Never computed client-side from createdAt + reservationTimeoutHours. |
| Display format | Largest non-zero unit + next unit (e.g. '2d 4h', '18h 32m', '47m'). Switches to a 'less than 1 minute' label at the final tick. |
| Refresh cadence | Tick once per minute while > 1h remains; once per second while ≤ 1h remains. Components must use a single shared interval, not one per row. |
| Empty state | If the reservation row has no holdExpiresAt (should not happen for active rows; defensive) the component renders an em-dash and a tooltip explaining the missing TTL. |
| Expired state | When holdExpiresAt is in the past, the component renders the literal label 'Expired' in red and stops ticking. The status transition to 'expired' is not the component's responsibility — it waits for the sweeper to flip the row and the page to re-fetch. |

Color thresholds (mirroring the partner-portal calendar countdown green / amber / red urgency scheme, scaled from days down to hours since reservation TTLs are short-lived):

> **Notification vs. countdown**
>
> The Amber transition does not emit the host.reservation_three_day_digest or host.reservation_expiring_am notification — the notification is owned by the Reservation Countdown Scanner on the server. The component just tints the same in-portal row to match. This keeps the countdown a pure presentation concern.

Operational note: repeated expiry by a partner can trigger escalating consequences:

Reduce quota allocation / Trigger probation status / Temporarily suspend reservation rights
### 5. Scoped Quotas (Optional)

Quotas may be further restricted by additional dimensions for fine-grained partner agreements:

Country of recruitment / Job family / Visa type / Program model

A `reservation` quota row may also be scoped to a single job order (`job_order_id` set). Such a row is a ceiling on that job order and is matched by host plus job order only, never by the agreement the host quotes when it reserves: consumption prefers the job-order-scoped row over the broad row, surfaces its frozen or exhausted state instead of silently falling back to the broad quota, and the idempotent path resolves it the same way. Broad rows (no job order) keep their agreement scoping. The camp Support Staff auto job order uses this: its ceiling of 3 concurrent reservations is created at host organization creation, when only the auto trial agreement exists, while the host later reserves under its operating agreement (Task 2487). `ensureJobOrderReservationQuota` creates the row idempotently, keyed on host, reservation type and job order.

**Season capacity waitlist (staff routes).** Program-season capacity is separate from partner quotas. When a season is at capacity and its waitlist is enabled, applicants are queued. `GET /api/admin/programs/:programId/seasons/:seasonId/waitlist` (permission `capacity.view`) lists active entries in queue order (query `page`, `pageSize` up to 200 with default 50, `includeRemoved`, `includePromoted`). `POST` on the same path (permission `capacity.adjust`) enqueues an applicant by `applicantId` through the capacity engine: 201 with the entry, or 409 `CONFLICT` when no entry was created (season not full, or waitlist full or disabled). `POST /api/admin/waitlist/:entryId/promote` (permission `waitlist:promote`, no body) is the only way an entry is promoted; there is no automatic promotion. It clears the waitlisted flag on the application, renumbers the remaining queue and returns the entry (401 without a staff user). `POST /api/admin/waitlist/:entryId/remove` (permission `capacity.adjust`) requires `removalReason` of 5 to 500 characters.
## What Quotas Do Not Control

> **Quotas Are Volume Controls, Not Quality Controls**
>
> Quotas only control volume and allocation boundaries. They do not replace other governance mechanisms.

Quotas do not:

Override eligibility rules (see Rules Engine) / Guarantee placement / Replace compliance review / Automatically approve applicants / Gate host-user creation — since Aug 2026, adding host users does not require an allocated quota (the admin-exists and active-agreement governance rules still apply; see Host Portal &rarr; User CRUD Operations)
## Enforcement Model

For details on how RBAC and partner agreements interact with quota enforcement, see RBAC & Governance and Partner Portal Governance.

**Agreement and batch quota errors.** Before the per-resource quota is debited, the agreement spine (`enforceAgreementSubmission`) checks the agreement. It runs when a reservation hold is created and on a partner applicant invite. It returns 422 `AGREEMENT_EXPIRED` when the agreement status is `expired`, `terminated`, `suspended` or `ended`, or its end date has passed (`details`: `agreementId`, `status`, `endDate`, `reason`); 422 `AGREEMENT_SCOPE_MISMATCH` when the brand, program or season is outside the agreement scope; and 422 `AGREEMENT_QUOTA_EXHAUSTED` when the agreement's `quota_used` has reached its `quota` (`details`: `quota`, `quotaUsed`, `remaining` 0). An agreement with no `quota` is not capped. A bulk partner invite first runs `batchPreCheck` on the `submission` quota and returns 409 `BATCH_QUOTA_EXCEEDED` when the number of valid rows exceeds the remaining quota (`details`: `quotaType`, `requested`, `remaining`, `limitValue`, `usedValue`). With no active quota row the batch is allowed; a frozen quota returns 403 `QUOTA_FROZEN`.

## Why Quotas Matter Architecturally

Quotas connect recruitment, demand, and compliance. They are a governance control, not a marketing tool.

> **Quotas Are a Governance Control**
>
> Without quotas, “quota” remains a vague promise. With this enforcement model, the CTO understands it is a system layer, ops understands what gets blocked, stakeholders understand fairness, the roadmap dependency becomes clear, and the Partner Portal necessity becomes obvious.

> **Where the operator sees this**
>
> The People & Placements dashboard exposes quota fill (Allocated / Filled / Remaining) as the live readout of this model. See the live-app build spec: Placements Overview &rarr; Summary Endpoint .
