---
title: "Host Camp Overview"
slug: host-camp-overview
section: Portals
group: Host Portal
status: stable
description: "Frozen contract for the Camp Overview tab in the Host Portal profile section — endpoints, field map (direct columns vs."
last-updated: 2026-10-07
---

# Host Camp Overview

Frozen contract for the Camp Overview tab in the Host Portal profile section — endpoints, field map (direct columns vs. metadata JSONB), addresses sub-resource, RBAC, the host.profile_created event, and resolved ambiguities.

> **Who reads this**
>
> Globant platform engineers and Host Portal engineers. This page is the authoritative contract for GET/PATCH /api/partner/host/overview and the companion /api/partner/host/addresses sub-resource. It is sibling to the Host Portal Activity Log (#169), Cultural Activities (#170), Contacts (#175), Documents (#176), and Offer Details (#171) specs and reads as a coherent contract bundle with them.

## Purpose & Scope

The Camp Overview tab is the host's top-level profile page in the Host Portal. Hosts maintain their identity (name, description, website), location, capacity, EIN, and a set of camp-specific marketing/operational fields (camper age range, season dates, accreditation, video embed, camp type/style, counselor headcounts, counselor age band). Globant's Host Portal renders this tab; Globus owns the data, the validation, and the events. This spec freezes the wire shape so the Host Portal can implement against a stable interface without back-and-forth.

> **Sponsor onboarding vs. host self-service**
>
> The sponsor-side create-org flow (covered in Org Creation — Program-Type Field Capture ) is what initially populates a host org row, and may pre-fill some of these fields. This Camp Overview spec is the host's ongoing self-service surface for the same row. Both write paths converge on the same partner_organizations row and the same metadata JSONB column — neither side maintains a parallel store. The host.profile_created event is owned by this PATCH endpoint, not by create-org.

## Globus System of Record

The CampOverview resource is a projection over a single partner_organizations row. Direct columns are read/written as columns; metadata JSONB keys are merged into the response as flat top-level fields and merged on PATCH (never replaced wholesale). The Host Portal acts as a proxy/cache and never persists overview fields locally.

## Endpoints

| Method | Path | Description | Permission |
| --- | --- | --- | --- |
| GET | /api/partner/host/overview | Retrieve the camp overview profile for the authenticated host org. Returns the merged shape (direct columns + set metadata keys). | host.profile.view |
| PATCH | /api/partner/host/overview | Partial update of any combination of direct columns and metadata keys for the authenticated host org. Metadata is merged. Returns the updated CampOverview in the same shape as GET. | host.profile.manage |

## Auth & RBAC

### Authentication Modes

Identical to the sibling Host Portal endpoints. Three modes, in any combination: Direct Auth, Host Proxy Auth (with X-Host-Org-Id), and User Attribution (X-Host-User-Email, required on all mutations). See Host Portal Authentication Modes for the full header set and rules.

### Permission Matrix

Two new host RBAC permissions are introduced by this contract. They join the existing host permission catalog documented in Host Portal RBAC .

| Permission Key | Description | Admin | Hiring Mgr | Read-Only |
| --- | --- | --- | --- | --- |
| key | desc | | | |

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

> **Profile writes: admin and recruiter**
>
> Host admins and recruiters (hiring managers) may edit the camp overview: PATCH /api/partner/host/overview requires the admin or recruiter host role, and recruiters hold host.profile.manage. Read-only users may view it but cannot mutate identity, capacity, EIN, or marketing copy.

## Field Mapping

### Direct Columns on partner_organizations

Stored as proper columns on the partner_organizations row, read and written as columns. These fields apply to every host regardless of program type.

| Field | Type | Description |
| --- | --- | --- |
| field | type | desc |

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

### Metadata JSONB Fields

Stored under the metadata JSONB column on partner_organizations. Surfaced as flat, top-level fields on GET. Every key listed here is camp-only — see the host-type gate below.

| Field | Type | Scope | Description |
| --- | --- | --- | --- |
| field | type | | desc |

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

### metadata JSONB Convention

### Camp-Only Fields & Host-Type Gate

The nine metadata fields above are camp-only. They are accepted on PATCH only when the host org is linked to a program with programModelType = "camp" (per the program-model rules in Org Creation — Program-Type Field Capture ). Sending a camp-only key on a non-camp host returns 422 with a per-field error pointing at the offending key. Reading a non-camp host always omits these keys (per the omit-unset rule) and never errors. Adding new program-model types in the future does not change this contract — those types own their own field sets; camp-only keys remain camp-only.

### Staff Information Fields (As Built)

The overview also carries the host's Staff Information answers as top-level fields on GET and PATCH. They are not camp-only. Contract as shipped in the partner API (BackOfficePrototype `development` endpoint schema for GET/PATCH /api/partner/host/overview) and as consumed by the Host Portal (HostPortalPrototype `development`):

| Field | Wire shape | Notes |
| --- | --- | --- |
| drugTestingRequired | boolean \| null | null = unanswered. Strict boolean or null on write; 422 otherwise. |
| drugTestingCost | string \| number \| null | Participant-paid cost in USD: a non-negative amount with at most two decimals, always returned as a fixed two-decimal string (a submitted "45" reads back as "45.00"). Null or blank clears it, and it is cleared whenever drugTestingRequired is set to false or null (GET returns it only while drugTestingRequired is true). The free-text drugTestingNotes it replaced is retired: a host overview PATCH that carries it is rejected 422 with details.code RETIRED_FIELD (replacedBy drugTestingRequired and drugTestingCost), and GET no longer returns it. The admin organization PATCH accepts the same drugTestingRequired and drugTestingCost fields with the same normalization. |
| closestSsaOffice | string \| null | Closest Social Security Administration office name/address. Non-string, non-null values reject 422. |
| ssaOfficeDistance | string \| null | Free-text distance to that office (for example "12 miles"). Non-string, non-null values reject 422. |
| remoteArea, remoteAreaTransport | boolean \| null, string \| null | Remote-area flag and its free-text transport note. remoteArea must be a strict boolean or null and remoteAreaTransport a string or null; anything else rejects 422 with details.field naming the key. |
| uniform | { provided: boolean, cost: string \| null, currency: string \| null, costDue: string \| null } \| null | Host uniform default. On write, send null to clear; a supplied cost defaults currency to USD, and an explicit ISO 4217 alpha-3 currency is preserved. On GET, cost currency defaults to USD and is omitted when unset. |
| requiredPayment | { required: boolean, details: string \| null } \| null | Whether participants are required to make a payment, plus free-text details (for example the amount). On write, send null to clear; details are removed when required is false and an omitted details reads back as null. The object carries no cost or currency fields. Omitted on GET when unset. |
| backgroundChecks | { required: boolean, details: string \| null } \| string | Extra background checks or fingerprinting. Details are trimmed and capped at 2000 characters; null clears; malformed objects reject 422 with details.field = backgroundChecks. Valid legacy strings remain readable and editable. |
| staffToParticipantRatio, staffTraining | string | Validated: the ratio must be n:m (1-4 digits each side), the free-text key is trimmed and capped at 2000 characters; null or blank clears. |

The structured `drugTesting` / `onboardingPayment` objects and the `socialSecurityInfo` field are not part of this contract; `requiredPayment` and the flat drug-testing and SSA fields above replace them. Only the structured `requiredPayment` spelling is accepted — flat or alternative spellings of the same answer are silently dropped rather than stored.

**Fields not persisted.** On the Host Portal overview write path (`PATCH /api/v2/profile/overview`) the Hub drops keys it does not support for the organization and still answers success, so the portal compares the Hub's echo with what was submitted. A submitted key missing from the echo (other than a null for `uniform` or `requiredPayment`, which clears to an absent key) is not reported as saved: the portal returns 422 with `code` and `details.code` both FIELDS_NOT_SUPPORTED and `details.fields` listing the keys. The Staff Information fields and `einNumber` are additionally checked against a fresh overview read, and a mismatch returns the same 422. A non-null `drugTestingCost` is rejected 400 VALIDATION_ERROR unless the same request also sets `drugTestingRequired` to true.

Authorized host admins and recruiters may save or clear `uniform` and `requiredPayment`; these writes synchronize automatic staff orders only (partner API change log). `minMaxAgeForCounselors` is PATCHable as the canonical `{ min, max }` object (non-negative integers, min <= max; legacy strings such as "18-30" are normalized server-side; anything else rejects 422; null clears). Staff can also write two camp overview keys, sent inside the `metadata` object of `PATCH /api/admin/partners/organizations/:id`: `videoEmbedCode` (sanitized against the same YouTube/Vimeo allow-list as the Host Portal write path and stored as a minimal safe iframe snippet; null or blank clears it; a value that cannot be resolved to an allow-listed embed is rejected 400 VALIDATION_FAILED with `details.field` set to `videoEmbedCode`) and `camperAgeRange` (the same `{ min, max }` shape as `minMaxAgeForCounselors`). The staff GET returns `videoEmbedCode` as an explicit top-level field, null when unset or blank.

## CampOverview Resource Shape

Both GET and PATCH return a single CampOverview object. The shape is camelCase across the wire (see Resolved Ambiguities). Direct columns are always present (with their stored value or null/empty per the column's default); metadata keys are present only when set.

// GET /api/partner/host/overview — example response (camp host)

"id": "org_abc",

"type": "host",

"name": "Pinewood Adventure Camp",

"description": "Coed residential camp in northern Maine.",

"yearEstablished": 1962,

"website": "https://pinewoodcamp.example",

"capacity": 320,

"einNumber": "12-3456789",

"einEditable": false,

"einLockReason": "already_set",

"address": "12 Pine Lake Rd",

"city": "Bridgton",

"state": "ME",

"postalCode": "04009",

"country": "US",

"camperAgeRange": { "min": 7, "max": 15 },

"seasonDates": "June 5 – August 18",

"accreditation": "American Camp Association",

"videoEmbedCode": " ",

"campType": "Overnight",

"campStyle": ["Traditional", "Co-Ed"],

"numberOfJ1Counselors": 40,

"numberOfJ1SupportStaff": 12,

"minMaxAgeForCounselors": min / max

// note: campStyle would be omitted entirely (not set to null) if unset

// PATCH /api/partner/host/overview — example request body

"capacity": 340,

"campType": "Overnight",

"campStyle": ["Traditional"]

## GET /api/partner/host/overview

## PATCH /api/partner/host/overview

| HTTP | When it fires |
| --- | --- |
| code | reason |

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

## Addresses Sub-Resource

The primary location is rendered inline on the overview via the address, city, state, postalCode, and country direct columns. Camps that operate from multiple locations (e.g., a winter office and a summer site) manage the full address book through the dedicated sub-resource below. Addresses live in their own table and are not stored on partner_organizations.

### Endpoints

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

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

> **Errors mirror /overview**
>
> 401 (auth), 403 (permission or cross-org access), 404 (address not found or not owned by the resolved host org), 409 (partial-unique conflict on (partner_org_id, season_label) — see Seasonal Addresses below), 422 (validation). 400 for malformed bodies. Cross-org access (PATCH/DELETE on an :id belonging to a different host org) is treated as 404, not 403, to avoid leaking existence.

### Address Shape

// Address

"id": "addr_xyz",

"type": "Summer", // free-text label, e.g., "Summer", "Winter Office", "Mailing"

"details": "A specific place", // free-text human-readable description

"seasonLabel": "summer", // "summer" | "winter" | "year_round" | null

"street": "12 Pine Lake Rd", // structured column on partner_addresses

"city": "Bridgton",

"state": "ME",

"postalCode": "04009",

"country": "US"

Only type and details are required on POST. seasonLabel and the structured address parts (street, city, state, postalCode, country) are optional — a row may carry only a free-text description if the host has not split out the structured fields yet, and seasonLabel defaults to null when omitted.

### Seasonal Addresses

Camps and other hosts that operate from different physical locations across the year model that fact directly on partner_addresses via a structured seasonLabel field plus first-class structured address columns. Hosts no longer have to encode season information into the free-form details blob.

### Seasonal Addresses Projection

In addition to the flat list returned by GET /api/partner/host/addresses, the host overview and the composite host profile expose a denormalized seasonalAddresses projection so partners can render summer vs winter without parsing the list client-side.

// GET /api/partner/host/overview (and GET /api/partner/host/profile) — partial response

// ...all existing CampOverview fields...

"seasonalAddresses":

"summer": id / addr_s / seasonLabel / summer / street / 12 Pine Lake Rd / city / Bridgton / state / ME / postalCode / 04009 / country / US | null,

"winter": id / addr_w / seasonLabel / winter / street / 200 Harbor Blvd / city / Tampa / state / FL / postalCode / 33602 / country / US | null

Each non-null slot also carries sameAsPrimary (boolean). When true, the slot is durably linked to the organization's primary business address: the projection (on the host overview and on the staff GET /api/admin/partners/organizations/:orgId/seasonal-addresses) returns the org's current address, city, state, postalCode and country rather than a copied snapshot, so later primary-address edits flow through. Staff set a slot with PUT /api/admin/partners/organizations/:orgId/seasonal-addresses/:season (season is summer or winter; partners.manage): sameAsPrimary true creates the link, while sameAsPrimary false (the default) requires country, street and city for a custom address. Host-portal address writes never create a link, and editing the address fields of a summer or winter row through the host address routes clears it. Existing rows are unlinked (sameAsPrimary false).

> **Legacy single-address columns are unaffected**
>
> The legacy single-address columns on partner_organizations (address, city, state, postalCode, country) remain on the row and continue to be returned by GET /overview for backward compatibility. They are not stored in seasonalAddresses and seasonalAddresses is not stored in them — with one exception: a seasonal slot with sameAsPrimary true (see above) resolves its street, city, state, postalCode and country from the org's current primary address at read time.

## Resolved Ambiguities

The source brief flagged two open questions. Both are resolved here, in writing, so the Host Portal team can implement against a single answer.

### einNumber: One Field, Not Two

> **Single field: einNumber (formerly irsEinNumber)**
>
> The U.S. IRS-issued Employer Identification Number (EIN) is the only number this field carries. There is no separate "IRS number". The wire field is einNumber (string) and the storage column is ein_number; the value is the EIN in standard NN-NNNNNNN format (a value that does not match is rejected 422, and an EIN already carried by another org is rejected 409 DUPLICATE_EIN). A host may set the EIN only while none is on file (einEditable is true whenever einNumber is empty); an EIN on file is locked and a write is rejected with details.code EIN_LOCKED_ALREADY_SET — corrections go through Alliance Abroad staff. The field was originally named irsEinNumber (column irs_ein_number); that name was retired in a breaking change (Task #2496) with no compatibility alias and no dual-emit window. GET and the PATCH echo carry einNumber only, and a PATCH body that still carries irsEinNumber is rejected 422 VALIDATION_ERROR with details.code RETIRED_FIELD (details.field einNumber, details.retiredField irsEinNumber) instead of being silently stripped. On the Host Portal overview write path (`PATCH /api/v2/profile/overview`), a submitted `einNumber` is also verified after the write: it is checked against a fresh overview read before success is reported (the Staff Information fields are verified the same way). The comparison uses digits only, so a Hub that re-punctuates "123456789" as "12-3456789" is not reported as a failed save, while a different or missing EIN returns 422 with code FIELDS_NOT_SUPPORTED and details.fields naming the field.

> **einNumber is host-locked after compliance approval**
>
> For Camp and Camp SWT hosts, once compliance approves an effective approved-terms revision, einNumber becomes a controlled field: the host-facing PATCH rejects it atomically with HTTP 409 approved_terms_locked, GET carries its lockState in the additive approvedTerms block, and only Globus staff holding the amendment permission may change it (reason required; a material amendment creates a pending revision and re-enters compliance review). All other Camp Overview fields keep the existing write contract. Full contract: Host Verification & Approved Terms.

### Casing & Shape: camelCase, Typo Fixed

> **camelCase across the wire**
>
> All request and response bodies on this surface (overview and addresses) use camelCase field names — matching the rest of the Host Portal API (/api/partner/host/*) and the existing job_order_requirements contract on Host Offer Details . The snake_case shape sketched in the source brief is rejected. The brief's adresses spelling is a typo: the correct field/path token is addresses.

## Events & Audit

### host.profile_created Event

PATCH /api/partner/host/overview emits a single host.profile_created event the first time both description and website transition from empty (null or empty string) to set, in the same write or in any prior write. Concretely: after a successful PATCH, if both fields are now non-empty and at least one of them was empty before this PATCH (and the event has not yet fired for this org), emit the event. The event fires at most once per host org over its lifetime — a subsequent edit that clears and re-sets either field does not re-fire it.

"event": "host.profile_created",

"host_org_id": "org_abc",

"actor": type / host_user / email / admin@pinewoodcamp.example,

"occurred_at": "2026-04-19T14:22:11Z"

> **Fire-and-forget**
>
> The event, the audit-log entry, and the downstream notification are dispatched fire-and-forget after the row write commits. A failure to enqueue the event does not roll back the PATCH and does not change the 200 response. Delivery observability lives in the standard Notification Model dashboard, not on this endpoint.

> **Owned by Camp Overview, not by create-org**
>
> The host create-org flow (sponsor side) does NOT fire host.profile_created — even when description and website are populated at create time. This event is scoped to the host's own first publish via the Camp Overview tab. See Org Creation — Program-Type Field Capture for the complementary side.

### Audit Log Entry

Every successful, non-no-op PATCH writes one audit row with action = "host.overview.updated", the resolved actor (from X-Host-User-Email), and a before/after diff for direct columns and the merged-vs-prior diff for metadata. The first-time PATCH that triggers host.profile_created writes an additional audit row with action = "host.profile_created" for the audit timeline.

## Host Notes (running log)

Free-text notes against a host organization — the shared running log that account managers, compliance, and ops use to keep context on each host without leaving the docs site / host governance surfaces. Every notes endpoint is open to any authenticated staff caller (a staff session or an x-admin-key header); there are no anonymous notes. Notes can be created, edited, pinned or unpinned, and soft-deleted (see Contract). Edit and delete were added later on the updatedAt and deletedAt columns reserved when the notes table was created, with the prior body preserved in audit rows.

### Notes Panel (live)

The surface below is live against /api/hosts/directory and /api/partner/host/:hostId/notes. Pick a host from the directory to bind the panel — switching hosts re-binds the list and composer so you can verify per-host scoping (notes saved against host A do not appear when host B is selected, even though every authenticated Globus user can browse either). Unauthenticated viewers see a 401 error; that is expected.

### Contract

| Method | Path | Description | Permission |
| --- | --- | --- | --- |
| GET | /api/partner/host/:hostId/notes | List all non-deleted notes for the host org (the active pinned note first, then newest-first). Open to any authenticated staff user: a staff session OR a valid x-admin-key header (partner session bearer tokens are not accepted). There is no per-host scoping on read. Soft-deleted notes are hidden from this list - their audit trail remains queryable from the Audit Console. | staff session or x-admin-key |
| POST | /api/partner/host/:hostId/notes | Create a note. Body: body: string (trimmed, 1-8000 chars; there is no documentIds attachment field). Returns 201 with the created note. Writes a host_organization.note.created audit row (after payload = hostOrganizationId, authorUserId, authorDisplayName, body, actorLabel), best-effort after the response. | staff session or x-admin-key |
| PATCH | /api/partner/host/:hostId/notes/:noteId | Edit an existing note's body. Body: body: string (trimmed, 1-8000 chars). Returns the updated note. 404 if the note does not belong to :hostId or is already soft-deleted. Writes a host_organization.note.updated audit row with before = body and after = body (plus hostOrganizationId and actorLabel). | staff session or x-admin-key |
| PATCH | /api/partner/host/:hostId/notes/:noteId/pin | Pin or unpin a note. Body: pinned: boolean (required). Pinning atomically replaces the host's previous pin, so at most one non-deleted note per host is pinned (enforced by a partial unique index on the pinned timestamp and a per-host advisory lock). Returns the updated note; 404 if the note does not belong to :hostId or is soft-deleted; 400 for a non-boolean body. Writes a host_organization.note.pinned or .unpinned audit row, plus a separate .unpinned row for any pin it replaced. | staff session or x-admin-key |
| DELETE | /api/partner/host/:hostId/notes/:noteId | Soft-delete a note by stamping deletedAt. Returns 204. The note disappears from the default list but its prior body and author display name are preserved in a host_organization.note.deleted audit row (before = body, hostOrganizationId, authorDisplayName; after = deletedAt, actorLabel). 404 if the note does not belong to :hostId or is already deleted. | staff session or x-admin-key |

Data model — host_organization_notes (id, hostOrganizationId FK &rarr; partner_organizations.id, authorUserId (nullable — null for admin-key staff who carry no userId), authorDisplayName (captured at write time so the list never has to do a per-render user lookup — staff sessions write the user's name and email, e.g. "Jane Doe · jane@example.com", falling back to name or email alone; admin-key staff write as "Staff (admin-key)"), body (there is no documentIds column and no document attachment or preview), createdAt, pinnedAt (nullable timestamp, set while the note is the host's pinned note; a partial unique index allows at most one non-deleted pinned note per host), updatedAt (nullable), deletedAt (nullable)). Indexed on hostOrganizationId.

Access model - staff only, no per-permission gate. Every notes endpoint (including the host directory) uses requireGlobusUser, which accepts either an authenticated staff session (when staff are impersonating, the real staff user is recorded as proxiedByUserId on the audit row) OR an x-admin-key: header matching the server session secret (admin-key staff). Partner session bearer tokens are not accepted. Anonymous callers receive 401. There is no per-host scoping and no author/ownership check on read, create, edit, pin or delete - any staff caller can browse and modify notes on any host org.

Audit — every successful note mutation (create, edit, pin or unpin, delete) writes an audit_logs row, fire-and-forget, through recordAudit(), a helper local to the notes route module rather than a shared one (a failed audit write never fails the request); a pin that replaces an existing pin also writes a separate unpinned row for the replaced note. For a create: entityType = "host_organization_note", action = "host_organization.note.created", entityId = note.id, before = null, after = the saved note payload (hostOrganizationId, authorUserId, authorDisplayName, body, actorLabel). Actor is resolved by the requireGlobusUser middleware: a staff session records the signed-in session user's id as userId (when staff are impersonating, that is the impersonated user, and the initiating staff user is recorded as proxiedByUserId), and admin-key callers are recorded with the system:staff_admin label and a null userId. Filter the Audit Console on entityType = host_organization_note or actionPrefix = host_organization.note. to browse the resulting trail.

> **Edit / delete / pin (Tasks #437, #2594)**
>
> Notes were created under Task #434. Edit and delete were wired in Task #437 against the updatedAt and deletedAt slots reserved when the table was created - no migration required - and explicit pin/unpin was added in Task #2594. PATCH stamps updatedAt and writes a host_organization.note.updated audit row whose before field carries the prior body so the original content stays recoverable. DELETE soft-deletes by stamping deletedAt and writes a host_organization.note.deleted audit row (before = body, hostOrganizationId, authorDisplayName; after = deletedAt, actorLabel). Soft-deleted notes drop out of the default list but the audit trail remains queryable from the Audit Console. Edit and delete are not author-gated and there is no host.notes.manage permission at HEAD - any staff session or x-admin-key caller may edit or delete.

## Cultural Activities — Cost and Transportation Fields

Cultural activities (host_cultural_activities, /api/partner/host/cultural-activities) carry an optional structured expected cost plus two free-text notes. Existing rows return null for all four fields.

| Field | Type | Rule |
| --- | --- | --- |
| costAmount | string | null | Fixed 2-decimal numeric string (e.g. "25.00"). Writes accept a non-negative number or numeric string with at most 2 decimal places (up to 8 whole digits), normalized to 2 decimals. |
| costCurrency | string | null | ISO 4217 alpha-3 code; case-insensitive on write, stored uppercase. |
| costNotes | string | null | Free text, max 2000 characters. |
| transportationDetails | string | null | Free text, max 2000 characters. |

costAmount and costCurrency are a pair: any write that touches either must send both, either both non-null (record a cost) or both null (clear it). An unpaired or invalid value returns 400 VALIDATION_ERROR. Staff and host-portal reads share the identical response shape.

## Housing — Cost Fields

Housing records on the Host Portal carry three cost fields, all optional and null when not provided. The Host Portal wire names are `isFreeHousing`, `costPerBed` (USD) and `costFrequency`. Globus spells the free flag `costIsFree`; the portal accepts either name on create and update and treats them as one flag (sending both with conflicting values is rejected, as is a non-boolean `costIsFree`). Cost fields are validated before the Hub is called:

| Field | Type | Rule |
| --- | --- | --- |
| isFreeHousing | boolean \| null | Must be a boolean when supplied. True cannot be combined with a cost per bed or a cost frequency. On the Globus side, `costIsFree` = true clears costPerBed and costFrequency on every housing-lead write path. |
| costPerBed | number \| null | A finite number of 0 or greater; zero is a valid answer. A value above zero requires costFrequency. |
| costFrequency | string \| null | A non-empty string such as weekly or monthly. Always requires costPerBed, but a frequency is not required when costPerBed is 0. |

The same three fields are returned on each housing record the Host Portal reads, alongside the record's programId.

**Hub camp-support housing fields.** Staff housing-lead writes (`POST /api/admin/housing-leads` and `PATCH /api/admin/housing-leads/:id`; permission `job_orders.create` or `job_orders.manage`) also accept `housingCost` (a non-negative whole amount or null; anything else is rejected 400 VALIDATION_ERROR), `costDeductedFromPaycheck` (boolean or null) and `employerProvided` (boolean, default false, separate from `housingType`). `cabin` is a `housing_type` lookup value. When `programId` and `hostOrgId` are both supplied on create, the program must be an active program association of the host organization, otherwise the request fails 400; on update the check runs only when the program or host organization actually changes.

## Acceptance Criteria (Verbatim)

Restated from the source brief so the implementer can grep against this page:

## Cross-References
