From aa45c2294cc8f8b70e5ee3eeba4f6ec3356a4f68 Mon Sep 17 00:00:00 2001 From: QSchlegel Date: Sun, 23 Aug 2026 20:29:56 +0200 Subject: [PATCH] feat(documents): contract parties, roles, optional parties and dual roles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the closed #385 after both open questions came back maximal: one human MAY hold two roles, and optional / non-signing parties ARE a launch requirement. They turn out to be the same change — both need the capacity in the signed bytes — so doing them together costs far less than either alone. NO SIGNOFF_DOMAIN BUMP, CONTRARY TO WHAT I EXPECTED `partyId` is an OPTIONAL payload field. `canonicalize` filters undefined keys, so a threshold signature produces bytes byte-identical to before this field existed and every proof already issued keeps verifying. A domain bump is only needed to RENAME or re-mean a field, not to add one. Proven by a test that asserts threshold bytes contain no partyId and that partyId: null canonicalizes identically to omitting it. It has to be in the SIGNED bytes rather than beside them: a contract's claim is "the Buyer signed as Buyer", and once one human can hold two roles, two signatures from one address are otherwise indistinguishable. The verifier binds it, so a relabelled capacity fails verification instead of quietly re-attributing a signature. A PARTY-AWARE OUTCOME RULE `evaluateThreshold` counts approvals anonymously, which cannot decide a contract: an optional Witness in the snapshot lets one be Approved over a REQUIRED party's explicit rejection, and leaving the Witness out denies them at submission instead. `evaluateContractOutcome` decides on WHICH parties acted — every required party approved, any required party rejected, optional parties recorded and never counted. A test asserts the two rules genuinely diverge on the same facts. It also refuses to decide a party set with nothing required: "every required party approved" is vacuously true over an empty set, and approving a contract nobody had to sign is the worst possible default. THE COST OF DUAL ROLES, PAID EXPLICITLY Allowing one address two capacities means dropping DocumentReview_versionId_signerAddress_key. That would silently weaken THRESHOLD mode, where one signer acting twice on a version is still wrong and the in-transaction check reads rows fetched before the write. A partial unique index restores exactly the old guarantee exactly where it still applies (WHERE partyId IS NULL). Prisma cannot express a WHERE on @@unique, so it is raw SQL with a note on how migrations are generated here and why that keeps it safe. VERIFIED AGAINST A THROWAWAY POSTGRES - one human on two parties with one address: allowed - that human signs the same version twice, once per capacity: allowed - twice as the SAME party: refused - threshold mode, one signer twice: still refused - RLS on both new tables, in this migration rather than a follow-up 1174 unit tests, 85 integration tests, tsc clean, next build exit 0. Still required before parties mode works, unchanged from #385: the access layer (a named party cannot reach any document procedure today), the startReview parties branch, the ordering gate and row lock in submitSignerAction, and rendering ContractField values into the body before hashing. Co-Authored-By: Claude Opus 5 --- .../migration.sql | 145 +++++++++++++ prisma/schema.prisma | 203 +++++++++++++++++- src/__tests__/contractOutcome.test.ts | 198 +++++++++++++++++ src/lib/documents/payload.ts | 64 ++++++ src/lib/documents/proof.ts | 29 ++- 5 files changed, 634 insertions(+), 5 deletions(-) create mode 100644 prisma/migrations/20260823110000_contract_parties_fields_and_roles/migration.sql create mode 100644 src/__tests__/contractOutcome.test.ts diff --git a/prisma/migrations/20260823110000_contract_parties_fields_and_roles/migration.sql b/prisma/migrations/20260823110000_contract_parties_fields_and_roles/migration.sql new file mode 100644 index 00000000..c551a40d --- /dev/null +++ b/prisma/migrations/20260823110000_contract_parties_fields_and_roles/migration.sql @@ -0,0 +1,145 @@ +-- CreateEnum +CREATE TYPE "DocumentSigningMode" AS ENUM ('threshold', 'parties'); + +-- CreateEnum +CREATE TYPE "ContractFieldKind" AS ENUM ('signature', 'initials', 'date', 'text', 'checkbox'); + +-- CreateEnum +CREATE TYPE "SignatureMethod" AS ENUM ('cip8Wallet', 'ausweisApp', 'eudiWallet'); + +-- DropIndex +DROP INDEX "DocumentReview_versionId_signerAddress_key"; + +-- AlterTable +ALTER TABLE "Document" ADD COLUMN "signingMode" "DocumentSigningMode" NOT NULL DEFAULT 'threshold'; + +-- AlterTable +ALTER TABLE "DocumentReview" ADD COLUMN "method" "SignatureMethod" NOT NULL DEFAULT 'cip8Wallet', +ADD COLUMN "partyId" TEXT; + +-- CreateTable +CREATE TABLE "ContractParty" ( + "id" TEXT NOT NULL, + "documentId" TEXT NOT NULL, + "role" TEXT NOT NULL, + "displayName" TEXT NOT NULL, + "email" TEXT, + "address" TEXT, + "required" BOOLEAN NOT NULL DEFAULT true, + "signingOrder" INTEGER NOT NULL DEFAULT 0, + "inviteTokenHash" TEXT, + "invitedAt" TIMESTAMP(3), + "inviteExpiresAt" TIMESTAMP(3), + "inviteConsumedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ContractParty_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ContractField" ( + "id" TEXT NOT NULL, + "documentId" TEXT NOT NULL, + "partyId" TEXT NOT NULL, + "kind" "ContractFieldKind" NOT NULL, + "label" TEXT, + "anchor" TEXT NOT NULL, + "required" BOOLEAN NOT NULL DEFAULT true, + "value" TEXT, + "filledAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ContractField_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ContractParty_inviteTokenHash_key" ON "ContractParty"("inviteTokenHash"); + +-- CreateIndex +CREATE INDEX "ContractParty_documentId_idx" ON "ContractParty"("documentId"); + +-- CreateIndex +CREATE INDEX "ContractParty_address_idx" ON "ContractParty"("address"); + +-- CreateIndex +CREATE INDEX "ContractParty_inviteExpiresAt_idx" ON "ContractParty"("inviteExpiresAt"); + +-- CreateIndex +CREATE INDEX "ContractField_partyId_idx" ON "ContractField"("partyId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ContractField_documentId_anchor_key" ON "ContractField"("documentId", "anchor"); + +-- CreateIndex +CREATE INDEX "DocumentReview_partyId_idx" ON "DocumentReview"("partyId"); + +-- CreateIndex +CREATE UNIQUE INDEX "DocumentReview_versionId_partyId_key" ON "DocumentReview"("versionId", "partyId"); + +-- AddForeignKey +ALTER TABLE "DocumentReview" ADD CONSTRAINT "DocumentReview_partyId_fkey" FOREIGN KEY ("partyId") REFERENCES "ContractParty"("id") ON DELETE NO ACTION ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ContractParty" ADD CONSTRAINT "ContractParty_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ContractField" ADD CONSTRAINT "ContractField_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ContractField" ADD CONSTRAINT "ContractField_partyId_fkey" FOREIGN KEY ("partyId") REFERENCES "ContractParty"("id") ON DELETE CASCADE ON UPDATE CASCADE; + + +-- Threshold mode keeps its one-action-per-signer guarantee. +-- +-- Dropping DocumentReview_versionId_signerAddress_key above is the direct cost +-- of letting one human hold two roles: a tenant who is also their own guarantor +-- signs the same version twice, once per capacity. But that only applies to +-- party-attributed reviews. For wallet-threshold sign-off — every row where +-- partyId IS NULL — one signer acting twice on one version is still wrong, and +-- the in-transaction "has already acted" check reads rows fetched before the +-- write, so it cannot stop two concurrent submissions on its own. +-- +-- A partial unique index restores exactly the old guarantee, exactly where it +-- still holds. Prisma cannot express `WHERE` on @@unique, so it lives here. +-- +-- KEEP THIS. Migrations in this repo are generated with +-- `prisma migrate diff --from-schema --to-schema `, which compares +-- two schema files and never sees this index, so it will not be dropped by +-- accident — but a diff taken `--from-migrations` would propose removing it. +CREATE UNIQUE INDEX "DocumentReview_versionId_signerAddress_threshold_key" + ON "DocumentReview" ("versionId", "signerAddress") + WHERE "partyId" IS NULL; + +-- Row Level Security — same contract as 20251215090000_enable_rls_disable_postgrest +-- and the per-table block in 20260805090000_add_document_signoff. In this +-- migration rather than a follow-up because ContractParty holds the only +-- identifiable third-party data in the document stack (a counterparty's email), +-- and 20260823090000 is the precedent for what happens when it is forgotten. +DO $$ +DECLARE + tbl TEXT; +BEGIN + FOR tbl IN + SELECT unnest(ARRAY['ContractParty', 'ContractField']) + LOOP + IF EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND tablename = tbl) THEN + EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tbl); + + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN + EXECUTE format( + 'CREATE POLICY "deny_all_anon_%s" ON %I FOR ALL TO anon USING (false) WITH CHECK (false)', + tbl, tbl + ); + END IF; + + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN + EXECUTE format( + 'CREATE POLICY "deny_all_authenticated_%s" ON %I FOR ALL TO authenticated USING (false) WITH CHECK (false)', + tbl, tbl + ); + END IF; + END IF; + END LOOP; +END $$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e1f07dce..653a8a00 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -470,6 +470,13 @@ model Document { events DocumentEvent[] draft DocumentDraft? + /// Which rule decides this document's rounds. Defaulting to `threshold` is + /// what makes this migration a no-op for every document that already exists. + signingMode DocumentSigningMode @default(threshold) + + parties ContractParty[] + contractFields ContractField[] + @@index([walletId]) @@index([walletId, status]) @@index([createdBy]) @@ -524,9 +531,40 @@ model DocumentReview { signedAt DateTime // client-asserted, server-validated against a window createdAt DateTime @default(now()) - @@unique([versionId, signerAddress]) + /// The party this signature was made as; null in threshold mode, where the + /// signer is a wallet signer and signs in no particular capacity. + /// + /// NoAction, not Prisma's default. The default for an optional relation is + /// SetNull, which would silently strip the capacity off an already-signed, + /// already-exported review on an append-only table. Cascade is worse: it + /// deletes the signature itself, with none of deleteDocument's retype-the- + /// title guard or its pre-delete AuditLog row, and would revert a fully + /// executed contract to InReview on the next recount. NoAction is checked at + /// the end of the statement, so deleting a whole document still cascades + /// cleanly while a party who has signed cannot be deleted on their own. + partyId String? + party ContractParty? @relation(fields: [partyId], references: [id], onDelete: NoAction) + + /// How this signature was produced. Only `cip8Wallet` is written today. + /// + /// This column is not yet part of the signed bytes, and until it is it is an + /// index rather than evidence — see the party-signing work. Nothing but the + /// default is written while parties mode is unimplemented, so there is + /// nothing to misrepresent yet. + method SignatureMethod @default(cip8Wallet) + + /// NOT unique on (versionId, signerAddress) any more: one human holding two + /// roles signs twice on the same version, once per capacity. Threshold mode + /// keeps its one-action-per-signer guarantee through the already-acted check + /// inside submitSignerAction's transaction, which is covered by an + /// integration test rather than by an index that parties mode cannot keep. + /// + /// One review per party per version. NULLs are distinct in Postgres, so every + /// threshold-mode row (partyId null) is unaffected by this. + @@unique([versionId, partyId]) @@index([versionId]) @@index([signerAddress]) + @@index([partyId]) } // The signer set and threshold that applied when the round started. Frozen so @@ -721,3 +759,166 @@ model DocumentDraft { updatedAt DateTime @updatedAt createdAt DateTime @default(now()) } + +// --------------------------------------------------------------------------- +// Contracts — named parties on top of Document Sign-Off. +// +// Sign-off is M-of-N over a wallet's own signers. A contract is N-of-N over +// named parties who are usually NOT members of the wallet. The roster lives on +// the Document so it survives a re-issue; everything per-round stays where it +// already is — frozen in DocumentSignerSnapshot, proved by DocumentReview — so +// the two can never drift. +// --------------------------------------------------------------------------- + +/// Which rule decides a version's outcome. +enum DocumentSigningMode { + /// M-of-N over the wallet's signers, read from the Wallet row at startReview. + threshold + /// Every named party signs. startReview builds the snapshot from the parties + /// with requiredSigners = the party count, so `evaluateThreshold` yields + /// N-of-N with no second evaluator and no change to the verifier. + parties +} + +/// What a party is expected to put at an anchor in the body. +enum ContractFieldKind { + signature + initials + date + text + checkbox +} + +/// How a signature was produced. Only `cip8Wallet` is implemented; the others +/// exist now because adding an enum value later is its own migration, and +/// migrations reach production through an action that does not self-retry. +/// +/// Note these describe two different things that will need separating: how the +/// bytes were signed, and how the human was identified. AusweisApp and EUDI are +/// identification protocols — a signature made after an eID check may still be +/// a CIP-8 one. +enum SignatureMethod { + /// CIP-8 COSE_Sign1 from a Cardano wallet, including utxos.dev passkey wallets. + cip8Wallet + /// German eID. Not implemented. + ausweisApp + /// EUDI wallet. Not implemented. + eudiWallet +} + +/// A named party to a contract: who they are, in what capacity they sign, and +/// the invite that lets a non-member of the wallet reach the review at all. +/// +/// Deliberately holds NO per-round state — no status, no viewedAt, no +/// decidedAt. `uploadVersion` and `publishDraft` supersede the version and +/// reset approvals to zero without touching this row, so a per-round column +/// here would survive a reset it has no business surviving: an ordering gate +/// reading a stale "signed" would wave through a countersignature on a version +/// the first party has never seen. Whether a party signed is not an opinion +/// this table stores — it is the existence of a DocumentReview carrying their +/// signature over the canonical payload. +model ContractParty { + id String @id @default(cuid()) + documentId String + document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) + + /// Free text, because every contract names its parties differently ("Buyer", + /// "Lessee", "Witness"). Copied with displayName into the frozen snapshot's + /// signersDescriptions at startReview — that is how the capacity a signature + /// was made in reaches the exported proof without a proof-format bump. + role String + displayName String + /// Where the invite is sent. The only identifiable third-party data in the + /// document stack, which is why this table gets its RLS block in the same + /// migration rather than a follow-up. + email String? + + /// Null until the party redeems their invite and connects a wallet. A round + /// cannot start while any party is null here: the snapshot freezes addresses + /// and the signed payload binds signerAddress, so a party with no address at + /// startReview could never act on that version. + address String? + + /// Whether this party's signature is needed for the contract to complete. + /// + /// An optional party (a Witness, an observer) may sign or decline without + /// blocking. This is exactly why `evaluateThreshold` cannot decide a + /// contract: it counts approvals anonymously, so an optional party sitting in + /// the snapshot would let one be Approved over a REQUIRED party's explicit + /// rejection. Parties mode uses the party-aware evaluator in payload.ts. + required Boolean @default(true) + + /// Parties with equal values sign in parallel; a party may act only once + /// every party with a strictly lower value has a review on the version being + /// signed. Enforced in submitSignerAction — it is a rule about rows in + /// another table, so no constraint here can express it. + signingOrder Int @default(0) + + /// sha256 of a single-use invite token; the token itself is never stored. + /// This is what makes the feature reachable at all — a party is not in + /// wallet.signersAddresses, so assertWalletAccess rejects them. + inviteTokenHash String? @unique + + invitedAt DateTime? + /// Expiry and consumption, mirroring EmailVerificationToken and + /// BotClaimToken. Without them the invite is a bearer credential valid + /// forever: whoever holds the link last claims the party slot, so a mistyped + /// or later-compromised mailbox could bind a stranger's wallet to "Buyer". + inviteExpiresAt DateTime? + inviteConsumedAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + fields ContractField[] + reviews DocumentReview[] + + /// Deliberately NOT unique on (documentId, address): one human may hold two + /// roles — a tenant who is also their own guarantor — so the same wallet can + /// back two parties. That is only safe because the signed payload carries + /// partyId, so two signatures from one address are distinguishable by the + /// capacity they were made in rather than collapsing into one. + @@index([documentId]) + @@index([address]) + @@index([inviteExpiresAt]) +} + +/// A per-party placeholder in the body: which anchor belongs to whom, and what +/// kind of thing goes there. +/// +/// `value` is PRE-PUBLISH input only. A field is a term of the contract, and +/// the whole model rests on a signature binding an exact contentHash — so +/// values must be rendered into the body by publishDraft and hashed with it. A +/// value left live after the freeze would be an unsigned, mutable contract +/// term: change 50000 to 5000 after signing and every signature still verifies. +model ContractField { + id String @id @default(cuid()) + + /// Denormalised from the party so anchor uniqueness can be enforced across + /// the whole body, which spans parties. Two fields on one anchor are + /// unresolvable for the renderer, and ContractField has no other path to the + /// document. Same pattern DocumentAttestation uses for documentId. + documentId String + document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) + + partyId String + party ContractParty @relation(fields: [partyId], references: [id], onDelete: Cascade) + + kind ContractFieldKind + label String? + /// Stable marker in the body that the renderer resolves. + anchor String + required Boolean @default(true) + + /// Pre-publish input. publishDraft renders these into the markdown body + /// before hashing, so contentHash covers them; nothing may write here once + /// the document's latest version has a snapshot. + value String? + filledAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([documentId, anchor]) + @@index([partyId]) +} diff --git a/src/__tests__/contractOutcome.test.ts b/src/__tests__/contractOutcome.test.ts new file mode 100644 index 00000000..573e1083 --- /dev/null +++ b/src/__tests__/contractOutcome.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from "@jest/globals"; + +import { + buildSignOffPayload, + canonicalizeSignOffPayload, + evaluateContractOutcome, + evaluateThreshold, +} from "@/lib/documents/payload"; + +/** + * The contract outcome rule, and why it cannot be `evaluateThreshold`. + * + * A threshold counts approvals anonymously. A contract is a set of named + * obligations — so the two differ precisely where it matters: an optional + * party's refusal must not sink the agreement, and a required party's refusal + * must, no matter how many other signatures exist. + */ + +const P = (id: string, required = true) => ({ id, required }); +const R = (partyId: string | null, action: "approve" | "reject") => ({ + partyId, + action, +}); + +describe("evaluateContractOutcome", () => { + it("approves once every required party has approved", () => { + expect( + evaluateContractOutcome({ + parties: [P("buyer"), P("seller")], + reviews: [R("buyer", "approve"), R("seller", "approve")], + }), + ).toBe("Approved"); + }); + + it("stays in review while a required party has not acted", () => { + expect( + evaluateContractOutcome({ + parties: [P("buyer"), P("seller")], + reviews: [R("buyer", "approve")], + }), + ).toBe("InReview"); + }); + + it("is rejected the moment any required party rejects", () => { + expect( + evaluateContractOutcome({ + parties: [P("buyer"), P("seller")], + reviews: [R("buyer", "approve"), R("seller", "reject")], + }), + ).toBe("Rejected"); + }); + + it("ignores an optional party's refusal", () => { + // The case a threshold cannot express. A Witness declining is not the + // contract failing. + expect( + evaluateContractOutcome({ + parties: [P("buyer"), P("seller"), P("witness", false)], + reviews: [ + R("buyer", "approve"), + R("seller", "approve"), + R("witness", "reject"), + ], + }), + ).toBe("Approved"); + }); + + it("does not let an optional party's approval stand in for a required one", () => { + expect( + evaluateContractOutcome({ + parties: [P("buyer"), P("seller"), P("witness", false)], + reviews: [R("buyer", "approve"), R("witness", "approve")], + }), + ).toBe("InReview"); + }); + + it("completes without the optional party ever acting", () => { + expect( + evaluateContractOutcome({ + parties: [P("buyer"), P("witness", false)], + reviews: [R("buyer", "approve")], + }), + ).toBe("Approved"); + }); + + it("refuses to call a party set with nothing required decided", () => { + // "Every required party approved" is vacuously true over an empty set, and + // approving a contract nobody had to sign is the worst possible default. + expect( + evaluateContractOutcome({ + parties: [P("witness", false)], + reviews: [R("witness", "approve")], + }), + ).toBe("InReview"); + expect(evaluateContractOutcome({ parties: [], reviews: [] })).toBe( + "InReview", + ); + }); + + it("ignores reviews that carry no party", () => { + expect( + evaluateContractOutcome({ + parties: [P("buyer")], + reviews: [R(null, "approve")], + }), + ).toBe("InReview"); + }); + + it("counts one human holding two roles as two obligations", () => { + // Both parties may resolve to the same wallet address; the rule never sees + // addresses, only capacities, which is what makes dual roles safe. + expect( + evaluateContractOutcome({ + parties: [P("tenant"), P("guarantor")], + reviews: [R("tenant", "approve")], + }), + ).toBe("InReview"); + expect( + evaluateContractOutcome({ + parties: [P("tenant"), P("guarantor")], + reviews: [R("tenant", "approve"), R("guarantor", "approve")], + }), + ).toBe("Approved"); + }); + + it("differs from evaluateThreshold on the case that matters", () => { + // Same facts, both rules. Two approvals out of three signers with a + // required party having rejected: the anonymous count says Approved. + expect( + evaluateThreshold({ + approvals: 2, + rejections: 1, + signerCount: 3, + requiredSigners: 2, + }), + ).toBe("Approved"); + + expect( + evaluateContractOutcome({ + parties: [P("buyer"), P("seller"), P("witness", false)], + reviews: [ + R("buyer", "approve"), + R("witness", "approve"), + R("seller", "reject"), + ], + }), + ).toBe("Rejected"); + }); +}); + +describe("partyId in the signed bytes", () => { + const base = { + action: "approve" as const, + contentHash: "a".repeat(64), + documentId: "d", + signedAt: "2026-01-01T00:00:00.000Z", + signerAddress: "addr", + versionId: "v", + versionNumber: 1, + walletId: "w", + walletPolicyHash: "p", + }; + + it("leaves threshold bytes byte-identical to before contracts existed", () => { + // The reason this needs no SIGNOFF_DOMAIN bump: canonicalize drops + // undefined keys, so every proof already issued keeps verifying. + const threshold = canonicalizeSignOffPayload(buildSignOffPayload(base)); + expect(threshold).not.toContain("partyId"); + expect( + canonicalizeSignOffPayload( + buildSignOffPayload({ ...base, partyId: null }), + ), + ).toBe(threshold); + }); + + it("binds the capacity when there is one", () => { + const parties = canonicalizeSignOffPayload( + buildSignOffPayload({ ...base, partyId: "cp_buyer" }), + ); + expect(parties).toContain('"partyId":"cp_buyer"'); + // Sorted like every other key — canonical form is not insertion order. + expect(parties.indexOf('"documentId"')).toBeLessThan( + parties.indexOf('"partyId"'), + ); + }); + + it("gives two roles of one human different bytes to sign", () => { + const asTenant = canonicalizeSignOffPayload( + buildSignOffPayload({ ...base, partyId: "cp_tenant" }), + ); + const asGuarantor = canonicalizeSignOffPayload( + buildSignOffPayload({ ...base, partyId: "cp_guarantor" }), + ); + // Same address, same version, same action — and not interchangeable. This + // is what stops one signature counting twice. + expect(asTenant).not.toBe(asGuarantor); + }); +}); diff --git a/src/lib/documents/payload.ts b/src/lib/documents/payload.ts index e3d60557..c46dbc73 100644 --- a/src/lib/documents/payload.ts +++ b/src/lib/documents/payload.ts @@ -35,6 +35,19 @@ export const SIGNOFF_STATEMENTS: Record = { */ export interface SignOffPayload { action: SignOffAction; + /** + * The contract party this signature is made AS. Present only in parties mode. + * + * Optional on purpose. `canonicalize` drops undefined keys, so a threshold + * signature produces byte-identical bytes to before this field existed and + * every proof already issued keeps verifying — no SIGNOFF_DOMAIN bump. + * + * It has to be in the SIGNED bytes rather than beside them: a contract's + * claim is "the Buyer signed as Buyer", and one human may hold two roles, so + * without this two signatures from one address are indistinguishable and role + * attribution would rest on a database column nobody signed. + */ + partyId?: string; /** "" when no comment — the field is always present so it is always signed. */ comment: string; contentHash: string; @@ -52,6 +65,8 @@ export interface SignOffPayload { export interface BuildSignOffPayloadInput { action: SignOffAction; + /** Set in parties mode only; omitted for wallet-threshold sign-off. */ + partyId?: string | null; comment?: string | null; contentHash: string; documentId: string; @@ -68,6 +83,9 @@ export function buildSignOffPayload( ): SignOffPayload { return { action: input.action, + // undefined, never null: canonicalize filters undefined out, so a threshold + // payload keeps exactly the byte layout it had before parties existed. + ...(input.partyId ? { partyId: input.partyId } : {}), comment: input.comment ?? "", contentHash: input.contentHash, documentId: input.documentId, @@ -163,6 +181,52 @@ export interface ThresholdInput { * - enough signers have rejected that the threshold is unreachable → Rejected. * Anything else is still open. */ +export interface ContractPartyOutcomeInput { + /** Every party on the contract, required and optional alike. */ + parties: readonly { id: string; required: boolean }[]; + /** Reviews recorded against this version, party-attributed. */ + reviews: readonly { partyId: string | null; action: SignOffAction }[]; +} + +/** + * The outcome rule for parties mode. + * + * `evaluateThreshold` cannot decide a contract, and the reason is worth stating + * once: it counts approvals ANONYMOUSLY. Put an optional Witness into the + * snapshot and a contract can reach the count while a REQUIRED party has + * explicitly rejected it — Approved over a refusal. Take the Witness out and + * they are denied at submission instead. Neither is a contract. + * + * So this rule is about WHICH parties acted, not how many: + * + * Approved every required party approved + * Rejected any required party rejected — one refusal ends it, because + * every required signature is load-bearing in an N-of-N agreement + * InReview otherwise + * + * Optional parties are recorded and never counted: they may sign or decline + * and neither moves the outcome. + */ +export function evaluateContractOutcome( + input: ContractPartyOutcomeInput, +): ThresholdOutcome { + const required = input.parties.filter((p) => p.required); + + // A party set with nothing required would make "every required party + // approved" vacuously true and approve a contract nobody signed. startReview + // must refuse such a set; this refuses to call it decided in the meantime. + if (required.length === 0) return "InReview"; + + const byParty = new Map(); + for (const review of input.reviews) { + if (review.partyId) byParty.set(review.partyId, review.action); + } + + if (required.some((p) => byParty.get(p.id) === "reject")) return "Rejected"; + if (required.every((p) => byParty.get(p.id) === "approve")) return "Approved"; + return "InReview"; +} + export function evaluateThreshold(input: ThresholdInput): ThresholdOutcome { const { approvals, rejections, signerCount, requiredSigners } = input; if (approvals >= requiredSigners) return "Approved"; diff --git a/src/lib/documents/proof.ts b/src/lib/documents/proof.ts index b6a7662f..6e5c536d 100644 --- a/src/lib/documents/proof.ts +++ b/src/lib/documents/proof.ts @@ -27,6 +27,13 @@ export const PROOF_FORMAT = "mesh-multisig.document-signoff.proof.v1"; export interface ProofReview { signerAddress: string; signerDescription?: string | null; + /** + * The contract party this signature was made as, and the capacity it was made + * in. Absent for wallet-threshold sign-off, which is why both are optional — + * a proof exported before contracts existed stays a valid ProofReview. + */ + partyId?: string | null; + partyRole?: string | null; action: SignOffAction; comment?: string | null; /** The canonical JSON string that was signed, verbatim. */ @@ -87,7 +94,7 @@ export const VERIFICATION_INSTRUCTIONS = [ "1. Re-hash the document bytes with the algorithm in `version.hashAlgorithm` and confirm the digest equals `version.contentHash`.", "2. For each entry in `reviews`, parse `payload` as JSON and confirm `contentHash`, `versionId`, `documentId`, `walletId` and `walletPolicyHash` match this package.", "3. Verify each `signature` (COSE_Sign1) over the exact `payload` string against the signer's address, per CIP-8.", - "4. Count the entries with `action: \"approve\"` that passed step 3 and confirm the count is at least `policy.requiredSigners`.", + '4. Count the entries with `action: "approve"` that passed step 3 and confirm the count is at least `policy.requiredSigners`.', "This package is an approval attestation by the wallet's signers. It is not a qualified electronic signature.", ]; @@ -162,7 +169,12 @@ export async function verifyProofPackage( const reviews: ReviewVerdict[] = []; for (const review of pkg.reviews) { - const verdict = await verifyReview(review, pkg, snapshot, options.checkSignature); + const verdict = await verifyReview( + review, + pkg, + snapshot, + options.checkSignature, + ); if (seen.has(review.signerAddress)) { verdict.valid = false; verdict.errors.push("Duplicate review for this signer"); @@ -171,8 +183,12 @@ export async function verifyProofPackage( reviews.push(verdict); } - const approvals = reviews.filter((r) => r.valid && r.action === "approve").length; - const rejections = reviews.filter((r) => r.valid && r.action === "reject").length; + const approvals = reviews.filter( + (r) => r.valid && r.action === "approve", + ).length; + const rejections = reviews.filter( + (r) => r.valid && r.action === "reject", + ).length; const outcome = evaluateThreshold({ approvals, rejections, @@ -251,6 +267,11 @@ async function verifyReview( ["walletId", payload.walletId, pkg.document.walletId], ["walletPolicyHash", payload.walletPolicyHash, pkg.policy.walletPolicyHash], ["signerAddress", payload.signerAddress, review.signerAddress], + // Bound, not merely carried. Without this the capacity in the signed bytes + // is never checked against the package, and role attribution — the whole + // claim of a contract — would rest on an unverified field. `?? null` keeps + // threshold reviews (no party either side) passing unchanged. + ["partyId", payload.partyId ?? null, review.partyId ?? null], ["action", payload.action, review.action], ["comment", payload.comment, review.comment ?? ""], ["signedAt", payload.signedAt, review.signedAt],