Skip to content

fix(agreements): certificate says "via signing link", versioned so signed certificates never change (#10513) - #10711

Merged
ToddHebebrand merged 9 commits into
mainfrom
fix/10513-certificate-signing-link-wording
Nov 2, 2026
Merged

ToddHebebrand merged 9 commits into
mainfrom
fix/10513-certificate-signing-link-wording

Conversation

@ToddHebebrand

Copy link
Copy Markdown
Collaborator

Summary

  • The signature certificate's "How" line for a token-link acceptance now reads "Electronically accepted via signing link". The old wording was "via emailed link", but the link may have been copied and shared another way (the neutral wording Todd chose on [Agreements] Signature certificate says "via emailed link" even when the tech copied the link #10513).
  • The certificate wording is now versioned, so signed evidence never changes after the fact. The certificate is derived on demand. Changing the string in place would have rewritten the certificate of every agreement already signed. The new column contract_documents.certificate_version is stamped at signing, and the append-only trigger then freezes it. NULL = v1: every agreement signed before this ships keeps printing "via emailed link". New signatures get v2.
  • Covers both sources: request-sourced agreements (agreement_requests.public_response_jti) and quote-sourced ones (quote_acceptances.acceptance_token_jti).

Linked Issues

Closes #10513

Design

  • services/agreementCertificateVersion.ts defines CertificateVersion = 1 | 2, CURRENT_CERTIFICATE_VERSION = 2, and certificateVersionFromColumn (NULL → 1, 2 → 2; anything else throws).
    • An unknown stored version fails loudly rather than printing another version's wording on signed evidence. This can only happen if a newer build stamps a row and the deploy is then rolled back.
  • freezeSignedAgreement (the single production insert site) writes certificateVersion: CURRENT_CERTIFICATE_VERSION explicitly, on both sources and every path (authored, uploaded template, external upload).
  • Renderer: howLines takes the version. Only the token-channel string is keyed by it (SIGNING_LINK_VIA). A test pins that v1 and v2 differ in that one line only. Portal, unknown, on-behalf, upload and unavailable certificates come out identical under both versions.
  • The internal channel value is renamed from emailed_link to signing_link, because the jti records that the token link was used, not how it was delivered.
  • Migration 2027-01-15-110000-contract-documents-certificate-version.sql:
    • ADD COLUMN IF NOT EXISTS certificate_version smallint with no default, so existing rows stay NULL = v1. ADD COLUMN … DEFAULT 2 would have stamped them v2.
    • A separate SET DEFAULT 2 affects future inserts only. It covers an instance still on older code that inserts during the rolling deploy.
    • contract_documents_certificate_version_chk (NULL OR >= 2) keeps NULL the only spelling of v1.
    • No backfill and no change to the append-only trigger. The migration writes no rows.
  • Export policy: certificate_version is included.
  • This adds a column to a per-org transactional table, not a config or policy table, so the partner-wide-first rule does not apply.

Overlap with #10654: merge order

Merge #10654 first, then this PR.

Merge Danger

Door: one-way, in a narrow sense. The new column is additive and nullable, and a revert of the code alone is safe because old code ignores it. But each certificate rendered for a v2 row says "via signing link" from then on. That is the intended frozen evidence. Rolling back after v2 rows exist would make old code print "via emailed link" for them until this PR is redeployed.

Blast radius: every signed agreement's certified copy (signature certificate page), both the portal/app download and the signed-copy email.

Type of Change

  • Bug fix

Testing

  • Added new tests
  • Red before green. I wrote the tests first. Against the unchanged code, agreementCertificate.test.ts + contractDocumentService.test.ts + the new export-policy pin gave 14 failed / 86 passed. After the change: 100/100. Red tests included:
    • v1 token channel keeps "via emailed link";
    • v2 prints "via signing link";
    • loader NULL → v1 / 2 → v2 for both request- and quote-sourced rows;
    • an unknown version throws;
    • freezeSignedAgreement stamps 2 on both sources;
    • certificate_version is included in the export policy.
  • New integration suite contractDocumentsCertificateVersion.integration.test.ts (real Postgres, through the real loader), 5/5:
    • request and quote sources each render "emailed link" for a NULL row and "signing link" for a v2 row;
    • an insert that omits the column defaults to 2;
    • the CHECK rejects 1 and 0 (23514);
    • breeze_app UPDATE is 42501 and owner UPDATE is 55000, and the value stays NULL.
  • Mutation control: with certificateVersionFromColumn forced to return 2 for NULL, that suite goes 3 failed / 2 passed.
  • Related integration suites, single-worker, 101/101:
    • portalAgreements (updated to assert v2 "signing link"), contractDocumentsAppendOnly, contractDocumentsEvidenceBackfill, contractDocumentsSourceCheck, agreementRequestAccept, agreementRequestRecordSigned;
    • tenant-export-policy, tenantExportErasureRoundtrip, tenantCascadeErasureBreadth.
  • Affected unit suites: 425/425 (certificate consumers, routes, export registry, agreementRequest*). Migration and schema unit suites: 3045/3045.
  • NODE_OPTIONS=--max-old-space-size=12288 npx tsc --build tsconfig.tests.json (apps/api) exit 0. pnpm db:check-drift: no drift. check-migration-naming --against-ref origin/main: OK.

Checklist

  • My code follows the project's code style
  • I have reviewed my own changes
  • No secrets, credentials, or personal data included

🤖 Generated with Claude Code

…gned certificates never change (#10513)

The signature certificate derived "Electronically accepted via emailed link"
from the public-token jti, which is also set when the technician copied the
link and shared it another way. New signatures now print the neutral
"via signing link".

The certificate is derived on demand, so changing the wording in place would
rewrite the certificate of every agreement already signed. The wording is
versioned instead: contract_documents.certificate_version is stamped at
signing (freezeSignedAgreement writes CURRENT_CERTIFICATE_VERSION = 2), and
the append-only trigger freezes it. NULL = v1 = every agreement signed before
this change, which keeps "via emailed link" byte for byte. No backfill and no
trigger change: the column is added without a default, then given DEFAULT 2
for future inserts only (covers an older instance inserting mid-rollout).
A CHECK keeps NULL the only spelling of v1; an unknown stored version fails
loudly rather than printing another version's wording.

Export policy: certificate_version is included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@strix-security

strix-security Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Strix Security Review

Warning

This pull request has 274 commits after the last Strix review (d7d0c33). Strix has not reviewed these changes.
Automatic review on push is off for this repository. To review the latest changes, tag @strix-security in a comment, or turn on re-review on push.

✅ No open security findings.

PR summary: Changes the signature certificate's token-link wording to "Electronically accepted via signing link" and makes the wording versioned: a new `contract_documents.certificate_version` column is stamped at signing (NULL = v1, new signatures = v2) so certificates of agreements already signed keep rendering the old text. Adds the migration, a version module, the renderer/loader plumbing, the insert-site stamp, and the export-policy entry.

Review summary

This PR changes the wording of the signature certificate's "How" line for token-link acceptances and, to avoid rewriting already-signed evidence, adds a certificate_version column to contract_documents that is stamped at signing and frozen by the existing append-only guard.

I reviewed every changed file, tracing the new column from the migration and schema through the single insert site, the derived-certificate loader/renderer, the export policy, and all certificate download and signed-copy email call sites.

No security vulnerability was confirmed. Key points verified: breeze_app holds only column-level UPDATE grants on contract_id/org_id while INSERT is table-level, so the new column is append-only without extra grants; freezeSignedAgreement is the only production insert and writes a compile-time constant, so no request body, portal or public-link input can influence the version (no mass assignment); the certificate loader's document scoping is unchanged and its system-scope read is still preceded by the caller's own org/RLS check; the emailed_link → signing_link rename leaves no consumer keying on the old literal and the channel never leaves the certificate module; and certificate_version is correctly classified in the fail-closed tenant export policy, riding the existing per-org row scope.

One candidate — the unbounded CHECK (certificate_version IS NULL OR certificate_version >= 2) leaving a value like 3 permanently unrenderable — was investigated and rejected: the value can only be written by a database owner or by a future build followed by a rollback, access that already permits disabling the append-only trigger or deleting the evidence row outright, and the fail-loud behaviour is deliberate and documented. The remaining suggestion is operational only (a deploy/rollback note for a future version bump), not a security fix.

Ruled out (1)
  • Can an out-of-range `certificate_version` permanently break a signed agreement's certified copy?
    The value is written only from the compile-time constant in `freezeSignedAgreement`, so setting anything else requires database-owner SQL or a future build plus rollback — access that already allows disabling the append-only trigger or deleting the row — and the fail-loud throw is a deliberate, doc…

Review 1 on this PR · d7d0c33 · requested Oct 29 17:39 UTC when the PR was opened


Reviewed by Strix
Re-run review · Configure security review settings

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Deploying breeze with  Cloudflare Pages  Cloudflare Pages

Latest commit: f5bc794
Status: ✅  Deploy successful!
Preview URL: https://614da1a7.breeze-9te.pages.dev
Branch Preview URL: https://fix-10513-certificate-signin.breeze-9te.pages.dev

View logs

…lt); document rollback + explicit-stamp rule (#10513 review)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ToddHebebrand

Copy link
Copy Markdown
Collaborator Author

Review run: one independent round, two Sonnet reviewers: pr-review-toolkit:code-reviewer (correctness, migration rules, the guarantee that old rows never change) and pr-review-toolkit:pr-test-analyzer (whether the tests discriminate).

Findings: 1 critical, raised by the test analyzer and addressed in 38d037d:

  • Gap: no test proved that rows existing at migration time stay NULL. A migration written as ADD COLUMN … DEFAULT 2 would have passed every test.
  • Fix: an integration test now asserts pg_attribute.atthasmissing = false for contract_documents.certificate_version.
  • Positive control: the same test first adds DEFAULT 2 on a probe table and confirms the probe sees the fast default.

The code reviewer found 0 significant issues. I addressed two of its low notes in 38d037d:

  • the migration header now states what a code rollback does (it re-labels v2 documents as "emailed link");
  • agreementCertificateVersion.ts now states that every insert path must stamp the version explicitly.

Left as is: a captureException for the unknown-version 500. It is optional, and the throw is deliberate.

0 findings outstanding.

Tests:

  • Unit: 125/125 (agreementCertificate, contractDocumentService, export-policy pin, migrationRlsScope).
  • New integration suite: 6/6, plus 101/101 related integration suites, run before the review fix.
  • Red before green: 14 failed against the unchanged code.
  • tsc --build tsconfig.tests.json: exit 0. db:check-drift: clean. Migration naming: OK. Test stack torn down.

Merge order: #10654 first, then rebase this PR. The rebase has one one-line conflict in tenantExportPolicyRegistry.ts; resolve it with the union of both column lists.

Status: review-clean, awaiting maintainer merge.

@ToddHebebrand
ToddHebebrand added this pull request to the merge queue Oct 30, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 30, 2026
…ract-documents-certificate-version → 2027-01-17-188000-contract-documents-certificate-version;)

Main's newest migration is 2027-01-17-140000 (#10719); the queue's Check Migrations job rejects anything that sorts before it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Todd Hebebrand and others added 2 commits October 31, 2026 14:34
…version migration after main's newest

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@ToddHebebrand

Copy link
Copy Markdown
Collaborator Author

Main merged; unshipped migration moved to 2027-01-18-162000-contract-documents-certificate-version.sql so it sorts after main's newest (83272d8).

@ToddHebebrand
ToddHebebrand added this pull request to the merge queue Oct 31, 2026
@ToddHebebrand
ToddHebebrand removed this pull request from the merge queue due to a manual request Nov 1, 2026
@ToddHebebrand
ToddHebebrand added this pull request to the merge queue Nov 1, 2026
@ToddHebebrand
ToddHebebrand removed this pull request from the merge queue due to a manual request Nov 1, 2026
@ToddHebebrand
ToddHebebrand added this pull request to the merge queue Nov 1, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Nov 1, 2026
…-01-18-170000)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@ToddHebebrand

Copy link
Copy Markdown
Collaborator Author

Main merged; unshipped migration moved to 2027-01-18-182000-contract-documents-certificate-version.sql so it sorts after main's newest (2027-01-18-170000). No functional change.

@ToddHebebrand
ToddHebebrand added this pull request to the merge queue Nov 1, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Nov 2, 2026
…-01-18-183000+)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@ToddHebebrand

Copy link
Copy Markdown
Collaborator Author

Main merged more migrations; unshipped migration moved to 2027-01-18-191000-contract-documents-certificate-version.sql so it sorts after everything queued ahead. No functional change.

@ToddHebebrand
ToddHebebrand added this pull request to the merge queue Nov 2, 2026
@ToddHebebrand
ToddHebebrand removed this pull request from the merge queue due to a manual request Nov 2, 2026
Resolve tenantExportPolicyRegistry.ts: contract_documents.included keeps
both template_name_snapshot (#10654) and certificate_version (this PR).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@ToddHebebrand

Copy link
Copy Markdown
Collaborator Author

Merged origin/main (01a6d59) into this branch, giving new head f5bc794.

Conflict: apps/api/src/services/tenantExportPolicyRegistry.ts had one conflict, in the contract_documents entry. #10654 added template_name_snapshot (varchar(255)) and this PR adds certificate_version (smallint). Both are scalar columns, so both stay in included, in migration order (188000 snapshot, then 191000 certificate version). I kept main's comment about template_name_snapshot. excludedOpen is unchanged (pdf_data). Nothing else conflicted.

Tests: these were run locally. The vitest runs for the tenantExportPolicyRegistry tests, agreementCertificate and contractDocumentService passed: 15 files, 170 tests. The tenantExportPolicy*, tenantExport and admin tenantExport route tests passed: 19 files, 130 tests. The DB-backed integration suites (orgCascadeFkOnDelete, tenantExportErasureRoundtrip, contractDocumentsCertificateVersion, portalAgreements) were not run locally, so CI's Integration Tests will cover them. git merge-tree --write-tree HEAD origin/main is clean.

@ToddHebebrand
ToddHebebrand added this pull request to the merge queue Nov 2, 2026
Merged via the queue into main with commit aab9b77 Nov 2, 2026
102 of 103 checks passed
@ToddHebebrand
ToddHebebrand deleted the fix/10513-certificate-signing-link-wording branch November 2, 2026 05:50
JonathanPitre pushed a commit to JonathanPitre/breeze that referenced this pull request Nov 2, 2026
…label; Send dialog expiry no longer jumps (LanternOps#10294) (LanternOps#10654)

## Summary

- **The internal Label no longer reaches the customer.** The Send
dialog's optional Label was the agreement's name in the portal list, the
dashboard "Awaiting your signature" card, the portal signed-agreements
list, the request and reminder emails, the signed-copy email, and the
certificate (both its "Agreement" row and the attachment filename). The
detail and public signing pages showed the template name, so the
customer saw two names for one document. Every customer-facing surface
now uses the template name. The portal/public detail DTO no longer
carries `request.label`. The Label field now has the hint "Internal
only. Customers see the template name." Staff surfaces are unchanged:
the web agreement lists, the outcome notice to the sender, and the
contract-document source note still show the label.
- **The Send dialog's expiry no longer jumps.** The date used to start
at a hard-coded today + 30 and then change to the partner default
(LanternOps#10235) once an org was picked. The default is resolved per org,
because a system user can pick orgs from different partners, so the date
now stays blank until that default is known. A hint says a blank date
uses the default. This needs no API change: `createAgreementRequest`
already resolves a blank `expiresAt` to the partner default. If the
defaults read fails, the date stays blank and the hint now says so. The
old hint said "the built-in default is shown".
- **The certificate keeps the template name it was signed under.**
Before this, the certificate and the other signed views read the live
template name, so renaming a template changed the certificate of every
agreement already signed from it. Signed evidence must not change. New
column `contract_documents.template_name_snapshot` stores the name at
signing. `freezeSignedAgreement`, the single insert site, writes it on
every path: authored template, uploaded template, external upload, and
both quote and request sources. These read the snapshot and use the live
name only when it is NULL:
- the certificate (its "Agreement" and "Template" rows and the
attachment filename)
  - the portal signed-agreements list
  - the signed-copy email
  - the portal and public-link detail view of a signed request

The staff `listContractDocuments` list keeps the live name, because
staff manage templates under their current names.

## Linked Issues

Closes LanternOps#10294

## Merge Danger

**This is now a migration PR.**

**Migration:**
`apps/api/migrations/2027-01-15-105000-contract-documents-template-name-snapshot.sql`.
- It adds the nullable `contract_documents.template_name_snapshot
varchar(255)` and backfills it from the current
`contract_templates.name`.
- It elects system scope first, is idempotent, and reports its row
count. There are no partner-export triggers on this table.
- The `contract_documents_block_update` append-only trigger freezes
every column, so the migration disables that trigger around its
NULL-only UPDATE and re-enables it in the same transaction, as the
header of `2027-01-06-170000` requires. The trigger function itself is
unchanged. Once the column is set, the trigger freezes it like any other
evidence column.
- **Rename at merge:** the file must sort after the newest migration on
main. It does today: main's newest is `2027-01-15-100000`, and this file
is slotted before LanternOps#10626 and LanternOps#10711 at `2027-01-15-110000`. Rename it if
another migration lands first.

**Backfill caveat:** existing rows get the template name as it is when
the migration runs. A template renamed *before* this ships keeps its
current name on its old certificates, not its original one. The original
survives only as the heading drawn into an authored template's frozen
PDF.

**Rolling deploy:** the column is nullable. An instance still on old
code inserts NULL, and readers fall back to the live name.

**Export policy:** `template_name_snapshot` is classified `included`.
Cascade and org-merge need no change because this is a column on an
existing table.

**Door:**
- The label change is two-way.
- The column is additive. A revert of the code leaves an unused column
behind.
- The backfill is data written under the append-only guard, so it cannot
be undone without disabling the guard again.

**Blast radius:**
- customer-facing agreement naming: portal, emails, certificate
- the staff Send dialog's expiry field
- one ALTER plus a single UPDATE on `contract_documents` at boot. Adding
a nullable column with no default only changes metadata. The UPDATE
reuses the TOAST pointers for `pdf_data`, so the PDFs are not rewritten.

**Contract change:**
- `request.label` is removed from the portal `GET
/portal/agreements/:id` and public `GET /agreements/public/:token`
payloads. The only consumer was the portal type, which this PR removes,
and it never rendered the field.
- `name` in `GET /portal/agreements` and `GET /portal/signed-agreements`
keeps its shape. It is now always the template name, and for a signed
agreement it is the name at signing.

**Decision to confirm:** the certificate previously named the agreement
by its label, as spec §3 in
`2026-10-15-standalone-agreement-signing-spec.md` (line 190) said. The
certificate is attached to the customer's signed-copy email and can be
downloaded in the portal, so it now uses the template name as it was at
signing, including for staff downloads. If staff need the label on the
certificate, that needs a staff-only variant in its own PR.

## Overlap

- LanternOps#10515 (LanternOps#10199) touches `agreementsPublic.ts`, `portal/agreements.ts`
(the `fireAndForget` hunk near line 171), and `PublicAgreementView*`.
This PR's hunks are separate: the list mapper near line 230, a fixture
line near line 20, and `agreementsPublic.test.ts:269`. `git merge-tree
--write-tree` against `fix/10199-agreement-link-signed-state` is clean.
- LanternOps#10642 (LanternOps#10290) touches only middleware. `git merge-tree` against
`fix/10290-portal-agreement-pdf-frame` is clean.

## Type of Change

- [x] Bug fix
- [x] Database migration

## Testing

- Template-name snapshot follow-up. Every test below failed before the
fix and passes after it:
- Certificate loader: renaming the template after signing leaves the
certificate content byte-for-byte unchanged, and a NULL or blank
snapshot falls back to the live name.
- `freezeSignedAgreement` writes the snapshot for the authored,
uploaded-template, external-upload and quote-source paths.
- The portal signed list, the signed-copy email (subject, html and
text), the portal detail and the public-link detail of a signed request
all show the name at signing.
  - The export-policy unit test checks that the column is classified.
- The integration test `contractDocumentsTemplateNameSnapshot` runs the
migration as the table owner with the guard on, against a signed row
that already exists. It checks the backfill, that the guard is enabled
afterwards, that a re-run is a no-op after a rename, and that rewriting
the snapshot is refused (55000). It failed with 55000 before the trigger
handling was added.
- The `portalAgreements` and `agreementsPublic` integration suites still
expected the staff label (`BAA 2027`) as the customer-facing name and
attachment filename. Verified red on this branch, then updated to the
template name. `agreementsPublic` now also asserts that the real write
path stores `template_name_snapshot`.
- After the rebase onto main: 45 agreement, contract-document, portal,
quote-acceptance and tenant-export-policy integration files pass
289/289.
- Local integration runs, single fork: that suite,
`contractDocumentsAppendOnly`, `contractDocumentsEvidenceBackfill`,
`tenant-export-policy`, `orgMergeRegistry`, `tenantCascade`,
`orgCascadeFkOnDelete`, `tenantCascadeErasureBreadth` and
`agreementRequestsRls` pass 98/98. `pnpm db:check-drift` reports no
drift. The migration guard unit tests (`autoMigrate`,
`migrationRlsScope`, `migrationPartnerExportLocks`) pass. API `tsc
--build tsconfig.tests.json` with a 12 GB heap exits 0 with 0 errors.
- [x] Added new tests. Each failed before the fix and passes after it.
The web dialog tests were also re-run against the `origin/main`
component: 4 failed.
- Portal list: the `GET /agreements` payload has no `label` key, the raw
JSON does not contain the label text, and `name` is the template name.
The same applies to the detail payload.
- Public DTO: `label` is absent. `agreementsPublic` GET asserts the
same.
- Signed-agreements list, send email, reminder email, and signed-copy
email: subject, html, and text never contain the label.
- Certificate: `agreement.name` is the template name, and the rendered
content omits the label.
- Send dialog: the date is blank until the default arrives and then
becomes the partner default with no +30 step. With no org picked, the
date is blank. A failed read leaves it blank, the hint says so, and the
create call carries no `expiresAt`. The Label hint renders.
- API: 21 agreement and portal unit test files pass, 525 tests, single
fork. Web: `SendAgreementDialog` passes 33/33, and the i18n parity,
keyUsage, terminology, coverage and locale suites pass 286/286. Portal
agreement components pass 37/37.
- `tsc --noEmit` is clean for web and portal. The full API `tsc --build
tsconfig.tests.json` exits 0 with a 12 GB heap.
- New locale keys `expiryBlankHint` and `labelHint`, plus the reworded
`expiryDefaultUnavailable`, are in all 8 locales.

## Checklist

- [x] My code follows the project's code style
- [x] I have reviewed my own changes
- [x] No secrets, credentials, or personal data included
- [ ] Settings rule 9: not applicable. This does not touch
`pages/settings/**` or add a setting.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Todd Hebebrand <todd@lanternops.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Todd Hebebrand <todd@pressless.io>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Agreements] Signature certificate says "via emailed link" even when the tech copied the link

1 participant