---
title: "Host Offer Details"
slug: host-offer-details
section: Portals
group: Host Portal
status: stable
description: "Per-position offer terms (title, salary, time-off, contract dates, benefits, transportation) — bidirectional read/write between the Host Portal and Globus, with one storage row and one program-scoped lookup for position type."
last-updated: 2026-10-07
---

# Host Offer Details

Per-position offer terms (title, salary, time-off, contract dates, benefits, transportation) — bidirectional read/write between the Host Portal and Globus, with one storage row and one program-scoped lookup for position type.

> **Who reads this**
>
> Globant platform engineers, Host Portal engineers, and Globus staff console engineers. This is the frozen contract for the OfferDetail resource — endpoints, shape, taxonomy, validation, and concurrency. Implementation lives in the Globus platform repo and the Host Portal repo, not in this docs site.

## Purpose & Scope

Hosts need to capture and amend the per-position offer that an applicant will see and ultimately accept: position type, title, salary and frequency, time-off policy, contract window, benefits, and the joined transportation block. These fields drive matching, the participant-facing job offer document, and downstream notifications. The same fields can also be entered or corrected by Globus staff inside the staff console — so the contract has to describe one resource, two write surfaces, and one read surface, with shared validation, audit, and event semantics.

## Globus System of Record

Globus owns the offer data. The OfferDetail resource is a projection over a single job_order_requirements row joined to the host's host_arrivals row for transportation. There is no shadow copy in the Host Portal — the Host Portal acts as a proxy/cache. Both write surfaces (host-facing API and Globus-internal staff console) write to the same row, run the same validation, emit the same activity event, and feed the same audit trail.

> **One row, two write surfaces, one read surface**
>
> Host Portal PATCH and Globus staff PATCH update the same job_order_requirements row. Neither side is allowed to maintain a parallel store. The host-facing read endpoint is the only surface the Host Portal renders from.

## Endpoints

### Host-Facing Endpoints

| Method | Path | Description | Auth |
| --- | --- | --- | --- |
| GET | /api/partner/host/offer-details?program_id=:program_id | List the authenticated host's active offers for the program. Requires program_id; returns 400 if missing/invalid and 404 if the program does not exist. A known program for which the host has no offers returns 200 with data:[]. | X-Host-Org-Id + X-Host-User-Email |
| PATCH | /api/partner/host/offer-details/:offer_id | Partial update of host-owned offer fields. Returns the full updated OfferDetail in the same shape as GET. Restricted to host_admin and host_hiring_manager. Requires If-Match: " " precondition. | X-Host-Org-Id + X-Host-User-Email |
| PATCH | /api/host/:hostId/offer-details/:offerId | Compatibility alias for the original Host Portal URL. Same body, If-Match precondition, response envelope and permissions as PATCH /api/partner/host/offer-details/:offer_id. The :hostId path segment must match the authenticated host context (otherwise 404) and is never used to choose the organization or as an authorization source. | X-Host-Org-Id + X-Host-User-Email |
| GET | /api/partner/host/offer-details/position-types?program_id=:program_id | Lookup endpoint returning the allowed position-type codes for the program. Returns [ code, label, description?, active ]. Both Host Portal and Globus staff UI must consume this; neither is allowed to ship its own list. Returns 404 when the program does not exist and also when the host has no job order in that program. | X-Host-Org-Id |

> **Approved job orders are not terminal (Aug 2026 fix)**
>
> The offer-details endpoint previously treated approved job orders as terminal and returned no offer rows for them, which rendered a blank Offer Details view in the host portal's My Camp. Fixed Aug 11 2026: approved job orders are in scope for the list endpoint, and hosts see their offer details as expected. The list still excludes orders that are rejected or cancelled, archived (`archivedAt` set) or closed (`closedAt` set).

### Globus-Internal Write Surface

Globus staff write the same fields through the existing internal job-orders endpoints (the staff PATCH on the underlying job-order-requirement row). The two surfaces map to the same storage row and share the same validation, activity event, and audit trail. The mapping below freezes the public field names against the internal column names so both surfaces stay aligned.

| Public field (offer-details) | Internal column (job_order_requirements) | Notes |
| --- | --- | --- |
| position_type | position_type_code | FK to program_position_types (Program Register-owned lookup). |
| position_title | positionTitle | Aliased — public name is the snake_case position_title; internal column keeps the existing camelCase positionTitle for backward compatibility. |
| salary | wage | Public name frozen as salary; internal column keeps wage for backward compatibility. |
| salary_frequency | wage_frequency | Same enum on both sides. |
| salary_for_season | salary_for_season | New column added by this contract. |
| time_off_policy | time_off_policy | New column. |
| contract_start_date | contract_start_date | New column. |
| contract_end_date | contract_end_date | New column. |
| benefits | additionalBenefits | Aliased — public name is benefits, internal column stays additionalBenefits. |
| participant_transportation | (joined from host_arrivals) | Read-only join. Not stored on job_order_requirements. |

### Path Convention Note

This spec uses the /api/partner/host/* path prefix to match the sibling Host Portal contract specs. The existing /api/host/job-orders rows in the Integration Contract → Host Portal Endpoints remain the source of truth for the parent job order (status, capacity, lifecycle). The new offer-details surface owns only the per-position requirement fields enumerated above; it never exposes job-order-level concerns (status, capacity counters, fill rate).

### Staff Structured Offer Defaults (separate resource)

Staff also maintain reusable, program-scoped **structured offer defaults** (the `host_offer_details` record). They are a standing record used to prefill matching blank fields on newly authored job orders; they never alter existing job orders or signed offer contracts, and they are independent of the job_order_requirements row described above.

| Method | Path | Description |
| --- | --- | --- |
| GET | /api/admin/partners/organizations/:orgId/offer-details | Requires partners.view plus organization, brand, and program scope. With programId (plus positionType when the program has an active position-type catalogue) returns that one record, or data: null. Without programId returns a cursor-paginated { items, page } of the caller's scoped records (several typed records per program are possible). |
| PUT | /api/admin/partners/organizations/:orgId/offer-details?programId=... | Requires partners.manage, an active host-program association, and matching organization/brand/program scope. Creates (201) or updates (200) one host-program-type record; audited as partner.org.offer_details.upsert. |
| GET | /api/admin/partners/organizations/:orgId/job-order-offers | Staff read of the host-entered offer terms stored on the host's job orders (projected with the same canonical offer-terms projection as this page). Includes non-archived, non-closed orders except rejected and cancelled, including orders with no entered terms. Never reads or writes the reusable defaults above. |

- **Host-facing read precedence.** The three host-facing offer reads (GET /api/partner/host/offer-details, GET /api/partner/host/job-orders/:id/offer-details, and the PATCH response) project the program's structured default OVER the job order's own terms (server/services/hostOfferDefaults.ts): a job-order value is served only where the default leaves that field blank. salary, salary_frequency and salary_for_season are projected as a unit: as soon as the default sets any of the three, all three come from the default. The default is chosen by position type; an untyped record is only a fallback while the host has authored no typed record for the program, so an offer with no record of its own for its position type has no default. The stored job order is not changed by this projection, and the PATCH response is built from defaults read after the write so it matches the next GET.
- **Typed cardinality.** When the program exposes an active position-type catalogue, positionType (an active catalogue code) is required and each (host, program, positionType) pair holds its own record; a concurrent duplicate create returns 409 DUPLICATE_OFFER_DEFAULT. Programs without a catalogue keep one untyped record and reject positionType. Responses carry positionType and positionTypeLabel.
- **Payload.** positionTitle (max 200), salary (non-negative decimal, max 2 places), salaryFrequency (weekly | monthly | annual | season), timeOffPolicy (max 2,000), contractStartDate / contractEndDate (ISO date, end on or after start), benefits (max 4,000), and editStatus. Unknown fields, invalid amounts, frequencies, lengths, dates or date order return 400. A missing or stale editStatus on update returns 409 (EDIT_STATUS_REQUIRED / EDIT_STATUS_CONFLICT).
- **Compensation validation.** When a save supplies salary, salaryFrequency or the season total, the proposed wage is evaluated against the currently published job_order_compensation rule using the program's brand, the host's associated seasons, the shared parameter cascade and the rule's authored reason-code message. A blocking violation returns the standard 400 validation envelope with details.fields.salary and leaves the default unchanged. A season frequency also requires a positive season total (details.fields.salary_for_season), and a season total is rejected for any other frequency.
- **State minimum wage reference data.** Staff maintain the per-state statutory hourly minimum through `GET /api/admin/state-minimum-wages`, `POST /api/admin/state-minimum-wages` and `PATCH /api/admin/state-minimum-wages/:id`, all requiring `system_settings.manage` (the writes also accept an API key with `admin.write` and are audited as `state_minimum_wage.create` and `state_minimum_wage.update`). A rate is `stateCode`, `hourlyCents` (whole cents, above zero, at most 100000), `effectiveFrom` (ISO date) and optional `notes` (up to 500 characters). The list returns each rate with `stateName` plus `statesWithoutARecordedRate`. The routes decide nothing themselves: the rate reaches the rules engine as the fact `jobOrderStateMinimumHourlyWageCents`. Recording, or moving, a rate onto a date the state already has returns 409 `CONFLICT` with `details.code` `DUPLICATE_STATE_EFFECTIVE_DATE` (correct the existing rate instead); PATCH on an unknown id returns 404.

### Signed Job-Offer Contracts (separate resource)

A host organization keeps one current signed job-offer contract document per program (table host_offer_contracts, unique per host org and program). Uploading for a program that already has one replaces it (200 instead of 201) and resets its review state. Files are stored in the document bucket and are only served through authenticated proxied download streams; raw storage URLs are never returned. Accepted types are PDF, Word documents and JPEG/PNG scans (400 UNSUPPORTED_FILE_TYPE otherwise; 400 FILE_REQUIRED when no file). Upload requires an active association between the host organization and the program (422 POLICY_VIOLATION, details.code program_not_associated_with_host).

| Method | Path | Description |
| --- | --- | --- |
| GET | /api/admin/partners/organizations/:orgId/offer-contract-programs | Staff: the programs actively associated with the host, limited to the caller's program scope (requires partners.view). |
| GET | /api/admin/partners/organizations/:orgId/offer-contracts | Staff: list the org's contracts, filtered to the caller's organization and program scope. |
| POST | /api/admin/partners/organizations/:orgId/offer-contracts | Staff: multipart upload (file plus programId). Requires partners.manage plus organization and program scope; audited as partner.org.offer_contract.upload. |
| GET | /api/admin/partners/organizations/:orgId/offer-contracts/:contractId/download | Staff: proxied download, scope-checked. |
| DELETE | /api/admin/partners/organizations/:orgId/offer-contracts/:contractId | Staff: delete the contract and its stored object (204). Requires partners.manage; audited as partner.org.offer_contract.delete. |
| GET | /api/partner/host/offer-contracts | Host Portal: list the host org's contracts (scope host.documents.view). Returns contracts and total, each with reviewState, reviewNotes and reviewedAt. |
| POST | /api/partner/host/offer-contracts | Host Portal: multipart upload (file plus programId). Scope host.documents.manage, host role admin, and X-Host-User-Email required (400 MISSING_HOST_USER otherwise). |
| GET | /api/partner/host/offer-contracts/:id/download | Host Portal: proxied download of one of the org's contracts (scope host.documents.view). Returns 404 `CONTRACT_NOT_FOUND` when the id is not one of the caller's organisation's contracts and 502 `STORAGE_UNAVAILABLE` when the stored file cannot be read (top-level `error.code`). |

There is no host-facing delete. Every upload or replacement sets reviewState to pending_review. Compliance staff decide through the document-review action route with source host_offer_contract and an action of approved, rejected or resubmission_requested (a reason is required for rejected and resubmission_requested, 400 REASON_REQUIRED). The caller must be in the contract's brand and program scope (403 otherwise), and a contract that is no longer pending_review returns 409 INVALID_STATE_TRANSITION. Each decision is written to host_offer_contract_review_history and audited as host_offer_contract_<action>. Contracts awaiting review appear in the Host Offer Contracts Review queue (slug host-offer-contracts-review, compliance family).

## Auth & RBAC

Dual auth model, identical to the sibling host portal endpoints. See the Host Portal Authentication Modes page for the full header set.

| Surface | Principal | Required headers | Allowed roles |
| --- | --- | --- | --- |
| GET (read) | Host user via Host Portal | X-API-Key (host_proxy or direct), X-Host-Org-Id, X-Host-User-Email | host_admin, host_hiring_manager, host_finance, host_readonly |
| PATCH (write) | Host user via Host Portal | X-API-Key, X-Host-Org-Id, X-Host-User-Email, If-Match | host_admin, host_hiring_manager (host_readonly → 403) |
| GET position-types lookup | Host user via Host Portal | X-API-Key, X-Host-Org-Id | any host role |
| Globus-internal PATCH | Globus staff session | Staff session cookie + If-Match | Sponsor Ops, Partner Ops (per existing job-order RBAC) |

### Ownership Scoping

Ownership is resolved server-side via the partner_organizations link on the underlying job order, never via client-supplied IDs. PATCH against an offer that belongs to a different host org returns 403 even if the offer_id is correctly formed and exists. GET filters silently exclude offers outside the host's scope.

### Program Filter (Required on GET)

program_id is a required query parameter on the list endpoint. Missing or malformed → 400 with the standard error envelope. If the program id does not exist → 404. If the program exists but the host has no offers in it, the response is 200 with data: [] (not a 404). This keeps the list surface scoped and avoids accidental cross-program reads.

### Host Portal Read Behaviour

The Host Portal reads this list through its own backend, which treats the program-scoped GET as authoritative for which offers belong to the program. The host-wide positions list is only a side-lookup: it never removes a returned row, and when it says a job order sits in another program the row is kept and simply left un-enriched. Only a row whose own program_id contradicts the requested program is dropped. When the list comes back empty (success with data: []), the portal does not take that as proof the program has no offers: it asks the host job-order listing and rebuilds rows only from job orders whose own program id equals the requested program. Rebuilt rows carry no updated_at and are read-only in the portal.

Two fields are back-filled from the job order when the offer lacks them, and a value stored on the offer always wins. A missing position_title is filled from the job order title. Salary is filled from the job-order wage only as an all-or-nothing pair: salary and salary_frequency are both borrowed, and only when the offer has neither (an amount is never mixed with the other source's frequency). A Hub offer row that cannot be normalized (not an object, no id, a field value that is neither text, a finite number nor null, or an `updated_at` that is not a non-blank string), or a position type without a code or label or with an invalid active flag, throws `MalformedOfferRecordError` in `shared/domain/v2/host-profile.ts`, which carries `statusCode` 502 and `errorCode` `OFFER_RECORD_MALFORMED`. On the offer-details and position-types reads the error propagates, so the read fails rather than rendering an all-empty offer; the job-order fallback path instead skips an unusable job-order offer block, and the stored-record merge falls back to the offer read.

In the portal UI every job-order row for a program renders as its own stacked position card, including rows with no terms published yet (blank values read Not provided). Changing a position type only requests the change: the host must confirm in a dialog before the PATCH is sent, with If-Match set to the offer's updated_at (the write sends position_type and position_title together). The Hub participant_transportation block is not rendered on the offer card; arrival and transportation details are shown and edited in Arrival Info.

## OfferDetail Resource Shape

Both GET (per item) and PATCH (response) return a single OfferDetail object. PATCH response is the full updated OfferDetail in the same shape as GET. There is no separate "summary" vs "detail" projection.

The Host Portal write path (`PATCH /api/v2/profile/offer-details/:offerId`) adds one optional key to that response, `unreflected_fields`. After the Hub accepts a PATCH with 200, the portal re-reads the program's offers (this needs `programId` in the request body) and compares each field the host changed with the offer row the host will be shown next. A disagreement is confirmed by a second read after a short pause (750 ms) before it is reported; if the re-read is unavailable the PATCH echo is used instead. Money fields (`salary`, `salary_for_season`) compare numerically, so "3000" and "3000.00" match. Fields that Globus still reports with the previous value are returned as `unreflected_fields` (an array of field names) merged into the otherwise unchanged 200 response, and the portal logs the offer, fields, values sent, values Globus reports and Globus trace id for support. The key is omitted when every field round-tripped.

The same portal PATCH is refused before any Hub write with `UNSUPPORTED_OFFER_FIELDS` (422, `details.fields` lists keys outside `hostOrgId`, `programId`, `ifMatch` and the writable offer fields) and `OFFER_IF_MATCH_REQUIRED` (428, no `ifMatch` in the body and no `If-Match` header). A body that changes `position_type` must also carry `programId` (400 `OFFER_PROGRAM_REQUIRED`); the portal then reads the program's offers and position types and refuses with `OFFER_PROGRAM_MISMATCH` (422, the offer is not one of the program's offers), `OFFER_PROGRAM_UNVERIFIED` or `OFFER_POSITION_TYPES_UNAVAILABLE` (503, the reads could not be verified) or `OFFER_POSITION_TYPE_UNAVAILABLE` (422, not an active position type for the program). When the body has no explicit `position_title`, it is set from the selected position type's label. `GET /api/v2/profile/offer-details/position-types` (query `hostOrgId` and `programId`) answers 404 `OFFER_PROGRAM_NOT_IN_HUB` when the Hub does not recognise the program, and 503 for other upstream failures.

The first offer for a program is created with `POST /api/v2/profile/offer-details` (body `hostOrgId`, `programId`, `position_type` and the optional text fields; 401 when the JWT has no email claim). The Host Portal reads the program's existing offers and position types before the Hub write and refuses with: `UNSUPPORTED_OFFER_FIELDS` (422, a key outside the allowed set), `OFFER_VALIDATION_ERROR` (422, with `details.field` or `details.fields`, for a missing `position_type`, a non-text field, an invalid salary or invalid contract dates), `OFFER_ALREADY_EXISTS` (409, the program already has an offer record, so edit it instead), `OFFER_PROGRAM_UNVERIFIED` (503, the existing offers could not be verified for the active program; refresh and retry), `OFFER_POSITION_TYPES_UNAVAILABLE` (503, the available position types could not be verified) and `OFFER_POSITION_TYPE_UNAVAILABLE` (422, the code is not an active position type for the program). The offer's `position_title` is set server-side from the selected position type's label.

### Field Source of Truth

| Field | Type | Required (PATCH) | Source of truth |
| --- | --- | --- | --- |
| name | type | required | source |

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

### participant_transportation (Read-Only)

The transportation block is composed by joining the host's host_arrivals row for the offer's program (filtered by host org id and program id). The camp-wide record keyed by host_org_id alone is the target contract described on the Host Arrivals page and is not yet shipped. It is included on GET so hosts and Globus staff can see the full participant-facing offer in one place, but PATCH rejects any transportation key with 400 VALIDATION_ERROR (details.fields names the key). The block carries nearest_airport, additional_airport (nullable), port_of_entry_airport, pickup_cost and general_arrival_instructions. Offer Details never collects, supersedes, or backfills arrival transportation values. Hosts edit transportation via the Host Portal Arrivals contract (/api/partner/host/arrivals), which owns the record this projection reads from; every offer of the same host in the same program shows identical transportation values.

| Sub-key | Description |
| --- | --- |
| key | desc |

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

## Position Type Taxonomy

> **No hardcoded position-type enum, anywhere**
>
> No hardcoded position-type enum may exist in the Host Portal, the Globus staff UI, or the database schema. The set of valid codes is data, not code. The original draft's CAMP-only values (counselor, support) become seed rows in a Program Register-owned lookup, not a typed enum.

### Generic, Multi-Program Rule

position_type is a code from a Program Register-owned lookup. Each program in the Program Register declares its own allowed position types — Camp → counselor, support; Work & Travel → its primary/secondary role codes; Internship/Trainee → discipline codes; Au Pair → its own set. Adding a new program or a new position type is a data change, not a code change.

The field accepts a single code per offer. The original draft's "one or both" CAMP wording is resolved by allowing one offer per position type (i.e., a host that needs both counselor and support roles has two offer rows on the same job order), not an array on a single offer. This keeps the model uniform across all programs.

### Lookup Endpoint Contract

GET /api/partner/host/offer-details/position-types?program_id=:program_id returns the allowed codes for that program. Both the Host Portal and the Globus staff UI are required to consume this lookup; neither is allowed to ship its own list.

// Response shape

code / counselor / label / Camp Counselor / description / Direct camper supervision / active,

code / support / label / Support Staff / description / Kitchen, maintenance, admin / active

Codes are stable identifiers (snake_case, ASCII). They are the persisted FK and must never change. / Labels are human-readable and localizable. The Host Portal and Globus staff UI render label, never code. / active = false means the code is retained for historical offers but cannot be selected for new writes. Writes against an inactive code return 422. / Unknown codes are rejected at write time on both the host-facing PATCH and the Globus-internal PATCH.
### Storage & Migration

Position-type codes for a program live in a Program Register-owned reference table (program_position_types). The offer record stores only the code (FK), never the label. Globus platform owns the seed data; the Program Register UI is the only place those codes are managed. The camp-family programs (program codes CAMP_WP, SWT_WP and SWT) are seeded with counselor and support (one row per program × code) by migration 20271202060000_seed_camp_family_position_types.sql, which also retypes untyped structured offer defaults for those programs and backfills job orders' requirements.positionType (a title containing "counsel" becomes counselor, everything else support). It supersedes the earlier seed that selected programs.code = 'CAMP', a code no program carries, so that seed matched no rows.

## Validation Rules

Identical validation runs on both write surfaces (host-facing PATCH and Globus-internal PATCH). All field and cross-field validations return 400 VALIDATION_ERROR with field-level details so the UI can surface them next to the offending input.

| Rule | Detail |
| --- | --- |
| rule | detail |

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

### Season Salary Frequency for Every Program

Corrective change, shipped Sep 2026 (change-registry effective version `2029-04-15` on `PATCH /api/partner/host/offer-details/:id`). `salary_frequency` accepts `weekly`, `monthly`, `annual` or `season` for **every** program; the `season` value no longer depends on the program's legacy seasonal flag. The Host Portal passes `season` through to Globus like any other frequency and does not pre-reject it. `salary_for_season` remains **required** (a positive amount) whenever the frequency is `season`. It is the authoritative total-season amount that seasonal compensation rules read: no hourly wage is derived from it, and it only feeds those rules when the frequency is `season` (the updated 2029-04-17 documentation note on the same endpoint). All other checks are unchanged: `job_orders.write` partner scope with host role admin or recruiter, ownership (cross-host access → 404), date order and season-window membership (422), the published compensation-rule checks, If-Match concurrency (409 with `current_state`), transportation fields still rejected with 422 (edit via `/api/partner/host/arrivals`), and unknown fields rejected with 422. The compensation-rule checks run only when the save touches salary, salary_frequency or salary_for_season on an offer that is past draft (saving a draft never validates), and only after field-level format validation is clean; the same seasonal-compensation rule is applied on the other save paths for the same data (the staff job-order PATCH, job-order workflow review, and the structured defaults above). The `host.offer.updated` audit + activity event and host notification on salary / contract-date change are emitted exactly as before.

## Error Envelope

Errors use the standard envelope defined in the Integration Contract → Error Handling : .

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

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

## Bidirectional Writes & Concurrency

Two write paths, one row. Both surfaces are first-class — neither is "primary" — and both must run identical validation, emit the same activity event, and feed the same audit trail. The Host Portal is a proxy/cache only; it does not store offer data.

### Actor Distinction (host_user vs staff_user)

Each successful PATCH emits a single host.offer.updated event with an actor field that distinguishes the write surface:

"event": "host.offer.updated",

"offer_id": 12345,

"host_org_id": "org_abc",

"program_id": "prog_camp_2026",

"actor": type / host_user / staff_user / id / ... / email,

"diff": salary,

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

### Controlled Fields Lock After Compliance Approval

> **salary_for_season and contract dates become host-locked after approval**
>
> *Status: design only. `approved_terms_locked` and the `approvedTerms` block are not implemented; the host PATCH currently accepts `salary_for_season`, `contract_start_date` and `contract_end_date`.* Once a host's compliance review approves an effective approved-terms revision (Camp and Camp SWT hosts), salary_for_season, contract_start_date, and contract_end_date are controlled fields: the host-facing PATCH rejects any of those keys atomically with HTTP 409 and reason code approved_terms_locked (per-field details name each locked key and the effective revision). GET responses gain an additive read-only approvedTerms block carrying the effective approved values, revision number, and per-field lockState (locked / pending_review / unlocked). Globus staff may amend the locked values through the Globus-internal PATCH with a reason (≥ 10 characters); a material amendment creates a new pending revision and re-enters the host into compliance review. All other fields on this contract are unaffected. Full contract: Host Verification & Approved Terms.

### Last-Writer-Wins with If-Match

The host-facing PATCH routes (canonical and compatibility alias) accept an optional If-Match: " " precondition. If the server's current updated_at does not match, the write is rejected with 409 and the response body returns the current OfferDetail so the loser can rebase and retry. Once preconditions pass, the write is last-writer-wins on the row.

## Activity & Audit Events

Every successful PATCH (from either surface) emits one host.offer.updated activity event with a before/after diff for changed fields only (no-op PATCH does not emit an event). The event surfaces in the Host Activity Log and the Globus internal audit trail. Position type, salary, salary frequency, salary_for_season, contract dates, time_off_policy, and benefits changes are all auditable.

> **No double-eventing**
>
> The host-facing PATCH and the Globus-internal PATCH each emit exactly one event per write. The Host Portal is a proxy — it must not re-emit a second event when it forwards a host write through to Globus.

## Notifications

Material changes on an active job order trigger downstream notifications routed through the Notification Model :

Host changes contract_start_date or contract_end_date on an active job order → notify the assigned partner staff (SendGrid email + Partner Portal in-app). / Host changes salary, salary_frequency, or salary_for_season on an active job order → notify the assigned partner staff and any partner org with an open reservation against the offer. / Position type changes on an offer with active reservations → notify the assigned partner staff and emit a critical-priority audit entry (the offer's matching profile changed materially). / All other field changes (time_off_policy, benefits) → no synchronous notification, but the diff appears in the Host Activity Log and the Globus audit trail. / Globus-internal staff edits emit the same notifications as host-facing edits, with the actor.type = staff_user surfaced in the email subject so partners know whether the change came from the host or from Globus staff.
## Cross-References
