---
title: "Host Operating Agreement Relay"
slug: host-operating-agreement-relay
section: Portals
group: Host Portal
status: stable
description: "How the Host Portal BFF (HostPortalPrototype) relays operating-agreement data from the Globus Hub to the browser: what it redacts, how it shapes errors, and how it resolves the agreement's legal copy."
last-updated: 2026-10-07
---

# Host Operating Agreement Relay

How the Host Portal BFF (HostPortalPrototype) relays operating-agreement data from the Globus Hub to the browser: what it redacts, how it shapes errors, and how it resolves the agreement's legal copy.

## Overview

> Read from the HostPortalPrototype `development` branch. As of 2026-10-01 the production branch (`main`) does not yet include the guarded trial agreement review and signing flow (PRs #601 and #602), so the trial-agreement behaviour described here is not live in production until `development` is promoted.

The Host Portal server sits between the browser and the Hub for every operating-agreement call. Three behaviours define that relay and are documented here; the acceptance gate, trial upgrade and audit contract are covered on [Host Portal Governance](/docs/host-governance), and the route list is on [Host Portal Endpoint Reference](/docs/host-portal-endpoints).

| Concern | Behaviour | Source file |
| --- | --- | --- |
| Context relay | `acceptedByEmail` is removed from every agreement row before the context envelope reaches the browser | `server/endpoints/v2/host-context-redaction.ts` |
| Error envelope | The six operating-agreement routes answer every failure with `message`, `code` and optional `details` | `server/endpoints/v2/operating-agreement-handlers.ts` |
| Content resolution | `GET /api/v2/profile/operating-agreement/content` resolves the legal copy through a status, listing, detail, context chain and fails closed when the choice is ambiguous | `server/facades/v2/operating-agreement-content.ts` |

The six routes under `/api/v2/profile/operating-agreement/` are `accept`, `upgrade`, `status`, `dismiss`, `content` and `signed-pdf`. On the Hub, the underlying dismiss route (`/api/partner/host/operating-agreement/dismiss`) now requires the `admin` or `recruiter` (Hiring Manager) host role plus the `agreements.write` scope; Read Only and Finance Manager users are refused with 403 `INSUFFICIENT_ROLE` because dismissing is a write action.

The `accept` route handles both full operating agreements and, for trial agreements, a nested `trialReview` object from the Hub `status` response. The `upgrade` route is not used for trial agreement acceptance. For a successful trial acceptance, the client expects a response body indicating `trialAcceptanceVerified: true`.

## Context Relay Redaction

The profile relay of the Hub host-context envelope, `GET /api/v2/profile/context`, passes the result through `redactHostContextForClient` before responding. The host relay, `GET /api/v2/host/context`, also references the function in `host-endpoint.ts`.

- The function removes `acceptedByEmail` from each row of `data.agreements` and from a legacy top-level `agreements` array.
- `acceptedAt` is kept; the client reads only that field to show the signed-agreement date.
- The reason recorded in code is that context rows reach every member of the org, and `acceptedByEmail` names a person other than the requester.
- The function returns a copy. The facade result is not mutated, so server-side readers still see `acceptedByEmail`.
- Envelopes that are not objects, or that carry no agreements array, pass through unchanged.

> **Client contract**
>
> Browser code must not expect `acceptedByEmail` on agreement rows from either context route. The `useOperatingAgreement` hook documents that the BFF strips it before relay.

## Error Envelope

Every failure on the six routes is returned as the same body, so a client can branch on `code` without knowing which route it called.

| Field | Meaning |
| --- | --- |
| `message` | Client-safe text |
| `code` | Stable machine-readable code |
| `details` | Optional object; for Hub-originated failures it holds `hubStatus` and, when the Hub sent one, `hubCode` |

The generic `error` field that the shared `EndpointResponse` helper would otherwise add is deliberately left out. The Hub body is logged server-side only. The Hub message text is withheld for validation-class refusals and on every route except accept and upgrade, which relay it after the generic message for Hub statuses below 500 (see Upstream Failure Classification).

### Error Codes

| Code | When |
| --- | --- |
| `HOST_ORG_ID_REQUIRED` | 400; `hostOrgId` is missing or blank (body on accept, upgrade and dismiss; query on status, content and signed-pdf) |
| `ATTRIBUTION_MISSING` | 400; a mutation (accept, upgrade, dismiss) has no resolvable user email |
| `IDENTITY_UNRESOLVED` | 401; status, content or signed-pdf has no email claim in the JWT |
| `AGREEMENT_REQUEST_INVALID` | The Hub refused the request shape and sent no code of its own. On accept, a Hub 400 with no code (or `ATTRIBUTION_FAILED`) is reported as `HUB_ATTRIBUTION_REJECTED` instead |
| `AGREEMENT_ACCEPT_FAILED` | Generic accept failure |
| `AGREEMENT_UPGRADE_FAILED` | Generic upgrade failure |
| `AGREEMENT_STATUS_UNAVAILABLE` | Generic status failure |
| `AGREEMENT_DISMISS_FAILED` | Generic dismiss failure |
| `AGREEMENT_CONTENT_FETCH_FAILED` | Generic content failure |
| `AGREEMENT_SIGNED_PDF_UNAVAILABLE` | Generic signed-pdf failure |
| `TRIAL_ACCEPTANCE_CONTRACT_UNAVAILABLE` | Returned by the `content` endpoint for a trial agreement when its engine-backed terms lack a safe commercial acceptance binding. |
| `AGREEMENT_CONTENT_UNAVAILABLE` | 502; the legal copy could not be resolved |
| `HOST_AGREEMENT_STATUS_BINDING_UNAVAILABLE` | 502; the Hub did not supply one complete agreement review and signing binding in the status response, or the selected status revision has no legal copy text. Thrown as `AgreementStatusBindingUnavailableError`, a subclass of `AgreementContentUnavailableError` |
| `AGREEMENT_CONTENT_NOT_PUBLISHED` | 404; the agreement has no published library version |
| `HUB_ATTRIBUTION_REJECTED` | 400 on accept; the Hub could not attribute the acceptance to a user |
| `AGREEMENT_ACCEPTANCE_REQUIRED` | 403; the Hub's acceptance gate refused a read because the host has not accepted its operating agreement. The `status` endpoint can also signal this state proactively with `acceptanceBlocking: true` in its response. Expected state, not a fault: the Host Portal relays it verbatim with `details.agreementId` and `details.waitingForAdmin` (true when the signed-in user is not an admin of the organization) instead of masking it as a 503. Relayed on the host context read and on the dashboard, host, reservation and talent-pool endpoints, and by the trial-agreement guard on guarded writes |

Typed facade errors keep their own codes and status: `AcceptanceUnverifiedError` on accept (with `details`), `UpgradeNotAvailableError`, `UpgradeAgreementNotActivatableError`, `UpgradeContractUnsupportedError` and `UpgradeIncompleteError` on upgrade, and the two content errors above. An accept refused because the agreement version has no published copy returns `AGREEMENT_VERSION_NOT_PUBLISHED_CODE` with a fixed plain-language message.

### Upstream Failure Classification

Untyped Hub failures are classified by `describeUpstreamFailure`:

1. A refusal is treated as request validation when the Hub status is 400 or 422, when the Hub code is one of `VALIDATION_ERROR`, `INVALID_REQUEST`, `BAD_REQUEST` or `INVALID_HOST_ORG`, or when the Hub message names an `X-` request header or the word header. The client receives a neutral message, "The agreement service rejected this request as invalid. Check the selected organization and try again.", with the Hub code as `code` (or `AGREEMENT_REQUEST_INVALID` when there is none).
2. Otherwise, for Hub status 500 and above, `code` is the route's generic code, whatever code the Hub sent.
3. Below 500, `code` is the Hub code when present, else the route's generic code.
4. Only accept and upgrade relay the Hub's own message, as the route's generic message, a colon and the Hub text, and only for statuses below 500. The other routes always send their generic message.

A coded Hub 422 therefore stays branchable on its code while its message is neutralised. A 404 whose message names no header is not treated as validation.

Hub-side codes behind these routes: `GET /api/partner/host/operating-agreement/signed-pdf` returns 409 `AGREEMENT_NOT_SIGNED` when the agreement has no `acceptedAt`, and 404 `NOT_FOUND` when no signed agreement exists or the id belongs to another org; the staff route `GET /api/admin/partners/agreements/:id/signed-pdf` returns the same 409. `POST /api/partner/host/operating-agreement/upgrade` returns 409 with `code` `CONFLICT` and `details.code` `NOT_TRIAL_RESTRICTED` when the org is not trial-restricted and has no recoverable active-but-unsent successor, or when the trial vanished before the successor could be drafted. `NOT_TRIAL_RESTRICTED` is therefore a discriminator inside `details`, not a top-level code.

## Content Resolution

`resolveOperatingAgreementContent` backs `GET /api/v2/profile/operating-agreement/content`. The route accepts `hostOrgId`, and optionally `agreementId` and `programId`, in the query string.

### Resolution Chain

| Step | Source | Used when |
| --- | --- | --- |
| status | Gate-exempt operating-agreement status | Consulted first; designates the agreement and may carry the copy inline |
| listing | `getHostAgreements` | Selects a record by id or by the selection rules below |
| detail | `GET /api/partner/host/agreements/{id}` | The record has no text; probed at most once per id |
| context | `getHostContext` agreements | Still no text; the context row's id is fed into a detail probe if it was not already probed |

Details that matter:

- On the status step, `pendingOperatingLegalContent` is checked before any outstanding row. When its `status` is `available` with a non-empty `bodyHtml`, and the designated agreement id matches any caller-supplied id, that copy is returned. A status of `unavailable` marks the agreement as unpublished.
- On the status step, `selectStatusRevision` is applied to the status response. When it selects a revision, that row's `contentHtml` is returned as the legal copy; neither the gated detail read nor a latest-copy listing replaces it. If the selection is `unavailable`, or the selected row's `contentHtml` is empty or not a string, resolution throws `AgreementStatusBindingUnavailableError`.
- An explicit `agreementId` is authoritative. Status rows for a different agreement (typically the active trial) are skipped, and the context fallback matches that id exactly.
- A failed status read no longer aborts resolution. The chain continues with listing, detail and context, and a blocked agreement id found in the error metadata is kept as the target.
- The listing unwrapper reads the Hub envelope `data.agreements` as well as the flatter shapes (`data`, `result`, `agreements`, and `items` or `result` one level down).

### Agreement Selection

With no id named, `selectOperatingAgreement` answers only when the choice is unambiguous: exactly one active operating agreement, or, when none is typed as operating, exactly one active agreement. Several active operating agreements (a multi-programme host) return no selection, because listing order is not a legal-document selector. When both listing and context hold several active agreements and none is named, no detail probe is made and the request fails closed.

### Content Format

`libraryContentFormat`, then `contentFormat`, is read as the explicit format. Only `html` and `text` are supported; any other explicit value (for example `pdf`) raises `AgreementContentUnavailableError` naming the format and agreement. With no explicit format, content that looks like markup is reported as `html`, else `text`.

### Failure Outcomes

| Condition | Error |
| --- | --- |
| No Hub source could be read | `AgreementContentUnavailableError` (502) |
| Agreement found but marked unpublished (`libraryContentHtml` null, `legalContentUnavailable`, or pending content unavailable) | `AgreementContentNotPublishedError` (404) |
| No agreement found, but the status step reported unpublished and not every Hub read failed | `AgreementContentNotPublishedError` (404) |
| Agreement found and published but no text extracted | `AgreementContentUnavailableError` (502) |
| No agreement found and the status step did not report unpublished | `AgreementContentUnavailableError` (502) |
| Unsupported explicit format | `AgreementContentUnavailableError` (502) |

Every message from the resolver ends with `(correlation ID: ...)`, so a host-reported failure can be matched to server logs.

## Diagnostics and Logging

Each resolution attempt generates a correlation id (`randomUUID`) and records one outcome per step (`status`, `listing`, `detail`, `context`).

- A failed step logs a JSON line to stderr with event `operating_agreement_content_step_failed` and fields `correlationId`, `step`, `agreementId`, `blockedAgreementId`, `hubStatusCode`, `hubErrorCode`, `errorMessage` and `reason`.
- Giving up logs event `operating_agreement_content_resolution_failed` with `reason` and the full `stepOutcomes` list. Reasons include `all-hub-reads-failed`, `agreement-found-without-published-library-version`, `no-operating-agreement-found`, `agreement-text-extraction-failed` and `globus-client-unavailable`.
- Hub error metadata is read across the SDK's envelope variants (`rawJson`, `response.data`, nested `error.details`) so the blocked agreement id is captured where present.
- The route handlers log every raw upstream failure with status, code and message; the accept route also logs the Hub body. The Hub body is never returned to the browser; the Hub message is returned only in the accept and upgrade case described under Upstream Failure Classification.

## Sources

- HostPortalPrototype#559: content resolution when the Hub status read fails, Hub listing envelope, fail-closed agreement selection
- HostPortalPrototype#560: `acceptedByEmail` redaction and the unified error envelope
- HostPortalPrototype#562: correlated diagnostics, `pendingOperatingLegalContent`, content format validation
- `server/endpoints/v2/host-context-redaction.ts`
- `server/endpoints/v2/operating-agreement-handlers.ts`
- `server/facades/v2/operating-agreement-content.ts`
- `server/facades/v2/operating-agreement-content-format.ts`
- `server/facades/v2/operating-agreement-content-diagnostics.ts`
- `server/facades/v2/operating-agreement-errors.ts`
