Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 <old> --to-schema <new>`, 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 $$;
203 changes: 202 additions & 1 deletion prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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])
}
Loading
Loading