---
title: "Partner Organizations"
slug: partner-organizations
section: Portals
group: Partner Portal
status: stable
description: "Canonical reference for the Partner Organization concept — taxonomy, governance, and how both categories operate across the ecosystem"
last-updated: 2026-10-07
---

# Partner Organizations

Canonical reference for the Partner Organization concept — taxonomy, governance, and how both categories operate across the ecosystem

## Taxonomy

> **Partner Organization Taxonomy**
>
> In the Globus ecosystem, “Partner Organization” is a single concept with two categories: (1) Employer (Host) — managed through the Host Portal, and (2) Recruiter — managed through the Partner Portal. Recruiters include both external third-party recruiting companies and internal sponsor staff (sales offices, regional recruiting teams). Internal recruiters use the same Partner Portal and perform the same functions as external recruiters (e.g., completing applications on behalf of applicants), but may have different RBAC configuration. This design keeps Globus focused on back-office processing.

Two Categories, One Concept

Employer (Host)

Organizations that place exchange visitors — employers, schools, camps. Managed through the Host Portal.

Creates job orders / Confirms placements / Manages housing & logistics / Submits evaluations
Recruiter

Organizations that recruit applicants — external agencies or internal sponsor staff. Managed through the Partner Portal.

Submits applicants (invite or bulk) / Tracks recruitment pipeline / Manages allocated quotas / Browses job orders (if permitted)
Recruiter Sub-Types

## Employer (Host) Scope

Host organizations are the demand side of the placement equation. They create job orders, confirm placements, manage housing, and submit evaluations. Full detail is available on the Host Portal Governance page.

### Lifecycle Summary

The first arrow — Pending Verification &rarr; Active — is a sponsor-authored transition gated by EIN-on-file, identity completeness, a primary contact, an automated verification report, and zero open blocking flags. The full gate checklist, responsible role (Sponsor Admin), UI surface, and the resulting partner_organization.activated event are specified in Host Portal Governance &rarr; Pending Verification &rarr; Active Transition .

> **Camp program type: verification gates DS-2019 only**
>
> For program type **camp**, pending_verification no longer blocks portal operations. A camp host with an accepted operating agreement runs the full portal (job orders, placements, documents, site visits) while verification is pending; verification's only hard gate is DS-2019 issuance (refused with 403 `HOST_VERIFICATION_REQUIRED` until passed). A verification *failure* still halts operations via a blocking flag, freezing confirmed placements pending sponsor remediation or reassignment. Non-camp program types retain the front-loaded gate described above unchanged. Canonical spec: Host Portal Governance &rarr; Camp Program Divergence — Verification Gates DS-2019 Only.

### Portal Capabilities

Create and manage job orders / Confirm placements / Update housing information / Submit evaluations / Confirm arrival compliance / Manage team (Host Admin, Hiring Manager, Finance, Read-Only) / Upload compliance documents / View assigned participants
For full capabilities, onboarding flow, agreement structure, and autonomy framework, see Host Portal Governance . Staff sync of a host's program associations: `POST /api/admin/partners/organizations/:orgId/program-associations/sync` (permission `partners.manage`; org scope enforced; audited as `partner.org.program_association.sync`) takes `programIds` and makes that list the org's active set. Associations missing from the list are deactivated, deactivated ones that reappear are reactivated, and new programs get an association without a season; the response is the org's full association list. It returns 403 `FORBIDDEN` when the org, or any listed program, is outside the caller's scope. The sponsor-side fields captured when a host org is created (and how they relate to the Host Portal Camp Overview profile) are documented in Org Creation — Program-Type Field Capture .

> **Host Portal Contract Surfaces (canonical index)**
>
> Each capability above is owned by a contract spec on the Host Portal side. This page is the index; the linked spec is the normative source for endpoint shape, RBAC, and audit rules.

### RBAC Summary

Full RBAC detail including enforcement model and scope hierarchy is on the RBAC & Governance page.

**Open staff reads (Task 969).** `GET /api/admin/partners/organizations/:orgId/uploads`, `.../operational-summary`, `.../document-requirements` and `GET /api/admin/partners/documents/:id` carry no `partners.view` permission check. They use `isAuthenticatedOrApiKey("partners.read")`, so any authenticated staff session passes (an API key needs the `partners.read` scope), and the RBAC route-gate registry lists them as `open-by-design`. Per-record org scoping is applied in the document-by-id handler through `enforceOrgScope`; the uploads, operational-summary and document-requirements handlers do not call it.

### Parent / Children Panel (Host Management)

Every host org record management surface renders a Parent / Children panel. The panel is the post-creation counterpart to the optional Parent Organization step in Org Creation and is the only surface that lets sponsor staff change or clear an existing parent link. Hosts do not see or edit this panel from any portal.

Panel Contents

- Current parent. Renders “Parent: ” with a link to the parent’s detail page, or “No parent” when parent_organization_id is NULL. A Clear parent button is shown whenever a parent is set.
- Children list. Renders every active host org whose parent_organization_id equals this org. Each row links to the child’s detail page and shows which inheritance flags are currently on for that child. Empty state: “This org has no children.”
- Change parent action. Opens the same typeahead picker described in Org Creation, validated under the same rules (single-level, no self-reference, no cycles, parent must be an active host org with NULL parent). On submit the change emits partner_organization.parent_changed when replacing a prior parent, or partner_organization.parent_linked when setting one for the first time.
- Clear parent action. Sets parent_organization_id = NULL and forces every inheritance flag off as part of the same transaction (a child cannot inherit from nothing). A confirmation modal lists each flag currently on and what value will be snapshotted into the child before the link is cleared. Emits partner_organization.parent_cleared.
- Inheritance toggles. One row per inheritable field (operating agreement, contacts, compliance documents). Each row shows the toggle, the resolved source (“Inherited from ” or “Own value”), and a peek link to the source record. Disabled when no parent is linked. Flipping a toggle emits partner_organization.inheritance_toggled.
> **Sponsor Staff Only**
>
> The Parent / Children panel and every action on it are gated to sponsor staff. Host Admins, Hiring Managers, Finance, and Read-Only host users cannot view or edit parent links or inheritance toggles from the Host Portal — the surface is admin-console only. The data model and full audit-event vocabulary live in Parent / Child Hierarchy below.

> **Performance Tiles render per program type**
>
> The Performance section on this same Host Management surface is program-type scoped. When the host org is associated to a Camp program, the section renders exactly five Camp-specific tiles (Conversion Rate — Hired vs Not Hired, Avg Processing Days — Reserved-to-Hire Time, Reservation Expiry Rate, Completion Rate — Hired to DS-2019 Issued, Participant Types — Returner vs New) and nothing else — the generic 10-metric strip does not render for that host. The full view contract, mapping back to the canonical metric catalog, and the multi-program / no-program behavior live in Performance Tiles &rarr; Program-Type Scoping .

> **Account Manager is not inherited**
>
> The sponsor-side Account Manager (partner_organizations.account_manager_user_id) lives on the host org row itself and is never inherited from parent to child. Each child host carries its own AM pointer (or NULL) independent of the parent’s. Inheritance toggles on this panel cover operating agreement, contacts, and compliance documents only — AM ownership is intentionally excluded in v1 so multi-location hosts can have per-site account managers without breaking the parent’s portfolio rollup. See Host Portal Governance &rarr; Host Account Manager Assignment .

**Unowned hosts (staff).** `GET /api/admin/partners/unowned-hosts` (`partners.manage`) lists hosts left without a working Account Manager: `reason` is `missing_account_manager` (no AM on a host that has assignment history) or `inactive_account_manager` (the AM is a deactivated user, returned as `staleAccountManager`). `POST /api/admin/partners/unowned-hosts/:id/reconcile` (`partners.manage`, optional body `reason`) redistributes one host through the same reconciliation as a live AM departure and writes a `host_organization.orphan.reconciled` audit row. The host is re-derived from the current unowned set, so a host that is no longer unowned returns 409 `CONFLICT` with `details.code` `HOST_NOT_UNOWNED`; the caller cannot supply the stale manager.

### Applicants Tab — Source Column

The sponsor-side Host Management surface has an Applicants tab listing every applicant currently associated with a given host org. The tab today does not tell staff how each applicant got attached to that host — whether sponsor staff pushed them via the AM-side recommend flow, or whether the host self-served by reserving them against an open job order from the Host Portal. The Source column closes that gap. It is derived per-read from two existing tables — no new column on applications or placements, no migration, no backfill.

#### Row Interaction

Each row on the Applicants tab is itself the click target. Clicking anywhere in the row — not just the Name cell — navigates to the full Applicant Detail Page at /applicants/ . The Name cell remains the visible affordance and accessible link text, but the entire row is the hit area so staff don’t have to aim for one column.

> **Full-page detail, not split-view**
>
> This surface opens the full-page Applicant Detail Page — it does not open the right-side panel of the Split-View Applicant Workspace documented on Applicant Lifecycle. The Split-View surface is reserved for entries from the Applicant Queue, where queue-context preservation matters. Host Management is a host-centric workspace, not a queue, so there is no surrounding queue state worth preserving in a left panel — the full-page detail is the right surface. Per the parity rule on the Applicant Detail Page section, both surfaces render the same blocks against the same data.

Keyboard & accessibility: each row is a single focusable link — Tab moves focus row-to-row, Enter activates and navigates to the full Applicant Detail Page. The row does not contain nested interactive controls (buttons, secondary links, menu triggers) that would compete with the row click or trap keyboard focus inside the row. The Source filter facet, the Reserved/Recommended At sort, and every other behavior documented on the surrounding sub-sections are unchanged by this contract.

#### Applicants Tab Column Contract

The Applicants tab has no canonical column contract documented today. The list below is the minimal column set the tab must render so the Source column has a documented home rather than floating; surface owners may add columns on top of this set but cannot drop one without an explicit spec change.

| Column | Source |
| --- | --- |
| Name | applicants.first_name + applicants.last_name. Rendered as the visible link text / accessible name for the row-level link defined in Row Interaction above — the entire row is the click target, not just this cell. |
| Program | applications.program_id → programs.name. |
| Season | applications.season_id → seasons.name. |
| Status | Current application state from applications.status (draft / started / in_review / withdrawn / rejected / lapsed / cancelled / completed); the lifecycle phase is applications.journey_position (application / validation / interview / placement / visa_sevis / pre_departure / in_program / completion). |
| Source | Derived from participant_recommendations + reservations as defined below. Two enum values plus an empty-state dash. |
| Reserved/Recommended At | Whichever timestamp drove the Source label — first-touch created_at from the winning row. Sortable column; default sort is most-recent first. |
| Assigned AM | partner_organizations.account_manager_user_id resolved to displayName. Empty when unassigned. Carried for traceability — see the host-governance cross-link callout below. |

#### Source Enum & Labels

| Enum Value | Human Label | Source of Truth |
| --- | --- | --- |
| recommended_by_am | Recommended by AM | A row in participant_recommendations for (applicant_id, host_organization_id) whose source is `staff` (recommendations made by host users from the talent pool carry source `host`) — the same row that fires the host.participant_recommended / _digest events. |
| selected_by_host | Selected by host | A reservation row for (applicant_id, host_organization_id) whose actor is a host-portal user — created via POST /api/partner/host/reservations from the Host Portal against an open job order. |

#### Derivation Rule (First-Touch Wins)

For each (applicant, host_org) row on the tab, look up the earliest matching row in participant_recommendations and the earliest matching reservation row. The label is decided by the earlier created_at — first-touch wins. On an exact-tie created_at (same wall-clock microsecond, vanishingly rare in practice), the tie-break favors recommended_by_am — an AM recommendation that landed in the same instant as a host reservation should still read as the sponsor-initiated path so staff don’t lose attribution.

| State | Rendered Label | Reserved/Recommended At |
| --- | --- | --- |
| Only a recommendation exists | Recommended by AM | participant_recommendations.created_at |
| Only a reservation exists | Selected by host | reservation_holds.created_at |
| Both exist — recommendation earlier | Recommended by AM | participant_recommendations.created_at (the earlier row) |
| Both exist — reservation earlier | Selected by host | reservation_holds.created_at (the earlier row) |

> **Empty state is not a third enum value**
>
> When neither a participant_recommendations row nor a reservation row exists for (applicant, host_org), the Source cell renders an em-dash (—) and the row is treated as a legacy / pre-flow edge case — applicants who landed on the host before either flow existed, or who were attached via a back-office repair script. This is intentionally not modeled as a third enum value because doing so would imply an ongoing entry path that isn’t supported. Surfaces that need to count these rows can query for them as “rows where neither source table has a matching tuple”; no stored marker is added.

#### Filter Facet

The Applicants tab gains a Source filter facet alongside the existing facets. Three options, single-select:

- Any — default. No filter applied; legacy / pre-flow edge-case rows are included.
- Recommended by AM — only rows whose derived source is recommended_by_am. Excludes the empty-state rows.
- Selected by host — only rows whose derived source is selected_by_host. Excludes the empty-state rows.
Filter is server-evaluated so it composes correctly with pagination. Because the value is derived per-read, the filter is implemented as an EXISTS / NOT EXISTS pair against the two source tables (with the first-touch comparison embedded) — not as a column predicate.

### Applicants Tab — Applicant Detail Popup

Today the Name cell on the Applicants tab is a hard link to the full Applicant Detail page. Staff working a host’s roster lose their filter, sort, and scroll position every time they want to glance at one applicant’s documents or notes. The applicant detail popup is a lightweight, read-only quick-look that opens in place — three cards (Documents, Progress, Notes) plus an escape hatch to the canonical full-page surface for any deeper interaction. The full Applicant Detail page and the Split-View Applicant Workspace on /docs/applicant-lifecycle are unchanged by this work; the popup is a sibling surface scoped to the host roster context.

#### Trigger & Surface

Clicking the Name cell on an Applicants-tab row opens the popup centered over the tab on top of a dimmed backdrop. The underlying tab keeps its active filter, sort, and scroll position for the session — closing the popup never reloads the list. The Name cell stops being a direct link to the full detail page; the popup carries an explicit “Open full applicant detail” escape-hatch link in the header that routes to the canonical Applicant Detail page anchor ( /docs/applicant-lifecycle#applicant-detail-page ). Dismissal via Esc, backdrop click, or the close button returns keyboard focus to the originating Name cell so staff can keep scanning the roster.

#### Popup Header

The header renders, in order: applicant name, program, season, current lifecycle status (mirroring the existing Status column on the tab — the applications.status value), and the “Open full applicant detail” escape link. No quick-actions toolbar, no assignment controls, no inline status edit — mutations route through the full detail page.

#### Three Cards Contract

| Card Title | Empty State | Contents & Source of Truth |
| --- | --- | --- |
| Documents | No documents tracked | Lists the applicant’s tracked program documents (name + review state). Sourced from the existing applicant document store; see the document review model on Host Governance &rarr; Compliance Documents . Read-only on this surface — no upload, no review actions, no state changes; those stay on the full detail page. |
| Progress | N/A — always renders (0% when no signal) | Headline percentage rendered as N% with the label Application Complete and a thin progress bar beneath. The percentage is the profile completeness signal already documented on /docs/applicant-lifecycle &rarr; Profile Completeness Algorithm / Document Completeness Signal . The formula is not restated here — the popup reads the existing signal so the figure stays consistent with every other surface that displays it. |
| Notes (N) | No notes yet | Title renders as Notes (N) where N is the count of non-deleted applicant notes. Lists the most recent 3 notes (body excerpt + author + relative timestamp) with a “View all” link to the notes tab on the full detail page. The composer is not in the popup — note authoring stays on the full detail page so the popup remains a clean, read-only quick-look. |

#### Card Data Shape

The popup is served by a single combined read endpoint so the three cards render from one round-trip rather than three parallel fetches:

GET /api/admin/host-management/:hostId/applicants/:applicationId/summary
Spec only — the handler is not written on this repo. Auth is sponsor-staff admin (same guard as the rest of Host Management). The response is scoped to (hostId, applicationId) so a stale or hand-crafted link cannot pivot the popup to an applicant who was never attached to this host — the endpoint must verify the applicant currently resolves into this host’s Applicants-tab set (via the same derivation rule that powers the tab listing) and return 404 when it does not.

> **Out of scope on this surface**
>
> The popup is intentionally a three-card, read-only quick-look. The following live only on the full Applicant Detail page or the Split-View Applicant Workspace: Quick Actions bar — the canonical six (Add note, Send message, Reassign, Escalate, Request document, Request payment) live on the Split-View Workspace. Activity Timeline tab — the operational timeline view is full-detail-only. Next Action Engine recommendations — not surfaced in the popup. Notes composer — note authoring, edit, and delete stay on the full detail page. Mobile layout is explicitly deferred — this is a staff back-office, desktop-first surface.

#### Implementation Follow-up — Admin-Console Tickets

Per the Cross-repo ticket routing rule in replit.md, the two follow-ups below are filed against the admin-console repo — not as project tasks on this docs-site repo. Both drafts follow the house Asana standard from docs/asana-ticket-standard.md.

Admin-console repo · Ticket 1 of 2

Host Management: Applicants tab applicant-detail popup component + summary endpoint wiring

FOR PRODUCT OWNERS / STAKEHOLDERS
Staff working a host's Applicants tab today lose their filter, sort, and scroll
position every time they click a Name cell to glance at one applicant. This
ticket adds a lightweight in-place popup with three cards (Documents, Progress,
Notes) so staff can quick-look an applicant without leaving the roster.
----------------------------------------
BACKGROUND
The Host Management surface has an Applicants tab listing every applicant
currently associated with a given host org. The tab columns and Source column
derivation are defined on /docs/partner-organizations. There is no per-applicant
quick-look today; the only way to inspect documents, completeness, or notes is
to navigate away to the full Applicant Detail page on /docs/applicant-lifecycle.
The applicant document store, the profile-completeness signal, and the applicant
notes store all already exist as separate reads.
WHAT THIS TICKET DOES
Builds the "Applicants Tab — Applicant Detail Popup" surface specified on
/docs/partner-organizations#applicants-tab-detail-popup. Adds the combined
summary endpoint "GET /api/admin/host-management/:hostId/applicants/:applicationId/summary"
serving the documents, progress, and notes cards in one round-trip, scoped to
the (hostId, applicationId) tuple as defined in the spec. Renders the three
cards with the titles, empty states, and read-only semantics from the spec. Does
not add a notes composer, a quick-actions bar, an activity timeline tab, or
next-action recommendations to the popup. Companion ticket:
"Host Management: re-point Applicants tab Name cell from page-nav to popup trigger".
ACCEPTANCE CRITERIA (Gherkin)
Feature: Applicants-tab applicant detail popup
Scenario: Summary endpoint shape
Given a host "host_id" with applicant "application_id" on its Applicants tab
When sponsor staff GET "/api/admin/host-management/host_id/applicants/application_id/summary"
Then the response is "200" with body keys "applicant", "documents", "progress", "notes"
And "progress.label" equals "Application Complete"
And "notes.recent" contains at most "3" entries ordered most-recent first
Scenario: Endpoint is scoped to the host
Given applicant "application_id" is not attached to host "host_id"
When sponsor staff GET the summary endpoint for "(host_id, application_id)"
Then the response is "404"
Scenario: Auth is sponsor-staff admin
Given the caller is not a sponsor-staff admin
When the summary endpoint is called
Then the response is "401" or "403"
Scenario: Documents card empty state
Given the applicant has no tracked documents
When the popup renders
Then the Documents card shows "No documents tracked" and no action controls
Scenario: Notes card empty state and count
Given the applicant has "0" non-deleted notes
When the popup renders
Then the Notes card title reads "Notes (0)" and shows "No notes yet"
And no notes composer is present in the popup
Scenario: Progress card reuses the profile-completeness signal
Given the applicant's profile-completeness signal is "62"
When the popup renders
Then the Progress card headline reads "62%"
And the value matches the same signal rendered on the full Applicant Detail page
Admin-console repo · Ticket 2 of 2

Host Management: re-point Applicants tab Name cell from page-nav to popup trigger

FOR PRODUCT OWNERS / STAKEHOLDERS
The Name cell on the Applicants tab currently navigates to the full Applicant
Detail page, costing staff their place in the host roster. This ticket re-points
that cell to the new in-place popup, with an explicit "Open full applicant
detail" link inside the popup so the deep-dive surface is still one click away.
----------------------------------------
BACKGROUND
The Applicants tab Name cell today is a direct link to the full Applicant
Detail page on /docs/applicant-lifecycle. The page-nav drops the staff user out
of the host roster context: active filter, sort, and scroll position are lost
on return. The applicant-detail popup component and its summary endpoint are
delivered by the companion ticket "Host Management: Applicants tab
applicant-detail popup component + summary endpoint wiring".
WHAT THIS TICKET DOES
Rewires the Applicants tab Name cell to open the popup over the tab instead of
navigating away. Preserves the tab's active filter, sort, and scroll position
across open/close cycles for the session. Adds the "Open full applicant detail"
escape-hatch link inside the popup header, routing to the canonical Applicant
Detail page anchor. Wires keyboard dismissal (Esc), backdrop-click dismissal,
and the close button; returns focus to the originating Name cell on close.
ACCEPTANCE CRITERIA (Gherkin)
Feature: Applicants-tab Name cell opens the in-place popup
Scenario: Click opens the popup, not a page navigation
Given staff is on a host's Applicants tab with filter "Source = Recommended by AM" applied
When staff clicks an applicant Name cell
Then the applicant-detail popup opens centered over the tab
And the URL path does not change
And the tab's active filter and scroll position are preserved underneath
Scenario: Escape-hatch link routes to the full detail page
Given the popup is open for "application_id"
When staff clicks "Open full applicant detail"
Then the browser navigates to "/applicants/application_id" anchored at "applicant-detail-page"
Scenario: Esc closes the popup and restores focus
Given the popup is open and opened via the Name cell of row "row_n"
When staff presses "Escape"
Then the popup closes
And keyboard focus returns to the Name cell of "row_n"
Scenario: Backdrop click closes the popup
Given the popup is open
When staff clicks the backdrop outside the popup body
Then the popup closes and the tab state is unchanged
Scenario: Reopening after close re-fetches the summary
Given staff has just closed the popup for "application_id"
When staff clicks the Name cell for "application_id" again
Then the summary endpoint is called again
And the popup renders with the latest documents, progress, and notes
> **Source column makes AM attribution traceable**
>
> Every participant_recommendations row already carries the recommending sponsor-staff user. When the Source column shows Recommended by AM, staff can drill from the applicant row back to the recommender without a new column on the recommendations table. This is the cross-link captured on Host Portal Governance &rarr; Host Account Manager Assignment .

### Overview Tab — Contact Information

The sponsor-staff Host Management surface has an Overview tab whose Contact Information section today renders contact name / email / phone only, and is read-only. This contract adds a Role column (the contact’s job title, free text) and four inline affordances — Add Contact, Edit, Remove, Set as Primary — so staff can manage the host’s contact roster without leaving the Overview tab. The surface is admin-console only; the Host Portal Camp Overview contract (Task #175) is unchanged and remains the source of truth for the underlying partner_contacts schema, validation, primary-contact rule, audit vocabulary, and notification routing.

#### Row Field Set

| Field | Notes |
| --- | --- |
| Name | partner_contacts.name. Full name, required. |
| Role | partner_contacts.title. Free text — no controlled vocabulary, no UI suggestions, mirroring the Host Portal contract (Task #175). Required. |
| Email | partner_contacts.email. Required, validated email format. |
| Phone | partner_contacts.phone. Required, free text. |
| Primary badge | Rendered when partner_contacts.is_default = true. At most one row per host org carries the badge. |

#### Affordances & Endpoints

Each affordance calls into the same /api/admin/partners/organizations/:orgId/contacts family already used elsewhere in the admin console — no Overview-tab-specific endpoint.

| Affordance | Endpoint | Behavior |
| --- | --- | --- |
| List | GET /api/admin/partners/organizations/:orgId/contacts | Initial render of the Contact Information section. Returns the full contact list for the host org. Inherited-contacts mode (see below) consumes the parent org's list via the same endpoint, host-id-substituted. |
| Add Contact | POST /api/admin/partners/organizations/:orgId/contacts | Opens an inline modal with the five fields above plus the contactType enum (primary \| secondary). On submit, validation rules from Task #175 apply. |
| Edit | PATCH /api/admin/partners/organizations/:orgId/contacts/:contactId | Opens the same modal pre-populated with the row's values. Partial update; validation rules identical to Add. |
| Remove | DELETE /api/admin/partners/organizations/:orgId/contacts/:contactId | Confirmation dialog before delete. Removing the current primary is allowed; the org is left with no primary until another row is promoted. |
| Set as Primary | PATCH /api/admin/partners/organizations/:orgId/contacts/:contactId | Sends isDefault: true . Server atomically clears isDefault on every other contact for the host org in the same transaction so the at-most-one invariant holds. Hidden on the row that already carries the Primary badge. |

#### RBAC

Read (GET) requires the sponsor-staff host-view capability; mutations (POST / PATCH / DELETE, including Set as Primary) require the sponsor-staff host-edit capability. These resolve through the shared capability registry on Roles & Governance &rarr; Capability Registry ; inline role checks at the route handler are forbidden per the same rule that governs Host Portal capabilities. Unknown capability keys deny by default.

#### Validation

Validation rules mirror the Host Portal Camp Overview contract (Task #175) exactly — the admin-console surface and the host-facing surface call the same shared validator so a contact authored on either side passes or fails identically:

- Name — required, non-empty string.
- Role (title) — required, free text. No enum, no controlled vocabulary, no suggestions.
- Email — required, valid email format.
- Phone — required, free text.
- contactType — required, enum primary | secondary. Any other value is a validation error.
- isDefault — optional boolean. At most one contact per host org may have isDefault = true; Set-as-Primary performs the atomic swap server-side.
#### Audit & Activity Events

Every mutation emits the canonical host.contact.* family defined in Task #175 — host.contact.created, host.contact.updated, host.contact.deleted, and host.contact.set_default (emitted when isDefault flips to true on a row). There are no sponsor-side variants such as admin.host.contact.* — the actor field on the audit row distinguishes the surface, not the action name, so a single audit feed reads cleanly regardless of whether the mutation originated on the Host Portal or the admin-console Overview tab. Notification routing is identical to the Host Portal path; no admin-console-only template is introduced.

#### Inheritance Interaction

> **Inherited contacts render read-only on the Overview tab**
>
> When the host has a parent and its governance_inheritance.contacts flag is on (see the Parent / Children Panel ), the Overview tab’s Contact Information section renders the parent’s contact list with an “Inherited from ” badge at the section header and hides Add Contact, Edit, Remove, and Set as Primary on every row. Edits must be made on the parent (or the child must flip its contacts-inheritance toggle off via the Parent / Children panel, which snapshots the parent’s current list into the child for local editing per the Inheritance Surfacing contract).

### Transportation & Safety Record (SWT Camp Hosts, staff API)

Task 2478. The Hub stores one structured Transportation & Safety record per host organization (table `host_transportation_safety`, unique on `partner_org_id`, every answer nullable so unanswered differs from an explicit No), recording how participants get around at a camp host. It is reachable through the staff admin API only: the Host Management detail page does not currently show a Transportation & Safety tab (a legacy `?tab=transportation-safety` deep link falls back to Overview), and there is no Host Portal surface.

- **Visibility.** The API applies only to hosts with at least one ACTIVE association to a Summer Work & Travel - Camp catalog program (`hasActiveSwtCampAssociation`, shared code, narrower than the camp-family check, so a Camp-Counselor-only host or an ordinary SWT host does not have it). An inactive association does not open the record. Removing the association closes the API surface but never deletes the stored answers.
- **Endpoints.** `GET /api/admin/partners/organizations/:orgId/transportation-safety` (permission `partners.view`) returns the record or null. `PUT /api/admin/partners/organizations/:orgId/transportation-safety` (permission `partners.manage`, audit action `partner.org.transportation_safety.upsert`) upserts the whole section, returning 201 on first save and 200 after. Both verbs check the org exists (404), enforce organization scope (403) and refuse a non-SWT-Camp host with a policy violation carrying `code: host_not_swt_camp`. The save is an `ON CONFLICT` upsert on `partner_org_id`, so concurrent saves cannot double insert.
- **Body.** The schema is strict (unknown keys are rejected, not dropped). Send the complete record on each save; null clears an answer. Free text is trimmed and blank becomes null: `shuttleDetails`, `walkBikeCommuteInfo`, `inaccessibleServicesAssistance` and `unavailableServicesAssistance` up to 2000 characters; `weeklyServiceTripSchedule`, `publicTransportEarliestRun`, `publicTransportLatestRun` and `publicTransportCost` up to 200. `soaHousingDistanceMiles` is non-negative with at most two decimals and at most 99999.99. The minutes fields (`groceryStoreMinutes`, `pharmacyMinutes`, `bankAtmMinutes`, `restaurantsMinutes`, `healthServicesMinutes`) are whole numbers up to 100000. `soaCommuteModes` and `serviceCommuteModes` are de-duplicated arrays over `public_transport`, `uber_taxi` and `walk_bike`. `publicTransportMapUrl` must be an absolute http or https URL up to 2048 characters. Yes/No answers are `alwaysProvidesTransportation`, `providesAfterHoursTransportation`, `providesWeeklyServiceTrip` and `publicTransportAvailable`.
- **Sections.** The answers fall into three groups: transportation between the site of activity and housing, transportation between housing and essential services, and public transport in the area. The response also carries `id`, `partnerOrgId`, `createdAt` and `updatedAt`; the creating and updating user ids stay internal.
- **Delete.** The table references the host organization with cascade, so the permanent host delete removes the record.

### Staff Info Tab — Host Staffing Answers

The sponsor-staff host detail page has a Staff Info tab: the single staff-side place to read and write the host's own answers about how it staffs and vets its program. It renders one row per answer — Field, Information, Status (Complete / Incomplete) and an Edit control — with "Not provided" shown for unanswered rows. Editing requires the partners.manage capability; without it the tab is read-only with no edit affordance. The rows are the declared staff-to-participant ratio (labelled "Staff to Camper Ratio" for camp hosts), staff training, background checks, drug testing, and the J1 staffing counts.

#### Read Path & Write Validation

- Read. GET /api/admin/partners/organizations/:id returns staffToParticipantRatio, staffTraining and backgroundChecks as explicit top-level fields (mirrors of the metadata keys of the same name that the Host Portal Overview page reads and writes). Each is null when unset, cleared, or holding a malformed value. backgroundChecks may be a plain string (legacy) or a structured { required, details \} value.
- One validator for both write paths. PATCH /api/partner/host/overview and PATCH /api/admin/partners/organizations/:id validate these keys through one shared module (shared/hostStaffFields.ts), so neither surface can accept a value the other rejects.
- Host default answers. The same module owns the `uniform` (`provided`, `cost`, `currency`, `costDue`) and `requiredPayment` (`required`, `details`) metadata keys, validated on both surfaces through `normalizeHostDefault`; the admin PATCH answers 400 `VALIDATION_FAILED` with `details.field` naming the key, `null` clears the key, and GET /api/admin/partners/organizations/:id returns them as top-level `uniform` and `requiredPayment`. `cost`, `currency` and `costDue` are kept only when `provided` is true (`currency` is an ISO 4217 code that defaults to `USD` when a cost is given), and `details` only when `required` is true. A change is synchronised onto the host's non-cancelled internal job orders titled `Camp Counselor` or `Support Staff` as `grooming` and `compensation` requirements.
- Ratio. Must match n:m — two whole numbers of up to four digits each, separated by a colon (for example 1:8). Anything else (for example "1 to 8") is rejected rather than stored.
- Free text. staffTraining is capped at 2000 characters. backgroundChecks accepts a string of up to 2000 characters, or an object { required: boolean, details?: string | null \} whose details are capped at 2000 characters.
- Clearing. null or a blank/whitespace-only string clears the answer: the key is removed from stored metadata, so reads omit it and both UIs show "Not provided". A key absent from the request body is left untouched.
- Rejection. Any other value type is rejected and nothing is persisted; the admin PATCH responds 400 VALIDATION_FAILED with details.field naming the key.

> **Declared ratio is not the computed ratio**
>
> staffToParticipantRatio is the host's own declared display string. It is deliberately distinct from the computed staff/participant ratio behind the Staff Ratio Review screen (/admin/staff-ratio-review), which is derived from the per-program-type numeric staff inputs.

#### J1 Staffing Counts

numberOfJ1Counselors and numberOfJ1SupportStaff are Hub-owned, non-negative-integer fields on the host overview contract (GET / PATCH /api/partner/host/overview, mirrored on the composite GET /api/partner/host/profile overview object). PATCH accepts an integer of 0 or more (digit-only numeric strings are coerced) or null to clear; fractional, negative or non-numeric values return 422 with details.field and persist nothing. Reads always carry j1StaffCountsEditable (true only for camp-model hosts) and j1StaffCountsLockReason ('not_camp_program' or null). For a non-camp host the counts are never returned (stale stored values included), a PATCH of either count is rejected like the other camp-only metadata keys, and the Host Portal shows locked ownership messaging ("Managed by WildPacks").

### Notifications Tab — Host Notification Settings

*Status: design spec, not built. No `notification-settings` route, per-host notification settings table or `host.notification_setting.*` audit action exists in BackOfficePrototype, and `governance_inheritance` only carries the keys `agreement`, `contacts` and `documents` (no `notifications`). The endpoints, data model, audit events and inheritance behaviour below are the intended design.*

The sponsor-staff Host Management surface gains a Notifications tab, a peer of the existing Overview / Applicants / Users / Audit tabs and scoped to a single host org. The tab gives staff per-host-org control over the host-facing emails Globus sends to that company, in two dimensions: (1) enable / disable each optional notification trigger, and (2) route each trigger to the contact role(s) on that host’s roster who should receive it. The tab does not introduce new triggers, new templates, or new delivery channels — it only governs routing and enablement of the existing host-facing catalog defined in Employer Notifications . Governance from the Notification Model (mandatory delivery, quiet hours, batching, dedup) applies unchanged and outranks any setting on this tab.

> **Scope — host-facing email only**
>
> This tab governs only triggers whose audience is the host company (delivered to host contacts by email). Staff-targeted triggers — host.ev_removed (Account Manager) and host.account_manager_assigned (sponsor staff) — are routed to internal staff, never to host contacts, so they do not appear on this tab and cannot be disabled or re-routed here. Applicant notifications route through HubSpot and are out of scope entirely. The tab also never governs **brand**: the brand each host email renders under is resolved server-side from the host's applicable agreement's Agreement Library brand scope (Wild Packs hosts get Wild Packs branding on every trigger in this table) and cannot be overridden per org or per trigger here — see Notification Model → Agreement-Derived Brand Resolution (Wild Packs).

#### Per-Trigger Settings Table

The tab renders one row per host-facing trigger drawn from the Employer Notifications catalog. Each row shows the trigger key, its default audience, whether it is mandatory (locked-on) or optional (toggleable), and the role-routing override. Mandatory compliance / legal / safety / authentication triggers cannot be disabled per the Notification Model opt-out rules — their enable toggle renders locked-on with a tooltip explaining why; only their routing is editable.

| Trigger key | Default audience | Enablement | Default role routing & rationale |
| --- | --- | --- | --- |
| host.user_invited | Invited host user (individual) | | Not role-routable — addressed to the specific invitee. Authentication / first-login activation; cannot be disabled or re-routed. |
| host.profile_created | Host company | | Primary contact. Account provisioning email carrying portal login credentials; transactional, cannot be disabled. Routing override allowed. |
| host.visa_denied | Host company | | Operations role; falls back to primary. Safety / placement-critical — host must know the placement will not arrive. Cannot be disabled. |
| host.document_expired | Host company | | Compliance / Billing role; falls back to primary. Compliance trigger on a lapsed document; cannot be disabled. |
| host.operating_agreement.renewed | Host company | | Billing role; falls back to primary. Contractual record of the renewed operating agreement; cannot be disabled. |
| host.document_expiring_soon | Host company | | Compliance / Billing role; falls back to primary. Advance (30-day) courtesy reminder; toggleable per org. |
| host.participant_matched | Host company | | Operations role; falls back to primary. Per-match prompt to review/confirm; toggleable. |
| host.participant_recommended | Host company | | Operations role; falls back to primary. Per-event recommendation; toggleable (often disabled in favor of the digest). |
| host.participant_recommended_digest | Host company | | Operations role; falls back to primary. Daily rollup of recommendations; toggleable. |
| host.reservation_expiring | Host company | | Operations role; falls back to primary. 24h pre-expiry reservation warning; toggleable. |

Mandatory / optional classification is a property of the trigger, not the org — it is seeded from the catalog and identical across all host orgs. A host org with no saved settings inherits every default above (all triggers enabled, default routing). Saved rows only capture deltas from these defaults.

#### Role-Based Recipient Routing

Routing is role-based, not per-individual-contact. For each trigger, staff select one or more contact roles that should receive it. Because contact roles are free text ( partner_contacts.title, no controlled vocabulary — mirroring the Overview-tab contact contract above), the routing is expressed against the role strings actually present on that org’s roster: the role selector is populated from the distinct, case-insensitively-normalized title values on the host’s contacts, plus the catalog’s seed role names (Operations, Billing, Compliance) so a useful default exists even before any contact carries that title.

- Match semantics. At send time the dispatcher resolves each configured role to the set of host contacts whose normalized title equals a configured role. A trigger configured for two roles delivers to the union of both matched sets (de-duplicated by email).
- No-match fallback. If no contact on the roster matches any configured role for a trigger, delivery falls back to the org’s primary contact ( is_default = true). Because the at-most-one-primary invariant holds (see the Overview-tab contract above), the fallback target is unambiguous.
- No primary, no match. If there is also no primary contact, the trigger delivers to the org-level notification email of record and the row surfaces an “unrouted” warning chip on the tab so staff can fix the roster. Mandatory triggers are never silently dropped.
- Default when no override is set. A trigger with no saved routing uses the catalog default role from the settings table above; that default is itself subject to the same no-match fallback chain.
#### Data Model, Endpoints & RBAC

Settings persist in a per-host-org model, one row per (host org, trigger) delta. Absence of a row means the trigger runs at its catalog default.

| Field | Notes |
| --- | --- |
| host_organization_id | FK to the host org. Composite-unique with trigger_key (one settings row per trigger per org). |
| trigger_key | The host.* trigger key from the Employer Notifications catalog. Must be a known host-facing key; staff-targeted keys are rejected. |
| enabled | Boolean. Defaults to the trigger's catalog default. Ignored (forced true) for mandatory triggers — server rejects an attempt to set it false. |
| role_routing | text[] of free-text role strings. Empty array = use catalog default routing. Order is not significant; duplicates are normalized out. |
| updated_by / updated_at | Actor and timestamp of the last change, for the audit trail and the tab's 'last edited by' line. |

| Operation | Endpoint | Behavior |
| --- | --- | --- |
| List | GET /api/admin/hosts/:id/notification-settings | Returns every host-facing trigger merged with this org's saved deltas: trigger_key, default audience, mandatory flag, effective enabled state, effective role routing, and the resolved recipient preview. Used to render the tab. |
| Update | PATCH /api/admin/hosts/:id/notification-settings/:triggerKey | Upserts the (org, trigger) row. Body accepts enabled?, roleRouting? . Validates against the rules below, then writes the audit event. Returns the updated effective row. |

The endpoint family reuses the existing /api/admin/hosts/:id/… pattern. Read (GET) requires the sponsor-staff host-view capability; the mutation (PATCH) requires host-edit — the same capabilities the Overview-tab contact roster uses. They resolve through the shared capability registry on Roles & Governance &rarr; Capability Registry ; inline role checks at the handler are forbidden and unknown capability keys deny by default.

#### Validation & Audit

- trigger_key — must be a known host-facing trigger key. Unknown keys and staff-targeted keys (host.ev_removed, host.account_manager_assigned) return 422.
- enabled — optional boolean. Setting false on a mandatory trigger returns 422 (locked-on). Mandatory triggers may still have their routing edited.
- roleRouting — optional array of non-empty trimmed strings; empty/whitespace entries are rejected. An empty array clears the override back to the catalog default. host.user_invited is not role-routable and rejects a non-empty array.
- Idempotency — a PATCH that produces no effective change is a no-op (no audit row written), mirroring the Overview-tab mutation discipline.
Every effective change emits the canonical host.notification_setting.* family, dotted-and-scoped consistent with the host.contact.* vocabulary above: host.notification_setting.enabled, host.notification_setting.disabled, and host.notification_setting.routing_changed (payload carries trigger_key plus before/after values). There are no sponsor-side admin.* variants — the actor field on the audit row distinguishes the surface, not the action name, so one audit feed reads cleanly. All events ride the shared recordAudit() helper and surface in the Audit Console .

#### Governance & Inheritance

This tab governs who receives a host-facing notification and whether optional ones are sent — never when or how. The quiet-hours, digest-batching, deduplication, and rate-limiting rules from the Notification Model apply unchanged downstream of routing. Mandatory delivery always wins: a mandatory trigger is delivered even if a host has every optional notification disabled, and quiet hours never suppress a critical / safety alert. The tab cannot override any of these.

> **Inherited notification settings render read-only**
>
> When the host has a parent and its governance_inheritance.notifications flag is on (surfaced on the Parent / Children Panel alongside the contacts flag), the Notifications tab renders the parent’s settings with an “Inherited from ” badge and disables every enable toggle and role-routing editor, consistent with the Overview-tab contact-inheritance behavior above. Because routing targets contact roles and the roster itself is inherited when governance_inheritance.contacts is on, notification routing is only meaningfully editable on the child once it owns its own roster. Edits are made on the parent, or the child flips the inheritance toggle off (which snapshots the parent’s current settings into the child for local editing) per the Inheritance Surfacing contract.

#### Implementation Follow-up

Per the Cross-repo ticket routing rule in replit.md, the live tab and its backing store are built by the portal / Globus-core team and are filed against those repos — not as project tasks on this docs-site repo. Both drafts follow the house Asana standard from docs/asana-ticket-standard.md.

Globus-core repo · Ticket 1 of 2

Host notification settings: per-host-org model + list/update admin endpoints + dispatcher role resolution

FOR PRODUCT OWNERS / STAKEHOLDERS
Today every host org receives the same host-facing emails routed the same way,
with no per-org control over which contacts get what. This ticket adds the
backing store and APIs so sponsor staff can, per host org, turn optional
notifications on/off and route each one to the right contact roles (Billing,
Operations, Compliance) — without touching mandatory compliance, safety, or
authentication emails, which stay locked on.
----------------------------------------
BACKGROUND
The host-facing trigger catalog is defined on /docs/employer-notifications and
maps to host.* events in shared/hostNotificationTypes.ts. Notification Model
governance (mandatory delivery, quiet hours, batching, dedup) is defined on
/docs/notification-model and is unchanged by this work. Contact roles are free
text (partner_contacts.title) with an at-most-one-primary invariant. The
Notifications tab spec lives on
/docs/partner-organizations#notifications-tab.
WHAT THIS TICKET DOES
Adds a per-host-org notification-settings model keyed by (host_organization_id,
trigger_key) holding , where
absent rows mean catalog defaults. Adds
"GET /api/admin/hosts/:id/notification-settings" (catalog merged with the org's
deltas, including a resolved-recipient preview) and
"PATCH /api/admin/hosts/:id/notification-settings/:triggerKey" accepting
. Reuses host-view (read) and host-edit (write)
capabilities via the shared registry. Rejects disabling mandatory triggers and
rejects unknown or staff-targeted trigger keys with 422. Emits
host.notification_setting.enabled / .disabled / .routing_changed via
recordAudit() (no admin.* variant). Updates the dispatcher to resolve configured
roles against the org's contact titles, union+dedupe matches, and fall back to
the primary contact when no role matches. Does not add new triggers, templates,
channels, or the n8n dispatcher changes.
ACCEPTANCE CRITERIA (Gherkin)
Feature: Host notification settings model and endpoints
Scenario: List merges catalog defaults with saved deltas
Given host "host_id" has a saved row disabling "host.participant_recommended"
When staff GET "/api/admin/hosts/host_id/notification-settings"
Then every host-facing trigger is returned with an effective enabled state
And "host.participant_recommended" has enabled "false"
Scenario: Mandatory trigger cannot be disabled
Given trigger "host.document_expired" is mandatory
When staff PATCH it with enabled
Then the response is "422"
Scenario: Staff-targeted trigger is rejected
When staff PATCH "host.account_manager_assigned" with any body
Then the response is "422"
Scenario: Routing override updates and audits
Given staff PATCH "host.document_expiring_soon" with roleRouting / Billing
Then the response is "200" with effective roleRouting ["Billing"]
And an audit event "host.notification_setting.routing_changed" is written
Scenario: No-match falls back to primary
Given a trigger routed to role "Operations" and no contact has that title
And the org has a primary contact
When the dispatcher resolves recipients
Then the email is delivered to the primary contact
Scenario: Auth is sponsor-staff
Given the caller lacks the host-edit capability
When the PATCH endpoint is called
Then the response is "401" or "403"
Admin-console / portal repo · Ticket 2 of 2

Host Management: Notifications tab UI (enable/disable + role routing, inherited read-only)

FOR PRODUCT OWNERS / STAKEHOLDERS
Sponsor staff need a place to manage a host's email notifications. This ticket
adds a Notifications tab to Host Management, alongside Overview / Applicants /
Users / Audit, where staff can toggle optional notifications and choose which
contact roles receive each one. Mandatory emails show as locked on.
----------------------------------------
BACKGROUND
The tab spec, settings table, role-routing rules, and inheritance behavior are
defined on /docs/partner-organizations#notifications-tab. The backing model and
endpoints are delivered by the companion Globus-core ticket "Host notification
settings: per-host-org model + list/update admin endpoints + dispatcher role
resolution". Contact roles are the free-text titles already shown on the
Overview tab's contact roster.
WHAT THIS TICKET DOES
Adds the Notifications tab to the Host Management surface. Renders one row per
host-facing trigger from
"GET /api/admin/hosts/:id/notification-settings" with: trigger key, default
audience, a mandatory/optional indicator, an enable toggle (locked-on with a
tooltip for mandatory triggers), and a role-routing multi-select populated from
the org's distinct contact titles plus the seed roles. Saves changes via
"PATCH /api/admin/hosts/:id/notification-settings/:triggerKey". Shows the
resolved-recipient preview and an "unrouted" warning chip when a trigger
resolves to no recipient and no primary exists. When
governance_inheritance.notifications is on, renders settings read-only with an
"Inherited from " badge and disabled controls, consistent with the
Overview tab's inherited-contacts behavior. Gated on host-view (render) and
host-edit (mutate).
ACCEPTANCE CRITERIA (Gherkin)
Feature: Host Management Notifications tab
Scenario: Tab is a peer of the Audit tab
Given staff opens a host org in Host Management
Then a "Notifications" tab appears alongside Overview, Applicants, Users, Audit
Scenario: Mandatory trigger toggle is locked on
Given trigger "host.visa_denied" is mandatory
When the tab renders
Then its enable toggle is on and disabled with an explanatory tooltip
Scenario: Optional trigger can be disabled
Given trigger "host.reservation_expiring" is optional
When staff turns its toggle off
Then a PATCH with enabled is sent and the row reflects disabled
Scenario: Role routing is chosen from roster titles
Given the host has contacts titled "Billing" and "Operations"
When staff opens the role-routing selector for a trigger
Then "Billing" and "Operations" are selectable alongside the seed roles
Scenario: Inherited settings are read-only
Given the host inherits notifications from its parent
When the tab renders
Then all toggles and routing selectors are disabled
And an "Inherited from " badge is shown
Scenario: Unrouted warning
Given a trigger resolves to no matching contact and the org has no primary
When the tab renders
Then the row shows an "unrouted" warning chip
### Host Audit Activity & Last Staff Action

Staff working a host company today can see individual audit vocabularies scattered across contract pages, but the Host Management surface itself has no consolidated answer to three questions: *what happened to this host*, *who did it*, and *when did a human staff member last touch this company*. This contract defines the staff-only host activity feed (an Activity tab on the host detail view), the derived **Last staff action** value surfaced on the host roster and the host detail header, and the read model that guarantees both surfaces always agree. It is a read-side presentation over the existing Globus `audit_logs` store — no parallel activity table, no new write path, and no retrofitting of display names into historical rows.

> **Mirrors the applicant model, host-scoped**
>
> The semantics deliberately mirror the applicant-side Activity Timeline & Last-Touch Signals on Applicant Lifecycle: the audit log is the single source of truth, "staff touch" excludes system and portal-user activity, and staleness-style summary values are derived at read time. The host side differs in one respect — the value is a durable, monotonic host-to-audit-event pointer (table `host_latest_staff_actions`, created by migrations `20290203000000_host_latest_staff_action` and `20290203000001_host_latest_staff_action_trigger`) rather than being derived per-read. An `AFTER INSERT` trigger on `audit_logs` advances the pointer whenever a qualifying staff mutation is audited, only moving it forward by `(created_at, id)`, and existing audit history was backfilled into it. It is read in a bounded query (a join of the pointer to the immutable audit row) by `GET /api/admin/partners/organizations`, `GET /api/admin/partners/organizations/:id` and `GET /api/admin/partners/organizations/:orgId/last-staff-action`. A successful `null` ("No staff activity recorded") is distinct from a permission or service failure ("Last staff action unavailable", signalled by `lastStaffActionAvailable: false`). The Company activity timeline is unchanged and still scans and filters audit history.

#### Qualifying Event Scope

The company timeline aggregates every audited mutation whose entity is the host org row itself **or a child entity owned by that host org**. Child rows resolve to their owning host at read time through their existing FK (`host_organization_id` / `partner_org_id`); deleted child rows remain attributable because the audit row's `before` payload carries the owning org id.

| Audit family | Entity association | In timeline | Advances Last staff action |
| --- | --- | --- | --- |
| partner_organization.* — org row edits, activation, parent link/change/clear, inheritance toggles | entityType partner_organization, entityId = host org id | Yes | Yes, when the resolved actor is sponsor staff |
| host_organization.* — incl. the account_manager.assigned / .reassigned / .cleared family | entityType host_organization | Yes | Yes |
| host_organization.note.* — notes running log | entityType host_organization_note (child) | Yes | Yes |
| host.contact.* — Overview-tab contact roster mutations | partner_contacts child | Yes | Yes |
| host.notification_setting.* — Notifications-tab changes | host notification settings child | Yes | Yes |
| host.activity_site.* — activity-site CRUD | entityType host_activity_site (child) | Yes | Yes, when the actor is sponsor staff |
| host_portal.user.* / partner.user.* / partner_user.* — portal-user lifecycle (create / update / delete, reissue_invite, accept) | entityType partner_user (child) | Yes | Only when the acting user is sponsor staff — a host_admin clicking Resend appears in the timeline but does not advance the value |
| Host-portal-user self-service actions (reservations, job-order edits, document uploads) | various | Yes — labelled as host-side activity for context | Never |
| System / automation rows (`system:unattended`, rule-engine, scheduled jobs, `system:staff_admin` API-key writes) | any | Yes — labelled as system | Never |
| Reads — page views, searches, exports, GET traffic | — | Never (reads are not audited and must not become audited to feed this view) | Never |

#### Actor Resolution & Proxy Attribution

Audit rows store only `userId` and (when staff act through impersonation) `proxiedByUserId`. Display names are resolved **at read time** by joining to the users table — historical rows are immutable and are never backfilled with names.

- **Direct staff action** — `userId` is a sponsor-staff user, `proxiedByUserId` is null. Rendered as the staff member's displayName plus their numeric id.
- **Impersonated / proxied action** — `userId` is the portal user being impersonated, `proxiedByUserId` is the staff member. Rendered as the portal user's name with an explicit "via impersonation by &lt;staff displayName&gt;" badge. For Last staff action purposes the touch is attributed to the **staff** user (`proxiedByUserId`).
- **Unresolvable actor** — when `userId` no longer resolves (deleted or deactivated user record), the row renders the unambiguous fallback `Deleted user #<userId>` — the numeric identifier is always preserved and the row is never rendered as anonymous or blank.
- **System actors** — null `userId` renders the stored actor label (`system:unattended`, `system:staff_admin`), never an empty cell.
- **"Human staff" predicate** — an event qualifies as a staff touch only when its attributed actor (per the rules above) is **positively classified** as a sponsor-side staff user. Classification must come from a durable source: user records are soft-deactivated (never hard-deleted), so the retained user row's type/role remains authoritative for former staff — a staff member who left still advances the value for actions taken while active. If an actor row is genuinely unresolvable (no retained record and no identity tombstone), the event still renders in the timeline with its `Deleted user #<id>` fallback but **never advances Last staff action** (fail-exclusive) — an unclassifiable actor could equally be a former host-portal user, and guessing would violate the rule that portal-user activity never counts as a staff touch. Implementations that cannot guarantee retained identity rows must instead snapshot the actor's audience (staff vs portal user) at audit-write time and classify from that snapshot.

#### Last Staff Action — Roster & Header Summary

**Definition.** Last staff action = the `timestamp` and attributed actor of the **newest qualifying human-staff audit event** for the host org (per the two tables above), across the org row and all child entities.

- **Roster column** — the sponsor-side host roster gains a Last staff action column rendering the relative timestamp (absolute on hover) plus the resolved actor name. Sortable; hosts with no qualifying event render an em-dash and sort last.
- **Detail header** — the host detail header renders the identical value ("Last staff action: &lt;relative time&gt; by &lt;actor&gt;") linking to the Activity tab.
- **Single derivation rule** — both surfaces are served by the same server-side read model (the roster list read embeds the value per row; the detail read embeds the same shape). Neither surface may compute it client-side from partial data, so roster and header cannot disagree.
- **Exclusions restated** — automated/system events, reads, and host-portal-user activity never advance the value, even though some of them appear in the timeline.
- **Directory metrics** — for host organizations the Host Management directory shows **Active Job Orders** (job orders in `approved`, `partially_approved` or `active` status), **Participants Reserved** (reservation holds in `active` status) and **Active Agreements** (the active Trial or Operating agreement, `None` otherwise; resolved from the parent organization when agreement inheritance is enabled, Operating preferred if both exist) in place of the Type, Country and Partner Model columns, which remain for non-host partner types. The values are batched for the authorised result set by `GET /api/admin/partners/organizations` (`activeJobOrderCount`, `activeReservationCount`, `activeLifecycleAgreementType`) and default to zero / null.
- **Staff brand scope** — a staff brand grant now expands to every current and future program of that brand at authorisation time (`getUserScope`), without rewriting the saved scope row, so newly created programs are inherited automatically.

#### Activity Tab — Timeline Contract

The host detail view gains a staff-only **Activity** tab rendering the company timeline.

| Aspect | Contract |
| --- | --- |
| Ordering | Newest-first: `timestamp DESC`, `id DESC` tie-break. |
| Pagination | Server-side cursor pagination, 25 per page (`limit` 1-25, `cursor`; response carries `nextCursor` and `hasMore`); no total count is returned. |
| Row fields | Human-readable action label; event time (relative + absolute tooltip); actor (resolved name + numeric id, proxy badge when impersonated, fallback label when unresolvable); entity chip naming the child entity type when the row is not the org row itself. |
| Friendly labels | A label map keyed on the dotted action string ("host_organization.account_manager.reassigned" → "Account Manager reassigned"). Unknown actions fall back to rendering the raw dotted action — never a blank label, so new vocabulary is visible before the map catches up. |
| Change details | Expandable per-row detail rendering only the fields that differ between `before` and `after` (the note-family convention), with before/after values shown side by side when both exist. |
| Filters | Entity family, action type, actor kind (staff / host user / impersonated / proxy), date range (`from` / `until`) and free-text search. Server-evaluated so they compose with pagination. |
| Empty state | "No activity recorded for this host yet." — also shown when filters exclude everything ("No events match the current filters."). |
| Loading / error | Skeleton rows while loading; explicit error callout on fetch failure (never a silent empty state). |

#### Host Activity API Contract

Implemented in the Globus Hub (`server/routes/partners/hostManagementActivity.ts`).

- `GET /api/admin/partners/organizations/:orgId/activity` — cursor-paginated timeline (requires `partners.view` and `audit_log.view`; staff sessions only, API keys get 403). Query params: `limit` (1-25, default 25), `cursor`, `entityFamily` (host | agreement | job_order | verification | participant), `actionType`, `actorKind` (staff | host_user | impersonated | proxy), `from`, `until`, `search`. Response (inside the standard `{ success, data }` envelope): `{ items, nextCursor, hasMore }`. Returns 404 when `:orgId` does not resolve to a host org.
- **Last staff action embedding** — the host roster list read and the host detail read each embed `lastStaffAction: { timestamp, actor: { userId, displayName, fallbackLabel } } | null`, computed by the same qualifying-event predicate as the timeline. No separate endpoint; embedding is what enforces the single-derivation rule.

#### Permissions & Store Boundary

Access requires the `partners.view` and `audit_log.view` permissions and a staff session; API keys and partner contexts receive 403. Host Portal users (host_admin, hiring manager, finance, read-only) never see the Activity tab, the roster column, or the header value; the feed can expose staff names and internal operational cadence.

> **This feed reads the Globus audit store — not the Nexus docs-service store**
>
> The host activity feed is a filtered view over the **Globus Hub** `audit_logs` (written by its `auditMutation` middleware, queried via the Globus audit endpoints). The docs-service Audit Console on this site reads a *different, Nexus-local* store that contains only `asana.*`, `mcp.*`, `partner_organization.*` (docs-side) and `host_organization.note.*` rows — it does **not** contain Globus host events and is not a substitute for this view. See Audit Console for the two-store boundary.

#### Host Activity Implementation Follow-up

Per the cross-repo routing rule, the read model/API and the staff-facing roster/detail UI are built by the platform team and are tracked as two Asana tickets on the P4.B Host Management Updates board, authored to the house standard in docs/asana-ticket-standard.md: "Host Management: host-scoped audit activity read model + last-staff-action derivation API" and "Host Management: host detail Activity tab + roster/header Last staff action UI". Repo-traceable archive copies live under attached_assets/asana-tickets/.

### Permanent Host Delete (Super-Admin Only)

A host company can be permanently deleted with DELETE /api/admin/partners/organizations/:id. The route is restricted to the super-admin (system.admin) role and does not go through the generic mutation-audit middleware, because the audit row is written inside the delete itself. Non-super-admins keep the deactivate / status-change flow.

- Guards. The two protected seed hosts (HOST-CAMP-PINERIDGE, HOST-TEACH-HORIZON) cannot be deleted. The delete is refused with 409 ORG_HAS_ACTIVE_ENGAGEMENTS (details include activePlacements and activeReservations) while the host has any confirmed or active placement or any active reservation hold.
- One transaction. The delete walks children before parents in a single transaction: job orders and their match, amendment, document and housing-link rows, housing leads, site visits, placements and reservation holds, host verifications and attempts, extension requests, participant recommendations and staff-ratio rows, the org's agreement-library acceptances, resign obligations, runtime resolutions and assignments, partner agreements and other partner-scoped rows, and host document metadata rows (including executed-agreement archives) — then the organization row, after which database-level cascades sweep the remaining satellite tables. Storage objects are intentionally not deleted.
- Applicant data survives. applications.partner_org_id and finance_account_parties.partner_org_id are set to NULL, so applicant-side data lives on as unassigned.
- Tombstone. A partner.org.hard_deleted row (entityType partner_org, previousValue carrying id, name, code and EIN, metadata tombstone = true) is written to audit_logs inside the same transaction; if the audit write fails the whole delete rolls back. Because audit_logs has no foreign key to the org, the tombstone survives.
- Lifecycle audit is preserved. partner_org_lifecycle_audit is append-only (a trigger rejects UPDATE and DELETE), so its host_org_id foreign key was detached (migration 20271028000000_task_2348_detach_lifecycle_audit_host_fk): the immutable rows survive the delete with their raw host id, and the host's identity resolves through the tombstone.
- Non-leaking database conflicts. A remaining NO ACTION foreign-key dependency returns 409 ORG_DELETE_DEPENDENCY; an append-only trigger rejecting a cascaded write returns 409 ORG_DELETE_APPEND_ONLY_BLOCK. Both carry only catalog identifiers (details.table, details.constraint) — never row data or host PII.

## Recruiter Scope

Recruiter organizations are the supply side — they source applicants and manage the recruitment pipeline. Full detail is available on the Partner Portal Governance page.

### Lifecycle Summary

### Portal Capabilities

Create applicant invites / Bulk CSV upload (quota-checked) / Track recruitment pipeline / Browse job orders (if permitted) / Request reservations on job orders / Manage team (Partner Admin, Recruiter, Read-Only) / Access document center / View agreement status and quota utilization
For full capabilities, agreement structure, autonomy framework, and notification system, see Partner Portal Governance .

### RBAC Summary

Effective Access Formula

Role Permissions &cap; Agreement Scope &cap; Brand / Program / Season Scope

Full RBAC detail including enforcement model and scope hierarchy is on the RBAC & Governance page.

## Shared Governance Model

Both partner organization categories share fundamental governance principles enforced by Globus. The full agreement model — lifecycle, enforcement, restrictions, performance, and the host-only operating-agreement concept — is documented in Partner Agreements below. This section captures the cross-cutting principles only.

## Partner Agreements

A partner agreement is the time-bound, structured record in Globus that authorizes a partner organization to operate within a defined scope (brand, program, season) for a defined window. This section is the canonical, single-page read for the entire partner agreement model. The two depth pages ( Partner Portal Governance and Host Portal Governance) extend it with side-specific operational detail.

> **Documentation-Only Concept Naming**
>
> “Operating agreement” in this document is a semantic ascribed to the existing active standard agreement row on the host side. No new lookup value, row type, schema column, or table is introduced — the term names an invariant that already holds in production so that the DS-2019 guard and host onboarding modal can be discussed precisely.

### Agreement Model — Shared Spine + Extensions

> **Implementation tickets — P12-P1**
>
> The shared spine, host-typed extension, operating-agreement invariant + DS-2019 guard, and renewal flow described on this page are delivered by the twelve sponsor-side host management tickets listed (with Asana GIDs and execution order) on the Host Portal Governance &rarr; Implementation Tickets — P12-P1 section.

Both employer (host) and recruiter agreements share a common structural spine. Each side layers its own typed extension on top: a single shared partner_agreements row plus exactly one side-specific extension row keyed 1:1 to it. There is no second “agreement” entity per side — the lifecycle, guard, quota counter, and audit trail all run off the shared spine. The concrete field lists of each typed extension are documented in the next subsection.

Shared Spine

Partner org reference / Brand + Program + Season scope / Start date / end date (time-bound) / Lifecycle status (draft \u2192 \u2026 \u2192 expired/terminated) / Approver identity + reason on every transition / Atomic counter mechanism (quota on recruiter side, capacity on host side) / Reservation hold policy (TTLs) / Narrative terms (freeform JSONB) / Audit trail of every transition
Host Extension

Capacity limits per program/season / Compliance requirements & doc set / Verification state (pending_verification \u2192 verified) / Adherence state (host-side flag) / Site visit schedule / Housing requirements
The operating-agreement invariant and DS-2019 guard are evaluated on the shared spine, not on this extension.

Recruiter Extension

Quota allocation per program/season / Country of operation restrictions / Job-type restrictions / Autonomy level (A\u2013D) / Bulk-upload entitlement / Origin (External / Internal)
### Typed Extension Records (Host / Recruiter)

The conceptual spine + extensions view above is made concrete by two thin, typed extension records keyed 1:1 to partner_agreements. The shared row continues to carry everything both sides have in common (lifecycle, scope, restrictions, quotas linkage, audit). The extension record carries only the side-specific fields that used to live as NULLs or did not exist in the schema at all.

> **One Agreement Row, One Typed Extension**
>
> Every agreement has exactly one extension record, determined by the parent org’s type at creation time. Hosts get a host_agreement_extension row; recruiters get a recruiter_agreement_extension row. The shared spine is never split, and the lifecycle, guard, quota engine, and audit trail continue to run off the shared core.

> **Operating Agreement Is Not a Field**
>
> The “operating agreement” concept (see next subsection) is a derived invariant over the shared spine — not a boolean on the host extension. No column, lookup value, or table is introduced. The host extension intentionally does not carry an is_operating_agreement flag; the predicate is computed from status = active, in-window endDate, and scope match.

host_agreement_extension

1:1 with partner_agreements when parent org is a host

| Field | Purpose |
| --- | --- |
| compliance_documents | References to required compliance artifacts (business license, workers’ comp, insurance, host-site questionnaire). Document records live in the document service; this field stores the binding + current status per required slot. |
| verification_state | Pre-state values before the shared lifecycle takes over: pending_verification → verified (or rejected). Shared lifecycle starts at draft only after verified. |
| adherence_state | Host-side adherence flag: in_good_standing / flagged / on_probation_host. Distinct from the shared lifecycle state — a host can be flagged on adherence while the agreement itself is still active. |
| capacity_limits | Capacity ceiling per program/season enforced by the placement guard. The shared quota counter covers recruiter quota; host capacity is governed here. |
| site_visit_schedule | Scheduled and ad-hoc site-visit cadence required by program policy. Drives the site-visit queue. |
| housing_requirements | Structured housing expectations (owned / vendor / not-applicable) for the DS-2019 and placement pipelines. |

recruiter_agreement_extension

1:1 with partner_agreements when parent org is a recruiter

| Field | Purpose |
| --- | --- |
| intake_submission_cap | Maximum number of applicant submissions the recruiter may lodge into the intake queue before sponsor review is required to raise the cap. Guards the intake endpoint independently of the seasonal quota counter. |
| portal_role_assignments | Named seats for recruiter portal users (Owner, Lead Recruiter, Viewer, Finance Contact). Governs which RBAC bindings are legitimately issuable against this agreement. |
| sponsor_origin | external / internal marker. Internal partners are Globus-owned recruitment arms; external partners are independent organisations. Drives finance routing, branding, and some review thresholds. |
| autonomy_tier_detail | Recruiter-side detail for the shared A–D autonomy level — e.g. sub-quota caps under tier D, auto-approval thresholds, escalation ceilings. The coarse A–D label lives on the shared spine; the specifics live here. |
| country_operation_scope | Snapshot of the country restrictions the recruiter is authorised to source from. The enforcement list is shared; this field holds the acknowledged source-of-truth the recruiter signed on to. |
| bulk_upload_entitlement | Whether the recruiter may use the bulk-upload surface, and at what row cap. Off by default for new recruiter agreements. |

> **Not a Lifecycle Split**
>
> Typed extensions add side-specific fields; they do not split the lifecycle, the enforcement guard, the quota engine, or the audit plumbing. The shared state machine on the next subsection continues to govern every agreement regardless of which extension is attached.

### Lifecycle & State Machine

Every agreement moves through a defined state machine. Transitions are not free — each one carries an approver requirement, a reason field, and downstream side-effects.

> **Host operating agreements skip draft → pending_approval**
>
> New host operating agreements are created `active` immediately — activation and send-for-signature happen in a single staff action (one-click activate-and-send). There is no intermediate `draft` status and no separate Activate step for the host operating-agreement creation path. The `draft` and `pending_approval` states remain in the shared state machine for recruiter agreements and for legacy host agreement rows predating this change; they are not part of the current host operating-agreement creation flow.

| State | Meaning | Transitions To | Side-Effects |
| --- | --- | --- | --- |
| draft | Authored by sponsor staff. Not yet executed. — **host operating agreements: legacy path only; new agreements land in `active` directly** | active | None. No portal access. |
| pending_approval | Not a state in the current agreement state machine (`AgreementStatus` is draft / active / probation / suspended / expired / terminated); no transition leads to or from it. | (none) | None. |
| active | Executed and in effect within its date window. | probation, suspended, expired, terminated | Portal access enabled. Quota counter live. Reservations allowed. |
| probation | Performance-flagged. Operating with reduced authority. | active, suspended, terminated | Quota cut to 50% of allocation. New reservations enhanced-reviewed. Audit verbosity raised. |
| suspended | Frozen by the sponsor. Reason required. | active, terminated | Portal access read-only. New reservations blocked. Existing holds frozen. |
| expired | End date passed. Auto-expiry job ran within 15 minutes of the boundary. | terminated | Portal access revoked. Open reservation holds are released when their TTL elapses. |
| terminated | Permanently ended by sponsor decision. | (terminal) | Portal access revoked. Records retained for compliance. |

### Enforcement & Quota Mechanics

Enforcement happens server-side in Globus on every mutation. The portals never decide whether an action is allowed — they ask Globus and reflect the response.

s brand/program/season falls within agreement scope, (4) the user role is permitted, (5) remaining quota is > 0.,
Atomic quota counter / Quota is enforced as a single atomic counter increment within a transaction. There is no read-then-write window where two concurrent requests can both pass the precheck. Over-allocation is structurally impossible.,
Quota scope matching / A broad agreement quota row (no job order) with a NULL program_id or season_id is a wildcard that applies to any program or season; a row scoped to a different non-null program or season is not applicable and is skipped, not reported as exhausted (if no applicable row exists the consume is an unlimited soft pass). On this consume path QUOTA_EXCEEDED (HTTP 409) is raised only when an applicable row (a job-order-scoped row for the targeted job order, or the broad row) is exhausted; a frozen applicable row raises QUOTA_FROZEN (HTTP 403).,
Reservation TTLs / Holds carry a TTL (typically 48\u201372h depending on agreement). The TTL starts at hold creation. The system tracks holdExpiresAt explicitly; reservation status reflects the current TTL position.,
Hold-age escalation ladder (3 / 5 / 8 hours, default) / Not built: no `hold_warn_hours`, `hold_cut_hours` or `hold_freeze_hours` exists in the server or shared code; holds are only auto-released when their TTL elapses.
The database also enforces uniqueness of quota rows with the expression index `uq_quota_entity_agreement_type` over entity type, entity id, agreement id, quota type, country scope, job type scope and job order id (NULL and the empty string are folded together with COALESCE). Season and program are not part of that key, so two quota rows that differ only by season or program collide and the second is rejected, while rows that differ by job order are both allowed.

> **No Client-Side Bypass**
>
> Portals may hide a button when a precheck fails, but the server still re-runs every guard. A request that bypasses the UI receives a structured 4xx with the failing precondition named in the response.

### Restrictions & Autonomy

Agreements carry structured restrictions that the guard layer enforces, plus an autonomy level that shapes the interaction model.

Country Restrictions

Recruiter agreements declare the countries the partner is authorized to source applicants from. Submissions whose applicant nationality falls outside the list are rejected at the applicant-create guard, not at intake review.

Job-Type Restrictions

Agreements may scope which job types (e.g., camp counselor, hospitality, technical internship) the partner may operate against. Reservations and matches outside the permitted job types are rejected by the reservation guard.

| Level | Posture | Operational Meaning |
| --- | --- | --- |
| A | Submission only | Partner submits applicants/job orders. Sponsor decides everything else. Minimal portal surface. |
| B | + Matching / reservations | Partner can hold seats (recruiter) or manage capacity on their job orders (host). Reservation guards apply. |
| C | + Post-arrival / on-program participation | Partner contributes to on-program signals: evaluations, check-ins, housing changes, issue escalations. |
| D | Semi-autonomous regional | Delegated authority within scope. Auto-approved actions within sub-quota and performance gates. Highest oversight cost. |

Autonomy levels apply to both sides and are described as side-specific depth on the Partner Portal Governance and Host Portal Governance pages.

### Performance & Adherence

*Status: the 10-metric table below is a design spec. The performance engine computes ten different metrics (`conversion_rate`, `completion_rate`, `rejection_rate`, `avg_processing_days`, `quota_utilization`, `compliance_score`, `visa_denial_rate`, `no_show_rate`, `sevis_error_rate`, `reservation_expiry_rate`) plus `returner_rate`, which the Camp tiles surface; see Performance Tiles below.*

Performance is computed from a fixed set of 10 metrics per partner. Each metric has a tier (Critical / Warning / Acceptable). The aggregate produces a status recommendation that feeds the lifecycle — sponsor staff act on the recommendation; the system does not auto-downgrade.

| # | Metric | Side | What It Measures |
| --- | --- | --- | --- |
| 1 | Submission Quality | Both | Rate of submitted records that pass intake review without rework. |
| 2 | Compliance Document Health | Both | Currency of required documents (pending/approved/expiring/expired mix). |
| 3 | Reservation Discipline | Both | Hold conversion vs expiry rate against TTL. |
| 4 | Time-to-Action | Both | Median time from sponsor request to partner response on review items. |
| 5 | Audit Hygiene | Both | Frequency of guard denials per 1k actions; high denial = scope drift. |
| 6 | Visa Outcome Rate | Recruiter | Visa issuance vs denial vs no-show on recruiter-attributed applicants. |
| 7 | Pipeline Throughput | Recruiter | Quota utilization at season midpoint vs end. |
| 8 | Placement Fill Rate | Host | Filled / requested positions across active job orders. |
| 9 | Site Visit Outcomes | Host | Pass rate on scheduled and unscheduled visits. |
| 10 | Participant Welfare Signals | Host | Composite of evaluations, check-in compliance, escalations, housing inspection results. |

> **Recommendation, Not Verdict**
>
> The performance engine produces a status recommendation. Lifecycle transitions still require an approver, a reason (host-organization status changes require at least 10 characters unless a deactivation reason category is supplied), and an audit entry. The system does not autonomously downgrade an agreement.

### Performance Tiles — Program-Type Scoping

The Performance section above defines what is measured per partner. The Host Management screen renders a program-type-scoped subset of those metrics as tiles — the visible tile set is selected by the host org’s associated program type so sponsor staff aren’t reading metrics that don’t apply to the seasonal cycle in front of them. This sub-section pins the tile contract for the Camp program type. Other program types (Trainee, Intern, Au Pair, Teach) are future spec extensions and are intentionally out of scope here.

> **View Contract, Not a New Metric Catalog**
>
> The Camp tile set is a view contract over the canonical 10-metric performance & adherence model. The underlying metrics (Reservation Discipline, Time-to-Action, Submission Quality, etc.) still feed these tiles where they map. Three of the five Camp tiles are new derivations that don’t have a 1:1 row in the 10-metric table (Conversion Rate — Hired vs Not Hired; Completion Rate — Hired to DS-2019 Issued; Participant Types — Returner vs New) — they are computed from the same source tables already documented in Reporting & Analytics &rarr; Partner Performance KPIs .

Camp host — the five tiles

| Tile | What It Measures | Source | Calculation | Refresh |
| --- | --- | --- | --- | --- |
| Conversion Rate — Hired vs Not Hired | Share of applicants submitted to this host that the host hired (Hired) vs those that did not convert to a hire — rejected, withdrawn, expired, or still open (Not Hired). Reframes the former rejection view as the conversion outcome staff actually read. | application_reviews + placements | Hired / (Hired + Not Hired) × 100, where Not Hired = total submitted to host − hired | Daily |
| Avg Processing Days — Reserved-to-Hire Time | Average number of days an applicant submitted to this host takes to reach placement, measured on the application record (the UI labels the tile "Reserved to Hire", but the engine does not read reservations). | applications | Mean(applications.stage_updated_at − applications.created_at) in days, over placed applications (journey position placement, visa, active or completion) | Daily |
| Reservation Expiry Rate | Share of reservations against this host that expired (TTL elapsed) instead of converting to a hire or being released. | reservations | Expired / Total reserved × 100 | Daily |
| Completion Rate — Hired to DS-2019 Issued | Share of host-confirmed hires for whom DS-2019 was successfully issued (vs stuck in pre-SEVIS, denied, or withdrawn before issuance). | placements + sevis_submissions | Hires with DS-2019 issued / Total hires × 100 | Daily |
| Participant Types — Returner vs New | Split of this host's confirmed hires this cycle into Returner (participant with a prior completed/placed program at this host) vs New (first-time at this host). Gives staff a host-side retention read on how much of the roster the host is re-hiring versus building fresh. | placements + participants | Returner hires / Total hires × 100 and New hires / Total hires × 100, over this host's confirmed hires this cycle | Daily |

Mapping back to the 10-metric catalog

| Camp tile | Catalog metric | Relationship | Source tables / fields |
| --- | --- | --- | --- |
| Reservation Expiry Rate | Reservation Discipline (#3) | Existing — the Camp tile surfaces the expiry side of the hold-conversion vs expiry ratio that the catalog metric already tracks. | reservations |
| Avg Processing Days — Reserved-to-Hire | Time-to-Action (#4) | Existing — Time-to-Action is the catalog parent; the Camp tile is the reserved-to-hire specialization of it. | reservations, placements |
| Conversion Rate — Hired vs Not Hired | (no 1:1 row) | Newly derived for Camp — frames the host-side outcome as the Hired vs Not Hired conversion split rather than the inverse rejection share; adjacent to Submission Quality (#1) but outcome-oriented. | application_reviews, placements |
| Completion Rate — Hired to DS-2019 Issued | (no 1:1 row) | Newly derived for Camp — distinct from Visa Outcome Rate (#6, recruiter-attributed); this tile attributes DS-2019 issuance success to the host. | placements, sevis_submissions |
| Participant Types — Returner vs New | (no 1:1 row) | Newly derived for Camp — a host-side retention read with no counterpart in the 10-metric catalog; classifies each confirmed hire by whether the participant has a prior completed/placed program at this host. | placements (current + prior cycles), participants |

Conversion Rate tile — interactive drill-down

The Conversion Rate — Hired vs Not Hired tile is interactive: clicking the tile opens a drill-down panel rather than navigating away. The drill-down leads with the count split — a Hired count and a Not Hired count (with the percentage the tile already shows) — so staff can move from the headline conversion number into the applicants behind it. This is a view-contract behavior: it defines what the panel shows and exports, not new backend computation — the Hired and Not Hired buckets are derived from the same application_reviews and placements sources already documented above.

Participant Types tile — interactive drill-down

The Participant Types — Returner vs New tile is interactive, mirroring the Conversion Rate drill-down: clicking the tile opens a drill-down panel rather than navigating away. The drill-down leads with the count split — a Returner count and a New count (with the shares the tile already shows) — so staff can move from the headline retention number into the applicants behind it. This is a view-contract behavior: it defines what the panel shows and exports, not new backend computation — the Returner and New buckets are derived from the same placements and participants sources already documented above.

BackOfficePrototype serves these drill-downs and the host KPI from three read routes under `/api/admin/partners/organizations/:orgId`, each requiring `partners.view` (API-key scope `partners.read`). `GET /conversion-buckets` returns `hired` and `notHired` applicant rows, `counts` (`hired`, `notHired`, `total`) and `conversionRate` (percent to one decimal, null when there are no rows); rows whose source is `placed` are Hired. `GET /participant-type-buckets` splits the org's confirmed hires into `returner` (the applicant has a prior placement at the same host in any status, or a completed application there, in a season that started earlier) and `newcomer`, with `counts`, `returnerRate` and `newRate`. Both accept optional `seasonId` and `programId` query filters. `GET /hired-count` returns `hired`, the number of the org's placements whose status is not `cancelled` (completed placements still count); it takes no filters.

s confirmed hires this cycle.,
Expand a bucket into its applicants / Clicking either bucket (Returner or New) expands it into the list of applicants in that bucket, reusing the same applicant-row presentation used elsewhere on the Host Management surface (applicant identity, program, reservation/placement status, last-touch date).,
Export to CSV / The drill-down exposes an Export to CSV action that downloads the currently drilled-down applicants as a CSV file, respecting whichever bucket/view is open: exporting from the count split (both buckets) yields all hires tagged by participant type, while exporting from an expanded bucket yields only that bucket
Scoping rules

s associated program type. When the host is associated to a Camp program, only the five tiles above render in this section — no other adherence/performance tiles appear for that host. The generic 10-metric strip is not the fallback when a Camp tile set is active.,
Multi-program hosts (e.g., Camp + non-Camp) / The Performance section renders one tab per associated program type. Each tab carries that program type
> **What this contract does not change**
>
> Backend computation of any of the five metrics, thresholds, alerting, notification, and the layout/visual treatment of the Performance section are all out of scope for this contract. This sub-section pins which tiles render for which program type on the Host Management surface — nothing else. Non-Camp program-type tile sets (Trainee, Intern, Au Pair, Teach) will be specified in future extensions to this sub-section.

### Operating Agreement (Host-Only)

“Operating agreement” names the host-side invariant that authorizes a host to operate within a given Brand + Program + Season context. It is not a new lookup value or row type. It is the active operating agreement row (agreement_type = 'operating') whose status is active and whose endDate has not passed. Recruiters have no equivalent — they operate against quota and scope, not against an operating-agreement predicate.

active / operating
> **Recruiters Have No Operating Agreement**
>
> The operating-agreement concept is host-only. Recruiter authorization is fully expressed by the agreement record itself (active status, in-window endDate, scope, quota).

> **Only operating rows satisfy the predicate**
>
> `findActiveOperatingAgreement` (used by `isOperatingAgreementSigned`) matches active `agreement_type = 'operating'` rows only; an active `standard` or `trial` row does not satisfy it, and the DS-2019 guard keys off the same type. A `standard` row matters in two other places: `getOperatingAgreementStatus` falls back to an accepted, acceptance-marker-armed agreement of another type so `isSigned` settles once the host accepts, and host reservation resolution treats `operating` and `standard` as reservation-authorising (operating preferred) while still refusing `trial`.

> **Inherited Operating Agreement Counts**
>
> When a child host org has governance_inheritance.agreement = true, the predicate above resolves by reading the parent’s operating row instead of the child’s. An inherited operating agreement counts as the child’s active operating agreement for invariant purposes — the DS-2019 guard and the host onboarding modal both treat the child as covered. The per-host partial unique index still applies per host_id, so a child cannot author its own active operating row in the same scope while inheriting; it must flip the inheritance toggle off first. See Parent / Child Hierarchy &rarr; Inheritance Surfacing on Existing Panels.

> **Acceptance archives the executed agreement; unsent drafts are never the acceptance target**
>
> Accepting a host agreement (POST /api/partner/host/operating-agreement/accept) archives the executed agreement as a host document: a PDF rendered from the exact accepted Agreement Library version and its acceptance evidence, stored as an approved, host-visible document (category agreement, source signed_agreement) linked to the acceptance id, so a replayed accept returns the existing archive instead of creating a second one. If the archive cannot be produced after the acceptance is recorded, the response is 503 (details.code SIGNED_AGREEMENT_ARCHIVE_FAILED, retryable — replaying the accept reconciles it) or, when the accepted version or its evidence cannot be resolved, 409 (details.code SIGNED_AGREEMENT_ARCHIVE_BLOCKED, not retryable). Executed-agreement archives are immutable legal records: deleting one through either host-document delete route (Host Portal or staff) returns 409 SIGNED_AGREEMENT_ARCHIVE_IMMUTABLE.
>
> An ordinary staff-created draft operating agreement is not a pending upgrade. A draft counts as the host's selected operating successor only when the explicit upgrade flow (POST /api/partner/host/operating-agreement/upgrade) prepared it for signing — both acceptance_required_at and last_sent_at are set. The status endpoint reports null pendingOperating* fields for an unprepared draft, so operating legal copy is never exposed for a draft the host cannot sign. An accept without an explicit agreementId prefers a prepared successor and otherwise resolves the active, sent trial agreement; an explicit accept of a draft that was never prepared or sent is still refused with 409 AGREEMENT_NOT_SENT.

### Operating Agreement Invariant Enforcement

*Status: partly superseded by the code. The partial unique index described below does not exist in the current schema: multiple active host agreements are supported and the legacy one-active-per-org unique indexes were dropped (`20271203025000_remove_legacy_active_agreement_uniques.sql`). The predicate is implemented as `isOperatingAgreementSigned` / `findActiveOperatingAgreement` in `server/services/operatingAgreement.ts`, not `hasOperatingAgreement(...)`. The DS-2019 guard returns the failure reasons `no_placement`, `no_host_org`, `no_operating_agreement` and `host_verification_required`, not the reason-code table below.*

The predicate above is only safe for the DS-2019 guard and onboarding modal to trust if Globus enforces the invariant rather than merely observing it. This subsection is the normative source for the data-layer constraint, the single reusable predicate, the probation policy’s interaction with DS-2019, the closed audit reason-code set, the required regression coverage, and the rollout sequence — the five deliverables covered here close task #160 (1)–(5).

1. Uniqueness Constraint

The database cannot hold more than one active operating agreement row for the same host within the same Brand + Program + Season scope. The constraint is a partial unique index:

`UNIQUE (host_id, brand_id, program_id, season_id) WHERE agreement_type = 'operating' AND status = 'active'` 
Before the index is applied, a backfill check runs against existing rows; any violation is reported for resolution, not auto-resolved. The constraint is additive — no existing schema is rewritten.

2. Single Reusable Predicate

The design names this predicate hasOperatingAgreement(hostId, brand, program, season, nowTs), a single function holding the logic. No function of that name exists in code: the shipped predicate is `isOperatingAgreementSigned(partnerOrgId, programId?)` with `findActiveOperatingAgreement` in `server/services/operatingAgreement.ts` (see the status note above). The design checks:

- host org precondition — program-type-sensitive: for non-camp program types, partner_organizations.status = 'active' is required for the owning host (see Pending Verification &rarr; Active, #185); if the org is in pending_verification, suspended, or terminated, the predicate short-circuits to false and no agreement-row scan runs. For program type **camp**, the precondition is relaxed to partner_organizations.status IN ('active', 'pending_verification') — a pending camp host does get an agreement-row scan, so an accepted, active, in-window, scope-covering operating row makes the predicate true while verification is still in flight. suspended and terminated short-circuit to false for every program type. Verification for camp is NOT part of this predicate: it is enforced as a separate conjunct at the DS-2019 issuance site only (hasOperatingAgreement(...) AND hostVerificationPassed(host), refusing with 403 `HOST_VERIFICATION_REQUIRED`), so the onboarding modal and portal capability gates — which consume only this predicate — stay open for pending camp hosts. See Host Portal Governance &rarr; Camp Program Divergence — Verification Gates DS-2019 Only.
- agreement_type = 'operating'
- status = 'active' (not draft, pending_approval, probation, suspended, expired, or terminated)
- endDate &ge; nowTs (guards against the 15-minute auto-expiry window producing a false positive)
- scope (brand, program, season) matches the caller’s context exactly
The DS-2019 issuance guard, the host onboarding modal trigger, the wizard’s soft-preview at Scope step, and any reporting surface that asks “does this host have an operating agreement right now?” all route through this one function. No caller re-implements the check.

Parent / child inheritance. When the host org being evaluated has governance_inheritance.agreement = true and a non-NULL parent_organization_id, the predicate resolves the row scan against the parent’s host_id instead of the child’s. An inherited operating agreement therefore counts as the child’s active agreement for invariant purposes — the DS-2019 guard, the onboarding modal trigger, and the agreement wizard’s soft preview all see the inheriting child as covered. The uniqueness constraint above still applies per host_id, so the child cannot author its own active operating row in the same scope while inheriting; the inheritance toggle must be flipped off first (which snapshots the parent’s current value into the child). See Parent / Child Hierarchy &rarr; Inheritance Surfacing on Existing Panels.

3. Probation Policy (On the Record)

Probation is a lifecycle state (status = 'probation'), not an adherence flag. Because the predicate requires status = 'active', an operating row in probation naturally fails the predicate — new DS-2019 issuance is blocked, and the host onboarding modal reflects the failing predicate. This rule is recorded here so documentation and runtime behavior cannot drift; no separate probation branch is needed inside the predicate.

Existing placements continue to honour the 50% probation quota cut described in Performance & Adherence; probation blocks new DS-2019 issuance, not existing placements. The host-side adherence flag on_probation_host on host_agreement_extension.adherence_state is a separate axis (see Typed Extension Records) and does not itself drive the operating-agreement predicate.

4. Audit on Guard Block

Every time the DS-2019 guard blocks on a failing predicate, an audit entry is written with the specific failure reason and the agreement id (if any) that was evaluated. The reason code set is closed:

| Reason Code | Meaning |
| --- | --- |
| no_operating_row | No operating agreement (agreement_type='operating') exists for this host in this Brand+Program+Season. |
| operating_expired | An operating row exists but endDate has passed. |
| operating_suspended | Operating row is suspended (pending investigation or admin action). |
| operating_terminated | Operating row has been terminated; a new one must be authored. |
| probation_block | Operating row is in lifecycle state probation; DS-2019 issuance blocked under probation policy. |
| scope_mismatch | Operating row exists but its scope does not cover the requested program. |
| host_verification_required | Camp only, guard-site conjunct — the operating-agreement predicate passed, but the host has not passed verification. Written by the DS-2019 issuance guard (403, response code `HOST_VERIFICATION_REQUIRED` when verification is the only failure, otherwise `OPERATING_AGREEMENT_REQUIRED`), never by hasOperatingAgreement(...) itself, so the onboarding modal and other predicate consumers are unaffected. |

> **Required Test Coverage**
>
> The predicate and guard are regression-tested for each case: no operating row, expired operating row, suspended operating row, terminated operating row, probation operating row, wrong-program scope, wrong-season scope, wrong-brand scope, and the happy path. Each case asserts both the boolean predicate result and, where applicable, the audit reason code.

5. Rollout Sequence

The invariant is shipped in a deliberate order so no deploy step can leave the system in a state where the predicate is trusted but not enforced. Each step below is independently deployable and rolls back cleanly on its own.

- Backfill audit. Run a read-only report that scans partner_agreements for any (host_id, brand_id, program_id, season_id) grouping with more than one row where agreement_type = 'operating' and status = 'active'. Zero rows returned &rarr; safe to proceed. Any rows &rarr; sponsor ops resolves each duplicate by hand (typically by suspending or terminating the older row with a reason) before the next step.
- Add the partial unique index. Ship the index definition in a standalone migration. The index is additive and non-blocking in Postgres (CREATE UNIQUE INDEX CONCURRENTLY). Monitor for insert-path errors for 24h before proceeding.
- Introduce the predicate function. Add hasOperatingAgreement(...) as a single pure function in the shared domain module. No callers yet — this step is library-only. Ship with the full unit-test matrix from “Required Test Coverage” above.
- Route the DS-2019 issuance guard through the predicate. Replace the existing ad-hoc check at the guard site with a call to hasOperatingAgreement(...). On false, emit the audit entry with the reason code and surface the canonical failure string from the portal-copy table.
- Route the onboarding modal trigger through the predicate. The host portal’s onboarding-modal trigger now calls the same predicate, inverted. One source of truth.
- Route reporting surfaces. Any existing read-models, dashboards, or exports that answer “does this host have an operating agreement right now?” are switched to the predicate. Remove the old inline checks once all call sites are migrated.
- Enable audit verbosity. Turn on the closed reason-code set in audit writes and verify the new entries land in the standard audit sink.
Rollback: the uniqueness constraint can be dropped in-place if a violation surfaces; the predicate function and its call sites can be reverted independently. Because the canonical operating row semantics are unchanged, rollback never leaves orphan data.

### Trial Agreement (HP-3)

A trial agreement (agreement_type = 'trial') grants a host view-only portal access before they have signed a full operating agreement. It is a distinct row type introduced by HP-3 and is not a substitute for the operating agreement — it satisfies none of the operational action gates.

trial_agreement_upgrade_required / talent_pool / participant_profile / company_information / superseded / operating / trial / standard
> **Trial Does Not Satisfy the Operating-Agreement Predicate**
>
> isTrial = true means the host has not signed a full operating agreement. The DS-2019 guard, job-order creation gate, and all other operational gates evaluate the operating-agreement predicate against agreement_type = 'operating' rows only. A trial row never satisfies those gates regardless of its status or date range.

## Parent / Child Hierarchy

Host organizations may be grouped into a two-level parent &rarr; child hierarchy so the ecosystem can model real-world parent companies (a camp group that owns several camps, an employer HQ that owns several site locations) without flattening them into independent records. This section is the canonical reference for the data model, the single-parent and single-level rules, what happens to children when a parent’s lifecycle changes, the inheritance surfacing rules, and the audit-event vocabulary. The creation-time picker and the host-management Parent / Children panel that author and edit these links live in Org Creation &rarr; Parent Organization (Optional) and Parent / Children Panel respectively.

> **V1 scope: hosts only, two levels, opt-in inheritance**
>
> Parent / child links are host-only in v1 — recruiter orgs cannot have parents or children. The hierarchy is strictly two-level (a parent cannot itself have a parent). Governance inheritance is per-field and opt-in — nothing is inherited by default; linking a parent without flipping any inheritance toggle behaves exactly like an un-linked org for every downstream consumer.

Hierarchy Shape

Parent Host Org

parent_organization_id = NULL

Owns the inheritable source records (operating agreement, contacts, compliance documents).

&rarr; 
Child Host Org(s)

parent_organization_id = id

Carries its own values by default; per-field inheritance toggles override individual panels to read through to the parent.

### Data Model & Invariants

Two additions to partner_organizations back the hierarchy. This is a spec; the migration is filed as a follow-up against the schema repo per the cross-repo routing rule.

| Column | Type | Purpose |
| --- | --- | --- |
| parent_organization_id | varchar (uuid) NULL, FK → partner_organizations.id, ON DELETE SET NULL | Identifies this org's parent host org. NULL means no parent. Self-referential. Indexed for child-list lookups. |
| governance_inheritance | jsonb NOT NULL DEFAULT ''::jsonb | Per-field opt-in inheritance flags. Allowed keys: agreement (bool), contacts (bool), documents (bool). Missing key = false. Unknown keys rejected by the domain-model validator. |

DB Invariants

- Single-level (max depth 1). parent_organization_id must reference a row whose own parent_organization_id is NULL. Enforced by a trigger (Postgres lacks a declarative way to express this) and re-checked at the API layer.
- No self-reference. parent_organization_id &ne; id. CHECK constraint.
- No cycles. Implied by single-level + no-self-reference; no separate constraint needed in v1.
- Hosts only. Both ends of the FK must be host orgs. Enforced at the API layer (no schema-level partner-type column today); validated under parent_org_not_host on create and change-parent.
- governance_inheritance keys. The JSONB document is validated by the domain model to contain only the three documented keys with boolean values; the Postgres column itself stays permissive JSONB to avoid migration friction.
- ON DELETE SET NULL. Deleting a parent (rare; hard delete is an admin-key operation) leaves children orphaned with parent_organization_id = NULL and forces every inheritance flag off in the same transaction, mirroring the explicit Clear Parent action.
### Hierarchy Rules & Suspension Semantics

s status-change confirmation modal listing every child that currently inherits any field, with one-click links to each child so the operator can decide whether to flip inheritance off, snapshot the parent / s current value, not a snapshot / When a child has agreement = true and the parent suspends its operating agreement, the child immediately loses operating-agreement coverage (the DS-2019 guard predicate evaluates against the parent / s live value\u201D — and is one of the items the warning modal calls out so sponsor staff are not surprised.,
Re-parenting / Changing the parent on a child runs the same single-level/no-cycle/active-host validations as creation. Every inheritance flag currently on is preserved on re-parent (the child continues inheriting from the new parent), but the change-parent confirmation modal lists each inherited field and the new source value so the operator can opt out per field before confirming.,
Clearing the parent / Clearing the parent always forces every inheritance flag to false in the same transaction. The confirmation modal shows the value that will be snapshotted into the child for each currently-inherited field; on confirm those values become the child
### Inheritance Surfacing on Existing Panels

Three panels on the host record participate in inheritance: Operating Agreement (see Operating Agreement (Host-Only)), Contacts (per the primary-contact gate documented in Host Portal Governance &rarr; Pending Verification &rarr; Active ), and Compliance Documents (per Host Portal Governance &rarr; Compliance Document Tracking ). Each renders the same affordance set when the corresponding inheritance flag is on.

Per-Panel Affordance Set

- “Inherited from ” badge. Shown at the top of the panel when the flag is on, with a link to the source record on the parent. The child’s local value (if any) is rendered below the badge as “Overridden — not in use while inheriting” for transparency.
- Read-through behavior. Every downstream consumer (DS-2019 guard, primary-contact gate, compliance dashboard, notification routing) resolves the field by reading the parent’s live value when the flag is on. There is no per-consumer override.
- Toggle Off &rarr; On. Clears the child’s local value in favor of the parent’s live value. The cleared value is preserved in the audit entry’s before payload so it can be reconstructed if the toggle is later flipped back. Emits partner_organization.inheritance_toggled with .
- Toggle On &rarr; Off. Snapshots the parent’s current value into the child so the child has its own value to edit on the spot (no temporary empty state). Emits partner_organization.inheritance_toggled with and an snapshotted_value field in the audit payload.
- Inherited operating agreement counts as the child’s active agreement. For the operating-agreement invariant predicate (see Operating Agreement Invariant Enforcement), a child with governance_inheritance.agreement = true resolves hasOperatingAgreement(child, …) by evaluating the predicate against the parent’s standard row. The partial unique index on (host_id, brand_id, program_id, season_id) no longer exists (multiple active host agreements are supported; the legacy one-active-per-org unique indexes were dropped by `20271203025000_remove_legacy_active_agreement_uniques.sql`). Agreement lifecycle writes on the child are blocked while `governance_inheritance.agreement` is on and a parent is linked, so the child must flip the inheritance toggle off before editing its own agreement.
### Audit Events

| Action | Emitted When | Payload |
| --- | --- | --- |
| partner_organization.parent_linked | An org's parent_organization_id moves from NULL to a value (creation with parent, or first change-parent on an unlinked child). | childOrgId, parentOrgId, linkedBy |
| partner_organization.parent_changed | An org's parent_organization_id moves from one non-NULL value to another. | childOrgId, fromParentOrgId, toParentOrgId, changedBy, preservedInheritanceFlags |
| partner_organization.parent_cleared | An org's parent_organization_id moves from a value to NULL (explicit clear, or parent deletion). | childOrgId, formerParentOrgId, clearedBy, snapshottedValues |
| partner_organization.inheritance_toggled | Any governance_inheritance flag flips, regardless of whether a parent change is also happening. | childOrgId, field, from, to, snapshottedValue (when to=false) |

All four actions ride on the shared recordAudit() helper documented in the Audit Logging Contract with entityType = "partner_organization" and entityId = childOrgId. Best-effort writes; never block the originating request.

### Implementation Follow-up

> **Cross-repo work — file against the owning repo, not this docs site**
>
> Per the cross-repo routing rule in replit.md, the build-out of parent / child support lives outside this docs-site repo. The downstream tickets below are recorded here so reviewers can track them; they are not created as project tasks on the docs site. Schema repo — add parent_organization_id self-FK + single-level trigger + governance_inheritance JSONB column and backfill empty objects on existing rows. Globus-core repo — create-org endpoint patch: accept parentOrganizationId and governanceInheritance, validate per the error-code table in Org Creation, emit the four audit events. Globus-core repo — extend hasOperatingAgreement() to resolve through governance_inheritance.agreement when set, and update the partial unique index rationale + test matrix to cover the inheriting-child case. Admin-console repo — ship the typeahead picker (creation + change-parent), the Parent / Children panel on the host record management surface, and the inheritance-toggle rows on Operating Agreement / Contacts / Compliance Documents. Host-portal repo — render the “Inherited from ” read-only badge on Operating Agreement, Contacts, and Compliance Documents panels when the corresponding flag is on; do not ship any edit affordance (sponsor-staff only).

### Policy Knobs & Enforcement Policy Step

*Status: design spec, not built. `partner_agreements` has no `policy` column and no `hold_warn_hours` / `hold_cut_hours` / `hold_freeze_hours` knobs exist (holds only expire at TTL). What exists today: `quotas.warning_threshold` (integer, default 80), `partner_agreements.auto_revoke_on_expiry` (boolean, default true) and `partner_organizations.autonomy_tier` / `autonomy_level`.*

Several enforcement parameters have historically lived as hardcoded constants. This subsection is the normative source for the canonical set of policy knobs — named, typed fields on the agreement record with system defaults and per-agreement overrides — the wizard step that authors them, the audit-on-change rule, and the rollout sequence that ships them. Defaults reproduce current behavior byte-for-byte; omitting a knob on an agreement yields today’s behavior exactly. The five deliverables covered here, together with Structured Knobs vs. Narrative Terms, close task #162 (1)–(6).

agreement.policy (structured sub-object)

| Knob | Type | Default | Consumed By |
| --- | --- | --- | --- |
| hold_warn_hours | int ≥ 1 | 3 | Reservation engine (warning) |
| hold_cut_hours | int ≥ 1 | 5 | Reservation engine (50% cut) |
| hold_freeze_hours | int ≥ 1 | 8 | Reservation engine (quota freeze) |
| quota_warn_percent | int 1–99 | 80 | Quota counter (threshold alert) |
| autonomy_level | enum A–D | B (recruiter); per host-class decision (host) | Restrictions guard, intake pipeline, Host Portal feature gating |
| auto_revoke_on_expiry | bool | true | Auto-expiry job (15-min tick) |

Guards and engines read these knobs from the agreement record, not from constants. The atomic-SQL pattern for the quota counter is preserved — only the threshold value is sourced from quota_warn_percent.

Enforcement Policy wizard step — wireframe spec

One scrollable form panel. Heading “Enforcement policy.” Subheading: “These rules govern how this agreement is enforced day-to-day. Defaults match current sponsor-wide behavior — only override what this partner needs differently.” A “Reset all to defaults” link, right-aligned, disabled until at least one knob is overridden.

Section 1 — Reservation hold escalation

“When an applicant is held past the agreed hours, escalate at these thresholds.”

Three numeric inputs on one row with the “hours” suffix: Send warning at (default 3), Cut reservation 50% at (default 5), Freeze new reservations at (default 8). Each value is an integer &ge; 1; the three must be strictly increasing left-to-right. Inline error under the offending input on blur. Below the row, a muted live-updating sentence renders the current setting in plain English, e.g. “Warn at 3h, cut to 50% at 5h, freeze at 8h.”

Section 2 — Quota warnings

Warn when allocation reaches — numeric input with “%” suffix, default 80, integer 1–99 inclusive. Below it, a thin progress-bar preview at the chosen percent so the threshold is tangible.

Section 3 — Autonomy & lifecycle

Autonomy level — segmented control with A / B / C / D. Default is B for recruiter agreements; for host agreements the default is drawn from the sponsor’s host-class decision (see Host Autonomy Decision Framework). Each option shows a one-line description below the control that swaps based on selection. Auto-revoke on expiry — toggle, default ON, helper text: “When the end date passes, the agreement is automatically moved to expired and dependent reservations are released. Turn this off only if a manual review is required for this partner.”

> **Override Affordance Pattern**
>
> Every input is pre-filled with its default. A grey default pill sits to the right of the field label. Once the value is changed, the pill swaps to a blue override pill and a small ↻ Reset link appears next to it that restores the default for that single field. This makes “what’s overridden vs default” scannable at a glance without reading every number.

Read-only mode (detail view). The same three sections render as a read-only card on the agreement detail page, each knob shown as Label: value [override], the [override] tag appearing only when the value differs from default. An “Edit” affordance re-opens just this step in the wizard. Audit. Saving with any knob changed writes one audit entry per changed knob with before/after values, under the agreement’s shared audit envelope.

Rollout Sequence

Knob fields are shipped in a deliberate order so no deploy step can leave guards reading stale constants while the record has new overrides, or vice-versa. Each step below is independently deployable and rolls back cleanly on its own.

- Add the structured sub-object to the schema. Extend partner_agreements with a nullable policy JSONB column holding the six documented knobs. Nullable on write so existing code paths are unaffected until the backfill completes.
- Backfill every row with explicit defaults. Run a one-shot migration that writes the documented defaults into every existing row: 3 / 5 / 8 for hold escalation, 80 for quota_warn_percent, B for autonomy_level on recruiter rows, the sponsor’s host-class autonomy decision on host rows, and auto_revoke_on_expiry=true. Verify COUNT(*) WHERE policy IS NULL = 0 before proceeding. After this step, the record “explains” every number a guard consumes.
- Make the policy column non-null + add validation. Enforce the documented types at the domain-model level (Zod schema — integer ranges, enum for autonomy, bool for auto-revoke). The wizard’s strictly-increasing rule (warn < cut < freeze) is a domain-model invariant validated on save.
- Cut the reservation engine over to the record. Replace the hardcoded 3 / 5 / 8 constants with a read of policy.hold_warn_hours / policy.hold_cut_hours / policy.hold_freeze_hours from the governing agreement. Because step 2 wrote the same defaults everywhere, behavior is byte-identical on deploy.
- Cut the quota counter over to the record. Replace the hardcoded 80% threshold with a read of policy.quota_warn_percent. The atomic-SQL update pattern is preserved — only the numeric threshold is sourced from the record.
- Cut the autonomy guard and auto-expiry job over to the record. Restrictions guard reads policy.autonomy_level; the 15-minute auto-expiry tick reads policy.auto_revoke_on_expiry.
- Ship the wizard step and the read-only detail card. Once every guard reads from the record, surface the Enforcement policy step in the agreement wizard with the override affordance pattern, and surface the read-only card on the detail view. Saves emit one audit entry per changed knob under the agreement’s shared audit envelope.
Rollback: any individual cutover step can be reverted to read the constant again without data loss, because step 2 made the record authoritatively match today’s constants. The wizard step can be hidden behind a flag without rolling back the schema.

### Agreement Authoring Wizard (End-to-End)

This subsection is the normative source for the agreement authoring wizard — the single orchestrated flow that owns scope, quota and restrictions, the side-specific extension, enforcement policy, narrative terms, and a final review. The individual step contents come from other sections of this page (typed extensions, policy knobs); the wizard owns the shell, step ordering, validation, draft/publish, and how agreements appear on the org detail page. The shell spec, step-flow table, draft/resume rules, submit + invariant check, org-detail Agreements surface, audit events, and phased rollout documented here close task #166.

Shell & Chrome

- Launched from a partner org’s detail page via a single New agreement affordance, regardless of host or recruiter.
- Linear flow with a left-rail step indicator. Completed steps are clickable to jump back; forward jumps are blocked until the current step validates.
- Persistent header shows the parent org name and, once chosen, the scope (Brand + Program + Season).
- Footer: Back · Save draft · Continue. Cancel/close prompts “are you sure?” when there are unsaved changes since the last autosave.
- Keyboard navigation through steps, focus management on step change, visible focus rings on all interactive elements.
Step Flow

| # | Step | Fields | Validation |
| --- | --- | --- | --- |
| 1 | Scope | Brand, program, season, startDate, endDate | endDate > startDate; soft warning if date range overlaps an existing active agreement of the same scope. For hosts authoring a standard agreement, soft-preview: “this will become the operating agreement for [program] starting [date]; current operating agreement ends [date].” |
| 2 | Quota & restrictions | Allocation count, country restrictions, job-type restrictions | allocation > 0; country/job-type lists may be empty (= no restriction). |
| 3 | Side-specific | Typed extension fields (host: compliance, capacity, housing, site visits; recruiter: intake cap, portal roles, sponsor origin, autonomy detail, country scope, bulk upload) | Owned by typed-extensions step. Conditionally rendered based on parent org type; step omitted entirely for org types that have no side-specific fields. |
| 4 | Enforcement policy | Policy knobs (hold escalation, quota warn %, autonomy, auto-revoke) | Owned by the policy-knobs step contract above. |
| 5 | Legal content & narrative terms | Host agreements: a required Agreement Library picker selecting a published library entry (filtered to host_agreement type and the org's brand scope) — the selected entry IS the legal content, stamped as engine_agreement_code at creation. Plus optional freeform operational notes saved to the existing terms JSONB. Recruiter agreements: freeform terms JSONB only (unchanged). | Host: server-side validation rejects the create unless the selected agreement has a published version, is of type host_agreement, and its brand scope covers the host org — no free-text fallback. Narrative notes: no enforcement; visible reminder that notes are operational, non-legal, and not enforced. |
| 6 | Review & submit | Read-only render of every prior step, with Edit-this-section links | Composite validation of all prior steps; for hosts, the operating-agreement invariant check runs at submit time. On success, the draft can only advance to active — there is no pending_approval state in the agreement state machine. |

Draft & Resume

- Autosave on every step transition; explicit Save draft action always available.
- Drafts are first-class records in the same lifecycle (status = draft).
- Listed on the org detail page’s Agreements section with a Resume affordance.
Submit & Invariant Check

- Composite validation runs on Review.
- For hosts, the operating-agreement invariant is evaluated at submit; a clear blocker points to the existing active standard row if present.
- On success: draft &rarr; pending_approval (unchanged approval/activation flow).
Org Detail Page — Agreements Section

A single Agreements section on the partner org detail page, grouped by Brand + Program + Season, lists every agreement for that org across four lanes: Drafts, Pending approval, Active, and Expired / historical. The operating-agreement marker is rendered on the active row for hosts. Drafts carry a Resume button that re-enters the wizard at the last-completed step. For host agreements, each row also displays the linked Agreement Library entry and version (library agreement name + code + published version) resolved from engine_agreement_code; legacy free-text rows with no library linkage render a "Legacy terms (grandfathered)" marker instead. See Agreements Engine &rarr; Host Agreement Library Linkage for the linkage contract.

> **Audit Events**
>
> The wizard writes audit entries for: draft_created, step_completed (light), draft_saved, submitted_for_approval, and withdrawn. Policy-knob and extension-field changes ride on the agreement’s existing audit envelope as described in their respective subsections.

> **Scope: Create & Draft Only**
>
> This flow covers create + draft + submit. In-place editing of an active agreement is a separate concern, planned separately. The wizard is launched from the org detail page and always produces a new agreement record.

Rollout Sequence

The wizard ships in phases so each slice is verifiable end-to-end before the next layer lands. Because steps 3 and 4 are owned by other tasks (typed extensions and policy knobs), the shell reserves their slots as placeholders so the full flow is demo-able before those task’s contents ship.

- Phase 1 — Shell. Routing entry from the org detail page’s New agreement affordance; left-rail step indicator; persistent header (org + scope); footer (Back / Save draft / Continue); Cancel-with-unsaved-changes prompt; keyboard navigation and focus management. Step orchestration: linear-by-default, left-rail jump-back to completed steps, forward jumps blocked until validation passes, conditional rendering of the side-specific slot by parent org type. Omitted entirely when the parent org type has no side-specific fields.
- Phase 2 — Draft persistence. Autosave on every step transition plus an explicit Save draft action. Drafts are first-class status=’draft’ rows on partner_agreements. At this phase the resume affordance lives behind a feature flag; flag is flipped once the org detail Agreements surface (Phase 6) ships.
- Phase 3 — Scope and quota steps. Ship Scope (brand, program, season, start, end — end > start validation, overlap soft-warning) and Quota & restrictions (allocation > 0, empty country/job-type lists allowed). For hosts, Scope surfaces the operating-agreement invariant as a soft preview that consumes hasOperatingAgreement(...) read-only (no write path yet). Depends on #160 rollout step 3 (predicate function) being deployed.
- Phase 4 — Reserved slots and narrative. Side-specific step renders a labelled placeholder so orchestration is verifiable end-to-end; real content lands with the type-extensions task. Enforcement policy step renders the placeholder that #162 rollout step 7 replaces with the real form. Narrative terms step ships in full: a single freeform area saved to the existing terms JSONB with a visible “descriptive, not enforced” reminder per Structured Knobs vs. Narrative Terms.
- Phase 5 — Review & submit with invariant check. Review renders every prior step read-only with per-group Edit this section links that jump back. Submit for approval runs composite validation; for hosts it also runs the operating-agreement invariant check and, on failure, surfaces a clear blocker linking to the existing active standard row. On success the draft transitions draft &rarr; pending_approval. Depends on #160 rollout step 4 (DS-2019 guard routed through the predicate) being deployed so the wizard check and the guard share one implementation.
- Phase 6 — Org detail Agreements section. Add the Agreements section to the partner org detail page, grouped by Brand + Program + Season, with four lanes (Drafts, Pending approval, Active, Expired / historical). Drafts expose the Resume affordance; active host rows render the operating-agreement marker. This is where the resume flag from Phase 2 is flipped on.
- Phase 7 — Audit and accessibility sweep. Wire the five wizard audit events (draft_created, step_completed, draft_saved, submitted_for_approval, withdrawn) under the agreement’s shared audit envelope. Run the a11y sweep: keyboard reachability, focus ring visibility, step-change focus management, screen-reader step-announcement.
Rollback: any phase can be reverted to its placeholder or previous behavior without data loss, because drafts are durable rows and the submit transition is the only state change into the canonical lifecycle. The submit check and the DS-2019 guard share one predicate by design; rolling back Phase 5 alone still leaves the guard as the authoritative enforcement point.

### Portal Copy & Label Alignment

This subsection is the normative source for the copy, labels, and status chips that the Sponsor Admin, Host Portal, and Partner Portal render on agreement-adjacent surfaces. Every portal surface routes through one lifecycle label set, one operating-agreement predicate, and one reason-code table — portals render what Globus decides and do not re-word, re-compute, or localize. The canonical copy table below plus the alignment rollout at the bottom of this subsection close task #161.

Voice & tone

Short sentences, no jargon, no exclamation marks. Status banners name the state in the first four words. Modals open with a direct verb (“Complete your operating agreement”) and explain what is paused until the user acts. Error strings name the blocked action first (“DS-2019 issuance blocked: …”) then the reason — never the other way around. No “Oops”, no “Sorry”, no emoji. The chip text is the raw lifecycle token with underscores replaced by spaces and no localization variants.

| Surface | Element | Canonical Copy / Behavior |
| --- | --- | --- |
| Sponsor admin agreement editor | Lifecycle state labels (canonical token set) | The six tokens are draft · active · probation · suspended · expired · terminated. These are the data-layer tokens; portals never define alternate names. The render rule for status chips (underscores → spaces) is in the Lifecycle status chip row below. |
| Sponsor admin agreement editor | Reason field on status change | Required (non-empty) for suspend, terminate, and probation transitions; the 10-character minimum applies only to host-organization status changes. Inline hint: “Please enter at least 10 characters describing the reason — this is written to the audit log.” |
| Sponsor admin agreement editor | Probation cut messaging | When moving to probation, show a confirmation modal: “Probation reduces the reservation quota for this agreement by 50% until the probation is lifted. Existing placements are not affected.” |
| Sponsor admin agreement editor | Auto-expiry note | On the detail view, a muted line under endDate: “Agreements auto-expire within 15 minutes of endDate (background job). You do not need to manually expire.” |
| Host onboarding modal | Trigger | Renders whenever the operating-agreement predicate fails for the host’s current Brand + Program + Season. Same predicate the DS-2019 guard uses. |
| Host onboarding modal | Copy | Title: “Complete your operating agreement.” Body names the missing Brand + Program + Season in a single sentence and explains that DS-2019 issuance is paused until the operating agreement is active. |
| Host onboarding modal | CTA | Single primary action: "Activate Agreement" — triggers the one-click activate-and-send flow (activates the operating agreement and sends it for signature in a single action). No secondary "remind me later" — the modal gates entry to the affected context. |
| Partner portal status banner | Active agreement | Muted banner: “Active agreement · program · ends date · quota used/allocated.” Quota progress bar colored by the quota's `warning_threshold` (default 80). |
| Partner portal status banner | Probation | Amber banner: “On probation — reservation quota reduced by 50% until lifted. See status for details.” Links to the performance & adherence page. |
| Partner portal status banner | Expired / suspended | Red banner naming the state, the endDate (if expired), and a single link to the sponsor contact for next steps. |
| Lifecycle status chip (everywhere) | Colors & tokens | draft = grey, pending_approval = blue, active = emerald, probation = amber, suspended = amber, expired = muted, terminated = red. Chip text is the raw state token with underscores replaced by spaces; no localization variants. The host adherence flag on_probation_host is a separate axis and renders on the host extension summary, not on the agreement lifecycle chip. |
| DS-2019 guard failure message | Display string | “DS-2019 issuance blocked: reason_code_human” where reason_code_human is a short sentence derived from the reason code table in Operating Agreement Invariant Enforcement (e.g. “No active standard agreement for this host in program”). |

> **One Label Set, One Predicate**
>
> Every portal surface routes through the same lifecycle label set, the same operating-agreement predicate, and the same reason-code table. Portals do not re-word or re-compute; they render what Globus decides. See the decision callout in Side Differences at a Glance.

Alignment Rollout

Portal copy is aligned to the canonical table in a deliberate order so each surface becomes internally consistent before the next migrates. Each step is independently deployable by portal team and rolls back cleanly.

- Extract shared copy constants. Move the lifecycle label set, probation confirmation body, auto-expiry note, reservation-reason hint, and DS-2019 failure template into one @shared/portal-copy module consumed by all three portals. No UI changes yet — this step is module-only.
- Sponsor Admin editor. Swap hard-coded lifecycle labels, the reason-field hint, the probation confirmation modal, and the endDate auto-expiry line to read from the shared module. This is the first visible alignment and validates the module contract before external-facing portals migrate.
- Host Portal. Align the onboarding-modal title / body / CTA and the host-side lifecycle chips. The modal trigger still calls hasOperatingAgreement(...) (see Operating Agreement Invariant Enforcement); only the copy is swapped here.
- Partner Portal. Align the three status-banner variants (active, probation, expired/suspended) and the quota progress bar’s threshold colour, which reads policy.quota_warn_percent from the governing agreement (see Policy Knobs).
- DS-2019 guard surface. Route the blocked-issuance display string through the shared module, pulling reason_code_human from the closed reason-code table in Operating Agreement Invariant Enforcement. After this step, no portal contains the failure string as a literal.
- Sweep. A grep pass removes the old literal strings from each portal repo; a lightweight lint rule prevents re-introducing them. The lifecycle label set, the onboarding modal copy, and the DS-2019 failure template now live in exactly one place.
Rollback: any portal’s swap can be reverted to literals if a defect is found; because the canonical strings match today’s wording by design (only inconsistencies change), per-portal rollback is non-breaking. The shared module itself can stay deployed and simply be unused by that portal.

### Structured Knobs vs. Narrative Terms

Agreement content splits into two categories: structured fields the guard layer enforces, and narrative terms a human reads.

Structured Knobs (Enforced)

Scope: brand / program / season / Date window (start / end) / Quota allocation per program/season / Country restrictions (recruiter) / Job-type restrictions (recruiter) / Capacity limits (host) / Compliance doc set (host) / Site visit schedule (host) / Autonomy level (A\u2013D) / Reservation TTL policy
Read by guards. Drift causes a structured denial.

Narrative Terms (Freeform JSONB)

Commercial side letters / Special arrangements not modelled as knobs / Operational notes for sponsor staff / Agreed exception language / Cross-references to external contracts
Stored as JSONB on the agreement. Not enforced. Human-readable only.

> **Narrative terms are not legal content (host agreements)**
>
> For host agreements there is a third category: the legal content itself, which is neither a structured knob nor a narrative term. The legal text of a new host agreement is a selected published Agreement Library entry (filtered to host_agreement type and brand scope), linked at creation via engine_agreement_code — gating, acceptance evidence, and signed-PDF rendering all resolve to that library version's HTML with no free-text fallback. Narrative terms on host agreements are non-legal operational notes only. Legacy free-text host agreements whose legal content still lives in the terms JSONB are grandfathered and unchanged. See Agreements Engine &rarr; Host Agreement Library Linkage.

### Side Differences at a Glance

| Field / Concept | Shared | Host-Only | Recruiter-Only | Depth Page |
| --- | --- | --- | --- | --- |
| Time-bound start/end | | | | Host · Recruiter |
| Lifecycle state machine | | | | Canonical (this page) |
| 10-metric performance model | | | | Canonical (this page) |
| Reservation hold TTLs | | | | Canonical (this page) |
| Autonomy levels A–D | | | | Canonical (this page) |
| Operating agreement predicate | | | | Host |
| DS-2019 guard | | | | Host |
| Host onboarding modal trigger | | | | Host |
| Capacity limits per program/season | | | | Host |
| Compliance doc set + site visits | | | | Host |
| Housing requirements | | | | Host |
| Country restrictions | | | | Recruiter |
| Job-type restrictions | | | | Recruiter |
| Quota allocation | | | | Recruiter |
| Origin (External / Internal) | | | | Recruiter |

> **Portals present. Globus decides.**
>
> A portal should never contain logic that determines eligibility, enforces compliance, or modifies governance state. If a portal needs a decision, it asks Globus. If Globus needs a user experience, it delegates to a portal. See Portal Functional Boundaries for the full ownership model.

## Entry Flows

Each partner organization category participates differently in applicant intake:

Direct Intake (Hosts)

Applicants discover the program through marketing channels. HubSpot captures and pre-qualifies leads, then triggers the Applicant Portal to create the applicant record. Hosts receive placements from the matching pool once their job orders are approved.

Marketing \u2192 HubSpot \u2192 Applicant Portal \u2192 Globus / Hosts do not initiate applicant intake
Partner-Initiated Intake (Recruiters)

Recruiting partners submit applicants via invites or bulk CSV upload. The Partner Portal calls the Applicant Portal onboarding API to create the applicant record, with recruitment attribution linking back to the partner.

Partner Portal \u2192 Applicant Portal \u2192 Globus / Recruiters actively source and submit applicants
Both paths produce the same result: an applicant record in the Applicant Portal and an operational record in Globus. For the full technical flow, see Direct vs Partner Entry .

## Finance & Billing

Partner organizations appear as billing parties in the finance model. The generic payer model supports partner billing without schema changes:

| Concept | Details |
| --- | --- |
| Party Type | PARTNER_ORG — the finance_account_parties table links to partner_organizations via partner_org_id (FK, nullable) |
| Payment Model | PARTNER_PAYS — partner organization collects and remits on behalf of applicant. Invoice visible in Partner Portal. |
| Split Payments | The billing model supports split payments where both applicant and partner org can be billed for different line items on the same application. |

For the full finance data model, payment models, and NetSuite integration, see Finance & NetSuite .

## API & Integration Surface

Partner organizations are identified in API calls through specific headers that establish organizational context and data isolation:

| Header | Portal | Purpose |
| --- | --- | --- |
| X-Partner-User-Email | Partner | Bootstrap only — resolves the user to a partner org on first request |
| X-Partner-Org-Id | Partner | Identifies the partner org on all subsequent requests after bootstrap |
| X-Host-User-Email | Host | Bootstrap only — resolves the user to a host org on first request |
| X-Host-Org-Id | Host | Identifies the host org on all subsequent requests after bootstrap |

API Scope per Portal

For the full integration contract including bootstrap flow, authentication, and endpoint catalog, see Portal Integration Contract .
