Partner agreement signing lives in apps/partners. An admin uploads a PDF, publishes it to a
partner organisation, the partner signs it by typing their legal name, and Matters.AI
counter-signs. Every signature is sealed into a tamper-evident hash chain called the
MAX Fingerprint, which anyone with access to the stored columns can recompute independently.
This page is the complete reference. The code is the source of truth; every claim below is
anchored to a file under apps/partners/lib/agreements/.
Warning
The feature ships dark. lib/feature-access.ts gives agreements the audience
["super_admin"]. Until "partner" is added to that audience, partners cannot see the list, the
sign page or the verify page, and the enforcement gate is inert for them. See Rollout
prerequisites.
Admin /admin/agreements ── upload PDF ──► R2 (agreements/…) row: status = draft
│ Publish
▼ computeDocumentHash(r2Key) → documentSha256 frozen on the row
status = sent ── partner notified; appears in /agreements
│ Partner signs (/agreements/[id]/sign → signAgreement)
▼ signature #1 (party = partner), previousFingerprint = null
status = partner_signed
│ Admin counter-signs (CounterSignForm → counterSignAgreement)
▼ signature #2 (party = matters), previousFingerprint = fingerprint #1
status = executed ── certificate of completion rendered to an "executed" PDF
| Concern | File |
|---|---|
| Status allow-list | lib/agreements/status.ts |
| Canonical encoding | lib/agreements/canonical.ts |
| Fingerprint + verifier | lib/agreements/fingerprint.ts |
| Payload construction | lib/agreements/signature-payload.ts |
| Consent wording | lib/agreements/consent.ts |
| Document hashing | lib/agreements/document-hash.ts |
| Expected parties | lib/agreements/expected-parties.ts |
| Enforcement resolver | lib/agreements/enforcement.ts, lib/agreements/path-exemption.ts |
| Enable-guard | lib/agreements/enforceability.ts |
| Delete guard | lib/agreements/deletability.ts |
| Certificate PDF | lib/agreements/certificate.ts |
| Server actions | lib/actions/agreement-signing.ts, lib/actions/agreements.ts |
| Enforcement toggle | lib/actions/portal-settings.ts, /admin/settings/enforcement |
| Partner pages | app/(portal)/agreements/ (list, [id]/sign, [id]/verify) |
| Download | app/api/agreements/download/[id]/route.ts |
| End-to-end spec | apps/partners/e2e/agreement-signing.spec.ts |
Statuses are draft, sent, partner_signed, executed, voided, superseded and legacy.
The transition table in status.ts is an allow-list: a pair that is not listed is illegal, so
adding a status without deciding its transitions fails closed.
draft ──► sent ──► partner_signed ──► executed ──► superseded
│ │ │
└─────────┴─────────────┴────────► voided (from draft, sent, partner_signed ONLY;
executed can never be voided)
legacy ──► voided (a paper record is created directly; it can only be voided, never deleted)
| From | Legal targets | Triggered by |
|---|---|---|
draft | sent, voided | Admin Publish; admin Void |
sent | partner_signed, voided | Partner signs; admin Void |
partner_signed | executed, voided | Admin Counter-sign; admin Void |
executed | superseded | Admin Supersede (a live replacement is required) |
voided | none | terminal |
superseded | none | terminal |
legacy | voided | Admin Void (the only action; Delete is refused) |
What each status means:
draft — an admin work-in-progress. Hidden from the partner. Deletable.sent — published. The document's SHA-256 is frozen on the row at this moment. The partner
list shows Awaiting your signature.partner_signed — the partner has signed; Matters.AI has not. Shown as Awaiting
Matters.AI counter-signature. The record is now immutable.executed — both parties signed. A certificate-of-completion PDF is produced as the
executed variant of the document.
Partners get it too: for an executed agreement the portal list shows Download signed PDF
(?variant=executed, falling back to the original if the copy is missing) and the verify page
offers the same signed download with its Certificate of Completion.voided — withdrawn, with a mandatory reason. Retained, never deleted.superseded — replaced by a newer live agreement; only reachable from executed.legacy — signed on paper outside the portal and recorded by an admin using the
"already signed on paper" checkbox. It has no signature rows and no fingerprint, and is
displayed as On file. It is a real contract whose stored copy is the only one, so it can be
voided (with a reason, audited) but never deleted. Voiding is the answer to a mis-upload.Every status change that matters is conditional on the status that was just read
(WHERE status = <what we read>), so a race between signing and voiding cannot void a freshly
signed agreement. Losing the race returns a "changed while you were…" message rather than
corrupting state.
A fingerprint is the SHA-256 of a canonical string built from the signature's own fields. The second signature's hash includes the first signature's fingerprint, forming a chain.
The field order is frozen in V1_FIELD_ORDER (canonical.ts). It must never be reordered —
only a new payload version may differ.
| # | Field | Type | Notes |
|---|---|---|---|
| 1 | v | number | Always 1 |
| 2 | agreementId | string (uuid) | Binds the signature to one agreement |
| 3 | documentSha256 | string (hex) | SHA-256 of the PDF bytes, frozen at publish |
| 4 | party | string | partner or matters |
| 5 | signerClerkUserId | string | Clerk user id of the signer |
| 6 | signerEmail | string | NFC-normalised, trimmed, lower-cased |
| 7 | typedLegalName | string | NFC-normalised, trimmed |
| 8 | signerTitle | string | NFC-normalised, trimmed |
| 9 | consentText | string | The attestation exactly as shown; NFC-normalised, trimmed |
| 10 | drawnSignatureSha256 | string | null | SHA-256 of the drawn-signature image, or null in typed mode |
| 11 | ipAddress | string | null | First x-forwarded-for entry, or null |
| 12 | userAgent | string | null | Request user-agent, or null |
| 13 | signedAt | string | ISO-8601 UTC with milliseconds, e.g. 2026-10-08T12:00:00.000Z |
| 14 | previousFingerprint | string | null | Preceding signature's fingerprint; null for the first |
V1_FIELD_ORDER, never
by JSON.stringify on an object. Object key order is an accident of how the object was built,
and the digest must be reproducible years from now by code that does not exist yet.signerEmail, typedLegalName,
signerTitle, consentText). Without it, the same person typing the same name on macOS and
Windows can produce different byte sequences and the signature would verify on one machine and
fail on the other.null is preserved, written as the literal null, never as an empty string or omitted. A
missing IP and an empty IP are different facts.JSON.stringify of the single string), so quotes, newlines
and non-ASCII characters are escaped deterministically. Numbers are written bare.{ + "key":value pairs joined by , + } with no whitespace.The digest is sha256(utf8(canonical string)), lower-case hex.
documentSha256 and previousFingerprint are inside the hash#documentSha256 inside the hash binds the signature to the exact bytes that were published.
A fingerprint over only the signer's identity would stay "valid" after someone swapped the PDF.
With the digest inside, changing a single byte of the document changes what the signature
attests to, and re-verification (which re-hashes the stored object) reports document_altered.previousFingerprint inside the hash turns two independent records into a chain. The
counter-signature commits to the partner's signature: edit, remove or reorder the first row and
the second row's stored predecessor no longer matches. It also makes the order of signing
part of the evidence.Everything needed is in the stored columns of agreement_signatures and the agreement's
document_sha256.
Select the row and its agreement:
SELECT s.*, a.document_sha256 FROM agreement_signatures s JOIN partner_agreements a ON a.id = s.agreement_id WHERE s.agreement_id = '<id>' ORDER BY s.signed_at;
Build the string, in exactly this order (shown wrapped; the real string has no whitespace
between pairs). signedAt is the column rendered as ISO-8601 UTC with milliseconds.
{"v":1,"agreementId":"<agreement_id>","documentSha256":"<document_sha256>",
"party":"<party>","signerClerkUserId":"<signer_clerk_user_id>",
"signerEmail":"<lower-cased signer_email>","typedLegalName":"<typed_legal_name>",
"signerTitle":"<signer_title>","consentText":"<consent_text>",
"drawnSignatureSha256":<"…" or null>,"ipAddress":<"…" or null>,
"userAgent":<"…" or null>,"signedAt":"<signed_at>",
"previousFingerprint":<"…" or null>}
Hash it, for example with Node:
const { createHash } = require("node:crypto");
createHash("sha256").update(canonicalString, "utf8").digest("hex");
Compare with agreement_signatures.fingerprint. For the second signature,
previousFingerprint must equal the first row's fingerprint.
The partner-facing verify page shows each full digest and its predecessor, so a partner can do the same computation without any access to the database by checking the displayed fields.
/agreements/[id]/verify re-reads the stored document from R2, re-hashes it, and runs
verifyAgreementChain. The set of parties that must be present is derived server-side from the
status just read (expectedPartiesFor) and never from the request: an empty expectation would
pass vacuously.
| Status | Parties required |
|---|---|
partner_signed | partner |
executed, superseded | partner, matters |
draft, sent, voided, legacy | none — the page shows the status, not a verdict |
The checks run in this order and the first failure wins:
| Outcome | Meaning | Operational reading |
|---|---|---|
ok | Document hashes to the stored digest; every signature is present, ordered, linked and recomputes exactly. | Verified. This is a statement about consistency, not about who could write the DB (see threat model). |
document_altered | The bytes in storage no longer hash to documentSha256. | The PDF object was replaced or corrupted. Treat as an incident. |
chain_incomplete | Wrong number of signatures, or a party in the wrong position. | A signature row was removed, added, or the order is wrong. |
signature_foreign | A signature cites another agreement id, another document digest, or an unsupported payload version. | A row was transplanted from another agreement or document. |
chain_broken | A signature's previousFingerprint is not its predecessor's fingerprint. | The first row was edited, replaced, or the second was re-pointed. |
fingerprint_mismatch | The stored fingerprint does not equal the recomputation from the row's own fields. | A field on that row was edited without recomputing the fingerprint. |
The page also has non-verdict states: Nothing to verify yet (not signed), voided (records retained and explicitly not verified against the document), no document digest recorded (a signed agreement with a missing digest is a hard failure), and Unable to verify right now (R2 unreadable — explicitly not an accusation of tampering; retry shortly).
The construction is deliberately unkeyed and public so a partner can verify independently. That choice defines exactly what it can and cannot prove.
document_altered.fingerprint_mismatch.chain_incomplete / chain_broken.signature_foreign.Anyone who can WRITE the database can edit a row and recompute that fingerprint and every later one. The hash is unkeyed and the algorithm is public, so a malicious or compromised database writer can produce a fully self-consistent forged chain, and verification will report
ok.
This is the same statement made in the code comment on verifyAgreementChain; the docs must not
claim more than the code does. Specifically it is not proof against:
Closing that gap needs an anchor outside the database: emailing the digest to the signers at signing time, an RFC-3161 trusted timestamp, append-only / WORM storage, or asymmetric signing. Switching to HMAC or a KMS-held key would make fingerprints unverifiable by the partner, which defeats the independent-verifiability goal, so it is not done.
Two further honest limits:
documentViewedAt is not part of the fingerprint (see below).Set per agreement at upload (signatureMode):
typed (default) — typed full legal name, job title, and the consent checkbox.typed_and_drawn — all of the above plus a drawn mark. The drawing is uploaded to
agreements/signatures/<clerkOrgId>/… through /api/agreements/signature-upload; the key must
sit under the signer's own org prefix (path traversal and cross-tenant keys are refused), and the
server re-reads the object and hashes it itself, rejecting a mismatch with the browser's digest.The typed attestation is the legal act in both modes; the drawn image is supplementary. The
canvas is pointer-only, so a keyboard or assistive-technology user cannot complete a
typed_and_drawn signing — prefer typed unless a drawn mark is specifically required.
The Sign agreement button is disabled until the typed legal name and the job title are both
non-blank (whitespace does not count), the consent checkbox is ticked, and — in
typed_and_drawn mode — a drawing is ready and any upload has finished. The server re-validates
all of it; the button state is convenience, not enforcement.
Built in lib/agreements/consent.ts:
{organisation} to this agreement, and I intend my typed
name above to be my legal electronic signature."The page builds the text and posts its SHA-256 (consentTextSha256); the server rebuilds it with
the same helper and refuses a mismatch, so the stored text is provably the text that was shown. It
is stored verbatim on the row (not as a version pointer) because a dispute is always about what
this person actually saw. The wording needs counsel review, and "my typed name above" depends on
the form layout, so someone reading only the stored string sees a reference that does not resolve.
Per signature row: signer Clerk user id, signer email, typed legal name, title, verbatim consent
text, drawn-signature key and digest (if any), IP address (first x-forwarded-for entry), user
agent, signedAt, previousFingerprint, fingerprint, and documentViewedAt.
documentViewedAt means opened, not read#The sign form records a timestamp the first time the signer clicks Open agreement. It is
client-reported, clamped server-side (values before the agreement was issued, unparsable,
or more than five minutes in the future are discarded and stored as null), and not part of the
fingerprint. It evidences that the document was opened; it says nothing about whether it was
read.
Enforcement is a single portal-wide setting at /admin/settings/enforcement (super-admin only,
audited as agreement.enforcement_toggled). There are two modes.
| Mode | Behaviour |
|---|---|
| Off | No blocking. A partner with an agreement in sent sees the action-required banner above portal pages linking to the sign page. |
| On | An organisation with a required agreement in sent is redirected from any portal page to that agreement's sign page until it signs. |
A required agreement is one uploaded with the Required checkbox. The oldest required sent
agreement is the blocker ("the one they have waited on longest").
Danger
Enforcement is PORTAL-WIDE, not per organisation. agreement_enforcement_enabled is a single
row in portal_settings. Switching it on enforces every partner org that holds a required
sent agreement. The Playwright suite e2e/agreement-signing.spec.ts toggles it: run that suite
only against an isolated environment with a disposable database. A killed run (SIGKILL, a
cancelled CI job) cannot clean up and can leave enforcement ON for every partner. Recovery:
/admin/settings/enforcement then Turn enforcement off.
Each would otherwise cause a mass lockout the first time enforcement is switched on.
partner_signed — the partner has done their part; we are the ones who are late. Blocking
them would be a self-inflicted outage.legacy — signed on paper before the portal existed. There is nothing for them to sign.Turning enforcement on is refused unless at least one agreement template exists
(assertEnforceable), otherwise newly approved partners would be blocked with nothing to sign.
Templates are agreements uploaded with the Template checkbox, optionally targeted to partner
types. A template must be finalized before it counts: on the admin agreements table a template
row offers Finalize template instead of Publish. Finalizing hashes the stored document
(computeDocumentHash) and writes only documentSha256; the template stays a draft, is never
sent, has no issuedAt, and nobody is notified (audit action agreement.template_finalized).
Finalizing again is a harmless no-op, and a finalized template shows Finalized — issuable.
Enforcement counts only finalized, non-voided templates, so an un-finalized template leaves
templateCount at 0 and the toggle refuses. When enforcement is on, the matching template is cloned into a sent agreement for each newly approved organisation (issueTemplateToOrg; an org that already holds a non-voided agreement is not issued another, and no template match means nothing is issued). The confirm dialog shows how many
organisations will be redirected immediately.
/agreements/* and /api/* are exempt from the gate (isAgreementGateExempt), because the sign
page is itself inside the (portal) layout and would otherwise redirect to itself forever. The
path reaches server components through the x-pathname header stamped by middleware.ts; a
missing header skips the gate (fails open) and logs an error.canAccessAgreements);
otherwise requireFeature would bounce them back to /dashboard and the two redirects would
loop.Turn enforcement off. That action is always permitted (assertEnforceable returns allowed when
not enabling), takes effect on the next request, and is audited. Then diagnose: check the
agreement's status, that the document is readable from R2, and that the partner's account type can
reach the agreements feature.
The gate is a server-component check in the portal layout. It does not re-run on client-side soft navigation. A partner who already has the portal open keeps access until a hard load (a refresh, or a navigation that triggers a full server render of the layout). Plan communications accordingly; do not expect enforcement to eject sessions mid-use.
Delete is refused once any signature exists (assertDeletable), and for any status other than
draft or sent. legacy is not deletable: it is a real paper contract and the stored copy may be
the only one, so it is voided instead. A signed agreement is evidence; deleting it destroys the only record of
a legal act. A foreign key from agreement_signatures also stops a delete that races a signature.
| Action | From | Effect |
|---|---|---|
| Void | sent, partner_signed, legacy (the table also permits draft, but the admin UI offers Delete for drafts) | Marks the agreement withdrawn with a mandatory reason, audited. The partner is notified unless it was a draft. Signature rows and R2 objects are retained. The verify page shows them as a historical record, not verified. |
| Supersede | executed only | Links the executed agreement to a live replacement belonging to the same organisation (supersededById). Replacement cannot be itself, a template, or a voided/superseded/legacy agreement. Audited. |
In short: void says "this should never have bound anyone"; supersede says "this was valid and is
now replaced". An executed agreement cannot be voided — it can only be superseded.
The certificate PDF is rendered after the counter-signature commits, so a storage outage or
renderer failure can leave an agreement legally executed with no executed copy. Symptom: the admin
row for an executed agreement offers Regenerate executed PDF (alongside plain Download) instead of Download executed,
and ?variant=executed has nothing to serve. Regenerate is offered for every executed agreement,
including those that already have an executed copy, so a corrupt or stale copy can be replaced. If a
stored drawn-signature image is unreadable, the render logs the agreement id and signature key,
omits that visual stamp and notes "Drawn mark: could not be embedded" on the certificate page; the
certificate otherwise still carries the full record. Signatures on the certificate are ordered by
their position in the hash chain (partner first), not by timestamp.
/admin/agreements, find the agreement, click Regenerate executed PDF.detail string; they name the position and both digests.document_altered: compare the two digests in the detail. Pull the object from R2 and hash it.
If the object differs from what was published, restore it from a bucket version or backup and
re-verify. Do not edit documentSha256 to make the page go green — that destroys the
evidence.fingerprint_mismatch / chain_broken / chain_incomplete / signature_foreign: a database row
changed. Check the activity log and database audit trails, restore the row from backup, and
treat it as a security incident, because the inverse (a writer who recomputed the chain) is
undetectable by this page.These are user-owned decisions tracked in apps/partners/PENDING_TASKS.md:
"partner" to the agreements audience in lib/feature-access.ts — this single change decides
whether any of the above does anything.pnpm db:migrate on each environment to apply drizzle/0004_*.Known limitation: inline document viewing is not implemented. The app sends X-Frame-Options: DENY
and CSP frame-src 'self', and the download route redirects cross-origin to R2, so a framed
document would render blank. The sign page uses an Open agreement link instead.
partner_agreements and agreement_signatures tables