---
title: "Agreements Engine"
slug: agreements-engine
section: Rules & Compliance
status: stable
description: "How Globus authors, versions, targets, serves, and records signed acceptance of legal agreements — with forensic-grade evidence of exactly which text every applicant saw and signed."
last-updated: 2026-10-07
---

# Agreements Engine

How Globus authors, versions, targets, serves, and records signed acceptance of legal agreements — with forensic-grade evidence of exactly which text every applicant saw and signed. Live in the `development` environment on Globus Hub (Portal ID 2).

## Overview

The Agreements Engine manages legal agreements (terms of use, participant terms, program policies) across the ecosystem. It guarantees three things:

1. **Legal integrity** — published agreement text is immutable; every acceptance is recorded against the exact version (and SHA-256 content hash) the person saw.
2. **Operational targeting** — staff decide *which* agreement applies to *whom* via scoped assignments (brand / program / operational cycle / workflow type), with deterministic most-specific-scope-wins resolution.
3. **Two enforcement surfaces** — agreements gate **portal sign-in** (`portal_access` context) and appear as **workflow steps inside an application** (`application_workflow` context), each with deliberately different version-resolution semantics.

The engine is composed of three layers:

```
Agreements Engine
├── Agreement Library        agreements (catalog, business code)
│   └── Version Manager      agreement_versions (immutable content lifecycle)
├── Targeting Engine
│   ├── Assignment Engine    agreement_assignments (scope + context + version policy)
│   └── Signature Policies   agreement_signature_policies (method + re-sign triggers)
└── Resolution & Signature Layer
    ├── Runtime Resolver     resolveAgreementsForContext (most-specific-scope-wins)
    ├── Pin Store            agreement_runtime_resolutions (workflow freeze)
    └── Acceptance Ledger    agreement_acceptances (append-only evidence)
```

Operators are fully self-service: agreements, versions, assignments, signature policies, and Agreement workflow steps are all created through the Hub UI — no engineering involvement. Deliberate exclusions: no external e-signature provider (signatures are checkbox or typed-name captured in-product); legal content never travels with code deploys (promotion to QA/production is an explicit content-migration operation); and the engine never places steps into workflows — placement is exclusively a Workflow Studio operation (see [Workflow Studio](/docs/workflow-builder)).

## The Core Design Decision — Pin-Once vs Live

The two serving surfaces intentionally resolve versions differently. An applicant must always be able to finish their application against the agreement text they began with — swapping legal text mid-process would invalidate the acceptance evidence. So workflow agreements **pin** per application: the pin is written at first serve and, once the applicant has signed (or the assignment is `fixed`, or the pin came from a governed cycle mapping), it never changes. Portal access, by contrast, is an ongoing relationship: when the terms of use change, continued access should require accepting the *current* terms. So portal agreements **resolve live** at every sign-in. One engine, two deliberate consistency models.

| | Workflow step (`application_workflow`) | Portal sign-in (`portal_access`) |
| --- | --- | --- |
| When resolved | Once, at first serve of the step | Live, on every sign-in |
| Mechanism | Resolve via assignments, then **pin** to `agreement_runtime_resolutions` | Re-resolve against the applicant's anchor application scope on each session start |
| Effect | Legal text is frozen per application once signed; later publishes and assignment changes never change what a signed applicant saw. Exception: an **unsigned** pin on a `latest_effective` assignment (default `assignment_version` mode) advances to a newly published version on the next serve | Always evaluates the *currently* applicable version and whether the applicant has accepted **that** one |

**One-line summary:** assignments decide *which* version applies (`fixed` vs `latest_effective`, most-specific scope wins); workflow steps pin that answer per application at first open (an unsigned `latest_effective` pin follows newly published versions until the applicant signs, then freezes); the portal re-asks on every sign-in and forces re-acceptance on new versions unless the signature policy suppresses that trigger.

## Domain Model

Schema lives in `shared/models/agreementLibrary.ts` on the Globus Hub.

| Entity | Table | Role |
| --- | --- | --- |
| Agreement | `agreements` | Catalog entry. Stable snake_case `code` (unique business key), `agreementType` (seed baseline `applicant_portal_terms`, `participant_terms`, `program_policy`, `host_agreement`; the runtime set is lookup-driven and `other` was retired), owner `brandId` (NULL = global), `status` ∈ `active` / `archived`. |
| Agreement Version | `agreement_versions` | Immutable legal content. `bodyHtml` (authoritative), `contentHash` (SHA-256), `effectiveDate`. Lifecycle: `draft` → `under_review` → `approved` → `published` → `superseded` / `withdrawn`. |
| Assignment | `agreement_assignments` | Targeting rule. `context` ∈ `portal_access` / `application_workflow`; scope dims `brandId` / `programId` / `seasonId` (operational cycle) / `workflowType`; `versionPolicy` ∈ `fixed` / `latest_effective`. |
| Signature Policy | `agreement_signature_policies` | Per-agreement default or per-assignment override. `signatureMethod` ∈ `checkbox` / `typed_name`; `resignTriggers` (JSONB array) e.g. `new_version_published`, `identity_changed`, `program_changed`, `manual_reset`. |
| Acceptance | `agreement_acceptances` | **Immutable** evidence row: `versionId`, `contentHash`, `acceptedAt`, `ipAddress`, `userAgent`, `sessionId`, `typedName`; `supersededByAcceptanceId` chains re-signs to the acceptance they replace. |
| Runtime Resolution (pin) | `agreement_runtime_resolutions` | Records the resolved version for a specific applicant/application the first time an Agreement workflow step is served, plus `cycle_mapping_id` provenance when a governed cycle mapping chose it. Unsigned pins on legacy `latest_effective` assignments advance to a newly published version; signed, fixed-version, and cycle-mapped pins never do. |
| Re-Sign Obligation | `agreement_resign_obligations` | Staff-imposed, per-application override that re-serves a carrier step with a target version (see Governed Re-Signing below). |
| Version Events | `agreement_version_events` | Append-only log of every version lifecycle transition. |
| Cycle Version Mapping | `agreement_workflow_cycle_versions` | Governed version mapping for an `application_workflow` carrier assignment and an operational cycle (`assignmentId`, `seasonId`, `versionId`). `status` ∈ `active` / `superseded` / `retired`, `supersedesId` / `supersededById` lineage, `firstServedAt` stamped by the first pin taken through it. One active row per (assignment, season); rows are never deleted. |
| Resolve Failure | `agreement_resolve_failures` | Rolled-up log of resolve-time failures (`failureClass`, e.g. `equal_specificity_tie`, `not_resolvable_archived`, `cycle_mapping_missing`), de-duplicated by `dedupeKey` while `active` (`occurrenceCount`, `firstSeenAt`, `lastSeenAt`). `status` ∈ `active` / `resolved` / `dismissed`. Listed at `GET /api/agreement-resolve-failures` (`agreement_library.view`) and closed with `PATCH /api/agreement-resolve-failures/:failureId` (`agreement_library.manage`). |
| Signed Artifact Request | `agreement_signed_artifact_requests` | One row per applicant acceptance (`acceptanceId` unique), enqueued inside the acceptance transaction. `status` ∈ `pending` / `processing` / `completed` / `failed` / `dead_letter`, with `attempts`, `lastError`, `nextAttemptAt` and the produced `documentId`. |

**Key invariants**

- At most one `published` version per agreement — publish supersedes the prior published version in the same transaction.
- At most one "open" version (`draft` / `under_review` / `approved`) per agreement at a time.
- Published version content is immutable; edits require a new draft version.
- Acceptance rows are never updated or deleted; re-signs create a new row chained via `supersededByAcceptanceId`.

## Business Identity — Agreement Codes

`agreements.code` is the **canonical identity** of an agreement. UUIDs are internal implementation identifiers only — seeds, data migrations, Studio carrier steps, integrations, and documentation all reference agreements by code, which is what keeps content promotion and cross-environment addressing portable.

| Question | Answer | Enforcement |
| --- | --- | --- |
| Can a code be renamed? | No — codes are immutable after creation. | The metadata PATCH accepts only `name` / `description` / `status`; `code` is not patchable. |
| Can a code be reused by a different agreement? | No. | DB-level `UNIQUE` on `agreements.code`, and agreements can never be deleted, so a code is occupied forever. |
| Can an archived agreement be restored? | Yes. | `status` is patchable back to `active`; after restore, new versions and assignments work normally. |
| Can an agreement be deleted? | No — there is no delete operation. | `archived` is the only end-of-life state, and it preserves all versions, pins, and acceptances. |
| Code format | snake_case, validated. | `^[a-z0-9_]+$` regex on the insert schema. |

**Consequence for integrators:** a code, once created, permanently and unambiguously identifies one agreement across all environments and all time. Codes can go dormant (archived) and come back (restored), but they can never change meaning.

## Version Lifecycle & Governance

```
draft ──▶ under_review ──▶ approved ──▶ published ──▶ superseded
  ▲            │                            │
  └────────────┘                            └──▶ withdrawn
   (return to draft)
```

- **Publish** marks the previous published version `superseded` transactionally.
- **Return to draft** sends an `under_review` version back to `draft` for further editing — review rejection does not require abandoning the version.
- **Withdraw** takes a published version out of service without publishing a replacement; the agreement becomes unresolvable until a new version is published.
- **Archive** (agreement-level `status = archived`) blocks new assignments, hides the agreement from resolution for *new* pins, and blocks authoring new Agreement steps against its code. Existing pins and acceptances are untouched.

Every version transition writes an `agreement_version_events` row, and all staff mutations emit standard six-dimension audit records (e.g. `agreement.version_published`) through the platform audit-capture layer.

**Agreement Library write errors**

| Code | Status | When |
| --- | --- | --- |
| `HOST_DESIGNATION_REQUIRED` | 400 | Creating a `host_agreement`-type entry without `hostDesignation` (`trial` or `host_company`), or clearing the designation of an existing one |
| `HOST_DESIGNATION_NOT_ALLOWED` | 400 | `hostDesignation` sent for any other agreement type |
| `HOST_AGREEMENT_BRAND_SCOPE_CONFLICT` | 409 | Another active entry already holds the same designation in the same brand tier; raised on create, on re-designation and on restoring an archived entry (details `hostDesignation`, `brandId`, `conflictingCodes`) |
| `DUPLICATE_AGREEMENT_CODE` | 409 | The agreement `code` already exists |
| `DUPLICATE_AGREEMENT_TYPE` | 409 | `POST /api/agreement-library/types` derives a key that already exists, retired types included |
| `IMPACT_CONFIRMATION_REQUIRED` | 409 | Archiving an agreement, or withdrawing a version, whose impact (in-flight applications, active assignments, portal gating) is non-zero without `confirmImpact` set to true |
| `OPEN_VERSION_EXISTS` | 409 | Starting a new draft while a version is `draft`, `under_review` or `approved` (details `versionId`) |
| `AGREEMENT_ARCHIVED` | 409 | Starting a new draft on an archived agreement |
| `VERSION_NOT_EDITABLE` | 409 | Saving content to a version that is not `draft` |
| `INVALID_STATUS_TRANSITION` | 409 | A status move not allowed from the version's current status (details `from`, `to`) |
| `VERSION_CONTENT_EMPTY` | 409 | Submit-for-review, approve or publish of a version with no legal content |
| `WITHDRAW_BLOCKED_BY_FIXED_ASSIGNMENTS` | 409 | Withdrawing a version that is the fixed version of active assignments (details `blockingFixedAssignments`) |

## Targeting — Assignments, Contexts, Specificity

**Contexts**

- `portal_access` — gates applicant-portal sign-in. Outstanding agreements force a blocking signature modal before the portal is usable.
- `application_workflow` — resolves which agreement/version an already-placed Agreement workflow step serves. Assignments in this context never place steps.
- `host_portal_access` — gates host-portal sign-in. Server-side context; the admin create/list surface landed in QA 2026-07 (see below).

**Scope dimensions:** `brandId`, `programId`, `seasonId` (operational cycle), `workflowType`. An all-NULL scope is a mis-configuration — every assignment must be scoped. The `host_portal_access` context rejects `workflowType` scope — workflow type is an applicant-journey dimension and does not apply to host sign-in.

### Host Portal Sign-in Assignments

The Agreement Library's Portal Sign-in tab manages both sign-in contexts (QA, 2026-07 — Task #1942; frontend-only change — the `host_portal_access` context already existed server-side):

- A portal-type dropdown switches between **Applicant Portal** (`portal_access`, default, unchanged) and **Host Portal** (`host_portal_access`), with per-portal counts in the dropdown and a combined count on the tab badge.
- The host-portal create dialog offers brand, program, and cycle pickers plus an optional **Partner Organization** picker; it deliberately omits workflow-type scope (the server rejects it for the host context).
- Host lists carry host-specific titles, empty states, an "All host organizations" wildcard label, and a Partner Org scope badge (name lookup with raw-id fallback).
- The resolution preview is labeled "Applicant Portal Access" with an explicit note that Host Portal resolution is **not** simulated.

**Resolution algorithm** (`resolveAgreementsForContext`):

```
resolve(context, scope):
  assignments ← active assignments WHERE context matches
                AND every non-null scope dim matches the caller's scope
  assignments ← reject those whose agreement is archived
  group by agreement:
    winner ← assignment with the highest specificity
             (specificity = count of non-null scope dimensions)
             # equal-specificity overlap is impossible: blocked at activation,
             # and re-checked at resolve time (fails loudly, never guesses)
    version ← winner.versionPolicy == fixed
                ? winner.fixedVersionId           # must still be published
                : agreement's current published version
    if no published version → not resolvable
  return resolved (agreement, version, signature policy) list
```

A program+cycle assignment beats a brand-wide one. `assertNoEqualSpecificityConflict` rejects activating two assignments for the same agreement/context with equal specificity and overlapping scope — ties are impossible by construction.

**Version policy per assignment**

- `latest_effective` — the assignment always follows the agreement's *current* published version.
- `fixed` — the assignment pins a specific published version ID at assignment time. This exists because some cohorts must be legally frozen on specific terms regardless of what is published later — a pilot program that ran under negotiated terms, a government/partner contract that mandates exact language, or a legal freeze during a dispute. `fixed` holds one scope on a known version while `latest_effective` scopes move forward.

**Assignment lifecycle**

```
created (active) ⇄ deactivated        — activate/deactivate via update; scope dimensions
        │                                may also be retargeted via update (same assignment identity; existing pins and acceptances stay attached)
        └──▶ deleted                  — hard delete allowed ONLY when no runtime pins reference
                                        the assignment; otherwise 409 ("deactivate instead")
```

Deactivated assignments stop participating in resolution immediately; existing pins are untouched — the pin always wins. Operators can dry-run resolution for a hypothetical context via the resolution-preview endpoint before targeting goes live.

## Publish, Reassign & Archive Scenarios

**A new version is published**

- Workflow step, signed (or on a `fixed` assignment, or pinned by a governed cycle mapping): no change. The pin (and any signature) references the old version ID.
- Workflow step, opened but **unsigned**, on a `latest_effective` assignment in the default `assignment_version` mode: the pin advances to the new version on the next serve (under the application row lock, and only while no current acceptance exists). The signature block carries the served version ID and content hash; a page opened before the advance is rejected on submit with `409 AGREEMENT_VIEW_STALE` and the applicant must refresh, review the current text, and sign again.
- Workflow step, not yet opened: first open resolves to the new version (under `latest_effective`) and pins it.
- Portal sign-in: by default the agreement **re-surfaces and blocks portal access** until re-accepted, because acceptance is checked against the latest resolved version. Exception: if a signature policy exists whose `resignTriggers` **omit** `new_version_published`, the older acceptance remains valid and no re-sign is forced.

**A new assignment is created / scope changes**

- Applications with a signed, fixed-version, or cycle-mapped pin: unaffected — that pin always wins.
- Applications that have not opened the step: the new assignment participates in resolution at first open (most-specific wins).
- Portal sign-in: picks up the new assignment on the very next sign-in (live resolution).

**The agreement is archived**

- New assignments: blocked at the API. New Agreement steps: blocked — Studio authoring validation rejects archived codes, and the guided Step Library dialog hides archived agreements.
- Already-pinned applications: continue serving the pinned version — archiving never removes text from under an in-flight applicant.
- Not-yet-pinned applicants: resolution filters out archived agreements, so the step has nothing to serve (`409 AGREEMENT_NOT_RESOLVABLE`); ops must fix targeting or restore/replace the agreement.
- Existing acceptances: permanent and untouched.

## Serving Surfaces

**Agreement workflow step (carrier step)**

A Step Library step is an Agreement **carrier** when its `schemaJson` carries the marker `agreement.agreementCode`; the stored layout is a placeholder. At serve time the Hub resolves the code through assignments, pins the version, and replaces the placeholder with dynamic UI: content sections derived from the version's `bodyHtml` headings plus a signature block per the signature policy. Submission validates the signature (a typed name is normalized and matched against the applicant's legal first + last name) and that the version ID and content hash displayed on the page still match the version being served (`409 AGREEMENT_VIEW_STALE` otherwise), records the immutable acceptance, and advances step status.

> **Governance boundary**
>
> Creating a carrier step (Step Library "New Agreement Step" guided dialog) only creates the library entry. Placement into a workflow happens exclusively in [Workflow Studio](/docs/workflow-builder) (draft → publish → reconciliation, with active-applicant guards). No migration or seeder may place steps. Authoring validation rejects steps referencing codes that don't exist (or are archived) in the library; a step whose agreement has no published version fails to render with `409 AGREEMENT_NOT_RESOLVABLE`.

**Portal sign-in gate**

On portal session start, the [Applicant Portal](/docs/applicant-portal-endpoints) calls `GET /api/portal/required-agreements` for outstanding `portal_access` agreements in the applicant's resolved scope, and blocks access behind a signature modal until `POST /api/portal/agreement-acceptances` succeeds for each. That endpoint returns flat `error` and `message` bodies: `400 VALIDATION_ERROR` (missing `agreementId` or `versionId`), `400 TYPED_NAME_REQUIRED` (the resolved signature method is `typed_name` and no `typedName` was sent), `422 AGREEMENT_NOT_APPLICABLE` (the agreement is not in the applicant's resolved set) and `409 AGREEMENT_VERSION_STALE` (the submitted `versionId` is no longer the resolved one; the body carries `currentVersionId` and `currentVersionLabel`). A repeat acceptance of the current version returns `200` with `alreadyAccepted: true`. Re-sign behavior on new versions is policy-driven (see scenarios above).

`POST /api/portal/agreement-acceptances` answers with the portal's flat `error` / `message` body rather than the standard envelope: `400 VALIDATION_ERROR` when `agreementId` or `versionId` is missing; `422 AGREEMENT_NOT_APPLICABLE` when the agreement is not among those resolved for the signed-in account; `409 AGREEMENT_VERSION_STALE` (with `currentVersionId` and `currentVersionLabel`) when a newer version is now in effect; `400 TYPED_NAME_REQUIRED` when the outstanding version's signature policy needs a typed name and `typedName` was not sent. A new acceptance returns `201`; a repeat for an already-accepted version returns `200` with `alreadyAccepted: true`.

A workflow step that carries an agreement marker (for example `agreement_participant_terms_v1_0`, the Camp Counselor Participant Agreement) does not use `POST /api/portal/agreement-acceptances`. Its acceptance is recorded by the Hub inside the generic step submit (`POST /api/portal/steps/:stepKey/submit`): `processAgreementStepSubmission` re-resolves the version currently servable to the application, validates the typed full legal name against the applicant's canonical legal name server-side, and writes the immutable acceptance row. A name mismatch returns a 422 field-error map and a resolution or publication conflict returns 409. That endpoint is the door for the sign-in gate; for a workflow step the step submit is the only place acceptance is recorded, so the portal does not call it in addition.

**Portal sign-in flow**

```
Applicant signs in
  → resolve portal_access agreements (live, resolution algorithm above)
  → for each: current acceptance for the resolved version?
      yes → pass through
      no  → blocking signature modal
             → POST /api/portal/agreement-acceptances (evidence recorded)
  → portal usable once no agreement is outstanding
```

**Workflow step flow (first open → acceptance)**

```
Applicant (or staff) opens the Agreement step
  → pin exists for this application?
      yes → serve the pinned version
            (unsigned + latest_effective + default mode: advance to the
             newly published version first)
      no  → resolve via assignments → write pin to agreement_runtime_resolutions
  → step renders bodyHtml sections + signature block (per policy)
  → submission validated (typed name vs legal name where required;
    displayed version/hash must match the served version)
  → immutable acceptance row written; step advances
```

## Governed Re-Signing (Re-Sign Obligations)

Pin-once remains the default for *signed* `application_workflow` applications — a published new version never silently re-opens an application the applicant has already signed. Staff can instead impose a **targeted, audited override** via re-sign obligations (permission `agreement_library.resign_manage`):

- **Obligation lifecycle:** `pending → completed | cancelled` (DB CHECK). At most one *pending* obligation per (application, agreement, step key) — a partial unique index makes concurrent creates race-safe (unique-violation → conflict, never a duplicate).
- **Preview before impose:** a dry-run endpoint classifies every candidate application without writing: `eligible`, `alreadyCurrent` (pinned version already is the target and step unsigned), `duplicatePending`, `ineligible` (terminal application status), `unresolved` (no carrier step / no pin). Bulk create applies the same matrix and reports per-application results plus `failed` for row-level errors.
- **Serve-time override:** a pending obligation re-serves the carrier step with the obligation's **target version** and a re-sign notice section; otherwise the pin-once path is unchanged. The prior step state is snapshotted on the obligation so **cancel** restores the step exactly as it was.
- **Completion is transactional:** submission records the new acceptance (chained via `supersededByAcceptanceId`) and completes the obligation in a single DB transaction — an obligation can never be marked complete without its acceptance row, and vice versa.
- **Audit trail:** `agreement.resign_previewed`, `agreement.resign_required` (single), `agreement.resign_bulk_required`, `agreement.resign_completed`, `agreement.resign_cancelled`.
- **Error contract:** conflicts render `409` with `AGREEMENT_RESIGN_*` detail codes; validation failures render `400 VALIDATION_ERROR`.
- **Re-sign and manual-reset conflict codes** (all `409 CONFLICT` with `details.code`): `AGREEMENT_RESIGN_TARGET_INVALID` (preview or create: the agreement is archived, the target version does not belong to it, is not published, or has no HTML body); `AGREEMENT_RESIGN_ALREADY_PENDING` (single-application create when a pending obligation already exists) and `AGREEMENT_RESIGN_NOT_ELIGIBLE` (single-application create for any other non-eligible classification; the details carry the `classification`); `AGREEMENT_RESIGN_ALREADY_COMPLETED` and `AGREEMENT_RESIGN_ALREADY_CANCELLED` (cancelling an obligation that is no longer pending). `POST /api/agreement-acceptances/:acceptanceId/manual-reset` (`agreement_library.acceptance_manage`) returns `AGREEMENT_ACCEPTANCE_NOT_RESETTABLE` (not an applicant acceptance), `AGREEMENT_ACCEPTANCE_NOT_CURRENT` (already superseded or invalidated) and `AGREEMENT_MANUAL_RESET_NOT_ENABLED` (the resolved signature policy does not list `manual_reset` in `resignTriggers`). `PATCH /api/agreement-resolve-failures/:failureId` (`agreement_library.manage`) returns `RESOLVE_FAILURE_ALREADY_CLOSED` when the failure is no longer `active`.

## Cycle Mapping — Projections & Governed Mappings

Shipped Aug–Sep 2026 (Globus 30-day window ending Sep 15 2026). An `application_workflow` carrier assignment resolves a version through its own version policy and the pin rules above. Cycle mapping makes the per-operational-cycle picture visible and then governable, in two phases. Each carrier assignment has a `cycleResolution` mode:

- `assignment_version` (default, "legacy") — the carrier's own version policy and pin behavior described above, including the unsigned `latest_effective` advance.
- `cycle_mapping_required` (strict) — an existing runtime resolution always wins unchanged (publishing never moves it); only an application with no resolution yet is pinned, atomically, to the active governed mapping for its exact operational cycle (keyed by carrier assignment + season UUID — never by label). Pins created this way carry `cycle_mapping_id` provenance and are never advanced by the legacy path, even after a rollback to `assignment_version`.

Staff work in the **Workflow cycle mappings** tab of Agreement Assignments (a local tab on that page; the selected carrier is local state, not part of the URL). Both requests are enabled only while the tab is active: the cross-carrier diagnostics (`GET /api/agreement-assignments/cycle-versions/diagnostics`, every page fetched, 100 per page) and, for the selected carrier, its detail (`GET /api/agreement-assignments/:assignmentId/cycle-versions`). It shows the carrier's Legacy or Strict mode and a Ready or Blocked badge, per-cycle activation coverage, full UUIDs, counts and statuses, unassignable-failure blockers, placement context and the complete mapping history (supersedes / superseded-by lineage, pinned / accepted / unpinned impact and mapping-pin provenance). Access is permission-based: view-only staff (`agreement_library.view`) get the read-only experience; `agreement_library.manage` holders additionally get the governed create / supersede / retire / mode-change controls. The server remains authoritative — hiding a control never replaces authorization.

### Read-Only Cycle Projections & Diagnostics

Two staff read endpoints (permission `agreement_library.view`) project, per assignment, which agreement versions are **pinned** to in-flight applications in each cycle versus which version an **unpinned** application would resolve to today:

| Method | Path | Purpose |
| --- | --- | --- |
| GET | `/api/agreement-assignments/:assignmentId/cycle-versions` | Per-carrier read model for one `application_workflow` assignment: current `cycleResolution` mode, mapping lineage (with per-mapping `impact` `{ pinned, accepted, unpinned }` and provenance counts), every workflow placement reaching the carrier, exact-season cohorts (`reachableApplications`, `reachablePinned`, `reachableUnpinned`, `orphanedPinned`), grouped open cycle-resolution failures, and `candidateVersions`. |
| GET | `/api/agreement-assignments/cycle-versions/diagnostics` | Paginated cross-carrier diagnostics (one item per carrier assignment; `limit` 1–100, default 25; `offset`; optional UUID filters `assignmentId`, `programId`, `seasonId`): cohorts with active-mapping servability, placement statuses, open cycle failures, unassignable failures, `unassignableFailureCount`, and `activationCoverage`. |

Contract rules:

- **Read-only.** Neither endpoint writes pins, acceptances, or assignments; they are safe to call from dashboards and support tooling.
- **Reachable = pinned + unpinned.** For every cycle the projection's `reachableApplications` equals `reachablePinned` plus `reachableUnpinned`. Orphaned pins (a pin whose application no longer reaches the carrier) are reported separately and never folded into reach, and `accepted` is a subset of pinned. A diagnostics response that breaks this invariant is a defect, not a data condition.
- **Candidate versions.** `candidateVersions` on the per-carrier read lists every version owned by the carrier agreement (newest version number first) as safe metadata `{ versionId, versionLabel, versionStatus, servable, reason }`. `servable` is computed server-side by the same evaluator used when creating a mapping; `reason` is null exactly when servable (otherwise one of `version_not_found`, `version_agreement_mismatch`, `version_not_published`, `content_format_unsupported`, `no_visible_text`, `same_version`). Diagnostics does not carry this field.
- **Activation coverage.** Each diagnostics item carries `activationCoverage` — the same canonical per-cycle coverage rows the mode preview renders (required cycles keyed by season UUID, with placements, placement statuses, grouped failure counts, server-derived blockers/warnings, `blocksActivation`, active mapping id, mapped version id/label, servability, and reachable/pinned/accepted/unpinned/orphaned counts). Honors the `programId` / `seasonId` filters.
- **Privacy boundary.** The projection returns counts and version identifiers, never applicant PII, legal content, content hashes, fingerprints, or failure ids; the acceptance-evidence RBAC redaction rules still apply to any drill-down into individual acceptances.

### Governed Cycle Mappings & Assignment Mode

The second phase turns the projection into a governed write surface (permission `agreement_library.manage`; the previews are manage-gated too because they issue the confirmation token):

| Method | Path | Purpose |
| --- | --- | --- |
| GET | `/api/agreement-assignments/:assignmentId/cycle-resolution/preview?requestedMode=` | Dry-run of a mode change (`assignment_version` or `cycle_mapping_required`) — returns required exact-cycle coverage (placements, failure counts, blockers, warnings, `blocksActivation`), aggregate impact, the current `revision`, and a short-lived HMAC `confirmationFingerprint`. |
| PATCH | `/api/agreement-assignments/:assignmentId/cycle-resolution` | Apply the reviewed mode change. Body: `requestedMode`, `reason`, `expectedMode`, `expectedRevision`, `confirmationFingerprint`, `acknowledgedImpact`. |
| POST | `/api/agreement-assignments/:assignmentId/cycle-versions` | Create a governed mapping `{ seasonId, versionId, reason }` for an exact cycle UUID and a servable published HTML version (`201`). |
| GET | `/api/agreement-assignments/cycle-versions/:mappingId/supersede-preview?newVersionId=` | Read-only supersede preview: impact, reachable/orphaned pin split, placements, failure counts, deterministic `blockerCodes` / `warningCodes`, target verdict, `confirmationFingerprint`, and expiry. |
| POST | `/api/agreement-assignments/cycle-versions/:mappingId/supersede` | Replace a mapping with a new one (prior mapping retained as history). Body: `newVersionId`, `reason`, `confirmationFingerprint`. |
| POST | `/api/agreement-assignments/cycle-versions/:mappingId/retire` | Retire a mapping without replacement. Body: `reason`. |

- **Preview-then-apply with a fingerprint.** Both the mode change and the supersede require the HMAC fingerprint issued by their preview and are refused with `409` when the underlying state changed in between (`CYCLE_RESOLUTION_CONFIRMATION_STALE`, `CYCLE_MAPPING_CONFIRMATION_STALE`). The mode change additionally carries an optimistic `expectedRevision`/`expectedMode` and the exact `acknowledgedImpact`; the supersede has no separate impact acknowledgement — its fingerprint (bound to status, versions, served state, counts, the reachable/orphaned pin split, blockers, and lineage) is the sole confirmation authority. This is the same review-before-commit posture used by re-sign obligations and by imported-rule publication.
- **Strict activation fails closed.** Moving to `cycle_mapping_required` is refused while any required cycle lacks a mapping (`CYCLE_MAPPING_MISSING`), its mapped version is not servable, or open cycle-resolution failures exist (`CYCLE_OPEN_RESOLUTION_FAILURES`, plus unassignable failures). A cycle is required when it is open/active, or whenever any application on the carrier's workflow still reaches it without a pin — regardless of status. Each cycle's coverage row lists blockers, and `blocksActivation` is true when any exists: `CYCLE_MAPPING_MISSING` (no active mapping), `CYCLE_MAPPED_VERSION_NOT_SERVABLE` (a mapping exists but its version is not servable) and `CYCLE_OPEN_RESOLUTION_FAILURES`. Advisory warnings that never block on their own are `CYCLE_ORPHANED_PINS`, `CLOSED_CYCLE_UNPINNED_REACH`, `ARCHIVED_PLACEMENT_WITH_APPLICATIONS` and `DRAFT_PLACEMENT_PRESENT`. At serve time a mapped version that is missing, not `published` or `superseded`, or has no content hash, HTML body or visible text fails the pin with `409 CYCLE_MAPPED_VERSION_NOT_SERVABLE` (details `reason`). Rolling back to `assignment_version` uses the same preview and confirmation.
- **Retirement safeguards.** A mapping that has served an application or is referenced by any runtime resolution cannot be retired (supersede it instead), and under strict mode a cycle with reachable unpinned applications keeps its mapping (`409`, e.g. `CYCLE_MAPPING_REFERENCED`, `CYCLE_MAPPING_REQUIRED_COVERAGE`).
- **Mapping write conflicts.** Creating a mapping for a cycle that already has an active one returns `409 CYCLE_MAPPING_EXISTS` (supersede it instead). Supersede and retire act only on an `active` mapping (`409 CYCLE_MAPPING_NOT_ACTIVE`, details `mappingId`, `status`). Superseding with the version the mapping already points at returns `409 CYCLE_MAPPING_SAME_VERSION`. Retiring a mapping that has already served an application returns `409 CYCLE_MAPPING_SERVED` (details `mappingId`, `firstServedAt`); supersede it instead.
- **Version validation on create and supersede.** Creating a mapping and superseding one both refuse a target version that does not belong to the carrier's agreement with `400 VALIDATION_ERROR`, `details.code` `VERSION_AGREEMENT_MISMATCH` (`details.field` is `versionId`). A version that is not currently published is `409 VERSION_NOT_PUBLISHED`, a non-HTML version is `409 AGREEMENT_CONTENT_FORMAT_UNSUPPORTED`, and a version with no visible text is `409 CYCLE_MAPPED_VERSION_NOT_SERVABLE`.
- **Generic assignment PATCH no longer touches cycle resolution.** Sending `cycleResolution` fields to `PATCH /api/agreement-assignments/:assignmentId` is refused with `CYCLE_RESOLUTION_GOVERNED_ROUTE`; the governed route above is the only writer.
- **Refusals specific to the mode change.** `GET .../cycle-resolution/preview` and `PATCH .../cycle-resolution` on an assignment whose context is not `application_workflow` return `400 VALIDATION_ERROR` with `details.code` `ASSIGNMENT_NOT_WORKFLOW_CARRIER` (creating a cycle mapping is refused the same way). `PATCH` returns `409 CONFLICT` with `details.code` `CYCLE_RESOLUTION_STATE_CHANGED` (`expectedMode` or `expectedRevision` no longer match; the details carry `currentMode` and `revision`), `CYCLE_RESOLUTION_IMPACT_STALE` (`acknowledgedImpact` no longer equals current reach; the details carry the current `impact`), `CYCLE_RESOLUTION_MODE_UNCHANGED` (the requested mode is already active) or `CYCLE_RESOLUTION_CONCURRENT_UPDATE` (the revision-guarded update matched no row); preview again and resubmit after any of these. Moving to `cycle_mapping_required` is also refused with `409` `CYCLE_RESOLUTION_COVERAGE_MISSING` (a required cycle has no servable active mapping; the details list the cycles) or `CYCLE_RESOLUTION_OPEN_FAILURES` (the details carry `failureIds`). While an Agreement step is being served, `409 CONFLICT` `CYCLE_RESOLUTION_MODE_CHANGED` means the carrier's mode changed mid-request; the server re-resolves the carrier and retries once before returning it, after which the caller can simply retry.
- **Pinned applications are never rewritten.** A mapping changes what *unpinned* applications resolve to in a cycle. Existing pins stay verbatim; moving an in-flight application onto a new version remains the job of a re-sign obligation.
- **Audit.** Mapping create / supersede / retire and cycle-resolution applies are captured through the audit layer in the same transaction as the write (no mapping change commits without its audit row).

## Acceptance Evidence & Compliance

Each acceptance stores `versionId`, `contentHash` (proves exactly which text was shown), `acceptedAt`, server-observed `ipAddress` + `userAgent`, `sessionId`, and `typedName` when the policy requires a typed signature. Typed-name validation is server-side against the applicant's legal name (whitespace/diacritic-normalized). Acceptances are append-only; re-signs chain via `supersededByAcceptanceId`, preserving full history.

**Acceptance state machine**

```
(outstanding — no row yet)
        │  applicant signs
        ▼
     current ──────────────▶ superseded      re-sign occurred; new row chains back via
        │                                    supersededByAcceptanceId (+ supersededAt stamp)
        │  re-sign trigger fires
        ▼
    invalidated                              one-time, first-write-wins stamp
                                             (invalidatedAt / invalidationReasonCode /
                                              invalidatedByUserId); agreement re-surfaces
                                             for signing
```

`superseded` and `invalidated` are the **only** writes the engine ever applies to an existing acceptance row; every evidence field is preserved verbatim in both cases. `invalidationReasonCode` carries a re-sign-trigger reason code (e.g. `identity_changed`) — this is how policy-driven re-signature marks an old acceptance as no longer current without destroying its evidentiary value. The one other change is database-level: for host acceptances, deleting the accepting host user does not delete the evidence — `partner_user_id` is `ON DELETE SET NULL`, so the row keeps its organization, agreement/version, content hash, and timestamp while only the optional actor link is detached.

### Signed-Agreement PDF Artifacts

Shipped Sep 2026. Every **applicant** acceptance — portal sign-in gate or Agreement carrier step — also produces an immutable PDF document of the exact published text that was accepted, filed on the applicant's Documents tab and in the Applicant Portal:

- **Document type** `signed-agreement`. The PDF is stamped with the agreement and version, the content fingerprint (`contentHash`), acceptance time and context, the signature method (typed name or checkbox), and the applicant's name, reference and email. IP address and user agent are deliberately **excluded** from the PDF; they remain on the evidence row only.
- **Immutable across every mutation path.** The type cannot be deleted, updated, replaced by upsert, or manually uploaded: ordinary document deletion returns `SIGNED_AGREEMENT_NOT_DELETABLE` and an upload of the type code is refused up front with `SIGNED_AGREEMENT_NOT_UPLOADABLE`. A re-sign adds a second signed-agreement document; both remain visible.
- **Generation is asynchronous and retried, never dropped.** A background worker renders the PDF after the acceptance commits and retries with increasing delays on transient failure (e.g. storage unavailable); after five attempts, or on a terminal data/configuration defect, the request is dead-lettered. Every request, retry, duplicate suppression and terminal failure is audited under `signed_agreement_artifact.*`, so a missing PDF is always explainable from the audit log.
- **Approved single-request recovery of dead-lettered artifacts.** `GET /api/agreement-acceptances/:acceptanceId/artifact` (`agreement_library.acceptance_view`) returns the artifact request's state, attempts, parsed last error, an eligibility verdict, the audit timeline, and whether the caller may retry (`canRetry`). `POST /api/admin/agreement-artifacts/:requestId/retry` with a `reason` (10–1000 characters) requeues exactly one `dead_letter` request; it requires a human staff session (API keys are refused) and the dedicated restricted permission `agreement_library.artifact_retry`. Under a row lock the server re-validates the worker's own source-integrity preconditions (acceptance exists, the exact accepted version is renderable, the content-hash binding is intact, an application exists to attach to, the document type exists); only status and scheduling fields change — `attempts` is never reset, so a retry that fails again returns to dead letter, and nothing on acceptances, versions, assignments, or existing documents is touched. Success is `202` (`QUEUED`); anything else is a stable non-2xx envelope (`ARTIFACT_NOT_DEAD_LETTERED`, `ARTIFACT_ALREADY_COMPLETED`, `ARTIFACT_PROCESSING`, `ARTIFACT_RETRY_CONFLICT` for a concurrent change, `ARTIFACT_RETRY_INELIGIBLE`). If a canonical document already exists the worker's duplicate suppression reconciles the request without rendering a second PDF. Audited as `signed_agreement_artifact.manual_retry_requested` followed by `manual_retry_queued` or `manual_retry_rejected`.
- **Eligibility and last-error codes.** The eligibility `code` on the artifact read is one of `ELIGIBLE`, `ELIGIBLE_EXISTING_DOCUMENT` (dead-lettered but a canonical document already exists; a requeue reconciles it without rendering a second PDF), `NOT_DEAD_LETTERED`, `ALREADY_COMPLETED`, `PROCESSING`, `ACCEPTANCE_NOT_FOUND`, `VERSION_MISMATCH`, `VERSION_NOT_RENDERABLE`, `CONTENT_HASH_MISMATCH`, `DOCUMENT_TYPE_MISSING` or `NO_APPLICATION` (the applicant has no application to attach the document to). The worker stores each failure as `<CODE>: <message>` in the request's last error; a failure that is not a terminal data or configuration defect is recorded as `PROCESSING_ERROR`, retried with backoff, and dead-lettered once the attempt limit is reached.
- **Host acceptances are excluded.** Host-organization operating-agreement acceptances by partner users appear in Acceptance Audit but do not generate a signed-agreement document; their evidence contract is the dual evidence-row + `operating_agreement.accept` audit entry described below.
- **Portal grouping and signing date.** On the portal document lists (`GET /api/portal/applications/:appId/documents` and `.../documents-by-step`) every signed-agreement row carries `official: true` and a `createdAt` equal to the acceptance's `accepted_at` (the moment the applicant signed, resolved through the document's permanent link to its acceptance; `null` if unlinked). All other document types report `official: false` and their upload time. Two signed versions therefore show as two official rows, each with its own signing date. Signed-agreement rows also carry `displayName: "Signed Agreement"` and `presentationGroup: "official-program-documents"` (a DS-2019 shares that presentation group but stays `official: false`).
- **Participant portal (PPPToo) precedence.** When the participant portal's document list contains one or more canonical signed-agreement rows, those rows are the legal record: the portal does not append its synthetic on-demand terms certificate (`terms-acceptance`), and each signed version stays a separate official row. The synthetic certificate remains only as a fallback for applications with no canonical signed-agreement document.

### Evidence Ledger vs Audit Trail

An acceptance produces **two distinct, linked records** — do not conflate them:

| | Evidence row (`agreement_acceptances`) | Audit trail entry (platform audit log) |
| --- | --- | --- |
| Purpose | Forensic proof of *what* was accepted: `versionId`, `contentHash`, `acceptedAt`, IP, user agent, `typedName` | Operational record of *who did what when*: actor, action, timestamp, entity, payload — queryable from the Audit Console |
| Mutability | Immutable; only `superseded` / `invalidated` stamps are ever applied by the engine (and the host-actor link is detached if that host user is deleted) | Append-only audit log entry |
| Written by | The acceptance transaction | The platform audit-capture layer (`recordAudit()`), in the same request |

The canonical example is the **host portal operating-agreement acceptance**: the acceptance write records the evidence row *and* emits an `operating_agreement.accept` audit entry carrying the acceptance timestamp, the accepting host user's identity (resolved from the required `X-Host-User-Email` attribution header), and — for library-issued agreements — the linked library agreement code and accepted version. Acceptance writes without valid user attribution are rejected before either record is created. See [Host Portal Governance → Acceptance Audit Contract](/docs/host-governance) for the full contract.

Re-signs follow the same dual-record rule: after a new library version is published, a re-acceptance writes a **new** evidence row chained to the superseded acceptance via `supersededByAcceptanceId` *and* emits its own audit entry with the re-signing user's attribution and timestamp — the superseded acceptance's evidence and audit records are never rewritten.

## RBAC & Audit

| Permission | Grants |
| --- | --- |
| `agreement_library.view` | Read catalog + assignments |
| `agreement_library.manage` | Create/edit agreements, drafts, assignments |
| `agreement_library.publish` | Approve and publish versions |
| `agreement_library.withdraw` | Withdraw published versions |
| `agreement_library.resign_manage` | Preview, impose, track, and cancel re-sign obligations |
| `agreement_library.acceptance_view` | View acceptance audit trail |
| `agreement_library.acceptance_manage` | View unredacted evidence (IP / session) |
| `agreement_library.artifact_retry` | Restricted: requeue one dead-lettered signed-agreement artifact request (human staff session only; no API keys) |

Staff visibility of raw evidence (IP/session) is gated behind `agreement_library.acceptance_manage`; the standard acceptance view is redacted. See [Roles & Governance](/docs/rbac) for the platform permission model.

## API Surface

**Agreement Library (staff)**

| Method | Path | Purpose |
| --- | --- | --- |
| GET | `/api/agreement-library` | List agreements |
| POST | `/api/agreement-library` | Create agreement |
| GET | `/api/agreement-library/:agreementId` | Agreement detail + versions |
| PATCH | `/api/agreement-library/:agreementId` | Update metadata / archive |
| POST | `/api/agreement-library/:agreementId/versions` | Start new draft version |
| PATCH | `/api/agreement-library/versions/:versionId/content` | Save draft HTML |
| POST | `/api/agreement-library/versions/:versionId/submit-for-review` | draft → under_review |
| POST | `/api/agreement-library/versions/:versionId/return-to-draft` | under_review → draft (send back for edits) |
| POST | `/api/agreement-library/versions/:versionId/approve` | under_review → approved |
| POST | `/api/agreement-library/versions/:versionId/publish` | approved → published (supersedes prior) |
| POST | `/api/agreement-library/versions/:versionId/withdraw` | Withdraw version |
| GET | `/api/agreement-library/:agreementId/archive-impact` | Read-only archive impact preview (`agreement_library.view`): unpinned in-flight applications, affected active assignments split fixed vs latest-effective, and whether portal gating currently resolves the agreement. Archiving through `PATCH` with non-zero impact is `409` `IMPACT_CONFIRMATION_REQUIRED` (impact in the details) unless `confirmImpact` is sent |
| GET | `/api/agreement-library/versions/:versionId/withdraw-impact` | Read-only withdraw impact preview (`agreement_library.view`): same payload plus `blockingFixedAssignments`. Withdraw is `409` `WITHDRAW_BLOCKED_BY_FIXED_ASSIGNMENTS` while an active assignment pins the version as its fixed version, otherwise `409` `IMPACT_CONFIRMATION_REQUIRED` for non-zero impact unless `confirmImpact` is sent |

**Assignments, Policies, Acceptances (staff)**

| Method | Path | Purpose |
| --- | --- | --- |
| GET | `/api/agreement-assignments` | List targeting rules |
| POST | `/api/agreement-assignments` | Create assignment |
| PATCH | `/api/agreement-assignments/:assignmentId` | Update version policy / activate / deactivate (scope dims can be retargeted) |
| DELETE | `/api/agreement-assignments/:assignmentId` | Remove assignment (409 if runtime pins reference it) |
| PUT | `/api/agreement-signature-policies` | Upsert signature / re-sign policy |
| GET | `/api/agreement-assignments/resolution-preview` | Dry-run scope resolution |
| GET | `/api/agreement-acceptances` | Query acceptance audit trail |
| GET | `/api/agreement-acceptances/:acceptanceId` | Single acceptance detail (evidence redacted per RBAC) |
| GET | `/api/agreement-acceptances/:acceptanceId/artifact` | Signed-agreement artifact request state, eligibility, audit timeline, `canRetry` (`agreement_library.acceptance_view`) |
| POST | `/api/admin/agreement-artifacts/:requestId/retry` | Requeue one dead-lettered artifact request; `reason` required; `202 QUEUED` (`agreement_library.artifact_retry`, human session only) |
| GET | `/api/agreement-assignments/:assignmentId/cycle-versions` | Read-only per-carrier cycle read model incl. `candidateVersions` (`agreement_library.view`) |
| GET | `/api/agreement-assignments/cycle-versions/diagnostics` | Paginated cross-carrier cycle diagnostics incl. `activationCoverage` (`agreement_library.view`) |
| GET | `/api/agreement-assignments/:assignmentId/cycle-resolution/preview` | Fingerprinted dry-run of a cycle-resolution mode change (`agreement_library.manage`) |
| PATCH | `/api/agreement-assignments/:assignmentId/cycle-resolution` | Apply a previewed mode change; 409 on stale fingerprint |
| POST | `/api/agreement-assignments/:assignmentId/cycle-versions` | Create a governed cycle mapping |
| GET | `/api/agreement-assignments/cycle-versions/:mappingId/supersede-preview` | Fingerprinted supersede preview (`newVersionId` query) |
| POST | `/api/agreement-assignments/cycle-versions/:mappingId/supersede` (+ `/retire`) | Supersede (fingerprint required) or retire a governed mapping |

**Re-sign obligations (staff — all require `agreement_library.resign_manage`)**

| Method | Path | Purpose |
| --- | --- | --- |
| POST | `/api/agreement-resign-obligations/preview` | Dry-run eligibility matrix (no writes) |
| POST | `/api/agreement-resign-obligations` | Impose obligation(s) — single or bulk |
| GET | `/api/agreement-resign-obligations` | List obligations (filterable) |
| GET | `/api/agreement-resign-obligations/:obligationId` | Obligation detail |
| POST | `/api/agreement-resign-obligations/:obligationId/cancel` | Cancel pending obligation + restore prior step state |

**Applicant portal (the only two endpoints the portal consumes)**

| Method | Path | Purpose |
| --- | --- | --- |
| GET | `/api/portal/required-agreements` | Outstanding `portal_access` agreements |
| POST | `/api/portal/agreement-acceptances` | Record acceptance |

Back-office UI surfaces on the Hub: Agreement Library (catalog + version lifecycle), Agreement Assignments (scope targeting, with an amber warning + deep-link CTA when an `application_workflow` assignment exists but no carrier step serves its code, and the **Workflow cycle mappings** tab for governed cycle mappings), Acceptance Audit (forensic evidence view), the Step Library "New Agreement Step" guided dialog (agreement picker, generated `agreement_<code>` step key, collision guard), and the re-sign governance panel (preview → impose → track → cancel) surfaced from the Agreement Library.

## Failure & Edge-Condition Matrix

| Condition | Surface | Result |
| --- | --- | --- |
| No matching assignment for the context/scope | Workflow step | `409 AGREEMENT_NOT_RESOLVABLE` — step cannot render |
| No matching assignment | Portal | Agreement simply isn't required (no gate) |
| Agreement archived, no pin yet | Both | Filtered out of resolution → workflow step `AGREEMENT_NOT_RESOLVABLE`; portal drops the requirement |
| Agreement archived, pin exists | Workflow step | Pinned version keeps serving — archive never pulls text from an in-flight applicant |
| No published version (withdrawn / never published) | Both | Not resolvable — `409 AGREEMENT_NOT_RESOLVABLE` on the step |
| Create an assignment for an archived agreement | Assignment create | `409 AGREEMENT_ARCHIVED` |
| Create an assignment for an agreement with no published version | Assignment create | `409 AGREEMENT_NOT_PUBLISHED` — publish a version first |
| Pin an agreement that resolves to no published version | Pin write | `409 NO_PUBLISHED_VERSION` (details `agreementId`) |
| Two assignments, equal specificity, overlapping scope | Activation time | Rejected — cannot be created: `409 ASSIGNMENT_SCOPE_CONFLICT` (details `conflictingAssignmentId`, `specificity`) |
| An assignment with the exact same scope already exists for the agreement, even if deactivated | Assignment write | `409 DUPLICATE_ASSIGNMENT_SCOPE` (details `conflictingAssignmentId`, `conflictingAssignmentStatus`, `conflictingAssignmentContext`) — edit that row instead |
| Any scope (brand, program, cycle, workflow type or partner org) set on an `application_workflow` assignment | Assignment write | `400 WORKFLOW_ASSIGNMENT_SCOPE_NOT_ALLOWED` — placement in Workflow Studio decides reach |
| `partnerOrgId` scope on a context other than `host_portal_access` | Assignment write | `400 PARTNER_ORG_SCOPE_NOT_ALLOWED` |
| `workflowType` scope on a `host_portal_access` assignment | Assignment write | `400 WORKFLOW_TYPE_SCOPE_NOT_ALLOWED` |
| Equal-specificity tie detected at resolve time (data drift) | Resolve time | Fails loudly ("deactivate or narrow one") — never guesses a winner |
| Typed-name signature mismatch vs legal name | Submission | Validation error; acceptance not recorded |
| Pinned version renders no text when the carrier step is served | Workflow step | `409 CONFLICT`, `details.code` `AGREEMENT_CONTENT_EMPTY` — a blank step is refused rather than served |
| Application has no linked applicant when the acceptance is recorded | Workflow step submission | `409 CONFLICT`, `details.code` `APPLICANT_REQUIRED`; acceptance not recorded |
| Page opened before an unsigned pin advanced to a newer version (or version changed during signing) | Submission | `409 AGREEMENT_VIEW_STALE` — acceptance not recorded; applicant refreshes and signs the current version |
| Assignment deleted while pins reference it | Delete API | `409` — must deactivate instead |
| Concurrent write did not converge (acceptance, assignment, signature policy, runtime pin, draft, version transition or publish) | Agreement writes | `409 CONFLICT` with `details.code` `ACCEPTANCE_WRITE_CONFLICT`, `ASSIGNMENT_WRITE_CONFLICT`, `SIGNATURE_POLICY_WRITE_CONFLICT`, `PIN_WRITE_CONFLICT`, `DRAFT_WRITE_CONFLICT` or `VERSION_WRITE_CONFLICT`; the message asks the caller to reload and retry |

## Ecosystem & Integration Notes

- **Addressing:** external integrations and seeds reference agreements by business `code`, never UUID — codes are stable across environments.
- **Content promotion:** UI-published agreement HTML is environment data. The seeder only creates fresh-DB *drafts*; promoting published legal text to QA/production requires an explicit full-snapshot content migration with a never-stomp-published guard. Published content never silently travels with code deploys.
- **Portal boundary:** the Applicant Portal consumes only the two `/api/portal/*` endpoints above; all resolution, policy, and evidence logic stays in the Hub. The portal never decides versions. See the [Integration Contract](/docs/integration-contract).
- **Error contract:** `409 AGREEMENT_NOT_RESOLVABLE` (no published version / archived at resolution time); assignment-time validation rejects archived agreements and equal-specificity scope conflicts.
- **Eventing:** version lifecycle transitions are captured in `agreement_version_events` and the platform audit log; staff mutations flow through the standard audit-capture middleware.
- **Brand legal policies are separate from the Agreement Library.** The brand-skinned portal's `disclaimer` and `privacy_policy` pages live in `brand_legal_policies` (unique per brand and `policyType`; `body` mirrors the live published text and `currentVersionId` points at the live version), with version history in `brand_legal_policy_versions` (`versionNumber`, `status` ∈ `draft` / `published` / `archived`). Staff routes under `/api/brands/:idOrSlug/legal-policies`: list the policies, and `GET /:policyType/versions` (`programs.manage` or `brand.legal.view`); `PUT /:policyType` saves a draft, `POST /:policyType/publish` promotes it and `POST /:policyType/restore` clones a historical version into a new draft (all `brand.legal.manage`). Only publish changes the live text.

## Host Agreement Library Linkage

Staff-created **host agreements** (the `partner_agreements` rows documented on [Partner Organizations → Partner Agreements](/docs/partner-organizations)) reference a published Agreement Library entry at creation time. The legal content of a new host agreement is a selected `agreements` / `agreement_versions` pair — not free-form legal text typed into `partner_agreements.terms`.

**Creation-time stamping (current contract)**

- `engine_agreement_code` is set on the host agreement row **at creation time**, pointing at the selected library agreement's business `code`. There is no window in which a new host agreement exists without its library linkage.
- Server-side validation on create enforces: the selected agreement must have a **published** version (draft / under_review / approved / withdrawn-only agreements are rejected), the agreement must be active and of type `host_agreement`, and its brand scope must cover the host org's brand. Library entries also carry a host designation (`trial` or `host_company`); the linked entry's designation must match the agreement type (trial agreements link a trial-designated entry, operating agreements a host-company-designated one — rejected with `ENGINE_AGREEMENT_WRONG_DESIGNATION` / `ENGINE_AGREEMENT_TYPE_CODE_MISMATCH`). Violations reject the create — there is no fallback to free-text terms.
- **Create and update error codes.** Both are 400 `VALIDATION_ERROR` responses carrying `details.code`. `ENGINE_AGREEMENT_REQUIRED`: on `POST /api/admin/partners/organizations/:orgId/agreements` (permission partners.agreements.manage) a host-typed organization submitted a non-trial agreement without `engineAgreementCode` ("Operating host agreements must select a published Host Company Agreement"; trial agreements have the trial library code filled in server-side), and `PATCH /api/admin/partners/agreements/:id` returns it for an existing operating host agreement that has no linked library code. `LEGACY_TERMS_NOT_ALLOWED`: the request `terms` contains any retired free-text key (`termsText`, `specialConditions`, `complianceRequirements` or `governanceNotes`), on create, PATCH and `POST /api/admin/partners/agreements/:id/upgrade`; `details.legacyKeys` lists the keys.
- **Designation and scope errors.** `GET /api/admin/partners/host-agreement-library` (picker feed; API key scope `partners.read`) returns `400 VALIDATION_ERROR` with `details.code` `INVALID_HOST_AGREEMENT_DESIGNATION` when the optional `designation` query is anything other than `trial` or `host_company`. `PATCH /api/admin/partners/agreements/:id` returns `422 AGREEMENT_SCOPE_REQUIRED` (a top-level `error.code`, with the effective scope in `details`) when the patch would leave the agreement with no brand, program or season.
- **Link and selection errors.** Rejections are `400 VALIDATION_ERROR` with a `details.code`: `ENGINE_AGREEMENT_NOT_FOUND` (unknown library code), `ENGINE_AGREEMENT_WRONG_TYPE` (not a `host_agreement` entry), `ENGINE_AGREEMENT_ARCHIVED`, `ENGINE_AGREEMENT_BRAND_MISMATCH` (the entry is scoped to a different brand than the agreement), `ENGINE_AGREEMENT_NOT_PUBLISHED` (no published version) and `ENGINE_AGREEMENT_REQUIRED` (no library code supplied for an employer organization agreement, including cloning an agreement that has no library linkage). When the entry is chosen by designation, a brand-scoped entry beats a global one and exactly one must exist in the winning tier: no candidate in either tier gives `HOST_AGREEMENT_LIBRARY_ENTRY_MISSING`, more than one gives `HOST_AGREEMENT_LIBRARY_AMBIGUOUS` (`details.candidateCodes`).
- **Readiness check.** `GET /api/admin/partners/organizations/:orgId/agreement-library-readiness` (permission `partners.view`, then the per-record organization scope clamp; `403 FORBIDDEN` outside scope) reports the host's resolved brand (`brandId`, `brandName`, `brandSource`), the entry each designation would serve (`resolved`) and named `gaps`. Gaps are returned as data, never as an error status: `HOST_BRAND_UNRESOLVED` (no programme brand and no organization brand, or programmes spanning several brands, so the global entry applies), `HOST_AGREEMENT_LIBRARY_ENTRY_MISSING` (no published entry for that designation), `HOST_AGREEMENT_LIBRARY_AMBIGUOUS` and `HOST_ORGANIZATION_NOT_FOUND`.
- **Type vocabulary.** For a host (employer) organization, the staff create (`POST /api/admin/partners/organizations/:orgId/agreements`) and update (`PATCH /api/admin/partners/agreements/:id`) routes, both gated by `partners.agreements.manage`, accept only `trial` or `operating` as the agreement type; anything else is `400 VALIDATION_ERROR` with `details.code` `INVALID_HOST_AGREEMENT_LIFECYCLE` and `details.allowedTypes`.
- Gating, acceptance evidence, and signed-PDF rendering for these agreements all resolve to the linked library version's `bodyHtml` (see [Host Portal Governance](/docs/host-governance) for the portal-side behavior).

**Legacy paths (retired; pre-existing rows only)**

- **Acceptance-time stamping** — older host agreements had `engine_agreement_code` stamped only when the host first accepted through the portal. This path no longer applies to newly created agreements; it is retained solely to describe how pre-existing rows acquired their codes.
- **`host_agreement_migrated_*` backfill** — pre-existing free-text host agreements were once backfilled into the library under generated `host_agreement_migrated_*` codes. These codes are now vestigial: the migrated catalog entries were deleted and their rows repointed or cleared, nothing mints new ones, and the prefix is only checked as defense in depth. Every active host-organization agreement that lacked a valid published library linkage matching its type was terminated by the Task #2222 data migrations (audit-logged; a fresh library-linked agreement must be issued), and boot-time reports list any active row still awaiting repoint.
- **Free-text terms are retired.** Host agreements are Agreement Library-only: the legacy `terms` keys (`termsText`, `specialConditions`, `complianceRequirements`, `governanceNotes`) were hard-deleted from every row, the API no longer accepts them, and the signed-PDF renderer serves only library version content (an explicit "no library content" note when nothing resolves).

**Brand scope drives host email branding (required contract)**

The linked library agreement's brand scope does double duty: beyond gating create-time validation, it is the **single authoritative input for host-facing email branding**. Every host-facing email Globus enqueues for a host company must resolve its brand from the host's applicable agreement's library brand scope — a host with an applicable Wild Packs-scoped `host_agreement` receives all host emails under Wild Packs branding. Grandfathered free-text rows and `host_agreement_migrated_*` rows without a brand scope resolve to the existing recorded brand or the default template — never to Wild Packs by guesswork. The normative resolution rule (precedence, inheritance, renewal timing, conflict fail-closed behavior, and audit fields) lives at [Notification Model → Agreement-Derived Brand Resolution (Wild Packs)](/docs/notification-model#host-brand-resolution).

### Host Agreement Signature Lifecycle & Portal Signing

Host agreements carry an enforced signature lifecycle on top of the library linkage (shipped Aug 10–11 2026, Host Testing Fixes Batches 8 & 9):

- **One-click activate-and-send** — creating a new host agreement activates it and sends it for signature in a single action; there is no intermediate `draft` status, no separate Activate step, and no separate Send step. The agreement lands `active` + `sent` the moment staff confirm creation. An agreement that was never sent cannot be accepted.
- **Derived signature state** — staff endpoints and the host agreements tab expose a derived signature state: `not sent` / `awaiting signature` / `signed`. The host portal prompts only for **active, sent** agreements.
- **Per-send acceptance matching** — when staff send a new agreement, the host signing modal reliably reappears: acceptance matching is per-send, so signatures recorded before the latest send no longer satisfy it, and signed status is forced false while the gate is blocking.
- **Send stamps and the no-signer warning** — every send (the create-time send and `POST /api/admin/partners/agreements/:id/send-signature`) stamps `last_sent_at` and, if not already set, the `acceptance_required_at` marker the host-portal acceptance gate keys off, and returns `noActivatedSigner: true` when the host organization has no active portal user who has accepted their invite. The send still goes out; the staff UI shows a non-blocking "No one can sign yet" warning asking staff to invite or re-invite a host user. A portable migration backfilled `acceptance_required_at = last_sent_at` on previously sent, unaccepted draft/active agreements so that agreements sent by older builds became visible to the gate.
- **Multiple active agreements** — a host may hold any number of active agreements, including several operating agreements for the same program (ADR 0005; the single-active partial unique indexes were dropped and a non-unique active lookup index remains). Create, edit, activate, and renew no longer reject an existing active agreement. Consumers that need one governing agreement (DS-2019 issuance, host access) pick the matching active agreement with the latest activation stamp, breaking ties by start time, creation time, then id, all descending. Activating an operating agreement still supersedes overlapping active trial agreements — an explicit upgrade-flow side effect, not a general invariant.
- **Full legal content in the portal summary** — the host-portal agreements summary returns the full Agreement Library legal content per linked agreement (name, version label, effective date, format, and the published HTML body), with graceful nulls for unlinked rows.
- **Unavailable legal-content states.** The operating-upgrade `legalContent` object (returned by `POST /api/partner/host/operating-agreement/upgrade` and, as `pendingOperatingLegalContent`, by the status read) is either `available` with the published copy or `{ status: unavailable, code, message }`. The `code` is `OPERATING_LEGAL_CONTENT_UNLINKED` (the pending operating draft carries no library agreement code), the library resolution's own `details.code` when resolution failed (for example `ENGINE_AGREEMENT_NOT_PUBLISHED` or `HOST_AGREEMENT_LIBRARY_ENTRY_MISSING`), `OPERATING_LEGAL_CONTENT_EMPTY` (the published Host Company version has no servable body HTML) or `OPERATING_LEGAL_CONTENT_UNRESOLVED` (resolution failed without a code). Portals must not fall back to the trial agreement's text.
- **No modal without legal text** — a real-database end-to-end suite guarantees the host-portal sign modal never appears without complete published legal text across the create → send → sign lifecycle, including the corrupted-content case.
- **Send and activation refusals.** `POST /api/admin/partners/agreements/:id/send-signature` (and the create-time send) refuses with `409` when the agreement is already signed, `409 AGREEMENT_NOT_ACTIVE` when its status is not `active`, `400 AGREEMENT_LIBRARY_LINK_REQUIRED` when it has no library link (or only a `host_agreement_migrated_*` code), then runs the linked-entry checks from the linkage section, `429 SIGNATURE_SEND_RATE_LIMITED` when the last send was under 60 seconds ago (`details.lastSentAt`), and `400 HOST_EMAIL_MISSING` when the host organization has no valid contact email. Trial activation for signing returns `400 NOT_A_TRIAL_AGREEMENT` for other agreement types and `400 TRIAL_AGREEMENT_SIGNING_PREREQ_FAILED` with `details.reason` `AGREEMENT_LIBRARY_LINK_REQUIRED` or `HOST_EMAIL_MISSING`. The Host Portal operating-agreement upgrade activation returns `400 NOT_OPERATING_AGREEMENT`, `409 AGREEMENT_ALREADY_ACCEPTED`, or `400 OPERATING_UPGRADE_SIGNING_PREREQ_FAILED` with `details.reason` `AGREEMENT_LIBRARY_LINK_REQUIRED`, `OPERATING_LEGAL_CONTENT_EMPTY` or `HOST_EMAIL_MISSING`; these prerequisites are checked before the agreement is activated.

See [Host Portal Governance → Operating Agreement — Portal Acceptance](/docs/host-governance) for the portal-side acceptance and audit contract.

## Deferred Payment Plan Agreement (Draft)

> **Draft — trigger scope to be determined**
>
> The agreement itself is being authored now; how it enters an applicant's workflow is deliberately undecided. Do not wire any Workflow Studio step or rule for this agreement until that scope is settled.

Alliance Abroad offers participants a **Deferred Payment Plan**: instead of paying the full program fee up front, the participant pays a down-payment before the program and equal monthly installments during it. Enrollment is governed by a legally binding **Deferred Payment Plan Agreement**, which will live in the Agreements Library as a first-class versioned agreement.

**Source-document shape** (reference values from the current legal draft; commercial terms are parameterized per participant/season, not hardcoded):

| Term | Reference value |
| --- | --- |
| Total Program Fee | $3,000 USD |
| Down-payment | $750 USD |
| Deferred Program Fee | $2,250 USD |
| Installments | $225 USD × 10 months, due the 15th |
| Payment start | Second month on program |
| Payment methods | Recurring ACH/wire from a U.S. bank account (established within 7 days of arrival) or recurring card payment |

The agreement carries seven sections — Payment Plan Purpose, General Obligations, Representations & Warranties, My Payment Plan, Parties (Alliance Abroad Group, Inc. + Alliance Strategies Ltd. as OpCo), OFAC Warranty, Binding Arbitration (AAA rules, Texas law), and a Final Acknowledgment requiring **per-section initials plus a signature** — completion requires every initial and the signature.

**Delivery status.** Work is tracked on the Asana board `P31: Deferred Payment Plan` (Globus Phase 2 portfolio). The first ticket covers authoring the agreement in the Agreements Library only. Explicitly deferred, to be scoped later:

- The Workflow Studio trigger — which workflow type, step placement, and the eligibility criteria for who is offered the plan.
- Payment-collection integration (recurring ACH/card capture and missed-payment handling — the source document ties missed payments to program termination).

## Open Items

- All four `resignTriggers` are enforced at runtime: `new_version_published` (portal re-gates on next sign-in unless the policy omits it), `identity_changed` (an applicant legal-name/identity change invalidates matching acceptances, stamping `invalidationReasonCode`), `program_changed` (fires when an application's programId actually changes and invalidates that application's current acceptances) and `manual_reset` (a staff action, `POST /api/agreement-acceptances/:acceptanceId/manual-reset`, that invalidates one current acceptance).
- In-application re-signing is implemented as staff-governed, per-application re-sign obligations — a targeted, audited override. Pin-once remains the default for signed `application_workflow` applications; there is no automatic policy-driven re-open of signed applications on version publish, and any such automation would be a deliberate follow-up.
