> ## Documentation Index
> Fetch the complete documentation index at: https://docs.soundchecklive.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Forms as a channel

> The target model for Forms as the one way an organization sends a questionnaire, feedback request, agreement or document to clients, contacts, vendors, contractors and crew: parties, delivery routing, the shared status model and the signing rule, with the current state it starts from.

Forms are the one primitive for asking a person outside the team for something: answers, a signature, an approval. This page is the engineering contract for the **Forms as a channel** project (Linear P-ENG-115). It describes where the code is today, the model it moves to, and the rules every ticket in the project keeps.

## Documents, forms and agreements

The primitive is a **document**: content blocks (text, questions, a file, a signature) with numbered revisions. A form and an agreement are two kinds of document, not one inside the other. The tables keep their `form_*` names.

| Term | What it is | Table |
| - | - | - |
| document | the primitive: blocks and revisions | `form_definitions`, `form_revisions` |
| request | one document revision sent to one party, with an optional gig | `form_assignments` |
| response | the party's answers or signature on a request | `form_submissions` |
| form | a reusable document that collects answers and never a signature (`kind = 'FORM'`) | `form_definitions` |
| agreement | a one-off document with one revision that collects a signature (`kind = 'AGREEMENT'`, `is_template = false`), created by a send | `form_definitions` |
| agreement template | reusable title and terms that a send copies into a new agreement (`kind = 'AGREEMENT'`, `is_template = true`) | `form_definitions` |

An agreement is left out of the Forms list because it is not reusable, not because it is a hidden form. Say "document" when a rule covers both kinds, and "form" or "agreement" only for one of them.

## Where it starts

The organization sends documents to people through four stacks. They share the `form_assignments` and `form_submissions` tables in part, but each has its own recipient model, status set and delivery code.

| Stack | Code | Recipients | Delivery | Signs? |
| - | - | - | - | - |
| Native forms | `api-events/src/systems/forms/` | a customer, for one gig | portal, token link, email to the portal | no |
| Native agreements | `api-events/src/systems/native-agreements/`, `agreement-forms/` | one client contact or one crew user, gig required | portal, in-app on web, token link for Flex quotes | yes |
| Gig document sends | `api-events/src/systems/gig-document-sends/` | roster, shifts, gig contacts, the client, a bare email | email, SMS, push, portal | approve or request changes |
| Member onboarding | `api-events/src/systems/org-docs-onboarding/` | org members | the onboarding wizard | yes, plus file upload |

The schema is already general. `FormAssignment` can target a client, a contact, an org member or a user (`form_assignments_party_shape` allows exactly one party), the gig is optional, and the row carries a share token, a due date, the recipient contact, the pinned `FormRevision`, a supersession chain, the linked `NativeAgreement`, a note and the deem-on-due flag. `NativeAgreement` holds the full signing evidence: strokes, consent text, disclosure, IP, user agent, content hash, viewed and voided stamps. `signature` is already a question type.

The narrowing is in code, not in the tables:

* Before ENG-1692, `createFormAssignment` refused anything but `customer_id` plus `event_id`, and only gig-document sends created org member and user assignees. It now takes any party (see **Parties and delivery as built**).
* The portal lists customer assignments only, and the assignment email always links to the portal.
* A respondent can never answer a `signature` question; signing happens only through the agreement path.
* Crew agreements are freeform `NativeAgreement` rows with no `FormDefinition`, and `NativeAgreement.eventId` is required, so there is no org-level document.
* Before ENG-1693, the builder had no static text block and no file question, and could not set the `required_when` rule that server templates use. Text blocks and `required_when` now exist (see **Content model as built**); file blocks follow.

## Target model

A **request** is one published `FormDefinition` revision assigned to one **party**, with an optional gig.

### Parties

| Party | Stored as | Portal? |
| - | - | - |
| Client | `customer_id`, optionally `recipient_contact_id` for the addressed contact | yes, when the contact has portal access |
| Contact (vendor, venue, other) | `recipient_contact_id` with no `customer_id`, naming a live `CustomerContact` of any `ContactKind`, with or without a customer | no |
| Crew or org member | `org_member_id`, or `user_id` for a gig's crew user with no active membership | no; account holders get FORM rows in the web engagement inbox |
| Email only | `org_member_id` of the claimed member whose account has that email, else of the unclaimed member with that email, else of a new unclaimed member | no |

The CHECK `form_assignments_party_shape` allows exactly these shapes: a client with or without an addressed contact, exactly one of `org_member_id` and `user_id` with no contact, or a contact alone. It replaced `form_assignments_one_assignee`. Each party has a unique key per form, gig and instance, with a partial twin for requests with no gig.

A form has no scope. A request names a gig when it is sent for one: from a gig's Staffing, Contacts or Documents tab, by a rule whose trigger names a gig, or because staff picked a gig in the organization-level assign modal. Otherwise it names none, as an annual crew agreement, a vendor tax form or a general feedback request usually does. The same form can go out both ways. A client receives requests with a gig only (`FORM_PARTY_REQUESTS.customer.gig` is `'required'`): the portal and the response views read a client's forms by gig, and the assign modal once created gig-less client rows by accident (ENG-1499). Everyone else may receive a request with or without a gig. The web picker and the MCP tool inherit the rule from the server.

### Delivery

Delivery is decided from the party in one place, never per call site:

* A portal contact gets the portal email and sees the request in the portal.
* Every other party gets an email with the token link (`/forms/respond#t=`).
* An account holder also gets a web engagement inbox row.
* A token link is issued by an explicit, audited action with its own route. It is never returned from the create call, so staff and agents do not hold the recipient's bearer credential (ENG-1576).

### Status

One enum, `FormRequestStatus`, covers forms and agreements. It is a Prisma enum with no column: it names the derived wire value, and `status-display/registry.json` gives each value its label and tone so web and mobile render it the same way.

| Value | Meaning |
| - | - |
| `SENT` | Assigned; the recipient has not opened it |
| `VIEWED` | The recipient opened it through the portal or the link (`form_assignments.viewed_at`) |
| `OVERDUE` | The due date passed with no response. `due_on` is a civil day in the gig's zone and turns overdue the day after; `due_at` is an instant |
| `SUBMITTED` | A response is on file (`form_submissions` row for the assignment and its document version) |
| `SIGNED` | Every party the agreement asks has signed (`form_signatures`, party `recipient`, and `company` when the revision asks the company) and the request is not withdrawn. Until then a two-party agreement stays sent, viewed or overdue, and `awaitingParties` names who is missing |
| `SUPERSEDED` | A plain form request whose successor points at it (`supersedes_assignment_id`). An agreement row ignores that pointer. A voided agreement is `VOIDED`. A live one stays sent, viewed, overdue or signed. |
| `VOIDED` | The linked agreement was voided |

Precedence is a single function, `formRequestStatus` in `api-events/src/systems/forms/formRequestStatus.ts`: signed beats superseded, which beats voided, then submitted, overdue, viewed, sent. `formAssignmentJson` derives it for every assignment response. `agreementFormView.ts` calls the same function with `superseded: false`, so an agreement never reads the pointer. Clients read `status` from the API and never recompute it from the raw fields, with one exception: a response that reaches the web before the assignment list has refreshed shows Submitted for an assignment that is still awaiting a response (`formRequestDisplayStatus`). A terminal state such as Signed or Voided is never overridden by a submission row.

The `NativeAgreement` wire carries both instants. `signedAt` is the recipient's own act, set as soon as the recipient signs, whether or not the company has signed. `completedAt` is the completion instant from `lifecycle()`, null until every asked party has signed and on an agreement voided before that. `signedAt` keeps the meaning it had before ENG-1833 because shipped mobile builds read it as "the recipient signed" (`summarizeGigAgreementTasks`, `gigAgreementSignRoute`) and read completion from `awaitingParties`; a change of meaning would not show up in `check:openapi-breaking`. A reader that means binding reads `completedAt` or `awaitingParties`. The recipient's engagement inbox (`recipientAgreementInboxArmText`) is their own view: an AGREEMENT row is `SIGNED` once complete, `AWAITING_COMPANY` once the recipient signed while the company is still awaited, and `PENDING` otherwise, and it is priority only while the recipient has not signed. The org inbox keeps `SIGNED` and `PENDING`, priority until complete, because the company's signature is still the org's to give.

`viewed_at` is stamped by the recipient's own open, through the portal or the token link. A staff fill-in on the client's behalf does not stamp it, because it is not the recipient viewing.

### Signing

A document whose content includes a signature block is an agreement. "Agreement" stops being a form purpose and becomes a document with terms and a signature. There is one signing model: every signature is a `form_signatures` row, one per request and party (`recipient` or `company`), with the signer, consent, strokes, IP and user agent, and for the recipient the submission, its response hash and the disclosure.

**An agreement always asks its recipient.** Every AGREEMENT revision has a signature question for the recipient (a question with no `party` is the recipient's). The company signs as well, never instead. `requireAgreementSignatureQuestion` (template save and send) and `assertPublishedSignature` (publish) refuse a company-only agreement, the template editor offers Recipient or Recipient and company, and `form_revisions_agreement_recipient_signature` refuses the row (ENG-1833). The party was named `client` until ENG-1833; `20261123100000_signing_party_recipient` renamed the stored rows and the revision JSON.

**Signed means every asked party has signed.** `lifecycle()` in `agreements/agreement.ts` is the one rule in TypeScript: pending with the awaited parties until every asked party has an act, then signed with the completion instant (the latest act's `signedAt`); a withdrawal wins and keeps whether the agreement had completed. `completedAtSql` in `agreementReadSql.ts` is the same rule in SQL, and every projection that says signed reads it: the ledger, the request status, the signed and unsigned request filters, the signed-contract and signing-act reads, the notice queries and member removal. The wire `completedAt` is the completion instant; the wire `signedAt` stays the recipient's act. Each party's own outcome (signed, repeat, conflict, stale, withdrawn) is unchanged. Reminders (manual, scheduled and the queued reminder email) and the recipient's `viewedAt` stay with the recipient's own act (`recipientActExistsSql`).

## Parties and delivery as built (ENG-1692)

### Create

`POST /api/v1/organizations/{orgId}/forms/{formId}/assignments` takes exactly one party: `customer_id` (with an optional `customer_contact_id` for the addressed client contact), `contact_id`, `org_member_id`, `user_id`, or `email` with an optional `name`. `event_id` names the gig the request is for: required when the party is a client, optional for everyone else. A client contact reached through the portal is spelled `customer_id` plus `customer_contact_id`; `contact_id` always stores a contact party, whatever customer the contact has.

`FORM_PARTY_REQUESTS` in `api-events/src/systems/forms/formPartyRules.ts` holds the rule for each request kind, including the one rule about gigs: `gig` is `'required'` for a client and `'optional'` for every other kind. A client must be linked to the gig. A user must be on the gig's crew when one is given. Contacts, members and email parties need no tie. The gig must exist, be live and not be the sample gig. A user with an active membership in the organization is stored as that org member. A user with no membership is stored by `user_id` only when they are on the gig's crew, so a gig-less request for an unknown user id is refused. `createFormRequest` in `db/forms.ts` inserts the row through `partyColumns` and queues delivery in one transaction. A duplicate answers 409 and names the party and the gig, or "with no gig".

A request naming only a client (`customer_id`, with or without `event_id`, nothing else) reads no flag. Every other shape reads `crm`; with the flag off the request gets the shipped answers ("customer\_id is required", "Pick a gig for this form").

### Delivery

`routeFormDelivery` in `api-events/src/systems/forms/formDelivery.ts` makes the one routing decision, from the `FORM_PARTY_DELIVERY` table:

| Party | Route |
| - | - |
| Client, no addressed contact | the portal email to a portal-ready primary contact, else no email |
| Client, addressed contact | the portal email when that contact passes the portal rule, else the link email to that contact |
| Contact | the link email to the contact |
| Org member or user | the link email to the person's account address, or to the roster email of an unclaimed member, whose placeholder account address is never used |

The portal rule is `portalContactJoin` in `db/formParty.ts`, shared with `getCustomerPortalRecipients`. `enqueueFormDelivery` is the only map from route to effect kind. The portal route queues `form_assigned_email`, or `agreement_assigned_email` keyed by the agreement when the request is a signature request, so a client contact lands on the portal Contracts page with the agreement rather than on Forms, which does not list signature requests. The link route queues `form_request_email`. Both form handlers read the row again with `getFormRequestDelivery`, route it again, and settle without sending when the channel or address no longer matches the payload. Reminders reach every party through the same router and are deduplicated against a pending reminder of the routed kind, on the assignment for the form emails and on the agreement for the agreement email. A reminder on a gig-document row whose party is not a client is refused.

### Response links

Create mints no token. `ensureFormAssignmentShareToken` in `db/formParty.ts` mints a form's token on first use and returns the same token afterwards. It is called by the `form_request_email` handler and by two routes:

| Route | Who | Audit |
| - | - | - |
| `POST /api/v1/organizations/{orgId}/form-assignments/{assignmentId}/share-link` | staff with `crm.manage`; 400 for an archived form or a form not built in Soundcheck | `form_link_issued` in the same transaction, with `form_definition_id`, `form_assignment_id`, `party_kind`, `minted` and `event_id`, never the token or the link |
| `POST /api/v1/users/me/form-assignments/{assignmentId}/share-link` | the signed-in holder of the request while `crm` is on for the request's organization; 404 for anyone else, staff included, and for a holder whose organization has the flag off | none, because it is the holder's own credential |

`forms.FormAssignment` carries no `share_url`. `can_share_link` says whether the staff route can issue a link, and the web Copy link button calls that route. Both routes are excluded from MCP. Gig-document sends still mint their own token when they upsert the row.

### Reads

Every `forms.FormAssignment` carries `party { kind, id, user_id?, name, email?, portal_capable }`, built from the same row view the router reads. `user_id` is the person behind an org member or user party, so a crew row keyed by user finds a request stored by member. A client or a contact has none. `email` is where a notify would go, and `portal_capable` is true only when the route is the portal. `submission_id` and `submitted_at` name the latest response, by `submitted_at`, under the rule `submitted` reads (same request and gig document version), so a list opens a response without fetching submissions. `gig_document_version_id` is set only on a request that delivers a gig document sent from the gig's Documents tab, so a list can hide the actions the API refuses for it: removing it, changing its edit window, and reminding a party that is not a client. `GET /api/v1/organizations/{orgId}/form-assignments` takes `party_kind` (`customer`, `contact`, `org_member` or `user`) and `without_event=true`. The flat `customer_id`, `org_member_id`, `user_id`, `recipient_contact_id`, `assignee_name` and `customer_name` stay for shipped clients.

`GET /api/v1/organizations/{orgId}/forms/assignment-targets` lists each gig with its `customers` and its `crew`. A customer lists its live `contacts`. A contact's `email` is where a request addressed to that contact would go, and `portal_ready` is true when that is the portal email, by the same `portalContactJoin` rule. `crew` is the set `isUserOnEvent` accepts for the gig, so it lists the gig's crew, its managers (managerial `event_assignments`) and its creator, and each of them can receive a `user_id` request for it. `listAssignmentTargetCrew` in `db/formAssignmentTargets.ts` reads it in one query for every listed gig. `crewCandidatesOnEventsSql`, beside the predicate in `db/isUserOnEvent.ts`, yields a candidate for each tie `crewOnEventPredicate` reads, and the predicate itself filters them. A person's `email` is where the link email would go, and `has_account` is false for an unclaimed member, whose placeholder account cannot sign in.

The staff email for a new response (`formSubmissionNotification.ts`) names the party from the same party view. It links a response with an event id to that gig's Documents Forms list (`/gigs/{eventId}?tab=documents&kind=form`). The event id is the submission's event id, or the assignment's event id when the submission has none. A response with no event id links to Forms on Assets (`/assets?view=forms`). That tab lists the form library, not the response. Agreement templates are the Agreements tab (`/assets?view=agreements`). An older `/assets?view=forms&tab=agreements` link opens that tab. The old `/settings/organization/forms` and `/settings/organization/agreement-templates` links still redirect there in `web/src/middleware.ts`, so emails sent before ENG-1812 still open. Only a response with no form request and no matched client is described as not matched to a customer.

### Engagement inbox

`GET /api/v1/users/me/engagements` returns `FORM` rows only when `kind` lists `FORM`. Installed mobile builds crash on a kind they do not know, so the default kinds are INVITATION, CREW\_OFFER, AVAILABILITY\_REQUEST and ONBOARDING. A FORM row is an ordinary native form request on an active form that the caller holds by `user_id` or through an org member claimed to their account. Signature requests (they list as AGREEMENT rows), gig documents and gigs scheduled for deletion are left out. `heldFormRequestSql` in `db/formParty.ts` defines the requests a holder can open through the holder share-link route, and the inbox FORM arm leaves signature requests out of that set. A FORM row is listed and counted only while `crm` is on for its organization. The operation reads the flag for each organization the caller holds a request in (`listHeldFormRequestOrgIds`), or for the one organization of the member engagements route, and the query keeps only those organizations' rows. With no organization on, the query has no FORM branch. The status is PENDING or SUBMITTED. The payload carries no link, because staff read the same rows through the member engagements route. The web card is labelled "Form request" and its Open form action calls the holder route. A pending request with a gig counts toward the pending badge until its gig day passes; one with no gig counts while it is pending.

### MCP

`create_form_assignment` takes `customer_id`, `contact_id`, `org_member_id`, `user_id`, `email`, `name` and an optional `event_id`. It does not take `customer_contact_id`. It refuses a one-off agreement, as the API does, and `publish_form` refuses a signature question. It is confirmation-gated when `notify_customer` is true or `email` is set, and it drops `share_url` from the result whatever API revision answers.

### Defaults kept

* A client with no addressed contact and no portal-ready primary contact gets no email. Staff address a contact to send that client the link.
* The portal still lists every assignment of a client to every portal contact of that client.
* Only staff issuance is audited. The link email's own mint and the holder route write no audit row.
* An `email` party can resolve to a removed org member, while an `org_member_id` request refuses one. An email names a person, and `lookupImportedOrgMember` resolves it to that person's member row, active or not; it is the read-only form of the lookup gig-document sends and roster imports run through `upsertUnclaimedOrgMember`. A roster id names a row picked from the active roster, so `org_member_id` refuses a removed member.

## Content model as built (ENG-1693)

### Items

A native form's content is `{ title, items }`. `draft_content` holds it, and a published revision keeps the item array in `form_revisions.questions` (the Prisma field is `items`; the column keeps its name until ENG-1696). Each item has a `kind`:

| Kind | Fields | Answers? |
| - | - | - |
| `question` | `id`, `type`, `label`, `required`, `placeholder`, `options`, optional `required_when` | yes |
| `text` | `id`, `markdown` | no |
| `file` | `id`, `label`, `file_id` | no |

`nativeFormContent.ts` owns the model. `parseStoredItems` reads a stored entry with no `kind` as a question, so revisions written before items need no migration. A `kind` this build does not know makes the parse fail: editing or reading that form's content answers 409, and the share link and the portal treat it as unavailable, so a newer kind is never saved over or dropped. `questionsOf(items)` projects the questions, without `kind`, for every reader that only needs questions: the answer validators, `respondentAnswers`, staff fill and edit, and the agreement signature checks. The agreement template fingerprint hashes that projection, so a revision saved with `kind` fingerprints like one saved before it.

### `required_when`

A question can be required when an earlier answer picks one option: `required_when: { question_id, option_id }`. It survives every hop: the editor, the content GET and PUT, the template list, publish, the respondent payload, the staff fill dialog and MCP `publish_form`. The save refuses:

* a parent that is not an earlier `multiple_choice` or `dropdown` question;
* an option the parent does not have;
* a question that depends on itself;
* a condition on a `signature` question;
* a condition on a question that is also always required: the condition replaces Required, and the editor turns one off when the other is set.

A new question or option may carry a client-proposed UUID, so a new question can depend on a new parent in the same save. Any other unknown id is a conflict, and the revision token still refuses a stale save. Creating a form from a template (`freshContent`) gives every item and option a new id and points each condition at the copied parent and option. The editor drops a condition whose parent or option it no longer holds before it saves.

### Text blocks

A text block is markdown shown to the respondent between questions. The text of all blocks of a form is at most 50000 characters, the bound agreement terms already had. Publish refuses a blank block, a merge field outside `PLAIN_FORM_MERGE_KEYS` (`organization_name`, `client_name`, `event_title`, `event_date`; an agreement-only key such as `{{deposit_amount}}` gets its own message) and agreement section syntax (`{{#…}}`). Agreement forms refuse text blocks for now; their wording stays in `terms`.

The four keys are the subset of the `agreementMerge.ts` registry a plain form can fill. They render leniently when the share link or the portal opens: a key with no value for that request (no gig, no client) renders empty, and every substituted value is markdown-escaped, so a name such as `Wave_Sound *Co*` prints as written instead of changing the formatting. The respondent payload `forms.NativeForm` carries `items` in revision order, with the text merged, beside `questions`, which the server derives from them. Agreement `terms` stay outside `items` until the signing view of ENG-1693 renders them through the agreement merge. The staff fill dialog and the editor preview show text blocks with the tokens unmerged. The submission detail returns `revision_items`, the items of the revision that submission answered, and the viewer shows its text blocks between the answers in that order. The content GET and PUT list all four as `merge_fields` (`plainMergeFieldsJson`) on every plain form. Each field carries `needs_gig`, true when its source is the gig or the client, and the editor notes that those fill only when the request is sent for a gig. Publish takes any of the four on any form. An AGREEMENT form lists every field its terms take, with the same flag.

### File blocks

A file block is a file the organization shows to the respondent, in item order, with a label. It asks for no answer. Staff upload the file first, then save the block with the id the upload returned.

`POST /api/v1/organizations/{orgId}/forms/{formId}/files` takes one multipart `file` part. It needs `crm.manage` (`authorizeCRMManager`) and a native form of the caller's organization, checked before the body is read, so a refused caller never makes the server buffer the file. A draft and a published form both take uploads, because the file row does not belong to a revision. An archived form and an agreement form answer 400, because neither can save a file block, so their uploads could only ever be orphans. The upload is at most 25 MB. The bytes decide the type, not the declared `Content-Type`. `detectUploadedFileContentType` must read a PDF, JPEG, PNG or GIF, and anything else answers 400 with a message that names those four. The server buffers the body (`parseMultipartUpload`), hashes it with sha256, stores the blob under `organizations/{org}/forms/{form}/files/` (`formFileBlobPath`), signs its read link, and writes one `form_files` row. A failed signing or insert deletes the blob and answers an error with nothing stored, so a retry never leaves a second copy behind. Each upload writes the `form_file_uploaded` audit action.

`form_files` rows never change after the insert. The sha256 is stored so the signing work of ENG-1693 can hash the file into the document a signer sees. The item stores only `file_id`, and a save refuses a `file_id` that is not a row of the same form with a 400 that names it. Publish refuses a file block with a blank label. Agreement forms refuse file blocks, as they refuse text blocks.

Removing a block from a form leaves its row and its blob in place. Deleting a form that has no submissions deletes its rows through the foreign key; a form with submissions is archived instead and keeps its rows. The blobs stay either way, and a sweeper is a follow-up. A row that any revision references must outlive that revision, because a pinned request presents it, so cleanup has to read every revision and the draft, not only the draft a save replaces. Every read looks up only the rows its items name, so a form's upload history does not slow its reads. A block whose row is gone reads with `missing: true` and the file id alone, so the editor shows it and staff can remove it, and the respondent never sees it.

Every read signs a fresh link that expires after one hour: the upload response, the content GET and PUT, the respondent payload from the share link and the portal (`presentNativeForm`), and the submission detail's `revision_items`. No link is stored, and no link enters a hash. The respondent payload carries a file block flat, as `{ kind: 'file', id, label, filename, content_type, size_bytes, url }`; the editor wire nests the same fields under `file`. The web renderer, the staff fill dialog and the submission viewer show the label and a link that opens the file in a new tab; images are links, not inline. MCP excludes the upload route, and `publish_form` passes a file block through unchanged.

### Pinned revisions

Every request presents the revision it was assigned with. Every assignment writer stores `form_assignments.form_revision_id` from the form's published revision: `createFormRequest`, the gig-document upsert and the signature chains select the pin from the published revision and `form_kind` from the definition's `kind` in the INSERT itself (`requestKindInsert`). The share link, the portal, the app channel and staff fill load the pinned revision, and the submit's stale check compares the echoed `revision_id` with the pin. A request with no pin opens the form's published revision: while publishing is final (`nativeFormOperations.ts` refuses an edit once a form is published) that is the only revision the form holds, so it is the one the request was assigned with. Every writer now pins, so the fallback covers only rows a build before this one wrote after the backfill ran. Staff edit validates against the submission's own revision and never rewrites it. Migration `20261103090000_form_assignment_pin_revision` backfilled the pin for native rows from the published revision, skipping any form that holds more than one revision and naming those forms in a NOTICE.

### Agreement record

An agreement is its request: a `form_assignments` row with `form_kind = 'AGREEMENT'`. `agreementStore.ts` in `api-events/src/systems/agreements/` reads the row and its `form_signatures` into an `Agreement` (`agreement.ts`) and throws on a row whose columns disagree, so every reader works on the agreement, not on raw columns. ENG-1831 deleted the `native_agreements` rows (freeform, Flex quote and document agreements issued before PR 4001), dropped that table and its sign links, and took the chain columns, so the request is the one residence.

**What the row holds.** `form_assignments_signature_link` holds the shape. An `AGREEMENT` request carries its pinned `form_revision_id`, `content`, `content_hash`, `presented_blocks` and `recipient_name`, and a client request names its contact. `withdrawn_by` needs `withdrawn_at`. A request of any other kind carries none of these columns. `quote_amount` and `quote_currency` hold a PDF quote's total. ENG-2011 deleted the contract chain rows (`agreement_kind` MSA or ADDENDUM), dropped `form_assignments_live_msa_key` and took the contract arm out of the CHECK (`20261218090000_retire_contract_chains`). It stopped the Prisma client naming `agreement_kind`, `issued_facts` and `form_definitions.agreement_kind` (`@ignore`). The columns and the `AgreementKind` type stay for the build that is still serving, and ENG-1999 drops them once this build serves in dev, qa and main.

**Scope.** `event_id` is nullable. An agreement's `scope` is `{ kind: 'gig', eventId }` when the request has a gig and `{ kind: 'org' }` when it has none. A client request always names a gig (`form_assignments_client_gig`). Gig-only routes narrow with `requireGigScoped`. The gig and portal lists read only gigs not scheduled for deletion.

**Recipient.** The request names exactly one party (`form_assignments_party_shape`): `user_id`, `org_member_id`, or `recipient_contact_id` with or without `customer_id`. A member recipient is held by the member's current user: `heldPartySql` in `db/formParty.ts` is the one predicate, shared by `heldFormRequestSql` and the inbox AGREEMENT arm. The wire sends a member recipient as `recipientKind: 'musician'` with `recipientId` set to that user and the optional `recipientOrgMemberId` set to the member. The org member claim repoints the request.

**Viewed.** `viewedAt` is the request's `viewed_at` when it is not after the recipient's signature or the withdrawal. `markFormAssignmentViewed` has no signed or voided guard, so opening an old link after signing or voiding stamps the request, and without that rule `viewedAt` and the receipt's "First opened" would fall after the signature or the void.

**Signatures.** Each party's act is a `form_signatures` row, one per request and party (`recipient` or `company`, unique on `(form_assignment_id, party)`). It holds the signer, the signer snapshot, consent, strokes, IP and user agent. A recipient act also holds its submission, response hash and the disclosure (`form_signatures_client_evidence_check`). The parties asked to sign come from the pinned revision's signature questions, and always include the recipient.

**One signing core, one void writer.** `commitAct` in `agreementStore.ts` is the only code that signs. It locks the request and its signatures, refuses a withdrawn agreement, a stale hash or a recipient act from someone other than the recipient, and inserts the act with `ON CONFLICT (form_assignment_id, party) DO NOTHING`. The result is `signed`, `repeat` (the same signer and hashes again), `conflict`, `stale` or `withdrawn`. The recipient signs through `signSignatureResponseInTx` in `systems/signature-requests/signatureRequests.ts`, which locks the gig, then the agreement, and writes the submission. The company signs through `countersignAgreement` in `agreements/countersign.ts`, or at send through `countersignIssuedAgreementInTx` (`company_signature` on the agreement-sends request). Both paths run the same step once the act commits: `completeIfSignedInTx` checks `lifecycle()`. No guard runs before the act. Whichever act completes the agreement writes the `contract_signed` audit row, applies `completionConsequences` with the completion instant and queues the one fully signed copy email (`agreement_signed_copy_email`, keyed on the agreement). The send-time company signature never completes an agreement, since the recipient is still awaited. The staff email (`agreement_signed_email`) goes out when the recipient's act commits, and says so when the company still has to sign.

`completionConsequences` in `systems/native-agreements/signatureDocument.ts` names what a completed agreement does:

| Agreement | Completion consequences |
| - | - |
| one-off or request, no quote | none, or `complete_contract_closeout` for a client one-off |
| request with a quote | `quote_signature`, and `complete_contract_closeout` for a client one-off |
| no gig | none |

Only `quote_signature` (`recordQuoteSignature`, through `stampFirstSigning`) sets the Billing terms `signed_on`. A signed agreement without a quote leaves it alone, so `signed_on` comes from a signed quote or from a day typed in Billing terms.

`withdraw` in `agreementStore.ts`, called through `voidAgreementInTx`, is the only writer of `withdrawn_at` and `withdrawn_by`. It updates only a request with no `withdrawn_at`, and with `onlyIfUnsigned` only one that has not completed. A staff void of a gig agreement releases the billing signed day a quote still holds (`releaseVoidedSigning`, which moves it to the earliest other completed quote on the gig, or clears it, and re-resolves the draft invoice due dates) and re-derives the closeout step, whether or not the agreement had completed. Staff void and the Clerk membership-removal path call it. Send does not. `agreementHash(title, content, attachmentHashes)` hashes `JSON.stringify([title, content, ...attachmentHashes])`.

**Audit scope.** `auditScope(orgId, eventId)` in `systems/forms/formAuditScope.ts` gives EVENT scope when the request has a gig and ORGANIZATION scope when it has none. Every `contract_sent`, `contract_voided`, `contract_countersigned` and `contract_signed` write goes through it. Send no longer writes `contract_revised`. `audit.log_change` audits an agreement request, and a `form_signatures` row, with no gig on its organization (see **Decisions** under **Signature requests as built**).

**Staff access.** `authorizeAgreementStaff(agreement, callerId, need)` gates view, PDF, void and countersign. A gig agreement keeps the event rules: view and PDF need money access or being the recipient, and void and countersign need money edit. An agreement with no gig needs `crm.manage`, and its holder may view and download their own.

**Closeout.** A signed, unvoided agreement counts as the gig's signed contract when it is a client one-off: a request on an `AGREEMENT` definition, addressed to a client contact. A crew-held (user or member) agreement and a signature request on any other document do not count. `requestSignedContractSql` in `agreementReadSql.ts` holds the SQL, through `completedAtSql`, so a revision that asks the company also needs the company act. `eventHasSignedContract` and `agreementCountsAsSignedContractSql` read only that fragment. `countsAsSignedContract(agreement)` applies the same rule and does not read whether the agreement is signed or voided. `completionConsequences` queues `complete_contract_closeout` from that predicate, once the agreement completes. A void re-derives the step through the SQL predicate.

**Agreements with no gig.** An organization-scope signature request is the one writer of an agreement with no gig; see **Signature requests as built**.

## Placed PDF fields (ENG-1866)

A one-off agreement with an uploaded PDF can place fields on it. Each signer fills bounded boxes, and each value is stamped inside its box in the signed copy. The certificate and consent pages stay.

**Where it lives.** `form_revisions.pdf_layout` holds `{ fields, covers }` and is null when the revision has no PDF or nothing placed, so a request's fields are frozen with its revision. Each party's values live on its act in `form_signatures.field_values`, keyed by field id, so the content hash and the replay rules do not change. A replay with different entered values is a conflict. `placedField.ts` in `systems/native-agreements/` owns the model, `fieldTags.ts` detects and `fieldStamp.ts` stamps.

**A field.** `{ id, page, x, y, width, height, type, party, required, label, default }`. The page is zero-based. The rect is in PDF points in the page's own coordinate system: origin at the bottom-left of the unrotated page, as pdf-lib and pdf.js report it. A page's `/Rotate` does not change the rect. `uprightBox` (`placedFieldGeometry.ts`) gives the transform that draws upright inside it, and the web's `rectToDisplay` gives the screen box. Types are `signature`, `initials`, `date_signed`, `text`, `checkbox`, `name` and `email`. Only `text` and `checkbox` are entered. The server sets `date_signed` (the civil day of the act in the gig's zone) and `email` (the signer's address on file) at the act. `signature`, `initials` and `name` come from the act's ink and signer name.

### Tag grammar

Detection runs when a PDF is attached to a send. `POST /organizations/{orgId}/agreement-sends/pdf-fields` returns the result to the send form, and a send with no `fields` detects the same tags itself, which is how MCP sends get fields. An empty `fields` list places none.

| Form | Example | Means |
| - | - | - |
| `{{type}}` | `{{date}}` | a recipient field |
| `{{type_name}}` | `{{text_venue}}`, `{{sig_1}}` | a recipient field labelled from the name; a name of only digits gives no label |
| `{{type_party}}` | `{{initials_client}}`, `{{sig_company}}` | the last word names the party |
| `{{type:party:Label}}` | `{{text:company:Company title}}` | party and label spelled out |

* **Types:** `sig` or `signature`, `initials`, `date` or `date_signed`, `text`, `check` or `checkbox`, `name`, `email`.
* **Parties:** `client` or `recipient` (the default), and `company`.
* **Required:** `signature`, `initials`, `date_signed`, `name` and `email` are required. `text` and `checkbox` are required with a leading `*` (`{{*text_venue}}`) or a trailing one on a colon label (`{{text:client:Role*}}`).
* **Malformed tags:** any other `{{...}}` makes no field. The send form lists it, and the signed copy still covers it. A `{{...}}` is hidden even if it was not meant as a tag.

**eSign text tags (ENG-2036).** Quote and rental tools export single-brace tags such as `{eSignSignHere1}`. They are read as aliases, in any letter case. The number names the party: `1` or none is the recipient (client), `2` is the company. Every alias is required.

| Tag | Field |
| - | - |
| `{eSignSignHereN}`, `{eSignSignatureN}`, `{eSignSignN}` | `signature` |
| `{eSignDateN}`, `{eSignDateSignedN}`, `{eSignSignDateN}` | `date_signed` |
| `{eSignTitleN}`, `{eSignNameN}`, `{eSignFullNameN}`, `{eSignPrintNameN}` | `name` |
| `{eSignInitialN}`, `{eSignInitialsN}`, `{eSignInitialHereN}` | `initials` |
| `{eSignEmailN}` | `email` |

There is no title field, so `{eSignTitleN}` (the "Name/Title" line) stamps the signer's name. Any other `{eSign...}`, or a party number above 2, makes no field and is listed and covered like a malformed tag. Other single-brace text is left alone.

The rect is the smallest drawn box around the tag, if one holds the tag's centre and takes up at most 60% of the page. Otherwise, when a drawn line of at least 40 points runs under the tag (its middle between 1.2 text sizes below the baseline and 0.2 above it), the field sits on that line: as wide as the line, and as tall as its type wants (a signature 36 points, initials 24, text, name, date and email 18), stopping 1 point under the next line above it. A checkbox never sits on a line. Otherwise it is the tag's own box grown to a minimum size for its type (a signature is at least 150 by 30 points, text 120 by 18) and kept on the page. Tag text is covered with a white rectangle under the value, stopping 1 point short of a surrounding box's border. When the tag's text was measured, its cover reaches as far as the tag would run in Helvetica Bold plus 15%, but stops half a point short of the next character on any text line the cover overlaps and of any drawn mark that crosses it, so a run whose reported width is too narrow is still hidden without painting over a neighbour. A cover hides the text from a reader, but the characters stay in the PDF's content stream, so a text search or copy can still find them. A cover is white, so it shows on a coloured background.

### Signing and stamping

A company field makes the revision ask the company to sign, with or without `company_signature`. With it, the send carries `company_signature.field_values` and validates them before anything is written. Without it, the company fills them through countersign. The recipient fills only recipient fields, and each party sees the other's read-only. A missing required field or a value for a field that is not yours answers 400 from the portal, holder and share-link paths and from countersign, before the act is written.

`renderAgreementReceipt` stamps every read, for each party that has signed, so a partly signed copy shows what is signed so far. Parsing refuses PDFs with AcroForm fields, and stamping only draws page content, so no live form field exists to flatten. Every field is clipped to its rect. Text starts at 11 points, wraps, shrinks to 5 points, then truncates with an ellipsis; the full value stays on the act and on the certificate, which lists each party's fields. Ink scales to fit, keeps its aspect ratio and is centred. Text is drawn in Helvetica: a letter outside WinAnsi loses its accent where it can, otherwise it prints as `?` in the box. The certificate is the record for such a value.

A PDF that pdf.js cannot read still sends: it has no detected fields, with a warning in the logs, because such a PDF sent before this feature existed.

**Not yet.** Stamped values cannot show letters outside WinAnsi. Placement is at send only. An agreement template cannot hold a file block, so the template editor has no PDF to place fields on.

## Signature requests as built (ENG-1693)

### The predicate

A form collects a signature when its published revision holds a `signature` question. Since ENG-1762 C1 only a one-off agreement does (an agreement template stores no items since ENG-1982); a library form refuses one (see **Form roles as built**). `form_revisions.collects_signature` records the fact, and `form_revisions_collects_signature` ties it to the item JSON. Every request copies it into `form_assignments.collects_signature` through the composite key `(form_revision_id, collects_signature)`. A request is a **signature request** exactly when it links an agreement: `form_assignments_signature_link` holds `collects_signature` and `native_agreement_id` together. `isSignatureRequestSql(alias)` in `systems/agreements/agreementSql.ts` states the shape every other site reads (`fa.native_agreement_id IS NOT NULL`).

### One chain

`issueSignatureChainInTx` in `systems/agreements/signatureRequests.ts` writes every agreement on its request chain, keyed by form, party and gig (`RequestChainKey`). The row's `instance_key` is `sign-<assignment id>` (a client party uses `sign-<contact id>-<assignment id>`) and its recipient comes from the party. ENG-2011 retired the contract chain (MSA and addendum rows keyed by organization, gig, client and agreement kind) and deleted its rows, so `SignatureChainRef` and the issue key have only the request shape.

A gig chain locks the gig row; an organization chain takes `pg_advisory_xact_lock(hashtext('signature_chain:…'))` on its key. A new issue renders the document. A live request (`voided_at` null) throws `AgreementAlreadyLiveError` (`AGREEMENT_ALREADY_LIVE`, HTTP 400). Nothing is voided on send. Staff void the open agreement, then send again. `instance_key` is the chain prefix plus the new assignment id, so a second send after a void stays unique. The document, the preview and the issue result print revision 1. Send does not write `contract_revised` or `revised_on`. `expected_content_hash` from a preview that no longer matches answers `preview_stale`. The agreement goes in through `issueDocument` in `agreementStore.ts`, which counts the e-sign cap and writes `contract_sent`, and the issue throws when the stored hash differs from the draft's. The recipient comes from the stored party: a user to `recipient_user_id`, an org member to `recipient_org_member_id`, a contact or a client contact to `recipient_contact_id`. A client party must name its contact (`needs_signer`). Every request row keeps `event_id` equal to its agreement's; the writer is the one place that holds this, and the integration tests count violating rows after every case.

**The send's definition is the request's definition.** ENG-1786 had the generator copy a contract template into a new `AGREEMENT` definition inside the issue (`copyContractTemplateInTx`, `ContractChainKey.copy`). ENG-1982 step 3 removed that with the generator: `POST …/agreement-sends` inserts the send's own one-off definition and revision (`insertOneOffAgreementInTx`, with `source_template_id` naming the template) before `issueSignatureRequestInTx`, so `issueSignatureChainInTx` takes the definition and revision it is given and never copies.

**An agreement template holds terms only.** Its body is `form_definitions.draft_content`, `{ title, terms, items: [] }`; the agreement-template routes save title and terms, and the forms write routes refuse the template (`assertManagedAsForm`). A send reads the terms (`readAgreementTemplateForSend`) and builds the agreement's items itself: the terms as a text block, the recipient's signature question and, when asked, the company's. The request names the send's own definition, never the template.

**One send per agreement (ENG-1768 P2b).** `issueSignatureChainInTx` refuses a request against an `AGREEMENT` definition unless the caller passes that definition's own `send_key`, with a 400 `ValidationError`: "This agreement was already sent. Send a new agreement instead." (`AGREEMENT_ALREADY_SENT_MESSAGE`). A C2 send passes its `send_id`. A backfilled `AGREEMENT` with no `send_key` cannot be sent again. Library forms and templates are not checked. No route reaches the refusal today; it keeps a future caller from sending an existing agreement to someone new.

**Sends issue request chains only (ENG-2011).** ENG-1982 step 1 added `gig_contract` to `POST …/agreement-sends`, which issued each client party on the contract chain with `agreement_kind = 'MSA'` and `issued_facts`. ENG-2011 removed the option. The parser ignores `gig_contract`, so a body that carries it builds the same request and the same fingerprint as one without it, and every party is issued on its request chain. The terms render per party with `renderSendTerms`, which applies the `{{#section}}` blocks from the gig's Billing terms. A gig-less send refuses a billing section the way it refuses a gig field (`giglessFieldProblems`). Any template, the MSA starter included, is sent as a plain agreement. A party may carry `expected_content_hash`, the `content_hash` its dry run returned. The issue runs the `requirePreviewed` check against it, and a send whose rendered document differs answers 409 `AGREEMENT_PREVIEW_STALE` with nothing stored. Two entries for one signer with different hashes are refused per party. The web sends the hash from its dry-run preview, so a billing-terms edit between preview and send is caught. The second ENG-2011 PR deleted the contract rows sends issued before, with the act guards, the MSA signed-on stamp and the addendum lock that read them.

**Templates are content only (ENG-1982, step 3).** The generator path is gone: `POST …/forms/{formId}/assignments` on an agreement template answers the forms routes' 400 ("This is an agreement, not a form. Agreements are sent from the gig.") like every other agreement document, `POST …/forms/{formId}/agreement-preview` no longer exists (the dry run on `agreement-sends` is the preview), and `expected_content_hash` left the assignment body. ENG-2011 stopped the Prisma client naming `form_definitions.agreement_kind` and `form_assignments.agreement_kind` (`@ignore`). The columns keep their audit classification, because the previous build selects them on every form read while the new one boots, and an image-only rollback needs them. ENG-1999 drops them once this build serves in dev, qa and main. `Document` for a template is `{ kind: 'agreementTemplate' }` with no kind, the wire `role` is `agreement_template` for every template (`contract_template` is gone), the template JSON and the starters carry no `agreement_kind`, chips carry no `required` and every template gets every field and every section (`agreementTemplateChips()`), and a template's notes are the terms' syntax problems (`agreementTermsProblems`). A template stores `items: []`; nothing in the API reads a template's items. `agreementFormOperations.ts` keeps `agreementFormsEnabled` and `agreementReminderRefusal`; `issueAgreementOperation`, `previewAgreementOperation`, `issueAgreementAssignment` and the chain copy are deleted, and the `AGREEMENT_SEND_CHAIN` code left the send's refusal enum. Integration tests that need a signed agreement issue a client or crew one-off through `seedSigningCase` or `issueGigOneOff` in `systems/agreements/nativeAgreementSigning.integration.fixtures.ts`, which insert the send-style one-off definition and call `issueSignatureRequest`.

**Purpose on the wire (ENG-1840).** Every forms response reports the definition's stored purpose in lower case: `formDefinitionJson`, `formAssignmentJson`, `formSubmissionJson` and the portal form embed all go through `formPurposeJson`. An agreement or an agreement template stores `GENERAL`, so it reports `general`; its `role` on a definition, or `native_agreement_id` on a request, says it is an agreement. The spec types these fields as `forms.FormPurpose` (`event_questionnaire`, `general`, `feedback`, and the gig-document approval purposes `music_schedule` and `details_sheet`) and the create and update bodies as `forms.FormPurposeChoice`, the first three. `parseFormPurpose` answers 400 for any other value, `agreement` included. The web labels a request with an agreement id "Agreement" (`listItemPurposeLabel` in `web/src/lib/forms/formRequest.ts`) and never reads the purpose for it.

### The document

`presentDocument` in `systems/signature-requests/presentDocument.ts` builds what the signer signs: the merged text blocks in item order, every question's label and options, each file as `label: filename (sha256)`, and nothing for the signature question. The hash is `agreementHash(title, content, fileHashes)` with the files' sha256 in item order. A revision that is terms and a signature presents exactly the terms the send rendered (pinned by `presentDocument.test.ts`).

Each field of the merge registry (`agreementMerge.ts`) names its source: org, gig, client or party. A form that collects a signature may print any field; a plain form keeps the four plain fields. ENG-2011 removed the addendum fields (`addendum_lines`, `addendum_total`, `addendum_due`), so a template that still prints one saves with the note "not a known merge field" and its send is refused with that message. Publish refuses no field for want of a gig, because a form has none. The editor marks the gig and client fields `needs_gig` (`merge_fields[].needs_gig`), since they render empty on a plain request with no gig. A signature request renders strictly when it is issued (`renderTextStrictly`): a field it cannot fill refuses the issue with the missing keys. `client_name` comes from the request's client, or, for a gig request addressed to anyone else, from the gig's linked customer when the gig has exactly one; with none or several the field is missing and the hint names both sources. `presented_blocks` stores the rendered blocks, never the merge inputs. A plain request still renders leniently when it is opened.

### Issue

`createAssignmentOperation` refuses an agreement document. For any other form it resolves the party and, when the published revision collects a signature, calls `issueSignatureRequest` instead of `createFormRequest`. A gig signature request needs money access on the gig (`resolveEventAuthz`, `editMoney`) on top of the route's `crm.manage`. A plain revision can print none of the billing fields, and a signature request with no gig cannot print one either, because the strict render refuses a field it cannot fill. No other assignment needs it. With `notify_customer`, the request is delivered inside the issue transaction through the form delivery router. A second send of the same live request is the 400 above, not a reminder. Reminders stay on the reminder route. A signature request is always required, whatever `required` says. `due_at` becomes its due day, `due_on`: the civil day of that instant in the gig's zone, or the organization's for a request with no gig. A changed due day does not send another request while one is live, and the agreement reports `OVERDUE` once the day passes. `editable_until` is refused, because a signed request is never revised.

| Outcome | Answer |
| - | - |
| issued | 201, the request with `native_agreement_id` and `agreement_issue_status: 'issued'` |
| a live one-off | 400 `AGREEMENT_ALREADY_LIVE` |
| replay of the same one-off `send_id` | 200, `agreement_issue_status` stays `unchanged` on that send route |
| a client party with no contact | 400 |
| a field the request cannot fill | 422 `SIGNATURE_MERGE_MISSING`, `data.missing` lists the keys |
| a stale preview | 409 `AGREEMENT_PREVIEW_STALE` |
| `editable_until` on a form that collects a signature | 400 |
| the e-sign cap | 403 `PLAN_LIMIT_REACHED` |

### Submit and sign

`submitSignatureResponse` in `systems/signature-requests/signatureRequests.ts` is the one path that signs a form agreement. In one transaction it locks the gig (gig scope) and the agreement row, then checks the signer's proof: a share-link token, the portal contact the agreement names, or the user who holds it. Then:

| Case | Outcome |
| - | - |
| already signed with the same signer, document hash and response hash | `already_signed`, no new rows or effects |
| already signed otherwise | `locked` |
| voided | `withdrawn` |
| a different `revision_id` or document hash | `stale` |
| answers that fail the pinned revision, or no signature | `invalid` |
| a quote whose gig was signed at another price, or whose Billing currency changed, since it was sent | `out_of_date` |
| otherwise | `signed` |

A signed response writes the submission (`insertSignatureSubmissionInTx`, the signature answer stored as `{ signer_name, content_hash, native_agreement_id }`, no strokes), signs the agreement with the submission id and the response hash through `applySignatureInTx`, records `form_submitted`, and runs `completeSignatureInTx`: `contract_signed`, the record's consequences and the two agreement emails. No staff "new response" email goes out for a signature request. `signed_by` is `member:<id>`, `contact:<id>` or the user id. The repeat check reads the agreement's own signing columns, not the submission key, so a row backfilled with a stand-in still answers `already_signed`. `signNativeAgreement` signs freeform agreements only.

### Channels

| Channel | Route | Proof |
| - | - | - |
| Share link | `POST /api/v1/public/form-links/open` and `/submit` | the token |
| Portal Forms | `GET /api/v1/portal/forms/{formId}` and `POST /api/v1/portal/forms/{formId}/submissions`, only for the contact the request names | the portal contact |
| Contracts and the gig page | `POST /api/v1/portal/agreements/{agreementId}/sign` and `POST /api/v1/agreements/{agreementId}/sign`, through `signAgreementThroughRequest`, which refuses a form that asks more than a signature (open the form instead) | the portal contact or the holder |

The link and portal submits go through `submitFormRequestResponse` (`systems/forms/native/formRequestSubmit.ts`), which sends a signature request to `submitSignatureResponse` and every other request to `submitNativeFormResponse`, and pass `signingEvidence(req)`. Signed and already signed answer `stored`, `locked` stays `locked`, `stale` and `out_of_date` answer 409, a withdrawn request 404 and an invalid answer 400; no wire status gains a value. The holder share-link route opens a signature request like any other request the holder holds. Every lock row carries `native_agreement_id`, and `revisionWindow` is closed for a signature request whatever its deadline or gig day, so a signed request opened by link or portal reports `completed` with `can_revise: false` and presents no form.

`presentRequest` in `systems/forms/native/nativeFormPresent.ts` serves both kinds. A signature request presents the revision's items with each text block replaced by the agreement's presented block (a legacy `terms` block folded in front, from the pinned revision), signed file links, and `signature { content_hash, consent, disclosure }`. A voided request presents no form.

### The request's gig

A request's gig is `form_assignments.event_id`, and it comes from where the request is sent. A gig's Staffing, Contacts or Documents tab sends for that gig. A rule sends for the gig its trigger names. The organization-level assign modal sends for the gig staff pick, or for none. A form has no scope: ENG-1832 dropped `form_definitions.scope`, `form_assignments.form_scope`, the `form_assignments_scope` CHECK and the `FormScope` type, which made a gig form name a gig and an organization form name none, and left no way to create an organization form from the web. The only gig rule left is the party table's: a client needs a gig.

### Exclusion sites

| Site | Rule |
| - | - |
| `getFormRequestDelivery`, `LINKABLE_ASSIGNMENT` (delivery, share token, holder route) | no agreement test since ENG-2011 retired contract chains |
| inbox FORM arm | exclude signature requests; they list as AGREEMENT rows |
| `getNativeFormLink` | no purpose test |
| `editNativeFormSubmissionAnswers`, `matchFormSubmission`, staff create and edit | refuse a signature request |
| `listPortalForms` | exclude signature requests; Contracts lists them |
| `resolvePortalForm` | a signature request only for the contact it names |
| `shareLinkRefusal` | refuse an archived form only; an agreement request takes a link like any request |
| `findGigDocumentForms` (gig-document sends) | exclude a form whose published revision collects a signature, so a send never writes a request row that breaks `form_assignments_signature_link` |

### Wire and receipt

Every agreement a form produced carries `request` (`NativeAgreementRequestLink`: the request, its form and revision, its chain revision and status). ENG-2011 dropped the contract link `form` (`NativeAgreementFormLink`) and the receipt's "Agreement type" row with the contract chain. The spec also carries `collects_signature` on the form list, `scope` on form content and on a new form, `native_agreement_id` on an assignment, `signature` on an opened form and the 422 body. A form agreement's receipt prints the presented blocks, the answers with their labels, the agreement and answer hashes and the signature. `systems/agreements/signatureRequests.ts` is imported only by `systems/signature-requests/` and the receipt (`signatureRequestStoreBoundary.test.ts`).

### Decisions

* **Response hash.** `response_hash` is `responseHash(answers)` over the non-signature answers (question id, label, value), not chained with the document hash. The row holds both hashes and the receipt prints both, which binds them; backfilled chain rows already carry the unchained value, and a conditional formula would be a second mechanism for one fact.
* **Audit scope of an agreement with no gig.** The registry keeps the four `contract_*` actions at EVENT. `audit.log_change()` audits an agreement request (`form_kind = 'AGREEMENT'`) and a `form_signatures` row with no gig at ORGANIZATION scope on `organization_id`, as flights and hotels with no gig fall back to their project. Migration `20261106090700_native_agreement_audit_org_scope` added the clause for the dropped `native_agreements` table. The explicit `contract_*` rows already take ORGANIZATION scope through `auditScope`. A gig's activity feed and forensic view keep their agreement rows.
* **Removing an org member who holds an agreement.** `releaseMemberAgreementsInTx` (`systems/agreements/memberAgreements.ts`) runs in the delete's transaction and takes what to do with an agreement still to sign. The staff path (`removeOrgMember`) refuses with the titles, so staff void or wait first. The Clerk membership removal voids every unsigned, unvoided agreement the member holds (`contract_voided` with no actor and the reason `member_removed`), because Clerk has already applied the removal and a webhook that cannot be applied never converges. Then every agreement, signed or voided, stays with the person: its recipient and its request move from the member to the member's user. The moved request rows take `:<orgMemberId>` as a suffix on `instance_key`, so a request the person already held as a user for the same form and gig does not collide on the party unique key; nothing parses the key. A live user-held request blocks the next issue until that request is voided. An unclaimed member with form requests is deactivated rather than deleted when its invitation is revoked, and an import rollback leaves a member who holds an agreement in place.

### What U3 still owes

* U3: the signature control on the share-link and portal signing views, and `NewAgreementFlow` on the party API behind `crm`. Since C1 a library form cannot hold a signature, so the editor never offers one; agreements come from the gig (C2 to C4). The editor's scope choice and a scope-aware assign picker are no longer owed: ENG-1832 removed the scope.
* Open: opening a request by link stamps only the request's `viewed_at`, and since ENG-1768 P2b the record reads the earlier of the two (see **Viewed** under **Agreement record**); the registry has no crew position or crew fee field, because no rule picks one position of a person on a gig; after a member removal the person can hold a user-era pending request beside the voided member-era one, and the next issue is refused while that user-held request is live.

## Documents as built (ENG-1768 P1 and P3)

A definition is one of three documents. `documentOf(row)` in `api-events/src/systems/documents/document.ts` reads it from `kind`, `is_template` and `source_template_id`, and throws when those columns disagree. Code outside `systems/documents/` asks the `Document` and never reads the columns itself. P3 added the kind columns beside `role` and wrote both. P4 reads and writes only the kind columns (see **Kind columns**).

| Document | Rows today | Kind columns (P3) | Sent | Rules |
| - | - | - | - | - |
| `form` | `LIBRARY` | `kind = FORM` | many times once published | counts toward `activeForms`, fires send rules, takes no signature |
| `agreementTemplate` | `AGREEMENT_TEMPLATE` | `kind = AGREEMENT`, `is_template` | never itself: a send copies its terms into a new `agreement`. Requests issued before ENG-1768 P2b still point at it | takes a signature |
| `agreement` | `AGREEMENT` | `kind = AGREEMENT`, not `is_template` | only by the send that created it, which the module's issue enforces by `send_key` | takes a signature |

`listFormDefinitions` keeps the Forms list to forms. It filters with `isFormSql` (`kind = FORM` and not a template). Agreement templates are a separate list, `GET /api/v1/organizations/{orgId}/agreement-templates`, filtered with `isAgreementTemplateSql`. `assertManagedAsForm` refuses every agreement template on the forms write routes. An agreement template's body is `form_definitions.draft_content`, `{ title, terms, items: [] }`. It has no published revision. A send reads the terms and builds the agreement's own items (see **An agreement template holds terms only**). A signing link for a request issued before that copy shows the stored terms block when the revision questions omit it, from the presented block. This build does not read `agreement_terms`. Legacy rows whose terms exist only in that column no longer present those terms. New publishes leave the column null. The column stays for the build that is running now, and the drop is the next release. ENG-1780 owns editing, through the agreement-template routes. Save accepts title and terms. ENG-1981 retired the `wcm.msa_second_deposit` and `wcm.addendum` starters, so the catalog offers `blank`, `crew_work_agreement` and `wcm.msa`. A template row stores `template_key` null, so a retired key survives only in the `agreement_template_created` audit metadata, and nothing resolves a stored key back through `agreementStarter`.

SQL reads and writes the kind through fragments from the same file: `isFormSql`, `isAgreementTemplateSql`, `isAgreementSql`, `revisionIsAgreementSql`, `publishedIsAgreementSql`, and `definitionKindInsert`, `revisionKindInsert`, `ONE_OFF_AGREEMENT_REVISION_KIND` and `requestKindInsert` for inserts. `listFormDefinitions` also filters a requested purpose on the stored column, beside `isFormSql`. `formDefinitionJson` reports the stored purpose, so a template says `general` (see **Purpose on the wire**). Those fragments read and write `kind`, `is_template` and `form_kind`. The wire field `collects_signature` is still emitted, and it is true when the published revision's `form_kind` is `AGREEMENT`. The wire `role` comes from `documentRoleJson`: every agreement template is `agreement_template` (ENG-1982 step 3 retired `contract_template`). The agreement-template list does not use `role` and carries no kind. On the web, `documentKindOf` in `web/src/lib/types/forms.ts` still reads the forms-list `role`.

Publishing a document that takes a signature answers 400 "An agreement needs at least one signature question to publish." unless its items hold at least one signature question (`assertPublishedSignature` in `documentContent.ts`). It counts with `signatureQuestionsOf`, the filter `signatureQuestionOf` uses: a question item of type `signature`. `form_revisions_kind_signature` counts every item whose `type` is `signature`; only question items carry a `type`, so the two agree on every revision the API writes. A draft may lack one. An agreement kind also runs `requireAgreementSignatureQuestion`, which requires one or more required signature questions and no other question. Several signature questions for one party are several placements of one signing act. The rule matters for a legacy contract template with no agreement kind, which `form_revisions_kind_signature` would otherwise refuse with a 500.

`documentBoundary.test.ts` fails when code outside `systems/documents/` names `FormRole`, `form_role`, `collects_signature`, `FormKind`, `form_kind`. Tests, migrations, generated code, the audit column list and the forms OpenAPI schema are exempt. `is_template` is not banned, because other tables use the same name.

### Kind columns (ENG-1768 P3)

P3 is expand only. It adds `FormKind` (`FORM`, `AGREEMENT`), `form_definitions.kind` and `is_template`, `form_revisions.form_kind` and `form_assignments.form_kind`, fills them, and constrains them. P4 is the build that reads them. A P3 constraint that is still NOT VALID is named in a NOTICE and left: production rows are test data, and raising would stop every later deploy. P4 leaves `role`, `form_role` and both `collects_signature` columns nullable so the previous build's queries still run.

P5 (`20261116090000_form_kind_contract`) drops `role`, `form_role`, both `collects_signature` columns, the `FormRole` type, and the definition fill trigger `form_definitions_fill_kind`. It builds `form_assignments_form_revision_id_idx` first, so the request's foreign key on `form_revision_id` stays covered after the index that led with `collects_signature` is gone. Before those drops, each row whose stored purpose is `AGREEMENT` is rewritten to `GENERAL`, and a kind the backfill left NULL is filled from `role` in that same update. A row an older check still refuses is left, and `form_definitions_purpose_not_agreement` stays NOT VALID. A replay after the functions are gone rewrites only a row whose kind is already set. The check rejects a new `AGREEMENT` purpose. ENG-1840 (`20261126090000_drop_form_purpose_agreement`) rebuilds `FormPurpose` without the value and drops the check in the same retrying transaction; while a row still holds `AGREEMENT`, or `form_definitions` stays locked, it leaves both with a NOTICE. Prisma's migrate output does not carry NOTICEs to the boot log, so after a deploy check that `pg_enum` lists no `AGREEMENT` label for `FormPurpose`. `20261116090100_audit_table_config_seed` stops auditing the dropped columns. `20261119090000_agreement_issue_facts` stops this build from naming `agreement_terms`, `revision_number`, `supersedes_assignment_id` and `agreement_snapshot`. The columns stay for the build that is running now. Issued facts live on `native_agreements.issued_facts`. The migration installs `form_assignments_copy_issued_facts` before it backfills `issued_facts` from `agreement_snapshot`, and runs that copy again after the check rewrite so a send from the build still serving cannot land between the two. This build writes `issued_facts` before the assignment insert. `form_assignments_fill_agreement_legacy` copies those facts onto `agreement_snapshot` and fills `revision_number` for the build that is still serving. `form_assignments_copy_issued_facts` copies `agreement_snapshot` back only while `issued_facts` is null. The drop is the next release. The child triggers `form_revisions_fill_form_kind` and `form_assignments_fill_form_kind` stay. They fill `form_kind` from the definition and do not read `role`.

**Migrations.** Five files, each step in short transactions so that no ACCESS EXCLUSIVE lock is held across a scan. Prisma runs a migration file as one multi-statement query, so a `COMMIT` inside a `DO` block is refused ("invalid transaction termination"); a file splits its work with several top-level `BEGIN` ... `COMMIT` pairs instead.

| Migration | What it does | Strongest lock, and for how long |
| - | - | - |
| `20261113090000_form_kind` | the enum, `form_kind_of_role`, `form_role_is_template` and the fill functions; then, one table per transaction, `ADD COLUMN` (nullable, no default) and the fill trigger | ACCESS EXCLUSIVE per table, for the catalog change only |
| `20261113090100_form_kind_backfill` | fills existing rows, one table per transaction, with `app.audit_mode` off | ROW EXCLUSIVE and row locks (reads and writes to other rows continue) |
| `20261113090200_form_kind_indexes` | `form_definitions_id_kind_key` and the two `(form_definition_id, form_kind)` indexes, one per transaction | SHARE per table for the build (reads continue, writes wait) |
| `20261113090300_form_kind_constraints` | every constraint below, `NOT VALID`, one table per transaction | ACCESS EXCLUSIVE per table, for the catalog change only |
| `20261113090400_form_kind_validate` | validates them; while `form_revisions_kind_signature` is still NOT VALID, names the first 50 revisions whose signature count does not match their kind, and counts the rest | SHARE UPDATE EXCLUSIVE (reads and writes continue) |

`20261113090500_form_kind_audit_table_config_seed` then audits `kind`, `is_template` and `form_kind` on definitions and requests (revisions are not audited).

**Which build writes what.** The P2 build never writes the new columns. The P3 build writes both the old and the new: `definitionKindInsert` writes `role`, `kind` and `is_template` from one table in `document.ts`; `revisionKindInsert` writes `collects_signature`, `form_role` and the definition's `kind`; `requestKindInsert` copies the revision's `collects_signature` into the old column and its `form_kind` into the new one, never one from the other. One P2 write stops working once P3 is migrated: a legacy contract template with no agreement kind skips the agreement publish rule, so the P2 build can publish it with no signature question, and `form_revisions_kind_signature` refuses that revision with a 500. Franco accepted that for the rollout window; the P3 build answers 400.

**The trigger.** A `BEFORE INSERT` trigger fills a new column that a writer leaves NULL, so rows the P2 build writes during the rollout get their kind:

* `form_definitions_fill_kind` takes `kind` and `is_template` from `role` through `form_kind_of_role` and `form_role_is_template`: `LIBRARY` is `FORM`; `AGREEMENT` is `AGREEMENT`; `CONTRACT_TEMPLATE` and `AGREEMENT_TEMPLATE` are `AGREEMENT` templates.
* `form_revisions_fill_form_kind` and `form_assignments_fill_form_kind` take `form_kind` from the definition.

The columns have no default because a default would fill the column before the trigger sees it, so the trigger could not tell an omitted kind from a stated one. The triggers fire on insert only: no writer changes a definition's role or a row's definition, and the foreign keys below freeze the kind once a revision or request exists. P5 drops `form_definitions_fill_kind`, `form_kind_of_role` and `form_role_is_template`. The two child triggers stay.

**Backfill.** `form_kind_backfill` (created and dropped by the backfill migration) updates a table in batches of 5,000 rows by id. PostgreSQL re-checks every CHECK on an updated row, NOT VALID ones included, and earlier form migrations (`20261106090400_form_signature_and_scope`, `20261111090000_form_definition_role`) leave theirs NOT VALID when a legacy row breaks them. So a batch that raises `check_violation` is retried row by row, each row with the batch's lock retry (`form_kind_fill_rows`): a row whose lock times out sleeps and retries, and raises only after 5 attempts. A row that still fails keeps a NULL kind and is named in a NOTICE (`form_definitions rows <id> break an older check, so their kind stays NULL until they are cleaned`), and its revisions and requests keep a NULL `form_kind` too, because the composite foreign keys need the definition's kind. The migration never fails on data. A definition left NULL leaves `form_definitions_kind_not_null` NOT VALID, which blocks P4 until the row is cleaned. Its new revisions and requests are still accepted on both builds, with a NULL `form_kind` from the fill trigger, because P3 adds no not-null check on `form_kind` (see **Constraints**). Filling is still a required change, because `form_definitions_kind_not_null` refuses every update of a definition left NULL: a batch whose row locks time out 5 times raises, and that table's transaction rolls back (recover with the P3009 runbook in `migration-lock-safety.mdx`; the file replays safely).

**Measured** on a copy with 20,001 definitions, 36,001 revisions and 76,001 requests, three runs each, interleaved:

* The five migrations and the seed take 7.2 to 7.3 seconds; the single-transaction version they replace took 6.8 to 7.0. The backfill is 6.9 to 7.0 seconds of it, mostly `form_assignments` (about 5 of 6.2 seconds in a separate timed run), bounded by non-HOT updates to its 26 indexes.
* ACCESS EXCLUSIVE, sampled from `pg_locks` every 0.4 ms: under 1 ms per table for the column and constraint steps. The longest hold is the audit seed's trigger re-bake, 32 to 37 ms, which every generated seed takes. The version they replace held ACCESS EXCLUSIVE on all three tables for 6.6 to 6.8 seconds.
* SHARE for the index builds: 18 ms on `form_definitions`, 29 ms on `form_revisions`, 57 ms on `form_assignments`.
* While another transaction holds `form_definitions` in SHARE mode for 6 seconds, the column step retries and finishes; held for 30 seconds, it raises after 5 attempts (about 20 seconds) and rolls back that table's transaction. With one definition row locked for 6 seconds, the backfill retries and finishes; locked for 30 seconds, it raises after about 21 seconds.

**Constraints.** Each is added NOT VALID and validated by `20261113090400_form_kind_validate`. A row that breaks one, or a lock it cannot take in 5 attempts, leaves it NOT VALID with a NOTICE; new rows are checked either way. P4's migration validates every P3 constraint again before it switches the reads, and names any that stay NOT VALID in a NOTICE. The kind checks pass a NULL kind and leave it to `form_definitions_kind_not_null`, so a definition the backfill could not fill breaks only that.

A revision's or a request's `form_kind` has no not-null check in P3. A NOT VALID check still refuses every new row that breaks it, and the fill trigger copies a NULL definition kind into every new revision and request of that definition, so such a check would refuse them on both builds. The fill trigger and the composite foreign keys together keep an inserted child's `form_kind` NULL only when its definition's kind is NULL at the moment of the insert: under a filled definition the trigger replaces a NULL `form_kind` with the definition's kind, and under a NULL definition the foreign key refuses any stated kind, because no `form_definitions (id, kind)` row matches it. An update that sets `form_kind` to NULL is not refused, because the trigger fires on insert only; no writer does that. So a child's `form_kind` can still be NULL under a definition that has a kind now, in two cases. Its own row broke an older check, and the backfill named it. Or a race: a previous-build revision or request whose INSERT ran before the definitions backfill committed got a NULL `form_kind` from the trigger, and if it commits after the child backfill scanned past its id, no step fills it. Nothing names that row. P4 fills every NULL child from its definition's kind, whatever left it NULL, once its row and its definition are cleaned, then adds and validates `form_revisions_form_kind_not_null` and `form_assignments_form_kind_not_null`.

| Constraint | Holds |
| - | - |
| `form_definitions_kind_not_null` | `kind` and `is_template` are set. A CHECK, not `SET NOT NULL`, which would scan under ACCESS EXCLUSIVE and fail on a NULL row. The revision and request checks come in P4 |
| `form_definitions_kind_template` | `is_template` only when `kind = AGREEMENT` (kept after P4) |
| `form_definitions_kind_role` | `kind` and `is_template` follow `role` (dropped in P4) |
| `form_revisions_definition_kind_fkey` | `(form_definition_id, form_kind)` references `form_definitions (id, kind)` |
| `form_assignments_definition_kind_fkey` | `(form_definition_id, form_kind)` references `form_definitions (id, kind)` |
| `form_revisions_kind_signature` | an `AGREEMENT` revision holds at least one signature question and a `FORM` revision none, counted with `$[*] ? (@.type == "signature")`, the path `form_revisions_collects_signature` uses. P3 required exactly one on an agreement. `20261117090000_form_revision_signature_count` drops that cap and drops `form_revisions_one_signature`. |

Both foreign keys are `ON DELETE CASCADE ON UPDATE NO ACTION` against the unique index `form_definitions_id_kind_key`. `NO ACTION` freezes a definition's kind once it has a revision or a request, as `form_revisions_definition_role_fkey` freezes its role. `CASCADE` would instead rewrite every request of a form into an agreement request, and every revision would then fail its signature check. Each foreign key is led by its own index (`form_revisions_form_definition_id_form_kind_idx`, `form_assignments_form_definition_id_form_kind_idx`), because `fkIndexCoverage.test.ts` counts an index as covering only when its leading columns are exactly the foreign key's columns; an index led by `form_definition_id` alone does not count. The indexes are plain builds, not `CONCURRENTLY`: Prisma's multi-statement query refuses `CONCURRENTLY`, and `20260923050000_mailbox_response_indexes` records that it deadlocks with boot-time migrations on multi-instance Cloud Run.

`formKindMigration.integration.test.ts` backfills each role, replays every migration and asserts no row changed (same `xmin`), inserts rows the way the P2 build does, keeps a legacy row that breaks an older check NULL and named while filling the rest, fills a row of the same batch that another session holds locked past `lock_timeout` through the row-by-row retry, inserts a revision and a request of a definition left NULL the way the P2 build does and asserts both keep a NULL `form_kind`, asserts that the foreign keys refuse a stated kind under that definition and that the trigger fills a NULL one under a filled definition, checks that a request copies `collects_signature` and `form_kind` from a legacy contract revision with no signature, names the NOTICE for that revision and caps it at 50, and refuses one illegal row per constraint.

## Agreements module as built (ENG-1768 P2a)

Agreement storage has one home, `api-events/src/systems/agreements/`. It owns the agreement columns on `form_assignments` (`content`, `content_hash`, `presented_blocks`, `recipient_name`, the quote columns and the withdrawal) and `form_signatures`. `agreementStore.ts` is the one parse of an agreement row. ENG-1831 dropped `native_agreements`, `native_agreement_sign_links`, `form_assignments.native_agreement_id`, `revision_number` and `supersedes_assignment_id` and `form_revisions.agreement_terms`, and renamed `agreement_snapshot` to `issued_facts`; ENG-2011 stopped the Prisma client naming `agreement_kind` and `issued_facts`, and ENG-1999 drops them. The wire keeps `native_agreement_id` (an agreement request's own id), `revision_number` (always 1) and `supersedes_assignment_id` (always null) for shipped clients.

**Owners.** The module and the four agreement feature folders: `systems/native-agreements/`, `systems/signature-requests/`, `systems/agreement-forms/` and `systems/agreement-sends/`. The four folders stay where they are, because each is an Encore service and its folder name is its OpenAPI tag.

**Storage files.** `agreementStore.ts`, `agreementReadSql.ts`, `agreementQuota.ts` (the monthly e-sign cap), `countersign.ts`, `nativeAgreement.ts`, `nativeAgreementSigning.ts`, `agreementForms.ts`, `signatureRequests.ts`, `agreementSends.ts`, `memberAgreements.ts` and `quoteAgreementSigning.ts`. The forms reminder path for a signature request, its recipient lookup and `AgreementRecordLockedError` moved from `db/forms.ts` into `agreementReminders.ts`. No module source file imports `db/forms.ts`.

**Public files.** Code outside the owners imports only these:

| File | What it gives |
| - | - |
| `agreementSql.ts` | The SQL vocabulary. It imports only `Prisma`, so forms storage can use it while its own module loads. Predicates: `isSignatureRequestSql(alias)`, `isNotSignatureRequestSql(alias)`, `requestSupersededSql(alias)` and `memberHasAgreementSql(alias)`. Fragments: `requestAgreementIdSql` and `requestAgreementColumnsSql` for the forms request queries, `gigAgreementsJoinSql` for open items, `orgContractCountSql` for setup progress, and `agreementInboxArmText`, `AGREEMENT_PAYLOAD_SOURCE_SQL` and `agreementCountText` for the engagement inbox. Row helpers: `AgreementRequestRef`, `RequestAgreementColumns`, `requestAgreementId`, `isSignatureRequest` and `agreementReminderOf`. |
| `agreements.ts` | Queries other systems run on their own transaction: `eventHasSignedContract`, `lockGigAgreements` (the contract-signed closeout delete), `customerHasSignatureRequests` (the customer delete refusal) and `moveAgreementRecipientUserInTx` and `moveAgreementRecipientMemberInTx` (org member claims). Its imports are types. `eventHasSignedContract` lives in `signatureDocument.ts` and is re-exported here. |
| `agreementReminders.ts` | `queueAgreementReminderEmailInTx`, `lockFormRequestReminder`, `getAgreementReminderRecipient` and `AgreementRecordLockedError`. |
| `memberAgreements.ts` | `releaseMemberAgreementsInTx`. |
| `nativeAgreement.ts` | The agreement reads the email handlers and the form presenter need. |

**Behavior in ENG-1768 P2b.** P2b changes three things and keeps every other query result. Contract chains compared first, then copied, until ENG-2011 retired them (see **One chain**). The issue refuses an agreement outside its own send. A document agreement's `viewedAt` comes from the earlier of the agreement and its request, and a request opened after the agreement closed does not count (see **Agreement record**). The wire keeps its shape. P2b also made a request on an agreement report `purpose: 'agreement'`; ENG-1840 replaced that with the stored `general` (see **Purpose on the wire**). `agreements.integration.test.ts` holds one table over every origin (crew and client one-offs, a voided request, a link-opened request and a request opened only after signing) with each record's status, recipient, `viewedAt`, source template, `countsAsSignedContract` and the row counts after each step.

There are five public files instead of one barrel because a barrel changes what each caller loads. Through one barrel, `db/customer.ts` loaded `agreementForms.ts`, whose `Prisma.sql` runs at module load, and 70 unit suites that mock `db/prisma` failed. Each caller now imports the file its old import pointed at, or a file that needs nothing new.

**Exempt sites.** The forms wire schema (`systems/forms/endpoints/formsEndpoints.ts` and `systems/shared/openapiSchemas.generated.ts`) and the forms wire build (`systems/forms/formsValidation.ts`) still name the wire fields `native_agreement_id` and `supersedes_assignment_id`. The wire build stays in forms because moving it changes the key order of the assignment JSON. `db/schema.prisma` maps the columns.

**The boundary test.** `agreementsBoundary.test.ts` fails when any source file names a dropped table, column or Prisma accessor (`native_agreements`, `native_agreement_sign_links`, `event_contracts`, `contract_blob_sweep_cursors`, `agreement_snapshot`, `agreement_terms`, `.nativeAgreement`, `.nativeAgreementSignLink`, `.eventContract`), when a source file outside the owners names `native_agreement_id` or `supersedes_assignment_id`, or when it imports a module file that is not public. It also checks that the exempt files still name the wire fields and that every public file has an outside caller. Tests, fixtures, migrations and generated code are skipped. `signatureRequestStoreBoundary.test.ts` still holds the narrower rule inside the owners: only `systems/signature-requests/` and the receipt import `signatureRequests.ts`. `loopQueryRatchet.test.ts` treats `systems/agreements/` as storage, like `db/`.

## Form roles as built (ENG-1762 C1)

### The role

`form_definitions.role` (`FormRole`) says what a definition is. The rules each role follows now live on its document (see **Documents as built**).

| Role | What it is | Forms list and forms routes | Counts toward `activeForms` | Signature |
| - | - | - | - | - |
| `LIBRARY` | a reusable form | yes | yes | never |
| `CONTRACT_TEMPLATE` | a `purpose = 'AGREEMENT'` contract form, issued by contract chains until U4 | yes | no | yes |
| `AGREEMENT_TEMPLATE` | the draft title and terms of an agreement (C3) | no | no | no revision |
| `AGREEMENT` | a one-off agreement, created by a send (C2) | no | no | its one revision |

A new form took its role from its purpose (`newDocumentOf`, removed in ENG-1840; a new form is now always a form). The forms routes (content save and publish, update, delete, assign, file upload) refuse `AGREEMENT` and `AGREEMENT_TEMPLATE` with 400 "This is an agreement, not a form. Agreements are sent from the gig." Reading a form's content stays open. `listFormDefinitions` keeps `LIBRARY` only; agreement templates have their own list (`GET …/agreement-templates`). `assertActiveFormRoom` counts `LIBRARY` only, and creating or restoring a form of a role that does not count skips the check (`activeFormsCap`). Send rules fire for `LIBRARY` forms only.

### The library rule

A library form never collects a signature. Every content save refuses a signature question on one, draft or publish, flag on or off: 400 "Signatures belong in agreements. Send one from the gig." (`assertDocumentContent`). `publish_form` inherits it. The flag-gated signature publish from ENG-1693 is gone.

### Constraints

Migration `20261111090000_form_definition_role` adds them NOT VALID and `20261111090100_form_definition_role_validate` validates them; a row that breaks one leaves it NOT VALID with a NOTICE.

| Constraint | Holds |
| - | - |
| `form_definitions_role_purpose` | `(purpose = 'AGREEMENT') = (role = 'CONTRACT_TEMPLATE')` |
| `form_definitions_role_kind` | `agreement_kind` only on a contract template; it went with `role` in P5; ENG-2011 ignores the column and ENG-1999 drops it |
| `form_definitions_one_off` | an agreement has a published revision (its insert pre-generates the revision id) |
| `form_definitions_template_draft` | an agreement template has none |
| `form_definitions_send_key` | `send_key` and `source_template_id` only on an agreement; `(organization_id, send_key)` is unique |
| `form_revisions_definition_role_fkey` | `form_revisions.form_role` equals the definition's role; `ON UPDATE NO ACTION` freezes the role once a revision exists |
| `form_revisions_library_no_signature` | a library revision collects no signature |
| `form_revisions_one_off_single` | an agreement's revision is number 1 and collects a signature |
| `form_revisions_no_template_revision` | an agreement template has no revision |

### Backfill

`purpose = 'AGREEMENT'` became `CONTRACT_TEMPLATE`. Any other form with a revision that collects a signature became `AGREEMENT`, named in a NOTICE as `organization/form (title)`: it left the Forms list, and its requests, links and signatures keep working. Everything else stayed `LIBRARY`. A converted form with more than one revision leaves `form_revisions_one_off_single` NOT VALID.

## Reminders and deem-on-due as built (ENG-1695)

### The policy

A request's reminder policy says when its recipient gets a reminder email while the request awaits a response. `ReminderPolicy` in `api-events/src/systems/forms/reminderPolicy.ts` holds a before-due reminder, after-due reminders, or both:

| Part | Wire | Meaning |
| - | - | - |
| before due | `before_due_days` (1 to 30) | one reminder that many calendar days before the due moment |
| after due | `after_due { every_days (1 to 30), max (1 to 10) }` | the first reminder at the due moment, then one every `every_days` days, `max` in total |

The type cannot hold neither part. `parseReminderPolicy` reads the wire shape at every boundary (request bodies and stored rows), refuses unknown keys and out-of-range numbers with the field's name, and maps `null` or `{}` to `null`, meaning no scheduled reminders. A stored policy that no longer parses reads as none.

The due moment is the moment the request reads `OVERDUE`. For `due_on` it is the start of the next day in the gig's zone (the organization's without a gig), through `resolveEventZone`. For `due_at` it is that instant. A request with no due date has no slots. `reminderSlots(policy, due, createdAt)` counts days on the calendar of the due moment's zone, so a reminder keeps its wall-clock time across a daylight saving change, and it drops every slot before the request was created. A request sent inside its before-due window never gets a catch-up reminder.

`nextReminderAt(policy, due, createdAt, after)` picks the next reminder from those slots. With `after` null it is the first slot at or after creation. Otherwise it is the first slot strictly after `after`. It is null when no slot remains. `assignmentNextReminderAt(row, after)` reads a stored row (policy, due date, zones, `created_at`) into it.

### Storage

| Column | Holds |
| - | - |
| `form_definitions.default_reminder_policy` jsonb | the policy a new request copies |
| `form_assignments.reminder_policy` jsonb | the request's policy; NULL is none |
| `form_assignments.next_reminder_at` timestamp(3) | the instant of the next reminder to send, in UTC like `due_at`; NULL when none remains |

`next_reminder_at` is the durable progress per request, so a re-run or a crash never sends a slot twice. Effect rows cannot hold it, because done effects are deleted after 28 days. Its only writer is `scheduleNextReminder(tx, id, after)` in `api-events/src/db/forms.ts`, which computes it with `assignmentNextReminderAt`:

| Moment | `after` |
| - | - |
| the request is created with a policy | null, so the first slot at or after `created_at` |
| the sweep handles a reminder at `now` | `now`, so the first slot strictly after it |

Several missed slots therefore produce one reminder and then the next slot after `now`, never a burst. Two partial indexes serve the job: `form_assignments_next_reminder_at_idx` on `(next_reminder_at, id)` where it is set, and `form_assignments_deem_on_due_due_at_idx` on `due_at` where `deem_on_due`. The audit seed allows both policies (reviewed JSON) and ignores `next_reminder_at`.

### Assign and form update

`POST .../forms/{formId}/assignments` takes `reminder_policy`:

| Body | Stored policy |
| - | - |
| omitted | the form's `default_reminder_policy` when the request passes every check below and `crm` is on; else none |
| `null` | none |
| a policy | that policy, or a 400 from the first check it fails |

| Check | 400 message |
| - | - |
| `crm` is on | Scheduled reminders are not available for this organization |
| the request has `due_at` | Set a due date to schedule reminders |
| `notify_customer` is true | Reminders are emailed, so turn on the email to the recipient |
| a signature request names a gig | Scheduled reminders for a signature request need a gig. Remind the signer by hand. |
| a signature request goes to a client | Scheduled reminders for a signature request reach a client contact only. Remind the signer by hand. |

The checks after the flag are one table, `REMINDER_REFUSALS` in `reminderPolicy.ts`, which send rules read too. A failed check refuses a stated policy and stops the form default from being copied, with no 400. A policy on an AGREEMENT form answers 400 too. A request naming only a client reads the flag only when it needs it. The sweep cannot remind a signature request with no gig or with a signer who is not a client contact, so neither can hold a policy.

A signature request stores the policy on the issue that creates it. A second send of a live request is refused and leaves the stored policy and `next_reminder_at` alone. A plain form has no such path, because a duplicate answers 409. `PUT .../forms/{formId}` takes `default_reminder_policy` under the same flag and agreement rules, and `null` clears it.

`forms.FormDefinition` carries `default_reminder_policy`. `forms.FormAssignment` carries `reminder_policy` and `next_reminder_at`, which reads the column. It can lie in the past until the next sweep. It is null once no slot remains or the request no longer awaits a response (`formRequestStatusClass`), and absent with no policy.

### The job

`formsScheduleSweep` (`forms-schedule`, `7 * * * *`) is a scheduled job like the others: a `scheduled-jobs.json` entry, the `scheduled-forms-schedule` topic, a subscription that runs `formsScheduleSweepHandler(signal, now)` under the shared job lock, and a private endpoint for a manual run. The handler runs its steps in sequence, each bounded and reporting counts. A failed step is captured, the next step still runs, and the first failure is rethrown so the delivery retries. Every step converges, so a retry is safe. The third step queues gig ended send rules (see **Send rules as built**).

### Deem on due

The deem step pages the live gigs (not scheduled for deletion) that hold at least one deemable client row, by id, 100 a page and 10 pages a tick (`listDeemableGigs`). One SQL fragment, `deemableAssignmentSql` in `api-events/src/db/gigDocumentForms.ts`, decides deemable for both the job's gig list and the stamp's row list (`listDueGigDocumentAssignments`). A deemable row is a client row with `deem_on_due` whose `due_at` has passed, on the latest rendered version of its kind with no answer for that version. Its form is active and is the organization's current approval form for its template: the earliest active native form with that `template_key` whose published revision takes no signature, the form `loadGigDocumentForms` returns. So the job never lists a gig the stamp cannot clear. A row on an archived or replaced approval form drops out instead of bringing its gig back every tick.

For each gig the step calls `stampDeemedGigDocumentApprovals(event, null, now)`, which inserts the system submissions under their deterministic keys and records `gig_document_deemed_confirmed` with a SYSTEM actor. A second run inserts nothing. The step reads no flag, as the lazy path reads none. The lazy callers in `gigDocumentSendOperations.ts` and `gigDocumentPortalOperations.ts` stay as the fallback between ticks.

### Reminders

The reminder step reads the requests whose `next_reminder_at` has passed, and SQL alone decides each candidate (`listScheduledReminderRequests`). A candidate still awaits a response (`STATUS_CLASS_SQL.awaiting`), sits on an active native form, is not a gig-document row, and is on a live gig that is not CANCELLED or on none. A signature request with no gig is left out. The step pages candidates by `(next_reminder_at, id)`, 200 a page, until none remain or 500 are handled. No candidate is filtered in JS and no page cap applies, so a request cannot wait behind others that the step skips. A request on a cancelled gig keeps its `next_reminder_at` and resumes when the gig is active again. The step reads `crm` once per organization per tick. With it off, nothing is queued and `next_reminder_at` stays, so the request catches up with one reminder when the flag turns on.

For each candidate, one transaction:

1. Takes the locks in the manual path's order: the gig row for a signature request, then the reminder lock (`lockFormRequestReminder`).
2. Re-reads the request through the candidate query, so a response or a reminder handled meanwhile drops it.
3. Queues one reminder through `queueRequestReminderInTx` (`formReminders.ts`), the core manual Remind also uses. A reminder of the same kind still pending for the same address is reused, as ENG-1224 requires.
4. Moves `next_reminder_at` to the first slot strictly after `now`, or NULL.

After the commit, a newly queued reminder records `form_reminder_sent` with a SYSTEM actor and `scheduled: true`. The audit is best effort (`recordAuditBestEffort`), as on manual Remind, so an audit failure never rolls back the reminder. A reused reminder and an unreachable party write no audit row, as on the manual path.

A party with no route still has its slot handled. The step attempts the queued effects after its transactions commit, and the outbox sweep delivers the rest. A failed request is captured and retried on the next tick.

### Known gaps

* A request that stops awaiting (answered, superseded or voided) keeps its `next_reminder_at`. The candidate query leaves it out, but the index range still holds it, so each tick reads more index entries as such rows build up. Clearing the column needs a hook in every path that completes a request.
* `next_reminder_at` is computed when it is written. A later change to the gig's or the organization's time zone does not move a stored instant.
* A plain form request's policy cannot change after it is sent.

## Send rules as built (ENG-1695)

A send rule sends a form by itself when something happens: a gig is confirmed, a gig ended some days ago, crew accept, or a person joins the organization. Each rule acts once per form, person or client, and gig, through the effect outbox, and only while `crm` is on.

### The rule

A rule is a trigger and a party, with optional fields. `FormSendRule` in `api-events/src/systems/forms/formSendRules.ts` holds it, and `FORM_RULE_TRIGGERS` and `FORM_RULE_PARTIES` in `formRuleVocabulary.ts` list what each trigger takes. Both tables are keyed by the Prisma enums `FormRuleTrigger` and `FormRuleParty`, so a new value fails the build until it has an entry.

| Trigger (wire) | Fires on | Parties | Extra field |
| - | - | - | - |
| `gig_confirmed` | a gig published from DRAFT or TEMPLATE to ACTIVE | `linked_client` | none |
| `gig_ended` | 09:00 in the gig's zone on the last day plus `after_days` plus one | `linked_client`, `roster` | `after_days` (0 to 30, required) |
| `crew_accepted` | a crew offer or a gig invitation moving into ACCEPTED | `accepted_crew` | none |
| `member_added` | a person becoming an active member | `new_member` | none |

Every rule also takes `due_days` (1 to 90), and the three gig triggers take `gig_type_label_ids`, Event Type labels of the organization (`label_groups.system_key = 'event_type'`). With labels, a rule acts only on gigs tagged with one of them. Empty or absent means every gig.

`linked_client` is the gig's customers (`event_customers`). `roster` is the gig's confirmed crew when the rule acts: accepted invitations, accepted crew offers on a non-draft offer, and accepted gig members. `listConfirmedGigCrewUserIds` (`db/gigChanges.ts`) reads it from the same union as the gig-change roster `listGigCrewRoster`, with the accepted statuses only and without the access-grant rows, so a gig member backed by an accepted offer counts once. Pending invitees, managers who only manage, and the creator are not on it.

`gig_confirmed` does not take `roster`. At publish the held invitations have only just gone out, so the confirmed roster is nearly empty, and crew who accepted an offer on the draft already got any `crew_accepted` form. The save refuses the pair and names `crew_accepted` instead.

A rule's identity is its trigger and party pair. A form holds each pair once. A rule acts only on what happens at or after its `created_at`, so saving a rule never sends for past gigs. Editing `due_days` keeps `created_at`, because it changes no occurrence the rule matches. Changing `after_days` or the labels gives the rule the save moment, as a new pair gets, so lowering `after_days` or adding an Event Type cannot send at once for gigs whose new due moment already passed. The ledger still keeps a restarted rule from sending again to anyone who already got the form.

### Which forms take rules

`sendRuleRefusal(rule, form)` decides, at save and again when a rule acts:

| Refusal | Why |
| - | - |
| the published revision collects a signature | a rule cannot issue a signature request yet |
| an AGREEMENT form | an agreement is sent from its gig |
| a Soundcheck-managed form | the gig-document approval forms are not sent by rules |

A rule sends for the gig its trigger names. `gig_confirmed` and `gig_ended` send for that gig, and `crew_accepted` for the gig of the accepted offer or invitation. These three are gig triggers (`onGig` in `FORM_RULE_TRIGGERS`), so an accepted crew offer with no gig sends nothing. `member_added` sends with no gig. `linked_client` is a party of gig triggers only, so a client always gets a request with a gig. A per-rule "once per person across gigs" option is not built.

No later write can turn a saved rule into one that never sends, because every fact the refusal reads is fixed or guarded. The Soundcheck template is set when the form is created. A change into or out of AGREEMENT is refused, and a Soundcheck-managed form refuses every update. A signature revision is refused at publish while rules exist (see **Saving rules**).

### Storage

| Column or table | Holds |
| - | - |
| `form_definitions.send_rules` jsonb, default `[]` | the rules in their wire spelling plus `created_at` |
| `form_rule_sends` | one row per form, person or client, and gig that a rule decided on |

`form_rule_sends` keys a person by `user_id`, never by `org_member_id`, so a member and the same person as non-member crew are one party. Its unique key `form_rule_sends_once_key` is `(form_definition_id, customer_id, user_id, event_id)` with NULLs folded to empty strings, across every rule of the form. A row records the rule's trigger, party and `after_days`, and `sent`: true when the rule created a request, false when a request for that form, party and gig already existed. `assignment_id` points at the request and becomes NULL when the request is deleted. The row stays, so a deleted rule-sent request is never sent again by any rule. When `claimUnclaimedForEmail` merges a placeholder user into a real account, the placeholder's rows move to the account, and a row the account already holds for the same form and gig wins. The audit seed leaves the table unaudited. The request itself is audited on `form_assignments`.

### Saving rules

`PUT .../forms/{formId}` takes `send_rules`, the whole list. An empty list clears the rules and an omitted field keeps them. Since ENG-1919 no web screen edits `send_rules` or `default_reminder_policy`. Only this route sets them. `parseFormSendRules` refuses unknown keys, out-of-range numbers, a party the trigger does not take, a duplicate pair, more than 10 rules, and a label that is not an Event Type of the organization. A label the same rule already held passes, so an archived Event Type does not block a save. Rules need `crm`. Clearing them does not.

A form with a signature revision cannot hold rules, and the guard runs on both sides. `updateFormDefinition` locks the form row, then reads the published revision, and refuses with 400. `publishNativeFormRevision` publishes only when the form has no rules, and refuses with 400 "Remove this form's send rules before it collects a signature", which also rolls back the new revision. Both writes lock the form row first, so a save racing a publish has one winner.

### Triggers

A trigger site calls `enqueueFormRuleOccurrence(tx, fact)` (`formRuleTriggers.ts`) inside its own transaction and does nothing else. The call writes one `form_rule_occurrence` effect, and only when the organization has an active form whose rules name that trigger, so an organization without rules writes no rows.

| Trigger | Writer | Fires when |
| - | - | - |
| gig confirmed | `updateEventWithDateCascade` (`db/eventCrud.ts`) | `publishesEvent`: DRAFT or TEMPLATE to ACTIVE |
| crew accepted, dated offer | `execRespondRecipientDays` (`db/crewOffer.ts`) | the recipient rollup moves into ACCEPTED |
| crew accepted, dateless offer | `acceptCrewOfferRecipientAtomic` | the conditional update accepted the recipient |
| crew accepted, invitation | `updateInvitationStatusFrom`, `updateInvitationStatusForManager`, `updatePendingInvitationStatus` (`db/invitationCrud.ts`) | the invitation moves into ACCEPTED |
| member added, admin | `addOrgMember` (`db/orgMemberCrud.ts`) | the person had no active membership before the write |
| member added, Clerk and provisioning | `addOrganizationMember` (`db/orgMember.ts`), called by both Clerk membership webhooks and provisioning | the person had no active membership before the write |
| gig ended | `enqueueDueGigEndedOccurrences` (`formsScheduleSweep.ts`) | see **Gig ended** |

Gigs created ACTIVE, imports and status syncs never reach the publish writer. Any move into ACCEPTED counts, a manager accepting again after a withdrawal included. An invitation created already ACCEPTED does not. Offers are not gated by gig status, so an acceptance on a draft gig counts. `updatePendingInvitationStatus` (the SMS reply) now runs in a transaction.

`addOrganizationMember` reads the membership row with `FOR UPDATE`, upserts it and enqueues in one transaction, so a webhook retried after a crash finds both written or neither. A second `organizationMembership.created` for an active member, and the webhook that follows an admin add, write nothing. A reactivated member fires. Two first writes racing for the same person can both enqueue, and the ledger sends once. Provisioning creates an organization with no rules, so it writes nothing. Unclaimed placeholder members never fire. An import creates them without a membership webhook, and `addOrgMember` enqueues nothing when an admin reactivates one (see **The occurrence**).

### The occurrence

The payload is `FormRuleOccurrence`: the trigger, the organization, the gig or the person, and `at`, the moment it happened (the due moment for gig ended). Its idempotency key names the fact, never a rule:

| Trigger | Key after `form_rule_occurrence:` |
| - | - |
| gig confirmed | `GIG_CONFIRMED:<eventId>` |
| gig ended | `GIG_ENDED:<eventId>:<after_days>:<due day>` |
| crew accepted | `CREW_ACCEPTED:offer:<recipientId>:<at>` or `CREW_ACCEPTED:invitation:<invitationId>:<at>` |
| member added | `MEMBER_ADDED:<organizationId>:<userId>:<at>` |

An occurrence names its person by an id that survives `claimUnclaimedForEmail`, and the handler reads the current person from that row. A crew offer acceptance names its `crew_offer_recipients` row and an invitation acceptance its `Invitation` row. A claim moves `user_id` on both rows and keeps their ids, so an acceptance by a placeholder that is claimed before the handler runs sends to the claimed account. When the account already holds its own row on the same offer, the claim deletes the placeholder's duplicate and the acceptance sends nothing. The account's own row decides. A member added occurrence carries the Clerk user id of a real account, which a claim never moves. The Clerk webhooks and provisioning only see Clerk ids. `addOrgMember` takes the user id from the admin's request, so it enqueues nothing for a placeholder user id, and an admin reactivating an imported member does not fire.

The outbox dedupes a key while its row lives. Done rows are deleted after 28 days, so once-ever rests on `form_rule_sends`, never on these keys.

### Sending

`runFormRuleOccurrence` (`formRuleDispatch.ts`), behind the `form_rule_occurrence` handler, re-reads the world before it sends:

1. It reads `crm` with `clientPortalFormsState`. Off settles the effect with nothing sent. Unreadable retries in 10 minutes.
2. It loads the gig and the person and refuses an occurrence that no longer holds (`subjectRefusal`). A confirm on a gig whose last day has passed in its zone sends nothing. Neither does a gig ended occurrence for a gig that moved or one still retrying once its three-day catch-up has closed (`gigEndedCatchUpEnds`, the bound the sweep uses), an acceptance withdrawn, or a member who left.
3. It plans the rules that fit (`planRuleSends`): the trigger and `after_days` match, `created_at` is no later than `at`, the gig carries one of the rule's labels, and the form passes `sendRuleRefusal`. Each plan names the occurrence's gig, or none for `member_added`.
4. It lists each party once per occurrence and resolves each person or client through `resolveFormParty`, the manual assign path. A refusal (a sample gig, a client no longer linked, a person off the crew) records nothing, so a later occurrence can still send.
5. For each form, recipient and gig it runs one transaction. It claims the ledger row first. If a request for that form, party and gig exists under any person column, it marks the row `sent = false`. Otherwise it creates the request through `createFormRequestInTx`, the manual create core, with delivery on and `created_by` NULL, and points the row at it.

A rule with `due_days` makes the request due at the end of the send day plus `due_days`, in the gig's zone or the organization's without a gig. The form's `default_reminder_policy` is copied when `REMINDER_REFUSALS` passes, as on a manual assign with the email on, and `createFormRequestInTx` schedules `next_reminder_at`. Without `due_days` the request has no due date and no policy.

A failed recipient is captured and the rest still send. The occurrence retries, and the ledger skips what already committed. After ten attempts the worker dead-letters it with the failing form and recipient ids in the reason.

### Gig ended

The forms schedule sweep's third step reads the live gigs, not DRAFT, TEMPLATE or CANCELLED, of organizations whose active published forms hold a `gig_ended` rule (`listGigEndedCandidates`), one row per gig and `after_days`. It reads only gigs whose last day falls between the window start minus 31 days (the largest `after_days` plus one) and the window end. The existing `"Event"` index on the organization and `eventDate` serves that read: a gig starts no later than its last day, and a gig longer than `GIG_MAX_SPAN_DAYS` (366) is out of scope, so the start-date range is exact for every gig the step can send for. The step adds no index to `"Event"`, so its migration takes no lock on the gigs table. It leaves out every row whose occurrence key is already in `effect_outbox`. The key is fixed per gig, `after_days` and due day, and outbox rows outlive the three-day window by weeks, so a queued gig drops out of later reads. `dueGigEndedFacts` keeps a row when its due moment, 09:00 in the gig's zone on the last day plus `after_days` plus one, passed less than three days ago and is no earlier than the oldest such rule. Each kept row is enqueued in its own transaction under the gig ended key. The step reads keyset pages of 200 and stops after queuing 500 (`GIG_ENDED_LIMITS`), and the next hourly run reaches the rest. A sweep down for more than three days skips those gigs.

### Wire

| Field | Shape |
| - | - |
| `forms.FormDefinition.send_rules` | `forms.FormSendRule[]`, present on Soundcheck forms |
| `v1.updateFormRequest.send_rules` | `forms.FormSendRuleInput[]`, at most 10 |
| `forms.FormAssignment.sent_by_rule` | `forms.FormSendRuleRef`: `{ trigger, party }` plus `after_days` for gig ended, absent on manual requests |

`sent_by_rule` comes from the ledger row that points at the request, so it survives a rule edit or deletion. `forms.FormRuleTrigger` and `forms.FormRuleParty` are registered in `api-enums/registry.json`, and a unit test holds them equal to the vocabulary tables. No route was added.

### Known gaps

* A manual request stored by `user_id` and a rule send stored by `org_member_id` for the same person can both be created when they race in the same instant. The manual path does not take the ledger claim.
* A rule never reaches crew whose invitation has no `userId`.

## Rollout

The whole channel ships behind the `crm` flag, which gates every forms surface on web, mobile and api-events. There is no second switch beside it (see **One rollout switch per feature** in `AGENTS.md`). The flag's key stays `crm` because renaming a PostHog flag turns it off for installed builds.

## Out of scope for the project

* Mobile fill and sign screens, a FORM mailbox kind on mobile and a form push type. These are a separate track.
* Folding gig-document sends or onboarding steps into the Forms UI. They keep their screens and share the tables; their recipient resolver and the onboarding signature and file-upload steps are promoted to the shared layer rather than replaced.

## Sequence

1. ENG-1691: this page, the shared status enum with `viewed_at`, and the flag description.
2. ENG-1692: assign a form to any party, with or without a gig; delivery routing; audited share-link issuance.
3. ENG-1693: text and file blocks, `required_when` in the editor, signature on any form; crew agreements through forms.
4. ENG-1694: web party picker, Forms on Staffing and Contacts, org-level responses.
5. ENG-1695: send rules on gig events, scheduled reminders, a real deem-on-due job.
6. ENG-1696: this change drops the legacy form-provider columns, stops closeout reading `event_contracts`, replaces the portal embed-url route, and leaves the table.
7. ENG-1831: every environment holds only test data, so legacy rows are deleted rather than migrated. It deletes the `native_agreements` rows with their requests and submissions, requests on templates, `event_contracts` and every row that breaks a form constraint, then drops `native_agreements`, `native_agreement_sign_links`, `event_contracts`, `contract_blob_sweep_cursors`, the chain columns, `agreement_terms`, the legacy provider columns and the compatibility triggers in one release, and validates every constraint on `form_definitions`, `form_revisions` and `form_assignments`. A request holds `is_template` (always false, through `form_assignments_definition_template_fkey`), and one gig and client hold at most one live MSA (`form_assignments_live_msa_key`, dropped in ENG-2011).
8. ENG-2011: sends stop issuing contract chains, then `20261218090000_retire_contract_chains` deletes the MSA and addendum rows with their signatures, submissions and orphaned one-off definitions, moves each billing signed day they held to the earliest signed quote still in force on the gig or releases it (re-resolving the draft invoice due dates by the `resolveSigningDueDatesOrDefer` rule, under the billing lock), and treats an unlinked day the pre-link MSA writer stamped as held by that MSA, by the rule `adoptUnlinkedMsaSigning` used. It deletes each derived contract-signed closeout step no signed client one-off backs, drops `form_assignments_live_msa_key` and takes the contract arm out of `form_assignments_signature_link`. The Prisma client stops naming `agreement_kind`, `issued_facts` and `form_definitions.agreement_kind`. ENG-1999 drops those columns and `AgreementKind` once this build serves in dev, qa and main.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.