diff --git a/ROADMAP.md b/ROADMAP.md
index 5f3df03f..57b145af 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -1,11 +1,51 @@
# 12-Month Roadmap: Mesh Multi-Sig Wallet
-**Timeline:** May 2026 - April 2027
-**Team:** Quirin + Andre, part-time (~25 hrs/week combined), feature-based ownership
+**Timeline:** April 2026 – March 2027
**Approach:** Month-by-month cadence combining baseline maintenance with feature delivery. No hard requirements for feature delivery or releases — tasks scale up/down based on project activity.
---
+## MRP task mapping
+
+The authoritative mapping between MRP reward tasks and the months below. **Month N of this roadmap = MRP Month N = the calendar month in the same row.** Use this table whenever an MRP task and a roadmap section appear to disagree.
+
+| MRP task | Calendar month | Roadmap section | On-chain task hash |
+|---|---|---|---|
+| MRP Month 1 | April 2026 | [Month 1](#month-1--april-2026) | — |
+| MRP Month 2 | May 2026 | [Month 2](#month-2--may-2026) | `02e1e7c8…65256f` |
+| MRP Month 3 | June 2026 | [Month 3](#month-3--june-2026) | `a833f41c…91cef8` |
+| MRP Month 4 | July 2026 | [Month 4](#month-4--july-2026) | `27034bf3…dd219a` |
+| MRP Month 5 | August 2026 | [Month 5](#month-5--august-2026) | — |
+| MRP Month 6 | September 2026 | [Month 6](#month-6--september-2026) | — |
+| MRP Month 7 | October 2026 | [Month 7](#month-7--october-2026) | — |
+| MRP Month 8 | November 2026 | [Month 8](#month-8--november-2026) | — |
+| MRP Month 9 | December 2026 | [Month 9](#month-9--december-2026) | — |
+| MRP Month 10 | January 2027 | [Month 10](#month-10--january-2027) | — |
+| MRP Month 11 | February 2027 | [Month 11](#month-11--february-2027) | — |
+| MRP Month 12 | March 2027 | [Month 12](#month-12--march-2027) | — |
+
+> **Why this table exists.** The month headings were renumbered on 2026-08-03 (`984aa46`) to match actual delivery: the original numbering started at "Month 1 — May 2026" while Month 1's own proof-of-completion table documented April work. MRP task cards created before that date therefore carry bullet text describing the **following** month — e.g. the card headed *MRP Month 2* lists the June workstreams. The table above is what governs; a card's bullet text does not.
+
+### Underlying PRs per MRP month
+
+Each MRP month resolves to a concrete, reproducible set of merged pull requests. The "all merged PRs" link runs the exact GitHub search; the counts are Quirin's authored merges in that window.
+
+| MRP month | Merged PRs (Quirin) | The actual PRs |
+|---|---|---|
+| **M1 — April 2026** | [10](https://github.com/MeshJS/multisig/pulls?q=is%3Apr+is%3Amerged+author%3AQSchlegel+merged%3A2026-04-01..2026-04-30) | [#215](https://github.com/MeshJS/multisig/pull/215) drep prerender fix · [#216](https://github.com/MeshJS/multisig/pull/216) missing User table on startup · [#217](https://github.com/MeshJS/multisig/pull/217) VKey witness filter + CI smoke system · [#218](https://github.com/MeshJS/multisig/pull/218) preprod environment · [#219](https://github.com/MeshJS/multisig/pull/219)/[#222](https://github.com/MeshJS/multisig/pull/222)/[#224](https://github.com/MeshJS/multisig/pull/224)/[#226](https://github.com/MeshJS/multisig/pull/226) 12-month roadmap + contributing guide · [#227](https://github.com/MeshJS/multisig/pull/227) invalid-CBOR guard in `addTransaction` · [#228](https://github.com/MeshJS/multisig/pull/228) M1 proof of completion |
+| **M2 — May 2026** | [3](https://github.com/MeshJS/multisig/pulls?q=is%3Apr+is%3Amerged+author%3AQSchlegel+merged%3A2026-05-01..2026-05-31) | [#257](https://github.com/MeshJS/multisig/pull/257) pin Mesh SDK + reject witnesses that don't verify against the tx body · [#259](https://github.com/MeshJS/multisig/pull/259) Import Wallet wizard · [#260](https://github.com/MeshJS/multisig/pull/260) `main`→`preprod` merge clearing #229 + CodeQL fixes |
+| **M3 — June 2026** | [51](https://github.com/MeshJS/multisig/pulls?q=is%3Apr+is%3Amerged+author%3AQSchlegel+merged%3A2026-06-01..2026-06-30) | Governance [#271](https://github.com/MeshJS/multisig/pull/271)–[#272](https://github.com/MeshJS/multisig/pull/272), [#279](https://github.com/MeshJS/multisig/pull/279), [#286](https://github.com/MeshJS/multisig/pull/286), [#296](https://github.com/MeshJS/multisig/pull/296)–[#297](https://github.com/MeshJS/multisig/pull/297), [#300](https://github.com/MeshJS/multisig/pull/300), [#302](https://github.com/MeshJS/multisig/pull/302), [#315](https://github.com/MeshJS/multisig/pull/315) · Signing & auth [#273](https://github.com/MeshJS/multisig/pull/273)–[#277](https://github.com/MeshJS/multisig/pull/277), [#281](https://github.com/MeshJS/multisig/pull/281)–[#282](https://github.com/MeshJS/multisig/pull/282), [#324](https://github.com/MeshJS/multisig/pull/324) · Mesh 2.0 groundwork [#229](https://github.com/MeshJS/multisig/pull/229), [#269](https://github.com/MeshJS/multisig/pull/269), [#278](https://github.com/MeshJS/multisig/pull/278) · Mobile & UX [#287](https://github.com/MeshJS/multisig/pull/287)–[#295](https://github.com/MeshJS/multisig/pull/295) · Landing/theme/SEO [#298](https://github.com/MeshJS/multisig/pull/298)–[#299](https://github.com/MeshJS/multisig/pull/299), [#308](https://github.com/MeshJS/multisig/pull/308)–[#318](https://github.com/MeshJS/multisig/pull/318), [#328](https://github.com/MeshJS/multisig/pull/328) · Infra & security [#284](https://github.com/MeshJS/multisig/pull/284), [#301](https://github.com/MeshJS/multisig/pull/301), [#319](https://github.com/MeshJS/multisig/pull/319) · Docs & releases [#280](https://github.com/MeshJS/multisig/pull/280), [#283](https://github.com/MeshJS/multisig/pull/283), [#285](https://github.com/MeshJS/multisig/pull/285), [#303](https://github.com/MeshJS/multisig/pull/303), [#309](https://github.com/MeshJS/multisig/pull/309), [#320](https://github.com/MeshJS/multisig/pull/320)–[#321](https://github.com/MeshJS/multisig/pull/321) |
+| **M4 — July 2026** | [16](https://github.com/MeshJS/multisig/pulls?q=is%3Apr+is%3Amerged+author%3AQSchlegel+merged%3A2026-07-01..2026-07-31) | Bot platform & API [#341](https://github.com/MeshJS/multisig/pull/341)–[#345](https://github.com/MeshJS/multisig/pull/345) · Agent/crawler surface [#346](https://github.com/MeshJS/multisig/pull/346) · DRep vote-history explorer [#337](https://github.com/MeshJS/multisig/pull/337)–[#339](https://github.com/MeshJS/multisig/pull/339) · Roadmap & delivery audit [#347](https://github.com/MeshJS/multisig/pull/347), [#350](https://github.com/MeshJS/multisig/pull/350)–[#352](https://github.com/MeshJS/multisig/pull/352) · Production hardening [#332](https://github.com/MeshJS/multisig/pull/332)–[#334](https://github.com/MeshJS/multisig/pull/334) |
+
+Reproduce any row:
+
+```bash
+gh pr list --repo MeshJS/multisig --state merged --limit 100 \
+ --search "merged:2026-06-01..2026-06-30 author:QSchlegel" --json number,title,mergedAt
+```
+
+---
+
## Baseline (applies every month)
- Issues and PRs do not stall
@@ -14,10 +54,12 @@
---
-## Delivered to date (May – July 2026)
+## Delivered to date (April – July 2026)
What the product can actually do today, as verified in the codebase on 2026-07-26. The per-month **Progress** tables below track plan-vs-actual; this section is the cumulative capability inventory, and it is the input that reshaped M4–M6.
+Coverage starts at **April**, the programme's first month — April's output is infrastructure rather than user-facing features (the preprod environment, the real-chain smoke system, transaction-integrity guards), so it shows up inside the sections below rather than as a headline capability of its own.
+
> **Caveat — delivered ≠ live.** Everything below is merged on `preprod`. `main` is 75 commits behind and the production database is four migrations behind, so a good share of this is not yet reachable on the production deployment. Closing that gap is the first item in August.
### Governance
@@ -46,11 +88,16 @@ Resend-backed email channel with a real outbox: `NotificationDelivery` carries a
### Testing & CI
- **Playwright E2E**: 11 spec files, ~54 tests, in `e2e/tests/` — wallet creation (legacy + SDK), ring transfers on real preprod, staking, proxy, DRep/ballot UI, bot management, notification settings, wallet access control, signing rejection, responsive smoke. Runs in Docker via `pr-playwright-browser.yml`, serialized against the v1 smoke job through a shared `ci-preprod-wallets` concurrency group ([#323](https://github.com/MeshJS/multisig/pull/323), [#335](https://github.com/MeshJS/multisig/pull/335), [#336](https://github.com/MeshJS/multisig/pull/336)).
-- Real-chain smoke system closed ([#213](https://github.com/MeshJS/multisig/issues/213)); deploy-migrations on Node 22 + manual dispatch ([#319](https://github.com/MeshJS/multisig/pull/319)); RLS follow-up migration authored ([#332](https://github.com/MeshJS/multisig/pull/332)); worktree gitlink fix ([#333](https://github.com/MeshJS/multisig/pull/333)).
+- **Preprod environment + real-chain smoke CI** — built in April: the `preprod` branch and environment ([#218](https://github.com/MeshJS/multisig/pull/218)) and the CI smoke-test system that exercises the route chain against real preprod ([#217](https://github.com/MeshJS/multisig/pull/217)), which skips gracefully when `SMOKE_*` secrets are absent. [#213](https://github.com/MeshJS/multisig/issues/213) closed once the first real run was linked. Everything since — the Playwright suite above included — runs on this foundation.
+- deploy-migrations on Node 22 + manual dispatch ([#319](https://github.com/MeshJS/multisig/pull/319)); RLS follow-up migration authored ([#332](https://github.com/MeshJS/multisig/pull/332)); worktree gitlink fix ([#333](https://github.com/MeshJS/multisig/pull/333)).
### Platform
-Mesh 2.0 groundwork (Prisma 7.8 + Next 16, tx-builder hardfork upgrade, wallet ops consolidated behind one bridge with an ESLint guardrail); signing & auth reliability (bech32 normalization, `signData` arg order, core-cst witness/body-hash merge, stuck-"Loading…" recovery, cross-instance import, non-opaque wallet-session status codes); mobile foundations, skeleton/empty states, error toasts, pagination; landing + SEO + glass theme overhaul; on-chain wallet registration and discovery ([#340](https://github.com/MeshJS/multisig/pull/340)).
+**Transaction & signing integrity** — the through-line from April onward: extraneous VKey witnesses filtered out of submitted transactions ([#217](https://github.com/MeshJS/multisig/pull/217)); an invalid-CBOR guard on `POST /api/v1/addTransaction` plus a degraded "unreadable transaction" card with Reject & Delete, so an API-poisoned row can no longer lock a wallet's UTxOs ([#227](https://github.com/MeshJS/multisig/pull/227), [#211](https://github.com/MeshJS/multisig/issues/211)); Mesh SDK pinned to exact versions after a lockfile patch drift changed Conway CBOR encoding and made multisig DRep votes fail on chain, with a client-side guard that now rejects witnesses which don't verify against the body they're attached to ([#257](https://github.com/MeshJS/multisig/pull/257)).
+
+**Wallet lifecycle** — Import Wallet wizard covering four sources (another multisig instance, Summon, native-script CBOR, JSON backup) with `importWallet`/`exportWallet` procedures, cross-instance endpoints reusing the CIP-8 `checkSignature` path, a downloadable JSON backup and a `lockedSigners` gate so imported wallets can't silently diverge from their origin ([#259](https://github.com/MeshJS/multisig/pull/259)); on-chain wallet registration and discovery ([#340](https://github.com/MeshJS/multisig/pull/340)).
+
+**Everything else** — Mesh 2.0 groundwork (Prisma 7.8 + Next 16, tx-builder hardfork upgrade, wallet ops consolidated behind one bridge with an ESLint guardrail); signing & auth reliability (bech32 normalization, `signData` arg order, core-cst witness/body-hash merge, stuck-"Loading…" recovery, cross-instance import, non-opaque wallet-session status codes); mobile foundations, skeleton/empty states, error toasts, pagination; landing + SEO + glass theme overhaul.
### Landed ahead of schedule
@@ -163,7 +210,7 @@ End-of-month snapshot. Last updated 2026-07-26.
| FROST research kickoff (#220) | Not started | Carried to August. Needs to start there to leave runway before the October go/no-go |
| CI/maintenance baseline | Watch item — unchanged | `pr-multisig-v1-smoke.yml` still `exit 1`s in its "Validate required CI secrets" step when secrets are absent, and dependabot-triggered runs never receive repo Actions secrets. Every dependabot PR is therefore red for systemic reasons, not because of the version bump — 7 are open, the oldest since 2026-06-15. The sibling `ci-smoke-preprod.yml` already has the skip-when-unconfigured guard to copy |
| Wallet V2 (#33) | Delivered | On-chain wallet registration + discovery shipped in [#340](https://github.com/MeshJS/multisig/pull/340) |
-| Unplanned July delivery | Delivered | Bot platform, DRep vote-history explorer, Playwright E2E, and agent/API documentation all landed this month — see [Delivered to date](#delivered-to-date-may--july-2026) |
+| Unplanned July delivery | Delivered | Bot platform, DRep vote-history explorer, Playwright E2E, and agent/API documentation all landed this month — see [Delivered to date](#delivered-to-date-april--july-2026) |
---
@@ -171,7 +218,7 @@ End-of-month snapshot. Last updated 2026-07-26.
**Focus:** Close the production release gap, then start Document Sign-Off (see [Flagship feature](#flagship-feature--document-sign-off)).
-Revised 2026-07-26. July's actual output ([Delivered to date](#delivered-to-date-may--july-2026)) freed the M7/M8 documentation and bot slots, and surfaced a release gap that outranks all feature work.
+Revised 2026-07-26. July's actual output ([Delivered to date](#delivered-to-date-april--july-2026)) freed the M7/M8 documentation and bot slots, and surfaced a release gap that outranks all feature work.
**Quirin**
diff --git a/prisma/migrations/20260805090000_add_document_signoff/migration.sql b/prisma/migrations/20260805090000_add_document_signoff/migration.sql
new file mode 100644
index 00000000..4df95649
--- /dev/null
+++ b/prisma/migrations/20260805090000_add_document_signoff/migration.sql
@@ -0,0 +1,190 @@
+-- Document Sign-Off (PRD-001) — five-entity model.
+--
+-- Approval binds to an exact content hash on a DocumentVersion, never to the
+-- mutable Document container. DocumentSignerSnapshot freezes the wallet's
+-- signer set + threshold at review start so later membership changes cannot
+-- rewrite history. DocumentEvent is append-only and feeds the proof export.
+
+-- CreateEnum
+CREATE TYPE "DocumentStatus" AS ENUM ('Draft', 'InReview', 'Approved', 'Rejected', 'Superseded', 'Archived');
+
+-- CreateEnum
+CREATE TYPE "DocumentReviewAction" AS ENUM ('approve', 'reject');
+
+-- CreateEnum
+CREATE TYPE "DocumentStorageMode" AS ENUM ('hashOnly', 'inline', 'external');
+
+-- CreateTable
+CREATE TABLE "Document" (
+ "id" TEXT NOT NULL,
+ "walletId" TEXT NOT NULL,
+ "title" TEXT NOT NULL,
+ "description" TEXT,
+ "documentType" TEXT,
+ "createdBy" TEXT NOT NULL,
+ "status" "DocumentStatus" NOT NULL DEFAULT 'Draft',
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ "archivedAt" TIMESTAMP(3),
+
+ CONSTRAINT "Document_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "DocumentVersion" (
+ "id" TEXT NOT NULL,
+ "documentId" TEXT NOT NULL,
+ "versionNumber" INTEGER NOT NULL,
+ "contentHash" TEXT NOT NULL,
+ "hashAlgorithm" TEXT NOT NULL DEFAULT 'sha256',
+ "fileName" TEXT,
+ "mimeType" TEXT,
+ "fileSize" INTEGER,
+ "storageMode" "DocumentStorageMode" NOT NULL DEFAULT 'hashOnly',
+ "contentRef" TEXT,
+ "contentInline" TEXT,
+ "reviewInstructions" TEXT,
+ "status" "DocumentStatus" NOT NULL DEFAULT 'Draft',
+ "createdBy" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "reviewStartedAt" TIMESTAMP(3),
+ "decidedAt" TIMESTAMP(3),
+ "supersededAt" TIMESTAMP(3),
+
+ CONSTRAINT "DocumentVersion_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "DocumentReview" (
+ "id" TEXT NOT NULL,
+ "versionId" TEXT NOT NULL,
+ "signerAddress" TEXT NOT NULL,
+ "action" "DocumentReviewAction" NOT NULL,
+ "comment" TEXT,
+ "payload" TEXT NOT NULL,
+ "signature" TEXT NOT NULL,
+ "signatureKey" TEXT NOT NULL,
+ "signedAt" TIMESTAMP(3) NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "DocumentReview_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "DocumentSignerSnapshot" (
+ "id" TEXT NOT NULL,
+ "versionId" TEXT NOT NULL,
+ "walletId" TEXT NOT NULL,
+ "signersAddresses" TEXT[],
+ "signersDescriptions" TEXT[],
+ "requiredSigners" INTEGER NOT NULL,
+ "walletPolicyHash" TEXT NOT NULL,
+ "capturedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "DocumentSignerSnapshot_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "DocumentEvent" (
+ "id" TEXT NOT NULL,
+ "documentId" TEXT NOT NULL,
+ "versionId" TEXT,
+ "type" TEXT NOT NULL,
+ "actorAddress" TEXT,
+ "metadata" JSONB,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "DocumentEvent_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "Document_walletId_idx" ON "Document"("walletId");
+
+-- CreateIndex
+CREATE INDEX "Document_walletId_status_idx" ON "Document"("walletId", "status");
+
+-- CreateIndex
+CREATE INDEX "Document_createdBy_idx" ON "Document"("createdBy");
+
+-- CreateIndex
+CREATE INDEX "DocumentVersion_documentId_idx" ON "DocumentVersion"("documentId");
+
+-- CreateIndex
+CREATE INDEX "DocumentVersion_contentHash_idx" ON "DocumentVersion"("contentHash");
+
+-- CreateIndex
+CREATE INDEX "DocumentVersion_status_idx" ON "DocumentVersion"("status");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "DocumentVersion_documentId_versionNumber_key" ON "DocumentVersion"("documentId", "versionNumber");
+
+-- CreateIndex
+CREATE INDEX "DocumentReview_versionId_idx" ON "DocumentReview"("versionId");
+
+-- CreateIndex
+CREATE INDEX "DocumentReview_signerAddress_idx" ON "DocumentReview"("signerAddress");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "DocumentReview_versionId_signerAddress_key" ON "DocumentReview"("versionId", "signerAddress");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "DocumentSignerSnapshot_versionId_key" ON "DocumentSignerSnapshot"("versionId");
+
+-- CreateIndex
+CREATE INDEX "DocumentSignerSnapshot_walletId_idx" ON "DocumentSignerSnapshot"("walletId");
+
+-- CreateIndex
+CREATE INDEX "DocumentEvent_documentId_createdAt_idx" ON "DocumentEvent"("documentId", "createdAt");
+
+-- CreateIndex
+CREATE INDEX "DocumentEvent_versionId_idx" ON "DocumentEvent"("versionId");
+
+-- CreateIndex
+CREATE INDEX "DocumentEvent_type_idx" ON "DocumentEvent"("type");
+
+-- AddForeignKey
+ALTER TABLE "DocumentVersion" ADD CONSTRAINT "DocumentVersion_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "DocumentReview" ADD CONSTRAINT "DocumentReview_versionId_fkey" FOREIGN KEY ("versionId") REFERENCES "DocumentVersion"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "DocumentSignerSnapshot" ADD CONSTRAINT "DocumentSignerSnapshot_versionId_fkey" FOREIGN KEY ("versionId") REFERENCES "DocumentVersion"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "DocumentEvent" ADD CONSTRAINT "DocumentEvent_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "DocumentEvent" ADD CONSTRAINT "DocumentEvent_versionId_fkey" FOREIGN KEY ("versionId") REFERENCES "DocumentVersion"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- Row Level Security — same contract as 20251215090000_enable_rls_disable_postgrest
+-- and 20260706100000_enable_rls_followup_tables: RLS on unconditionally, deny-all
+-- policies for the PostgREST roles when those roles exist. Prisma connects as the
+-- table owner / service role and continues to bypass RLS.
+DO $$
+DECLARE
+ tbl TEXT;
+BEGIN
+ FOR tbl IN
+ SELECT unnest(ARRAY[
+ 'Document', 'DocumentVersion', 'DocumentReview',
+ 'DocumentSignerSnapshot', 'DocumentEvent'
+ ])
+ LOOP
+ 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 LOOP;
+END $$;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index ef9363b8..953ea69b 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -245,26 +245,26 @@ model EmailVerificationToken {
}
model NotificationDelivery {
- id String @id @default(cuid())
- eventType String
- channel String
- recipientAddress String
- recipientEmail String?
- resourceType String
- resourceId String
- walletId String?
- idempotencyKey String @unique
- subject String
- payload Json
- status String @default("pending")
- provider String?
- providerMessageId String?
- attempts Int @default(0)
- lastError String?
- nextAttemptAt DateTime @default(now())
- sentAt DateTime?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ id String @id @default(cuid())
+ eventType String
+ channel String
+ recipientAddress String
+ recipientEmail String?
+ resourceType String
+ resourceId String
+ walletId String?
+ idempotencyKey String @unique
+ subject String
+ payload Json
+ status String @default("pending")
+ provider String?
+ providerMessageId String?
+ attempts Int @default(0)
+ lastError String?
+ nextAttemptAt DateTime @default(now())
+ sentAt DateTime?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
@@index([status, nextAttemptAt])
@@index([recipientAddress])
@@ -389,3 +389,142 @@ model ProposalTally {
@@unique([network, proposalId])
}
+
+// ---------------------------------------------------------------------------
+// Document Sign-Off (PRD-001) — five-entity model.
+//
+// A wallet-native, off-chain approval layer. Approval binds to an exact
+// content hash, never to the mutable document container, and inherits the
+// wallet's signer set + threshold frozen at the moment a review round starts.
+// No on-chain dependency in the MVP.
+// ---------------------------------------------------------------------------
+
+enum DocumentStatus {
+ Draft
+ InReview
+ Approved
+ Rejected
+ Superseded
+ Archived
+}
+
+enum DocumentReviewAction {
+ approve
+ reject
+}
+
+// How the version's bytes are retained. The MVP never requires the bytes —
+// the content hash is the binding — so `hashOnly` is the privacy-preserving
+// default (see the vault's "Storage Privacy Risk").
+enum DocumentStorageMode {
+ hashOnly
+ inline
+ external
+}
+
+// The stable container. Title/description may change; approval never attaches
+// here, only to a DocumentVersion.
+model Document {
+ id String @id @default(cuid())
+ walletId String
+ title String
+ description String?
+ documentType String?
+ createdBy String // signer address that created it
+ status DocumentStatus @default(Draft)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ archivedAt DateTime?
+ versions DocumentVersion[]
+ events DocumentEvent[]
+
+ @@index([walletId])
+ @@index([walletId, status])
+ @@index([createdBy])
+}
+
+// Each version is its own approval object — this is what makes version-bound
+// approval and approval-reset-on-new-version possible.
+model DocumentVersion {
+ id String @id @default(cuid())
+ documentId String
+ document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
+ versionNumber Int
+ contentHash String // lowercase hex digest of the exact bytes
+ hashAlgorithm String @default("sha256")
+ fileName String?
+ mimeType String?
+ fileSize Int?
+ storageMode DocumentStorageMode @default(hashOnly)
+ contentRef String? // external URI when storageMode = external
+ contentInline String? // base64 bytes when storageMode = inline
+ reviewInstructions String?
+ status DocumentStatus @default(Draft)
+ createdBy String
+ createdAt DateTime @default(now())
+ reviewStartedAt DateTime?
+ decidedAt DateTime? // approved or rejected
+ supersededAt DateTime?
+ reviews DocumentReview[]
+ signerSnapshot DocumentSignerSnapshot?
+ events DocumentEvent[]
+
+ @@unique([documentId, versionNumber])
+ @@index([documentId])
+ @@index([contentHash])
+ @@index([status])
+}
+
+// One signer's action on one version, with the exact payload that was signed.
+// Append-only: a signer gets one action per version, and a new version starts
+// a fresh round at zero approvals.
+model DocumentReview {
+ id String @id @default(cuid())
+ versionId String
+ version DocumentVersion @relation(fields: [versionId], references: [id], onDelete: Cascade)
+ signerAddress String
+ action DocumentReviewAction
+ comment String?
+ payload String // canonical JSON string that was signed (CIP-8 payload)
+ signature String // COSE_Sign1 hex
+ signatureKey String // COSE key hex
+ signedAt DateTime // client-asserted, server-validated against a window
+ createdAt DateTime @default(now())
+
+ @@unique([versionId, signerAddress])
+ @@index([versionId])
+ @@index([signerAddress])
+}
+
+// The signer set and threshold that applied when the round started. Frozen so
+// later membership changes never rewrite history.
+model DocumentSignerSnapshot {
+ id String @id @default(cuid())
+ versionId String @unique
+ version DocumentVersion @relation(fields: [versionId], references: [id], onDelete: Cascade)
+ walletId String
+ signersAddresses String[]
+ signersDescriptions String[]
+ requiredSigners Int
+ walletPolicyHash String // sha256 of the wallet's scriptCbor — binds the round to the policy
+ capturedAt DateTime @default(now())
+
+ @@index([walletId])
+}
+
+// Append-only audit log. Feeds the detail page history and the proof export.
+model DocumentEvent {
+ id String @id @default(cuid())
+ documentId String
+ document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
+ versionId String?
+ version DocumentVersion? @relation(fields: [versionId], references: [id], onDelete: Cascade)
+ type String // "document.created" | "version.uploaded" | "review.started" | ...
+ actorAddress String?
+ metadata Json?
+ createdAt DateTime @default(now())
+
+ @@index([documentId, createdAt])
+ @@index([versionId])
+ @@index([type])
+}
diff --git a/src/__tests__/documentSignoff.test.ts b/src/__tests__/documentSignoff.test.ts
new file mode 100644
index 00000000..113477a5
--- /dev/null
+++ b/src/__tests__/documentSignoff.test.ts
@@ -0,0 +1,394 @@
+/**
+ * Document Sign-Off (PRD-001) — payload binding, threshold, and proof verification.
+ *
+ * These cover the two rules the feature stands on: a signature is bound to one
+ * exact document version, and the threshold comes from the frozen signer
+ * snapshot. Both are enforced server-side, so both are tested server-side.
+ */
+
+import {
+ SIGNOFF_DOMAIN,
+ SIGNOFF_STATEMENTS,
+ buildSignOffPayload,
+ canonicalize,
+ canonicalizeSignOffPayload,
+ evaluateThreshold,
+ isSha256Hex,
+ isSignedAtWithinTolerance,
+ sha256Hex,
+ walletPolicyHash,
+} from "@/lib/documents/payload";
+import {
+ PROOF_FORMAT,
+ VERIFICATION_INSTRUCTIONS,
+ verifyProofPackage,
+ type ProofPackage,
+ type ProofReview,
+} from "@/lib/documents/proof";
+
+const SIGNER_A = "addr_test1_signer_a";
+const SIGNER_B = "addr_test1_signer_b";
+const SIGNER_C = "addr_test1_signer_c";
+const OUTSIDER = "addr_test1_outsider";
+
+const CONTENT_HASH = sha256Hex("the budget, version 1");
+const OTHER_HASH = sha256Hex("the budget, version 2");
+const POLICY_HASH = walletPolicyHash("8200581c-script-cbor");
+const SIGNED_AT = "2026-08-05T09:00:00.000Z";
+
+/** Accepts anything — isolates the non-signature checks. */
+const acceptAll = async () => true;
+const rejectAll = async () => false;
+
+function makeReview(
+ signerAddress: string,
+ action: "approve" | "reject" = "approve",
+ overrides: Partial<{ contentHash: string; versionId: string; comment: string }> = {},
+): ProofReview {
+ const payload = buildSignOffPayload({
+ action,
+ comment: overrides.comment,
+ contentHash: overrides.contentHash ?? CONTENT_HASH,
+ documentId: "doc_1",
+ signedAt: SIGNED_AT,
+ signerAddress,
+ versionId: overrides.versionId ?? "ver_1",
+ versionNumber: 1,
+ walletId: "wallet_1",
+ walletPolicyHash: POLICY_HASH,
+ });
+ return {
+ signerAddress,
+ action,
+ comment: overrides.comment ?? null,
+ payload: canonicalizeSignOffPayload(payload),
+ signature: "cose_sign1_hex",
+ signatureKey: "cose_key_hex",
+ signedAt: SIGNED_AT,
+ };
+}
+
+function makeProof(reviews: ProofReview[], requiredSigners = 2): ProofPackage {
+ return {
+ format: PROOF_FORMAT,
+ exportedAt: "2026-08-05T10:00:00.000Z",
+ document: {
+ id: "doc_1",
+ walletId: "wallet_1",
+ title: "Q3 Treasury Budget",
+ description: null,
+ documentType: null,
+ createdBy: SIGNER_A,
+ createdAt: "2026-08-01T00:00:00.000Z",
+ },
+ version: {
+ id: "ver_1",
+ versionNumber: 1,
+ contentHash: CONTENT_HASH,
+ hashAlgorithm: "sha256",
+ fileName: "budget.pdf",
+ mimeType: "application/pdf",
+ fileSize: 1024,
+ status: "Approved",
+ createdBy: SIGNER_A,
+ createdAt: "2026-08-01T00:00:00.000Z",
+ reviewStartedAt: "2026-08-02T00:00:00.000Z",
+ decidedAt: "2026-08-05T09:00:00.000Z",
+ },
+ policy: {
+ walletId: "wallet_1",
+ walletPolicyHash: POLICY_HASH,
+ requiredSigners,
+ signersAddresses: [SIGNER_A, SIGNER_B, SIGNER_C],
+ signersDescriptions: ["Alice", "Bob", "Carol"],
+ capturedAt: "2026-08-02T00:00:00.000Z",
+ },
+ reviews,
+ events: [],
+ verification: {
+ domain: SIGNOFF_DOMAIN,
+ instructions: VERIFICATION_INSTRUCTIONS,
+ },
+ };
+}
+
+// ---------------------------------------------------------------------------
+
+describe("canonicalization", () => {
+ it("is independent of key insertion order", () => {
+ expect(canonicalize({ b: 1, a: 2 })).toBe(canonicalize({ a: 2, b: 1 }));
+ });
+
+ it("produces no incidental whitespace", () => {
+ expect(canonicalize({ a: 1, b: "x" })).toBe('{"a":1,"b":"x"}');
+ });
+
+ it("drops undefined but keeps null", () => {
+ expect(canonicalize({ a: undefined, b: null })).toBe('{"b":null}');
+ });
+
+ it("recurses into nested objects and arrays", () => {
+ expect(canonicalize({ z: [{ b: 1, a: 2 }] })).toBe('{"z":[{"a":2,"b":1}]}');
+ });
+});
+
+describe("buildSignOffPayload", () => {
+ it("carries the plain-language statement that matches the action", () => {
+ const approve = buildSignOffPayload({
+ action: "approve",
+ contentHash: CONTENT_HASH,
+ documentId: "doc_1",
+ signedAt: SIGNED_AT,
+ signerAddress: SIGNER_A,
+ versionId: "ver_1",
+ versionNumber: 1,
+ walletId: "wallet_1",
+ walletPolicyHash: POLICY_HASH,
+ });
+ expect(approve.statement).toBe(SIGNOFF_STATEMENTS.approve);
+ expect(approve.statement).toMatch(/I approve this exact document version/);
+ expect(approve.domain).toBe(SIGNOFF_DOMAIN);
+ });
+
+ it("always includes comment, so an empty comment is still signed", () => {
+ const payload = buildSignOffPayload({
+ action: "reject",
+ contentHash: CONTENT_HASH,
+ documentId: "doc_1",
+ signedAt: SIGNED_AT,
+ signerAddress: SIGNER_A,
+ versionId: "ver_1",
+ versionNumber: 1,
+ walletId: "wallet_1",
+ walletPolicyHash: POLICY_HASH,
+ });
+ expect(payload.comment).toBe("");
+ expect(canonicalizeSignOffPayload(payload)).toContain('"comment":""');
+ });
+
+ it("rejects an unparseable signedAt rather than silently stamping now()", () => {
+ expect(() =>
+ buildSignOffPayload({
+ action: "approve",
+ contentHash: CONTENT_HASH,
+ documentId: "doc_1",
+ signedAt: "not-a-date",
+ signerAddress: SIGNER_A,
+ versionId: "ver_1",
+ versionNumber: 1,
+ walletId: "wallet_1",
+ walletPolicyHash: POLICY_HASH,
+ }),
+ ).toThrow(/not a valid date/i);
+ });
+});
+
+describe("version-hash binding", () => {
+ const base = {
+ action: "approve" as const,
+ documentId: "doc_1",
+ signedAt: SIGNED_AT,
+ signerAddress: SIGNER_A,
+ versionNumber: 1,
+ walletId: "wallet_1",
+ walletPolicyHash: POLICY_HASH,
+ };
+
+ it("produces a different payload for a different content hash", () => {
+ const v1 = canonicalizeSignOffPayload(
+ buildSignOffPayload({ ...base, contentHash: CONTENT_HASH, versionId: "ver_1" }),
+ );
+ const v2 = canonicalizeSignOffPayload(
+ buildSignOffPayload({ ...base, contentHash: OTHER_HASH, versionId: "ver_1" }),
+ );
+ expect(v1).not.toBe(v2);
+ });
+
+ it("produces a different payload for a different version id", () => {
+ const v1 = canonicalizeSignOffPayload(
+ buildSignOffPayload({ ...base, contentHash: CONTENT_HASH, versionId: "ver_1" }),
+ );
+ const v2 = canonicalizeSignOffPayload(
+ buildSignOffPayload({ ...base, contentHash: CONTENT_HASH, versionId: "ver_2" }),
+ );
+ expect(v1).not.toBe(v2);
+ });
+
+ it("a tampered comment changes the payload, so the signature no longer matches", () => {
+ const clean = canonicalizeSignOffPayload(
+ buildSignOffPayload({ ...base, contentHash: CONTENT_HASH, versionId: "ver_1" }),
+ );
+ const tampered = canonicalizeSignOffPayload(
+ buildSignOffPayload({
+ ...base,
+ contentHash: CONTENT_HASH,
+ versionId: "ver_1",
+ comment: "actually I meant no",
+ }),
+ );
+ expect(clean).not.toBe(tampered);
+ });
+
+ it("rebuilding from identical inputs is byte-identical — the server-side check", () => {
+ const input = { ...base, contentHash: CONTENT_HASH, versionId: "ver_1" };
+ expect(canonicalizeSignOffPayload(buildSignOffPayload(input))).toBe(
+ canonicalizeSignOffPayload(buildSignOffPayload(input)),
+ );
+ });
+});
+
+describe("hash + time helpers", () => {
+ it("recognises a sha256 digest and rejects near-misses", () => {
+ expect(isSha256Hex(CONTENT_HASH)).toBe(true);
+ expect(isSha256Hex(CONTENT_HASH.toUpperCase())).toBe(false);
+ expect(isSha256Hex(CONTENT_HASH.slice(0, 63))).toBe(false);
+ expect(isSha256Hex("")).toBe(false);
+ });
+
+ it("accepts a signedAt inside the window and rejects one outside it", () => {
+ const now = new Date("2026-08-05T09:00:00.000Z");
+ expect(isSignedAtWithinTolerance("2026-08-05T09:05:00.000Z", now)).toBe(true);
+ expect(isSignedAtWithinTolerance("2026-08-05T08:45:00.000Z", now)).toBe(false);
+ expect(isSignedAtWithinTolerance("nonsense", now)).toBe(false);
+ });
+});
+
+describe("evaluateThreshold", () => {
+ it("approves once the threshold is met", () => {
+ expect(
+ evaluateThreshold({ approvals: 2, rejections: 0, signerCount: 3, requiredSigners: 2 }),
+ ).toBe("Approved");
+ });
+
+ it("stays open while the threshold is still reachable", () => {
+ expect(
+ evaluateThreshold({ approvals: 1, rejections: 1, signerCount: 3, requiredSigners: 2 }),
+ ).toBe("InReview");
+ });
+
+ it("rejects as soon as the threshold has become unreachable", () => {
+ expect(
+ evaluateThreshold({ approvals: 1, rejections: 2, signerCount: 3, requiredSigners: 2 }),
+ ).toBe("Rejected");
+ });
+
+ it("handles unanimous policies", () => {
+ expect(
+ evaluateThreshold({ approvals: 2, rejections: 1, signerCount: 3, requiredSigners: 3 }),
+ ).toBe("Rejected");
+ expect(
+ evaluateThreshold({ approvals: 3, rejections: 0, signerCount: 3, requiredSigners: 3 }),
+ ).toBe("Approved");
+ });
+});
+
+describe("verifyProofPackage", () => {
+ it("accepts a well-formed, fully signed, threshold-reaching package", async () => {
+ const proof = makeProof([makeReview(SIGNER_A), makeReview(SIGNER_B)]);
+ const result = await verifyProofPackage(proof, { checkSignature: acceptAll });
+ expect(result.valid).toBe(true);
+ expect(result.approvals).toBe(2);
+ expect(result.thresholdReached).toBe(true);
+ expect(result.reviews.every((r) => r.valid)).toBe(true);
+ });
+
+ it("confirms a re-hashed document against the approved content hash", async () => {
+ const proof = makeProof([makeReview(SIGNER_A), makeReview(SIGNER_B)]);
+ const ok = await verifyProofPackage(proof, {
+ checkSignature: acceptAll,
+ expectedContentHash: CONTENT_HASH,
+ });
+ expect(ok.contentHashMatches).toBe(true);
+ expect(ok.valid).toBe(true);
+
+ const wrong = await verifyProofPackage(proof, {
+ checkSignature: acceptAll,
+ expectedContentHash: OTHER_HASH,
+ });
+ expect(wrong.contentHashMatches).toBe(false);
+ expect(wrong.valid).toBe(false);
+ expect(wrong.errors.join(" ")).toMatch(/does not hash to the approved content hash/i);
+ });
+
+ it("fails when a signature does not verify", async () => {
+ const proof = makeProof([makeReview(SIGNER_A), makeReview(SIGNER_B)]);
+ const result = await verifyProofPackage(proof, { checkSignature: rejectAll });
+ expect(result.valid).toBe(false);
+ expect(result.approvals).toBe(0);
+ expect(result.reviews[0]?.signatureValid).toBe(false);
+ });
+
+ it("fails when a review's payload names a different version's hash", async () => {
+ const proof = makeProof([
+ makeReview(SIGNER_A),
+ makeReview(SIGNER_B, "approve", { contentHash: OTHER_HASH }),
+ ]);
+ const result = await verifyProofPackage(proof, { checkSignature: acceptAll });
+ expect(result.valid).toBe(false);
+ expect(result.reviews[1]?.payloadBindsToVersion).toBe(false);
+ expect(result.reviews[1]?.errors.join(" ")).toMatch(/payload\.contentHash/);
+ });
+
+ it("rejects a signer who is not in the frozen snapshot", async () => {
+ const proof = makeProof([makeReview(SIGNER_A), makeReview(OUTSIDER)]);
+ const result = await verifyProofPackage(proof, { checkSignature: acceptAll });
+ expect(result.valid).toBe(false);
+ expect(result.reviews[1]?.signerInSnapshot).toBe(false);
+ expect(result.approvals).toBe(1);
+ });
+
+ it("rejects a duplicated signer rather than counting them twice", async () => {
+ const proof = makeProof([makeReview(SIGNER_A), makeReview(SIGNER_A)]);
+ const result = await verifyProofPackage(proof, { checkSignature: acceptAll });
+ expect(result.valid).toBe(false);
+ expect(result.reviews[1]?.errors.join(" ")).toMatch(/duplicate/i);
+ expect(result.approvals).toBe(1);
+ });
+
+ it("rejects a payload that is not in canonical form", async () => {
+ const review = makeReview(SIGNER_A);
+ const reordered = JSON.stringify(JSON.parse(review.payload), null, 2);
+ const proof = makeProof([{ ...review, payload: reordered }, makeReview(SIGNER_B)]);
+ const result = await verifyProofPackage(proof, { checkSignature: acceptAll });
+ expect(result.valid).toBe(false);
+ expect(result.reviews[0]?.errors.join(" ")).toMatch(/canonical/i);
+ });
+
+ it("reports not-yet-approved when the threshold is unmet", async () => {
+ const proof = makeProof([makeReview(SIGNER_A)]);
+ const result = await verifyProofPackage(proof, { checkSignature: acceptAll });
+ expect(result.thresholdReached).toBe(false);
+ expect(result.valid).toBe(false);
+ expect(result.approvals).toBe(1);
+ expect(result.requiredSigners).toBe(2);
+ });
+
+ it("counts rejections separately and does not credit them as approvals", async () => {
+ const proof = makeProof([
+ makeReview(SIGNER_A, "approve"),
+ makeReview(SIGNER_B, "reject"),
+ ]);
+ const result = await verifyProofPackage(proof, { checkSignature: acceptAll });
+ expect(result.approvals).toBe(1);
+ expect(result.rejections).toBe(1);
+ expect(result.thresholdReached).toBe(false);
+ });
+
+ it("flags an unknown proof format", async () => {
+ const proof = { ...makeProof([makeReview(SIGNER_A)]), format: "something-else" } as unknown as ProofPackage;
+ const result = await verifyProofPackage(proof, { checkSignature: acceptAll });
+ expect(result.valid).toBe(false);
+ expect(result.errors.join(" ")).toMatch(/unknown proof format/i);
+ });
+
+ it("survives a signature checker that throws", async () => {
+ const proof = makeProof([makeReview(SIGNER_A), makeReview(SIGNER_B)]);
+ const result = await verifyProofPackage(proof, {
+ checkSignature: async () => {
+ throw new Error("cbor decode failed");
+ },
+ });
+ expect(result.valid).toBe(false);
+ expect(result.reviews[0]?.errors.join(" ")).toMatch(/cbor decode failed/);
+ });
+});
diff --git a/src/components/pages/homepage/roadmap/data.ts b/src/components/pages/homepage/roadmap/data.ts
index c2a975c9..59193cfa 100644
--- a/src/components/pages/homepage/roadmap/data.ts
+++ b/src/components/pages/homepage/roadmap/data.ts
@@ -398,7 +398,7 @@ export const STATS = [
{
k: "Shipped",
v: "6",
- n: "workstreams delivered in May–July",
+ n: "workstreams delivered in April–July",
tone: "good" as const,
},
{
diff --git a/src/components/pages/homepage/roadmap/index.tsx b/src/components/pages/homepage/roadmap/index.tsx
index 632e908d..9b78f652 100644
--- a/src/components/pages/homepage/roadmap/index.tsx
+++ b/src/components/pages/homepage/roadmap/index.tsx
@@ -222,11 +222,11 @@ export function PageRoadmap() {
Roadmap
- Twelve months of Mesh Multisig, May 2026 to April 2027 — what has
+ Twelve months of Mesh Multisig, April 2026 to March 2027 — what has
shipped, what is blocked, and what comes next.
+ Uploading a new version supersedes the current one and starts a fresh
+ round at zero approvals — approval is bound to the content hash, not
+ the title.
+
+
+ An exported proof is an approval attestation by this wallet's signers.
+ It is not a qualified electronic signature.
+
+
+ );
+}
diff --git a/src/components/pages/wallet/documents/hash-file.ts b/src/components/pages/wallet/documents/hash-file.ts
new file mode 100644
index 00000000..31b774b5
--- /dev/null
+++ b/src/components/pages/wallet/documents/hash-file.ts
@@ -0,0 +1,17 @@
+/**
+ * Browser-side content hashing for Document Sign-Off.
+ *
+ * The bytes never have to leave the machine — hashing locally is what lets a
+ * team bind an approval to a confidential document without uploading it.
+ */
+
+export async function sha256HexFromBytes(bytes: ArrayBuffer): Promise {
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
+ return Array.from(new Uint8Array(digest))
+ .map((b) => b.toString(16).padStart(2, "0"))
+ .join("");
+}
+
+export async function sha256HexFromFile(file: File): Promise {
+ return sha256HexFromBytes(await file.arrayBuffer());
+}
diff --git a/src/components/pages/wallet/documents/index.tsx b/src/components/pages/wallet/documents/index.tsx
new file mode 100644
index 00000000..61d430d3
--- /dev/null
+++ b/src/components/pages/wallet/documents/index.tsx
@@ -0,0 +1,108 @@
+import Link from "next/link";
+import { useRouter } from "next/router";
+import { FileSignature, Plus } from "lucide-react";
+
+import { api } from "@/utils/api";
+import useAppWallet from "@/hooks/useAppWallet";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { EmptyState } from "@/components/common/empty-state";
+import PageHeader from "@/components/ui/page-header";
+import WalletDetailSkeleton from "@/components/pages/wallet/wallet-detail-skeleton";
+import DocumentStatusBadge from "./status-badge";
+
+/**
+ * Documents list — the status-scan view. A signer opening this page should be
+ * able to tell in one pass which documents are waiting on them.
+ */
+export default function PageDocuments() {
+ const router = useRouter();
+ const walletId = router.query.wallet as string;
+ const { appWallet } = useAppWallet();
+
+ const { data: documents, isLoading } = api.document.listByWallet.useQuery(
+ { walletId },
+ { enabled: !!walletId },
+ );
+
+ if (appWallet === undefined) return ;
+
+ return (
+
+
+
+
+
+
+ Approvals are bound to an exact version hash and inherit this wallet's
+ signers and threshold. Uploading a new version starts a fresh round at zero
+ approvals.
+
+
+ You are approving this exact content hash
+
+
+ {version.contentHash}
+
+ {version.fileName && (
+
+ {version.fileName}
+
+ )}
+
+
+ {snapshot && (
+
+ This round needs {snapshot.requiredSigners} of{" "}
+ {snapshot.signersAddresses.length} signers, frozen
+ when the review started. Changing the wallet's signers later
+ will not change this round.
+
+ )}
+
+
+
+ {alreadyActed ? (
+
+
+ You already{" "}
+
+ {alreadyActed.action === "approve" ? "approved" : "rejected"}
+ {" "}
+ this version. A decision is final for the version it was made on — a
+ new version starts a fresh round.
+
+
+ ) : (
+
+
+ Your decision
+
+
+
+
+
+
+
+
+
+
+
+ {!canSign && (
+
+ {version.status !== "InReview"
+ ? `This version is ${version.status} and is not open for signing.`
+ : "You are not in this round's signer snapshot."}
+
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/src/components/pages/wallet/documents/status-badge.tsx b/src/components/pages/wallet/documents/status-badge.tsx
new file mode 100644
index 00000000..24f671b2
--- /dev/null
+++ b/src/components/pages/wallet/documents/status-badge.tsx
@@ -0,0 +1,38 @@
+import { Badge } from "@/components/ui/badge";
+import { cn } from "@/lib/utils";
+
+/** The six lifecycle states from PRD-001, with a consistent colour per state. */
+const STATUS_STYLES: Record = {
+ Draft: "bg-muted text-muted-foreground",
+ InReview: "bg-blue-500/15 text-blue-600 dark:text-blue-400",
+ Approved: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400",
+ Rejected: "bg-red-500/15 text-red-600 dark:text-red-400",
+ Superseded: "bg-amber-500/15 text-amber-600 dark:text-amber-400",
+ Archived: "bg-muted text-muted-foreground",
+};
+
+const STATUS_LABELS: Record = {
+ Draft: "Draft",
+ InReview: "In review",
+ Approved: "Approved",
+ Rejected: "Rejected",
+ Superseded: "Superseded",
+ Archived: "Archived",
+};
+
+export default function DocumentStatusBadge({
+ status,
+ className,
+}: {
+ status: string;
+ className?: string;
+}) {
+ return (
+
+ {STATUS_LABELS[status] ?? status}
+
+ );
+}
diff --git a/src/lib/documents/payload.ts b/src/lib/documents/payload.ts
new file mode 100644
index 00000000..e3d60557
--- /dev/null
+++ b/src/lib/documents/payload.ts
@@ -0,0 +1,172 @@
+/**
+ * Document Sign-Off (PRD-001) — the signed payload and the rules around it.
+ *
+ * Deliberately dependency-free (node `crypto` only). Two reasons:
+ * - the server rebuilds the payload from its own records and compares it to
+ * what the client submitted, so this module is on the trust path;
+ * - the same functions are what an offline verifier needs, so a proof package
+ * can be checked without the app, a database, or a Mesh install.
+ *
+ * A signer never signs a filename. They sign a structured statement that names
+ * the exact content hash, the version it belongs to, the wallet whose policy
+ * governs it, and what the action means in plain language.
+ */
+
+import { createHash } from "crypto";
+
+/** Application domain — namespaces these signatures away from every other
+ * `signData` use in the product (auth nonces, DRep votes, ballots). */
+export const SIGNOFF_DOMAIN = "mesh-multisig.document-signoff.v1";
+
+/** How far a client-asserted `signedAt` may drift from server time. */
+export const SIGNED_AT_TOLERANCE_MS = 10 * 60 * 1000;
+
+export type SignOffAction = "approve" | "reject";
+
+export const SIGNOFF_STATEMENTS: Record = {
+ approve: "I approve this exact document version for this wallet.",
+ reject: "I reject this exact document version for this wallet.",
+};
+
+/**
+ * The object a signer signs. Field names and order are part of the contract:
+ * changing either invalidates every previously exported proof, so a change
+ * means a new `SIGNOFF_DOMAIN` version.
+ */
+export interface SignOffPayload {
+ action: SignOffAction;
+ /** "" when no comment — the field is always present so it is always signed. */
+ comment: string;
+ contentHash: string;
+ documentId: string;
+ domain: string;
+ /** ISO-8601, millisecond precision, UTC. */
+ signedAt: string;
+ signerAddress: string;
+ statement: string;
+ versionId: string;
+ versionNumber: number;
+ walletId: string;
+ walletPolicyHash: string;
+}
+
+export interface BuildSignOffPayloadInput {
+ action: SignOffAction;
+ comment?: string | null;
+ contentHash: string;
+ documentId: string;
+ signedAt: Date | string;
+ signerAddress: string;
+ versionId: string;
+ versionNumber: number;
+ walletId: string;
+ walletPolicyHash: string;
+}
+
+export function buildSignOffPayload(
+ input: BuildSignOffPayloadInput,
+): SignOffPayload {
+ return {
+ action: input.action,
+ comment: input.comment ?? "",
+ contentHash: input.contentHash,
+ documentId: input.documentId,
+ domain: SIGNOFF_DOMAIN,
+ signedAt: toIsoMillis(input.signedAt),
+ signerAddress: input.signerAddress,
+ statement: SIGNOFF_STATEMENTS[input.action],
+ versionId: input.versionId,
+ versionNumber: input.versionNumber,
+ walletId: input.walletId,
+ walletPolicyHash: input.walletPolicyHash,
+ };
+}
+
+/**
+ * Deterministic JSON: keys sorted, no incidental whitespace. Both sides must
+ * produce byte-identical output or the signature check is meaningless.
+ */
+export function canonicalize(value: unknown): string {
+ if (value === null || typeof value !== "object") {
+ return JSON.stringify(value) ?? "null";
+ }
+ if (Array.isArray(value)) {
+ return `[${value.map(canonicalize).join(",")}]`;
+ }
+ const entries = Object.entries(value as Record)
+ .filter(([, v]) => v !== undefined)
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
+ .map(([k, v]) => `${JSON.stringify(k)}:${canonicalize(v)}`);
+ return `{${entries.join(",")}}`;
+}
+
+/** The exact string handed to `wallet.signData` and stored on the review. */
+export function canonicalizeSignOffPayload(payload: SignOffPayload): string {
+ return canonicalize(payload);
+}
+
+export function sha256Hex(data: string | Uint8Array): string {
+ return createHash("sha256").update(data).digest("hex");
+}
+
+/** Binds a review round to the wallet policy that governed it. */
+export function walletPolicyHash(scriptCbor: string): string {
+ return sha256Hex(scriptCbor);
+}
+
+const SHA256_HEX = /^[0-9a-f]{64}$/;
+
+export function isSha256Hex(value: string): boolean {
+ return SHA256_HEX.test(value);
+}
+
+/** Normalizes a user-supplied digest before it is stored or compared. */
+export function normalizeContentHash(value: string): string {
+ return value.trim().toLowerCase();
+}
+
+function toIsoMillis(value: Date | string): string {
+ const date = typeof value === "string" ? new Date(value) : value;
+ if (Number.isNaN(date.getTime())) {
+ throw new Error("signedAt is not a valid date");
+ }
+ return date.toISOString();
+}
+
+export function isSignedAtWithinTolerance(
+ signedAt: Date | string,
+ now: Date = new Date(),
+ toleranceMs: number = SIGNED_AT_TOLERANCE_MS,
+): boolean {
+ const date = typeof signedAt === "string" ? new Date(signedAt) : signedAt;
+ if (Number.isNaN(date.getTime())) return false;
+ return Math.abs(date.getTime() - now.getTime()) <= toleranceMs;
+}
+
+// ---------------------------------------------------------------------------
+// Threshold evaluation
+// ---------------------------------------------------------------------------
+
+export type ThresholdOutcome = "InReview" | "Approved" | "Rejected";
+
+export interface ThresholdInput {
+ approvals: number;
+ rejections: number;
+ /** Signers in the frozen snapshot, not the live wallet. */
+ signerCount: number;
+ requiredSigners: number;
+}
+
+/**
+ * A round resolves as soon as the answer is certain:
+ * - approvals reach the threshold → Approved;
+ * - enough signers have rejected that the threshold is unreachable → Rejected.
+ * Anything else is still open.
+ */
+export function evaluateThreshold(input: ThresholdInput): ThresholdOutcome {
+ const { approvals, rejections, signerCount, requiredSigners } = input;
+ if (approvals >= requiredSigners) return "Approved";
+ const stillPossible = signerCount - rejections;
+ if (stillPossible < requiredSigners) return "Rejected";
+ return "InReview";
+}
diff --git a/src/lib/documents/proof.ts b/src/lib/documents/proof.ts
new file mode 100644
index 00000000..b6a7662f
--- /dev/null
+++ b/src/lib/documents/proof.ts
@@ -0,0 +1,296 @@
+/**
+ * Document Sign-Off (PRD-001) — proof package and its verifier.
+ *
+ * The proof package is the deliverable a team keeps: a self-contained JSON
+ * envelope holding the document metadata, the exact content hash, the frozen
+ * signer set + threshold, and every signer's signed payload and signature.
+ *
+ * `verifyProofPackage` deliberately takes the CIP-8 check as an argument. That
+ * keeps this module free of any Cardano dependency, so the same code runs
+ * server-side (with Mesh's `checkSignature`) and in an offline verifier with
+ * whatever COSE_Sign1 implementation is at hand.
+ */
+
+import {
+ SIGNOFF_DOMAIN,
+ SIGNOFF_STATEMENTS,
+ canonicalizeSignOffPayload,
+ evaluateThreshold,
+ isSha256Hex,
+ normalizeContentHash,
+ type SignOffAction,
+ type SignOffPayload,
+} from "./payload";
+
+export const PROOF_FORMAT = "mesh-multisig.document-signoff.proof.v1";
+
+export interface ProofReview {
+ signerAddress: string;
+ signerDescription?: string | null;
+ action: SignOffAction;
+ comment?: string | null;
+ /** The canonical JSON string that was signed, verbatim. */
+ payload: string;
+ signature: string;
+ signatureKey: string;
+ signedAt: string;
+}
+
+export interface ProofPackage {
+ format: typeof PROOF_FORMAT;
+ exportedAt: string;
+ document: {
+ id: string;
+ walletId: string;
+ title: string;
+ description?: string | null;
+ documentType?: string | null;
+ createdBy: string;
+ createdAt: string;
+ };
+ version: {
+ id: string;
+ versionNumber: number;
+ contentHash: string;
+ hashAlgorithm: string;
+ fileName?: string | null;
+ mimeType?: string | null;
+ fileSize?: number | null;
+ status: string;
+ createdBy: string;
+ createdAt: string;
+ reviewStartedAt?: string | null;
+ decidedAt?: string | null;
+ };
+ policy: {
+ walletId: string;
+ walletPolicyHash: string;
+ requiredSigners: number;
+ signersAddresses: string[];
+ signersDescriptions: string[];
+ capturedAt: string;
+ };
+ reviews: ProofReview[];
+ events: {
+ type: string;
+ actorAddress?: string | null;
+ createdAt: string;
+ metadata?: unknown;
+ }[];
+ verification: {
+ domain: string;
+ instructions: string[];
+ };
+}
+
+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`.",
+ "This package is an approval attestation by the wallet's signers. It is not a qualified electronic signature.",
+];
+
+/** Signature of the CIP-8 check the caller injects (Mesh's `checkSignature`). */
+export type CheckSignatureFn = (
+ data: string,
+ signature: { key: string; signature: string },
+ address?: string,
+) => Promise;
+
+export interface ReviewVerdict {
+ signerAddress: string;
+ action: SignOffAction | null;
+ /** All of the checks below passed. */
+ valid: boolean;
+ payloadWellFormed: boolean;
+ payloadBindsToVersion: boolean;
+ signerInSnapshot: boolean;
+ signatureValid: boolean;
+ errors: string[];
+}
+
+export interface ProofVerification {
+ valid: boolean;
+ format: string;
+ /** Present only when the caller supplied a re-hashed digest to compare. */
+ contentHashMatches?: boolean;
+ approvals: number;
+ rejections: number;
+ requiredSigners: number;
+ thresholdReached: boolean;
+ reviews: ReviewVerdict[];
+ errors: string[];
+}
+
+export interface VerifyProofOptions {
+ /** Digest of the bytes the verifier holds — step 1 of the instructions. */
+ expectedContentHash?: string;
+ checkSignature: CheckSignatureFn;
+}
+
+export async function verifyProofPackage(
+ pkg: ProofPackage,
+ options: VerifyProofOptions,
+): Promise {
+ const errors: string[] = [];
+
+ if (pkg.format !== PROOF_FORMAT) {
+ errors.push(`Unknown proof format "${String(pkg.format)}"`);
+ }
+ if (!isSha256Hex(normalizeContentHash(pkg.version.contentHash))) {
+ errors.push("version.contentHash is not a sha256 hex digest");
+ }
+ if (pkg.policy.requiredSigners < 1) {
+ errors.push("policy.requiredSigners must be at least 1");
+ }
+
+ let contentHashMatches: boolean | undefined;
+ if (options.expectedContentHash !== undefined) {
+ contentHashMatches =
+ normalizeContentHash(options.expectedContentHash) ===
+ normalizeContentHash(pkg.version.contentHash);
+ if (!contentHashMatches) {
+ errors.push(
+ "The supplied document does not hash to the approved content hash",
+ );
+ }
+ }
+
+ const snapshot = new Set(pkg.policy.signersAddresses);
+ const seen = new Set();
+ const reviews: ReviewVerdict[] = [];
+
+ for (const review of pkg.reviews) {
+ 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");
+ }
+ seen.add(review.signerAddress);
+ 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 outcome = evaluateThreshold({
+ approvals,
+ rejections,
+ signerCount: pkg.policy.signersAddresses.length,
+ requiredSigners: pkg.policy.requiredSigners,
+ });
+
+ const thresholdReached = outcome === "Approved";
+ const allReviewsValid = reviews.every((r) => r.valid);
+
+ return {
+ valid:
+ errors.length === 0 &&
+ allReviewsValid &&
+ thresholdReached &&
+ contentHashMatches !== false,
+ format: pkg.format,
+ contentHashMatches,
+ approvals,
+ rejections,
+ requiredSigners: pkg.policy.requiredSigners,
+ thresholdReached,
+ reviews,
+ errors,
+ };
+}
+
+async function verifyReview(
+ review: ProofReview,
+ pkg: ProofPackage,
+ snapshot: Set,
+ checkSignature: CheckSignatureFn,
+): Promise {
+ const verdict: ReviewVerdict = {
+ signerAddress: review.signerAddress,
+ action: null,
+ valid: false,
+ payloadWellFormed: false,
+ payloadBindsToVersion: false,
+ signerInSnapshot: snapshot.has(review.signerAddress),
+ signatureValid: false,
+ errors: [],
+ };
+
+ if (!verdict.signerInSnapshot) {
+ verdict.errors.push("Signer is not in the frozen signer snapshot");
+ }
+
+ let payload: SignOffPayload;
+ try {
+ payload = JSON.parse(review.payload) as SignOffPayload;
+ } catch {
+ verdict.errors.push("payload is not valid JSON");
+ return verdict;
+ }
+
+ // The stored string must itself be canonical — otherwise two different
+ // strings could carry the same JSON and only one of them is what was signed.
+ if (canonicalizeSignOffPayload(payload) !== review.payload) {
+ verdict.errors.push("payload is not in canonical form");
+ return verdict;
+ }
+ verdict.payloadWellFormed = true;
+ verdict.action = payload.action;
+
+ const bindings: [string, unknown, unknown][] = [
+ ["domain", payload.domain, SIGNOFF_DOMAIN],
+ ["documentId", payload.documentId, pkg.document.id],
+ ["versionId", payload.versionId, pkg.version.id],
+ ["versionNumber", payload.versionNumber, pkg.version.versionNumber],
+ [
+ "contentHash",
+ normalizeContentHash(payload.contentHash),
+ normalizeContentHash(pkg.version.contentHash),
+ ],
+ ["walletId", payload.walletId, pkg.document.walletId],
+ ["walletPolicyHash", payload.walletPolicyHash, pkg.policy.walletPolicyHash],
+ ["signerAddress", payload.signerAddress, review.signerAddress],
+ ["action", payload.action, review.action],
+ ["comment", payload.comment, review.comment ?? ""],
+ ["signedAt", payload.signedAt, review.signedAt],
+ ];
+
+ for (const [field, actual, expected] of bindings) {
+ if (actual !== expected) {
+ verdict.errors.push(
+ `payload.${field} does not match the proof package (${String(actual)} ≠ ${String(expected)})`,
+ );
+ }
+ }
+
+ if (payload.statement !== SIGNOFF_STATEMENTS[payload.action]) {
+ verdict.errors.push("payload.statement does not match the declared action");
+ }
+
+ verdict.payloadBindsToVersion = verdict.errors.length === 0;
+
+ try {
+ verdict.signatureValid = await checkSignature(
+ review.payload,
+ { key: review.signatureKey, signature: review.signature },
+ review.signerAddress,
+ );
+ } catch (error) {
+ verdict.signatureValid = false;
+ verdict.errors.push(
+ `Signature check threw: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+ if (!verdict.signatureValid) {
+ verdict.errors.push("CIP-8 signature does not verify for this signer");
+ }
+
+ verdict.valid =
+ verdict.payloadWellFormed &&
+ verdict.payloadBindsToVersion &&
+ verdict.signerInSnapshot &&
+ verdict.signatureValid;
+
+ return verdict;
+}
diff --git a/src/lib/seo.ts b/src/lib/seo.ts
index ed57fe69..2a46c361 100644
--- a/src/lib/seo.ts
+++ b/src/lib/seo.ts
@@ -86,7 +86,7 @@ export const routeSeo: Record = {
"/roadmap": {
title: "Roadmap",
description:
- "The twelve-month Mesh Multisig roadmap: what has shipped, what is in progress and what is planned for the Cardano multi-signature wallet, from May 2026 to April 2027.",
+ "The twelve-month Mesh Multisig roadmap: what has shipped, what is in progress and what is planned for the Cardano multi-signature wallet, from April 2026 to March 2027.",
},
"/roadmap/graph": {
title: "Feature Graph",
diff --git a/src/pages/wallets/[wallet]/documents/[documentId]/index.tsx b/src/pages/wallets/[wallet]/documents/[documentId]/index.tsx
new file mode 100644
index 00000000..53bf7c1d
--- /dev/null
+++ b/src/pages/wallets/[wallet]/documents/[documentId]/index.tsx
@@ -0,0 +1,7 @@
+import PageDocumentDetail from "@/components/pages/wallet/documents/detail";
+
+export const getServerSideProps = () => ({ props: {} });
+
+export default function PageWalletDocumentDetail() {
+ return ;
+}
diff --git a/src/pages/wallets/[wallet]/documents/[documentId]/review/[versionId].tsx b/src/pages/wallets/[wallet]/documents/[documentId]/review/[versionId].tsx
new file mode 100644
index 00000000..36054132
--- /dev/null
+++ b/src/pages/wallets/[wallet]/documents/[documentId]/review/[versionId].tsx
@@ -0,0 +1,7 @@
+import PageDocumentReview from "@/components/pages/wallet/documents/review";
+
+export const getServerSideProps = () => ({ props: {} });
+
+export default function PageWalletDocumentReview() {
+ return ;
+}
diff --git a/src/pages/wallets/[wallet]/documents/index.tsx b/src/pages/wallets/[wallet]/documents/index.tsx
new file mode 100644
index 00000000..24817a94
--- /dev/null
+++ b/src/pages/wallets/[wallet]/documents/index.tsx
@@ -0,0 +1,7 @@
+import PageDocuments from "@/components/pages/wallet/documents";
+
+export const getServerSideProps = () => ({ props: {} });
+
+export default function PageWalletDocuments() {
+ return ;
+}
diff --git a/src/pages/wallets/[wallet]/documents/new.tsx b/src/pages/wallets/[wallet]/documents/new.tsx
new file mode 100644
index 00000000..6d6e221f
--- /dev/null
+++ b/src/pages/wallets/[wallet]/documents/new.tsx
@@ -0,0 +1,7 @@
+import PageDocumentNew from "@/components/pages/wallet/documents/new";
+
+export const getServerSideProps = () => ({ props: {} });
+
+export default function PageWalletDocumentNew() {
+ return ;
+}
diff --git a/src/server/api/root.ts b/src/server/api/root.ts
index 4edc90dc..1b178ff6 100644
--- a/src/server/api/root.ts
+++ b/src/server/api/root.ts
@@ -10,6 +10,7 @@ import { authRouter } from "./routers/auth";
import { contactRouter } from "./routers/contacts";
import { botRouter } from "./routers/bot";
import { governanceRouter } from "./routers/governance";
+import { documentRouter } from "./routers/documents";
import { notificationRouter } from "./routers/notifications";
/**
@@ -30,6 +31,7 @@ export const appRouter = createTRPCRouter({
bot: botRouter,
governance: governanceRouter,
notification: notificationRouter,
+ document: documentRouter,
});
// export type definition of API
diff --git a/src/server/api/routers/documents.ts b/src/server/api/routers/documents.ts
new file mode 100644
index 00000000..263f5477
--- /dev/null
+++ b/src/server/api/routers/documents.ts
@@ -0,0 +1,932 @@
+/**
+ * Document Sign-Off (PRD-001) — tRPC router.
+ *
+ * Six operations: createDocument, uploadVersion, startReview,
+ * submitSignerAction, exportProof, verifyProof — plus the reads the four
+ * Documents pages need.
+ *
+ * Two rules carry the whole feature and are enforced here, not in the UI:
+ *
+ * 1. **Version-hash binding.** A review is attached to a DocumentVersion's
+ * content hash. The server rebuilds the signed payload from its own
+ * records and rejects the submission unless it is byte-identical to what
+ * the client signed, so a signature collected against one version can
+ * never be replayed onto another.
+ *
+ * 2. **Threshold inheritance from a frozen snapshot.** Required approvals
+ * come from the DocumentSignerSnapshot captured when the round started,
+ * never from the live wallet — changing the wallet's signers must not
+ * rewrite a decision that has already been made.
+ */
+
+import { z } from "zod";
+import { TRPCError } from "@trpc/server";
+import { checkSignature } from "@meshsdk/core";
+import { Prisma, type Wallet } from "@prisma/client";
+
+import { createTRPCRouter, protectedProcedure, publicProcedure } from "@/server/api/trpc";
+import type { AuthCtx } from "@/server/api/trpc";
+import { audit } from "@/lib/observability/audit";
+import {
+ buildSignOffPayload,
+ canonicalizeSignOffPayload,
+ evaluateThreshold,
+ isSha256Hex,
+ isSignedAtWithinTolerance,
+ normalizeContentHash,
+ sha256Hex,
+ walletPolicyHash,
+} from "@/lib/documents/payload";
+import {
+ PROOF_FORMAT,
+ VERIFICATION_INSTRUCTIONS,
+ verifyProofPackage,
+ type ProofPackage,
+} from "@/lib/documents/proof";
+import { SIGNOFF_DOMAIN } from "@/lib/documents/payload";
+
+/** Inline content is a convenience for small files, not a document store. */
+const MAX_INLINE_BYTES = 512 * 1024;
+
+const contentHashSchema = z
+ .string()
+ .trim()
+ .transform(normalizeContentHash)
+ .refine(isSha256Hex, { message: "contentHash must be a sha256 hex digest" });
+
+// ---------------------------------------------------------------------------
+// Access helpers
+// ---------------------------------------------------------------------------
+
+const getSessionAddresses = (ctx: AuthCtx): string[] => {
+ const sessionWallets: string[] = ctx.sessionWallets ?? [];
+ if (Array.isArray(sessionWallets) && sessionWallets.length > 0) {
+ return sessionWallets;
+ }
+ const single = ctx.session?.user?.id ?? ctx.sessionAddress;
+ return single ? [single] : [];
+};
+
+/** Wallet membership: a signer or the owner. Mirrors the ballot router. */
+const assertWalletAccess = async (
+ ctx: AuthCtx,
+ walletId: string,
+): Promise<{ wallet: Wallet; addresses: string[] }> => {
+ const wallet = await ctx.db.wallet.findUnique({ where: { id: walletId } });
+ if (!wallet) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Wallet not found" });
+ }
+
+ const addresses = getSessionAddresses(ctx);
+ if (addresses.length === 0) {
+ throw new TRPCError({ code: "UNAUTHORIZED" });
+ }
+
+ const authorized = addresses.some(
+ (addr) =>
+ (Array.isArray(wallet.signersAddresses) &&
+ wallet.signersAddresses.includes(addr)) ||
+ wallet.ownerAddress === addr,
+ );
+ if (!authorized) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Not authorized for this wallet",
+ });
+ }
+
+ return { wallet, addresses };
+};
+
+const assertDocumentAccess = async (ctx: AuthCtx, documentId: string) => {
+ const document = await ctx.db.document.findUnique({
+ where: { id: documentId },
+ });
+ if (!document) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Document not found" });
+ }
+ const { wallet, addresses } = await assertWalletAccess(ctx, document.walletId);
+ return { document, wallet, addresses };
+};
+
+/** The acting address for a write — one of the session addresses. */
+const actingAddress = (addresses: string[], claimed?: string): string => {
+ if (claimed) {
+ if (!addresses.includes(claimed)) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "signerAddress is not one of your session addresses",
+ });
+ }
+ return claimed;
+ }
+ const first = addresses[0];
+ if (!first) throw new TRPCError({ code: "UNAUTHORIZED" });
+ return first;
+};
+
+// ---------------------------------------------------------------------------
+// Router
+// ---------------------------------------------------------------------------
+
+export const documentRouter = createTRPCRouter({
+ /** Document list for a wallet — the list page. */
+ listByWallet: protectedProcedure
+ .input(
+ z.object({
+ walletId: z.string().min(1),
+ includeArchived: z.boolean().default(false),
+ }),
+ )
+ .query(async ({ ctx, input }) => {
+ await assertWalletAccess(ctx, input.walletId);
+ return ctx.db.document.findMany({
+ where: {
+ walletId: input.walletId,
+ ...(input.includeArchived ? {} : { status: { not: "Archived" } }),
+ },
+ orderBy: { updatedAt: "desc" },
+ include: {
+ versions: {
+ orderBy: { versionNumber: "desc" },
+ include: {
+ signerSnapshot: true,
+ reviews: {
+ select: { signerAddress: true, action: true, signedAt: true },
+ },
+ },
+ },
+ },
+ });
+ }),
+
+ /** Full document with history — the detail page. */
+ getById: protectedProcedure
+ .input(z.object({ documentId: z.string().min(1) }))
+ .query(async ({ ctx, input }) => {
+ const { document } = await assertDocumentAccess(ctx, input.documentId);
+ return ctx.db.document.findUnique({
+ where: { id: document.id },
+ include: {
+ versions: {
+ orderBy: { versionNumber: "desc" },
+ include: { signerSnapshot: true, reviews: true },
+ },
+ events: { orderBy: { createdAt: "asc" } },
+ },
+ });
+ }),
+
+ /**
+ * Everything the review page needs, including the exact payload the signer
+ * is about to sign. Handing the payload back from the server (rather than
+ * letting the client compose it) is what makes "what you see is what you
+ * sign" true — the same builder runs again on submit.
+ */
+ getVersionForReview: protectedProcedure
+ .input(
+ z.object({
+ versionId: z.string().min(1),
+ action: z.enum(["approve", "reject"]).default("approve"),
+ comment: z.string().max(2000).optional(),
+ signerAddress: z.string().min(1).optional(),
+ }),
+ )
+ .query(async ({ ctx, input }) => {
+ const version = await ctx.db.documentVersion.findUnique({
+ where: { id: input.versionId },
+ include: { document: true, signerSnapshot: true, reviews: true },
+ });
+ if (!version) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Version not found" });
+ }
+ const { addresses } = await assertWalletAccess(
+ ctx,
+ version.document.walletId,
+ );
+ const signerAddress = actingAddress(addresses, input.signerAddress);
+
+ const snapshot = version.signerSnapshot;
+ const alreadyActed = version.reviews.find(
+ (r) => r.signerAddress === signerAddress,
+ );
+
+ const payload = snapshot
+ ? buildSignOffPayload({
+ action: input.action,
+ comment: input.comment,
+ contentHash: version.contentHash,
+ documentId: version.documentId,
+ signedAt: new Date(),
+ signerAddress,
+ versionId: version.id,
+ versionNumber: version.versionNumber,
+ walletId: version.document.walletId,
+ walletPolicyHash: snapshot.walletPolicyHash,
+ })
+ : null;
+
+ return {
+ version,
+ document: version.document,
+ snapshot,
+ signerAddress,
+ canSign:
+ !!snapshot &&
+ version.status === "InReview" &&
+ snapshot.signersAddresses.includes(signerAddress) &&
+ !alreadyActed,
+ alreadyActed: alreadyActed ?? null,
+ payload,
+ payloadToSign: payload ? canonicalizeSignOffPayload(payload) : null,
+ };
+ }),
+
+ /** 1. Create a document, optionally with its first version. */
+ createDocument: protectedProcedure
+ .input(
+ z.object({
+ walletId: z.string().min(1),
+ title: z.string().trim().min(1).max(200),
+ description: z.string().max(5000).optional(),
+ documentType: z.string().max(100).optional(),
+ firstVersion: z
+ .object({
+ contentHash: contentHashSchema,
+ fileName: z.string().max(300).optional(),
+ mimeType: z.string().max(200).optional(),
+ fileSize: z.number().int().nonnegative().optional(),
+ storageMode: z.enum(["hashOnly", "inline", "external"]).default("hashOnly"),
+ contentRef: z.string().max(2000).optional(),
+ contentInline: z.string().optional(),
+ reviewInstructions: z.string().max(5000).optional(),
+ })
+ .optional(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const { addresses } = await assertWalletAccess(ctx, input.walletId);
+ const creator = actingAddress(addresses);
+
+ const document = await ctx.db.$transaction(async (tx) => {
+ const created = await tx.document.create({
+ data: {
+ walletId: input.walletId,
+ title: input.title,
+ description: input.description ?? null,
+ documentType: input.documentType ?? null,
+ createdBy: creator,
+ status: "Draft",
+ },
+ });
+
+ await tx.documentEvent.create({
+ data: {
+ documentId: created.id,
+ type: "document.created",
+ actorAddress: creator,
+ metadata: { title: created.title },
+ },
+ });
+
+ if (input.firstVersion) {
+ const version = await createVersionRow(tx, {
+ documentId: created.id,
+ versionNumber: 1,
+ createdBy: creator,
+ ...input.firstVersion,
+ });
+ await tx.documentEvent.create({
+ data: {
+ documentId: created.id,
+ versionId: version.id,
+ type: "version.uploaded",
+ actorAddress: creator,
+ metadata: {
+ versionNumber: version.versionNumber,
+ contentHash: version.contentHash,
+ },
+ },
+ });
+ }
+
+ return created;
+ });
+
+ void audit(ctx.db, {
+ actorAddress: creator,
+ actorType: "user",
+ action: "document.create",
+ resourceType: "document",
+ resourceId: document.id,
+ outcome: "success",
+ metadata: { walletId: input.walletId },
+ });
+
+ return document;
+ }),
+
+ /**
+ * 2. Upload a new version. Approvals never carry forward: the previous
+ * version is superseded and the new one starts at zero approvals, because
+ * approval is bound to the content hash, not to the title.
+ */
+ uploadVersion: protectedProcedure
+ .input(
+ z.object({
+ documentId: z.string().min(1),
+ contentHash: contentHashSchema,
+ fileName: z.string().max(300).optional(),
+ mimeType: z.string().max(200).optional(),
+ fileSize: z.number().int().nonnegative().optional(),
+ storageMode: z.enum(["hashOnly", "inline", "external"]).default("hashOnly"),
+ contentRef: z.string().max(2000).optional(),
+ contentInline: z.string().optional(),
+ reviewInstructions: z.string().max(5000).optional(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const { document, addresses } = await assertDocumentAccess(
+ ctx,
+ input.documentId,
+ );
+ if (document.status === "Archived") {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message: "Document is archived",
+ });
+ }
+ const actor = actingAddress(addresses);
+
+ const version = await ctx.db.$transaction(async (tx) => {
+ const latest = await tx.documentVersion.findFirst({
+ where: { documentId: document.id },
+ orderBy: { versionNumber: "desc" },
+ });
+
+ if (latest && latest.status !== "Superseded" && latest.status !== "Archived") {
+ await tx.documentVersion.update({
+ where: { id: latest.id },
+ data: { status: "Superseded", supersededAt: new Date() },
+ });
+ await tx.documentEvent.create({
+ data: {
+ documentId: document.id,
+ versionId: latest.id,
+ type: "version.superseded",
+ actorAddress: actor,
+ metadata: { versionNumber: latest.versionNumber },
+ },
+ });
+ }
+
+ const created = await createVersionRow(tx, {
+ documentId: document.id,
+ versionNumber: (latest?.versionNumber ?? 0) + 1,
+ createdBy: actor,
+ contentHash: input.contentHash,
+ fileName: input.fileName,
+ mimeType: input.mimeType,
+ fileSize: input.fileSize,
+ storageMode: input.storageMode,
+ contentRef: input.contentRef,
+ contentInline: input.contentInline,
+ reviewInstructions: input.reviewInstructions,
+ });
+
+ await tx.documentEvent.create({
+ data: {
+ documentId: document.id,
+ versionId: created.id,
+ type: "version.uploaded",
+ actorAddress: actor,
+ metadata: {
+ versionNumber: created.versionNumber,
+ contentHash: created.contentHash,
+ approvalsReset: true,
+ },
+ },
+ });
+
+ await tx.document.update({
+ where: { id: document.id },
+ data: { status: "Draft" },
+ });
+
+ return created;
+ });
+
+ return version;
+ }),
+
+ /**
+ * 3. Start a review round: freeze the wallet's signer set and threshold onto
+ * the version. Everything downstream reads the snapshot, not the wallet.
+ */
+ startReview: protectedProcedure
+ .input(z.object({ versionId: z.string().min(1) }))
+ .mutation(async ({ ctx, input }) => {
+ const version = await ctx.db.documentVersion.findUnique({
+ where: { id: input.versionId },
+ include: { document: true, signerSnapshot: true },
+ });
+ if (!version) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Version not found" });
+ }
+ const { wallet, addresses } = await assertWalletAccess(
+ ctx,
+ version.document.walletId,
+ );
+ const actor = actingAddress(addresses);
+
+ if (version.signerSnapshot) {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message: "A review round has already started for this version",
+ });
+ }
+ if (version.status !== "Draft") {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message: `Cannot start a review on a ${version.status} version`,
+ });
+ }
+
+ const signers = wallet.signersAddresses ?? [];
+ const required = wallet.numRequiredSigners ?? signers.length;
+ if (signers.length === 0) {
+ throw new TRPCError({
+ code: "PRECONDITION_FAILED",
+ message: "Wallet has no signers",
+ });
+ }
+ if (required < 1 || required > signers.length) {
+ throw new TRPCError({
+ code: "PRECONDITION_FAILED",
+ message: "Wallet threshold is not usable",
+ });
+ }
+
+ const result = await ctx.db.$transaction(async (tx) => {
+ const snapshot = await tx.documentSignerSnapshot.create({
+ data: {
+ versionId: version.id,
+ walletId: wallet.id,
+ signersAddresses: signers,
+ signersDescriptions: wallet.signersDescriptions ?? [],
+ requiredSigners: required,
+ walletPolicyHash: walletPolicyHash(wallet.scriptCbor),
+ },
+ });
+ await tx.documentVersion.update({
+ where: { id: version.id },
+ data: { status: "InReview", reviewStartedAt: new Date() },
+ });
+ await tx.document.update({
+ where: { id: version.documentId },
+ data: { status: "InReview" },
+ });
+ await tx.documentEvent.create({
+ data: {
+ documentId: version.documentId,
+ versionId: version.id,
+ type: "review.started",
+ actorAddress: actor,
+ metadata: {
+ requiredSigners: required,
+ signerCount: signers.length,
+ walletPolicyHash: snapshot.walletPolicyHash,
+ },
+ },
+ });
+ return snapshot;
+ });
+
+ void audit(ctx.db, {
+ actorAddress: actor,
+ actorType: "user",
+ action: "document.review.start",
+ resourceType: "document",
+ resourceId: version.documentId,
+ outcome: "success",
+ metadata: { versionId: version.id, requiredSigners: required },
+ });
+
+ return result;
+ }),
+
+ /**
+ * 4. Submit a signer's approve/reject with its CIP-8 signature.
+ *
+ * The submitted payload is never trusted. The server rebuilds it from its
+ * own records and requires a byte-identical match before the signature is
+ * even checked — a signature harvested for version 2 cannot be recorded
+ * against version 3, and a tampered comment invalidates the whole thing.
+ */
+ submitSignerAction: protectedProcedure
+ .input(
+ z.object({
+ versionId: z.string().min(1),
+ action: z.enum(["approve", "reject"]),
+ comment: z.string().max(2000).optional(),
+ signerAddress: z.string().min(1),
+ signedAt: z.string().min(1),
+ payload: z.string().min(1),
+ signature: z.string().min(1),
+ signatureKey: z.string().min(1),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const version = await ctx.db.documentVersion.findUnique({
+ where: { id: input.versionId },
+ include: { document: true, signerSnapshot: true, reviews: true },
+ });
+ if (!version) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Version not found" });
+ }
+ const { addresses } = await assertWalletAccess(
+ ctx,
+ version.document.walletId,
+ );
+ const signerAddress = actingAddress(addresses, input.signerAddress);
+
+ const deny = (reason: string, code: "CONFLICT" | "FORBIDDEN" | "BAD_REQUEST" = "BAD_REQUEST"): never => {
+ void audit(ctx.db, {
+ actorAddress: signerAddress,
+ actorType: "user",
+ action: "document.review.submit",
+ resourceType: "document",
+ resourceId: version.documentId,
+ outcome: "denied",
+ reason,
+ metadata: { versionId: version.id },
+ });
+ throw new TRPCError({ code, message: reason });
+ };
+
+ const snapshot = version.signerSnapshot;
+ if (!snapshot) deny("No review round has been started for this version", "CONFLICT");
+ if (version.status !== "InReview") {
+ deny(`Version is ${version.status}, not open for review`, "CONFLICT");
+ }
+ if (!snapshot!.signersAddresses.includes(signerAddress)) {
+ deny("Signer is not in this round's signer snapshot", "FORBIDDEN");
+ }
+ if (version.reviews.some((r) => r.signerAddress === signerAddress)) {
+ deny("This signer has already acted on this version", "CONFLICT");
+ }
+
+ const signedAt = new Date(input.signedAt);
+ if (Number.isNaN(signedAt.getTime())) deny("signedAt is not a valid date");
+ if (!isSignedAtWithinTolerance(signedAt)) {
+ deny("signedAt is outside the accepted time window");
+ }
+
+ // --- version-hash binding: rebuild, then compare byte for byte --------
+ const expected = canonicalizeSignOffPayload(
+ buildSignOffPayload({
+ action: input.action,
+ comment: input.comment,
+ contentHash: version.contentHash,
+ documentId: version.documentId,
+ signedAt,
+ signerAddress,
+ versionId: version.id,
+ versionNumber: version.versionNumber,
+ walletId: version.document.walletId,
+ walletPolicyHash: snapshot!.walletPolicyHash,
+ }),
+ );
+ if (expected !== input.payload) {
+ deny(
+ "Signed payload does not match this document version — refusing to record the signature",
+ );
+ }
+
+ // --- CIP-8: this address's key signed exactly that payload ------------
+ let signatureValid = false;
+ try {
+ signatureValid = await checkSignature(
+ input.payload,
+ { key: input.signatureKey, signature: input.signature },
+ signerAddress,
+ );
+ } catch (error) {
+ deny(
+ `Signature verification failed: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+ if (!signatureValid) deny("Invalid signature for this signer", "FORBIDDEN");
+
+ const result = await ctx.db.$transaction(async (tx) => {
+ const review = await tx.documentReview.create({
+ data: {
+ versionId: version.id,
+ signerAddress,
+ action: input.action,
+ comment: input.comment ?? null,
+ payload: input.payload,
+ signature: input.signature,
+ signatureKey: input.signatureKey,
+ signedAt,
+ },
+ });
+
+ await tx.documentEvent.create({
+ data: {
+ documentId: version.documentId,
+ versionId: version.id,
+ type: input.action === "approve" ? "review.approved" : "review.rejected",
+ actorAddress: signerAddress,
+ metadata: { versionNumber: version.versionNumber },
+ },
+ });
+
+ const reviews = await tx.documentReview.findMany({
+ where: { versionId: version.id },
+ });
+ const approvals = reviews.filter((r) => r.action === "approve").length;
+ const rejections = reviews.filter((r) => r.action === "reject").length;
+ const outcome = evaluateThreshold({
+ approvals,
+ rejections,
+ signerCount: snapshot!.signersAddresses.length,
+ requiredSigners: snapshot!.requiredSigners,
+ });
+
+ if (outcome !== "InReview") {
+ await tx.documentVersion.update({
+ where: { id: version.id },
+ data: { status: outcome, decidedAt: new Date() },
+ });
+ await tx.document.update({
+ where: { id: version.documentId },
+ data: { status: outcome },
+ });
+ await tx.documentEvent.create({
+ data: {
+ documentId: version.documentId,
+ versionId: version.id,
+ type: outcome === "Approved" ? "threshold.reached" : "version.rejected",
+ actorAddress: signerAddress,
+ metadata: {
+ approvals,
+ rejections,
+ requiredSigners: snapshot!.requiredSigners,
+ },
+ },
+ });
+ }
+
+ return { review, approvals, rejections, outcome };
+ });
+
+ void audit(ctx.db, {
+ actorAddress: signerAddress,
+ actorType: "user",
+ action: "document.review.submit",
+ resourceType: "document",
+ resourceId: version.documentId,
+ outcome: "success",
+ metadata: {
+ versionId: version.id,
+ action: input.action,
+ result: result.outcome,
+ },
+ });
+
+ return result;
+ }),
+
+ /** 5. Export the proof package for a version. */
+ exportProof: protectedProcedure
+ .input(z.object({ versionId: z.string().min(1) }))
+ .mutation(async ({ ctx, input }): Promise => {
+ const version = await ctx.db.documentVersion.findUnique({
+ where: { id: input.versionId },
+ include: {
+ document: { include: { events: { orderBy: { createdAt: "asc" } } } },
+ signerSnapshot: true,
+ reviews: { orderBy: { signedAt: "asc" } },
+ },
+ });
+ if (!version) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Version not found" });
+ }
+ const { addresses } = await assertWalletAccess(
+ ctx,
+ version.document.walletId,
+ );
+ const actor = actingAddress(addresses);
+
+ const snapshot = version.signerSnapshot;
+ if (!snapshot) {
+ throw new TRPCError({
+ code: "PRECONDITION_FAILED",
+ message: "This version has no review round to export",
+ });
+ }
+
+ const descriptionFor = (address: string): string | null => {
+ const idx = snapshot.signersAddresses.indexOf(address);
+ return idx >= 0 ? (snapshot.signersDescriptions[idx] ?? null) : null;
+ };
+
+ const pkg: ProofPackage = {
+ format: PROOF_FORMAT,
+ exportedAt: new Date().toISOString(),
+ document: {
+ id: version.document.id,
+ walletId: version.document.walletId,
+ title: version.document.title,
+ description: version.document.description,
+ documentType: version.document.documentType,
+ createdBy: version.document.createdBy,
+ createdAt: version.document.createdAt.toISOString(),
+ },
+ version: {
+ id: version.id,
+ versionNumber: version.versionNumber,
+ contentHash: version.contentHash,
+ hashAlgorithm: version.hashAlgorithm,
+ fileName: version.fileName,
+ mimeType: version.mimeType,
+ fileSize: version.fileSize,
+ status: version.status,
+ createdBy: version.createdBy,
+ createdAt: version.createdAt.toISOString(),
+ reviewStartedAt: version.reviewStartedAt?.toISOString() ?? null,
+ decidedAt: version.decidedAt?.toISOString() ?? null,
+ },
+ policy: {
+ walletId: snapshot.walletId,
+ walletPolicyHash: snapshot.walletPolicyHash,
+ requiredSigners: snapshot.requiredSigners,
+ signersAddresses: snapshot.signersAddresses,
+ signersDescriptions: snapshot.signersDescriptions,
+ capturedAt: snapshot.capturedAt.toISOString(),
+ },
+ reviews: version.reviews.map((r) => ({
+ signerAddress: r.signerAddress,
+ signerDescription: descriptionFor(r.signerAddress),
+ action: r.action,
+ comment: r.comment,
+ payload: r.payload,
+ signature: r.signature,
+ signatureKey: r.signatureKey,
+ signedAt: r.signedAt.toISOString(),
+ })),
+ events: version.document.events
+ .filter((e) => e.versionId === null || e.versionId === version.id)
+ .map((e) => ({
+ type: e.type,
+ actorAddress: e.actorAddress,
+ createdAt: e.createdAt.toISOString(),
+ metadata: e.metadata,
+ })),
+ verification: {
+ domain: SIGNOFF_DOMAIN,
+ instructions: VERIFICATION_INSTRUCTIONS,
+ },
+ };
+
+ await ctx.db.documentEvent.create({
+ data: {
+ documentId: version.documentId,
+ versionId: version.id,
+ type: "proof.exported",
+ actorAddress: actor,
+ metadata: { reviewCount: pkg.reviews.length },
+ },
+ });
+
+ return pkg;
+ }),
+
+ /**
+ * 6. Verify a proof package. Public on purpose: a counterparty holding the
+ * JSON and the file must be able to check it without an account, and this
+ * endpoint reads nothing from the database — it only re-runs the maths.
+ */
+ verifyProof: publicProcedure
+ .input(
+ z.object({
+ proof: z.unknown(),
+ /** sha256 of the bytes the verifier holds, if they have the file. */
+ expectedContentHash: z.string().optional(),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ const pkg = input.proof as ProofPackage;
+ if (!pkg || typeof pkg !== "object" || !pkg.version || !pkg.policy) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Not a Document Sign-Off proof package",
+ });
+ }
+ return verifyProofPackage(pkg, {
+ expectedContentHash: input.expectedContentHash,
+ checkSignature,
+ });
+ }),
+
+ /** Archive a document — history is retained, it just leaves active use. */
+ archiveDocument: protectedProcedure
+ .input(z.object({ documentId: z.string().min(1) }))
+ .mutation(async ({ ctx, input }) => {
+ const { document, addresses } = await assertDocumentAccess(
+ ctx,
+ input.documentId,
+ );
+ const actor = actingAddress(addresses);
+ return ctx.db.$transaction(async (tx) => {
+ const updated = await tx.document.update({
+ where: { id: document.id },
+ data: { status: "Archived", archivedAt: new Date() },
+ });
+ await tx.documentEvent.create({
+ data: {
+ documentId: document.id,
+ type: "document.archived",
+ actorAddress: actor,
+ },
+ });
+ return updated;
+ });
+ }),
+});
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+type VersionRowInput = {
+ documentId: string;
+ versionNumber: number;
+ createdBy: string;
+ contentHash: string;
+ fileName?: string;
+ mimeType?: string;
+ fileSize?: number;
+ storageMode: "hashOnly" | "inline" | "external";
+ contentRef?: string;
+ contentInline?: string;
+ reviewInstructions?: string;
+};
+
+/**
+ * Creates the version row, and — when the bytes are actually supplied —
+ * re-hashes them server-side. A client-declared hash that does not match the
+ * bytes it shipped is a version-drift bug or an attack; either way it must
+ * never reach the signers.
+ */
+async function createVersionRow(
+ tx: Prisma.TransactionClient,
+ input: VersionRowInput,
+) {
+ if (input.storageMode === "inline") {
+ if (!input.contentInline) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "storageMode 'inline' requires contentInline",
+ });
+ }
+ const bytes = Buffer.from(input.contentInline, "base64");
+ if (bytes.length > MAX_INLINE_BYTES) {
+ throw new TRPCError({
+ code: "PAYLOAD_TOO_LARGE",
+ message: `Inline content exceeds ${MAX_INLINE_BYTES} bytes`,
+ });
+ }
+ if (sha256Hex(bytes) !== input.contentHash) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "contentHash does not match the supplied bytes",
+ });
+ }
+ }
+ if (input.storageMode === "external" && !input.contentRef) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "storageMode 'external' requires contentRef",
+ });
+ }
+
+ return tx.documentVersion.create({
+ data: {
+ documentId: input.documentId,
+ versionNumber: input.versionNumber,
+ contentHash: input.contentHash,
+ hashAlgorithm: "sha256",
+ fileName: input.fileName ?? null,
+ mimeType: input.mimeType ?? null,
+ fileSize: input.fileSize ?? null,
+ storageMode: input.storageMode,
+ contentRef: input.contentRef ?? null,
+ contentInline: input.storageMode === "inline" ? input.contentInline : null,
+ reviewInstructions: input.reviewInstructions ?? null,
+ status: "Draft",
+ createdBy: input.createdBy,
+ },
+ });
+}