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 theirform_* names.
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 theform_assignments and form_submissions tables in part, but each has its own recipient model, status set and delivery code.
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,
createFormAssignmentrefused anything butcustomer_idplusevent_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
signaturequestion; signing happens only through the agreement path. - Crew agreements are freeform
NativeAgreementrows with noFormDefinition, andNativeAgreement.eventIdis 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_whenrule that server templates use. Text blocks andrequired_whennow exist (see Content model as built); file blocks follow.
Target model
A request is one publishedFormDefinition revision assigned to one party, with an optional gig.
Parties
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.
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 aform_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:
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:
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
Everyforms.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
emailparty can resolve to a removed org member, while anorg_member_idrequest refuses one. An email names a person, andlookupImportedOrgMemberresolves 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 throughupsertUnclaimedOrgMember. A roster id names a row picked from the active roster, soorg_member_idrefuses 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:
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_choiceordropdownquestion; - an option the parent does not have;
- a question that depends on itself;
- a condition on a
signaturequestion; - 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.
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 outsidePLAIN_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 storesform_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: aform_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:
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.
- Types:
sigorsignature,initials,dateordate_signed,text,checkorcheckbox,name,email. - Parties:
clientorrecipient(the default), andcompany. - Required:
signature,initials,date_signed,nameandemailare required.textandcheckboxare 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.
{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.
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 withoutcompany_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 asignature 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.
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:
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
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 isform_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
Wire and receipt
Every agreement a form produced carriesrequest (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_hashisresponseHash(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 aform_signaturesrow with no gig at ORGANIZATION scope onorganization_id, as flights and hotels with no gig fall back to their project. Migration20261106090700_native_agreement_audit_org_scopeadded the clause for the droppednative_agreementstable. The explicitcontract_*rows already take ORGANIZATION scope throughauditScope. 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_voidedwith no actor and the reasonmember_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 oninstance_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
NewAgreementFlowon the party API behindcrm. 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).
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 addsFormKind (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.
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_kindtakeskindandis_templatefromrolethroughform_kind_of_roleandform_role_is_template:LIBRARYisFORM;AGREEMENTisAGREEMENT;CONTRACT_TEMPLATEandAGREEMENT_TEMPLATEareAGREEMENTtemplates.form_revisions_fill_form_kindandform_assignments_fill_form_kindtakeform_kindfrom the definition.
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_locksevery 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 onform_revisions, 57 ms onform_assignments. - While another transaction holds
form_definitionsin 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.
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.
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:
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).
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
Migration20261111090000_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.
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:
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
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:
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:
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 whosenext_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:
- Takes the locks in the manual path’s order: the gig row for a signature request, then the reminder lock (
lockFormRequestReminder). - Re-reads the request through the candidate query, so a response or a reminder handled meanwhile drops it.
- 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. - Moves
next_reminder_atto the first slot strictly afternow, or NULL.
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_atis 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 whilecrm 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.
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:
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
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 callsenqueueFormRuleOccurrence(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.
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 isFormRuleOccurrence: 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:
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:
- It reads
crmwithclientPortalFormsState. Off settles the effect with nothing sent. Unreadable retries in 10 minutes. - 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. - It plans the rules that fit (
planRuleSends): the trigger andafter_daysmatch,created_atis no later thanat, the gig carries one of the rule’s labels, and the form passessendRuleRefusal. Each plan names the occurrence’s gig, or none formember_added. - 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. - 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 throughcreateFormRequestInTx, the manual create core, with delivery on andcreated_byNULL, and points the row at it.
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 agig_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
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_idand a rule send stored byorg_member_idfor 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 thecrm 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
- ENG-1691: this page, the shared status enum with
viewed_at, and the flag description. - ENG-1692: assign a form to any party, with or without a gig; delivery routing; audited share-link issuance.
- ENG-1693: text and file blocks,
required_whenin the editor, signature on any form; crew agreements through forms. - ENG-1694: web party picker, Forms on Staffing and Contacts, org-level responses.
- ENG-1695: send rules on gig events, scheduled reminders, a real deem-on-due job.
- 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. - ENG-1831: every environment holds only test data, so legacy rows are deleted rather than migrated. It deletes the
native_agreementsrows with their requests and submissions, requests on templates,event_contractsand every row that breaks a form constraint, then dropsnative_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 onform_definitions,form_revisionsandform_assignments. A request holdsis_template(always false, throughform_assignments_definition_template_fkey), and one gig and client hold at most one live MSA (form_assignments_live_msa_key, dropped in ENG-2011). - ENG-2011: sends stop issuing contract chains, then
20261218090000_retire_contract_chainsdeletes 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 theresolveSigningDueDatesOrDeferrule, under the billing lock), and treats an unlinked day the pre-link MSA writer stamped as held by that MSA, by the ruleadoptUnlinkedMsaSigningused. It deletes each derived contract-signed closeout step no signed client one-off backs, dropsform_assignments_live_msa_keyand takes the contract arm out ofform_assignments_signature_link. The Prisma client stops namingagreement_kind,issued_factsandform_definitions.agreement_kind. ENG-1999 drops those columns andAgreementKindonce this build serves in dev, qa and main.