{
  "slug": "contracts-esignature",
  "spec_file": "docs/specs/2026-05-31-contracts-esignature.md",
  "findings": [
    {
      "id": "contracts-esignature-001",
      "severity": "P0",
      "type": "security",
      "summary": "POST /api/contracts/:id/sign is public and signs by contract UUID — bypasses signatory token entirely",
      "spec_ref": "Public endpoint security — /api/sign/:token only; token is per-signatory capability",
      "code_ref": "apps/zync-api/src/routes/contracts/index.ts:277",
      "evidence": "Handler has no authMiddleware/requirePermission; comment says 'PUBLIC, no auth — contractId is the signer token'. Calls getContractPublic(db, contractId) with no tenant session (packages/db/src/queries/contracts.ts:76) then signContract(db, tenantId, contractId, …) which sets contracts.status='signed' when status='sent' (contracts.ts:243-251). Any caller who knows the contract UUID can sign without a signatory token.",
      "repro": "POST /api/contracts/{sent-contract-uuid}/sign {\"signatureData\":{}} with no session cookie → 200, contract status becomes signed.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contracts-esignature-002",
      "severity": "P0",
      "type": "broken",
      "summary": "contracts:read/write/delete permissions never seeded — requirePermission on all authed contract routes always 403",
      "spec_ref": "Permissions — contracts:read, contracts:write, contracts:delete seeded and assigned to roles",
      "code_ref": "packages/db/src/seed/permission-keys.ts:6",
      "evidence": "PERMISSION_KEYS array (lines 6-48) has no contracts:* keys. SYSTEM_ROLE_PERMISSIONS OWNER uses ALL from that list. Routes call requirePermission('contracts:read'|'contracts:write') (contracts/index.ts:199+). requirePermission rejects when permission not in session.permissions (packages/auth/src/middleware.ts:38). grep 'contracts:delete' in packages/db/src/seed → 0 matches.",
      "repro": "Log in as OWNER → GET /api/contracts → 403 { requiredPermission: 'contracts:read' }.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contracts-esignature-003",
      "severity": "P0",
      "type": "broken",
      "summary": "POST /api/sign/:token stores signature_type 'drawn'/'typed' but DB CHECK only allows 'draw'/'type'",
      "spec_ref": "contract_signatories.signature_type CHECK ('drawn','typed'); signSubmissionSchema signature_type enum",
      "code_ref": "apps/zync-api/src/routes/sign.ts:138",
      "evidence": "signSubmitSchema accepts z.enum(['drawn','typed']) at :32. Handler casts to 'draw'|'type' via TypeScript only (no value mapping) at :138. contractSignatories schema CHECK: IN ('draw','type') (packages/db/src/schema/contracts.ts:121-122). Insert uses input.signatureType verbatim (packages/db/src/queries/contract-signing.ts:117).",
      "repro": "POST /api/sign/{valid-token} with signature_type:'drawn' → DB CHECK violation → 500 Failed to sign.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contracts-esignature-004",
      "severity": "P0",
      "type": "security",
      "summary": "POST /api/sign/:token does not reject voided contracts or enforce signing order",
      "spec_ref": "GET/POST /api/sign/:token — VOIDED → 410; out-of-turn cannot submit",
      "code_ref": "apps/zync-api/src/routes/sign.ts:113",
      "evidence": "POST handler checks token expiry (:131-133) only. No row.contract.status === 'voided' guard. isSignatoryTurn/recipientsForDispatch exist (apps/zync-api/src/contracts/signing-order.ts:47-65) but grep isSignatoryTurn in apps/zync-api/src/routes/sign.ts → 0 matches.",
      "repro": "Void contract → POST /api/sign/{token} for any signatory → 200 ok:true; sequential second signatory can sign before first.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contracts-esignature-005",
      "severity": "P0",
      "type": "broken",
      "summary": "First signatory signature marks entire contract SIGNED — no wait-for-all-signatories completion flow",
      "spec_ref": "Completion Flow — SIGNED only when all signatories signed; completeIfAllSigned in same transaction",
      "code_ref": "packages/db/src/queries/contract-signing.ts:143",
      "evidence": "signContract (token path) unconditionally updates contracts SET status='signed', signed_at=now() after one signatory signs (:143-147). No query of other signatories, no completeIfAllSigned, no PDF/email. grep completeIfAllSigned in repo → 0 implementation files.",
      "repro": "Contract with 2 unsigned signatories → first POST /api/sign/{token} → contracts.status='signed' while second signatory still unsigned.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contracts-esignature-006",
      "severity": "P0",
      "type": "security",
      "summary": "GET /api/sign/:token skips token expiry and voided checks (spec requires 410 Gone)",
      "spec_ref": "Public endpoint security — token_expires_at < now() → 410; VOIDED → 410",
      "code_ref": "apps/zync-api/src/routes/sign.ts:58",
      "evidence": "GET handler resolves token (:66-67), logs view (:70-77), returns 200 payload (:108). No comparison of row.tokenExpiresAt to Date.now(). No row.contract.status void check. POST checks expiry at :131-133 but GET does not.",
      "repro": "Expired or voided contract token → GET /api/sign/{token} → 200 with full contract content.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contracts-esignature-007",
      "severity": "P1",
      "type": "missing",
      "summary": "contract_templates table and /api/contract-templates CRUD not implemented",
      "spec_ref": "Data Model contract_templates; API GET/POST/PATCH/DELETE /api/contract-templates*",
      "code_ref": "packages/db/src/schema/contracts.ts:36",
      "evidence": "grep 'contract_templates' in packages/db/migrations/*.sql → 0 CREATE TABLE. grep contract-templates in apps/zync-api/src/routes → 0 route files. settings-contracts references table in raw SQL (settings-contracts.ts:87) but table never migrated.",
      "repro": "Any /api/contract-templates request → 404 (route absent).",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contracts-esignature-008",
      "severity": "P1",
      "type": "missing",
      "summary": "packages/contracts service layer (validators, variables, content-security, pdf, completion) absent",
      "spec_ref": "Plan Architecture — @zync/contracts package with state machine, PDF, email completion",
      "code_ref": "docs/plans/tasks/contracts-esignature.md:10",
      "evidence": "Glob packages/contracts → path does not exist. grep validateContractContent|substituteVariables|generateSignedPdf in /home/user/Projects/zync.is (excluding docs/.claude) → 0 implementation files.",
      "repro": "N/A — module package never created.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contracts-esignature-009",
      "severity": "P1",
      "type": "missing",
      "summary": "PATCH and DELETE /api/contracts/:id not implemented",
      "spec_ref": "API PATCH /api/contracts/:id (DRAFT only); DELETE (DRAFT only, contracts:delete)",
      "code_ref": "apps/zync-api/src/routes/contracts/index.ts:729",
      "evidence": "contractsRoute exports GET/POST/PATCH signatories/renew/amend only. grep 'contractsRoute.delete' or 'contractsRoute.patch' on '/:id' (excluding signatories) in contracts/index.ts → 0 matches.",
      "repro": "DELETE /api/contracts/{draft-id} → 404. PATCH /api/contracts/{draft-id} → 404.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contracts-esignature-010",
      "severity": "P1",
      "type": "missing",
      "summary": "GET /api/contracts/:id/pdf (staff signed-PDF download + downloaded audit) not implemented",
      "spec_ref": "API GET /api/contracts/:id/pdf — contracts:read, signed R2 URL",
      "code_ref": "apps/zync-api/src/routes/contracts/index.ts:729",
      "evidence": "grep '/pdf' in apps/zync-api/src/routes/contracts → 0 matches. Public GET /api/sign/:token/pdf exists in sign.ts:193 but staff endpoint from spec table is absent.",
      "repro": "GET /api/contracts/{signed-id}/pdf with session → 404.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contracts-esignature-011",
      "severity": "P1",
      "type": "missing",
      "summary": "sendContract does not mint signatory tokens, set token_expires_at, or email signatories",
      "spec_ref": "POST /api/contracts/:id/send — SENT, UUID tokens, token_expires_at=sent_at+30d, email lowest-order signatory",
      "code_ref": "packages/db/src/queries/contracts.ts:170",
      "evidence": "sendContract only UPDATE contracts SET status='sent', signerEmail, signerName, sentAt (:192-196). No insert into contract_signatories, no token/crypto.randomUUID, no sendEmail. Signatories created separately via bulkUpsertSignatories without token_expires_at on insert (contract-signatories.ts:154-163).",
      "repro": "Send contract → contract_signatories rows lack token_expires_at; no signing email dispatched from send path.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contracts-esignature-012",
      "severity": "P1",
      "type": "missing",
      "summary": "Signed PDF generation to R2 (html-to-pdf) and signed_pdf_r2_key population never run on completion",
      "spec_ref": "Completion Flow — POST html-to-pdf.zync.is; R2 key contracts/{tenant_id}/{contract_id}/signed.pdf",
      "code_ref": "packages/db/src/queries/contract-signing.ts:143",
      "evidence": "grep html-to-pdf in packages/db → 0 matches. signContract token path sets status signed but never writes signedPdfR2Key. Column exists (schema contracts.ts:57) but no writer in completion flow.",
      "repro": "Fully signed contract → contracts.signed_pdf_r2_key remains null → GET /api/sign/{token}/pdf → 409 pdf_not_ready.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contracts-esignature-013",
      "severity": "P1",
      "type": "missing",
      "summary": "invoices.contract_id FK column not added to invoices schema",
      "spec_ref": "Schema delta — ALTER TABLE invoices ADD COLUMN contract_id UUID REFERENCES contracts(id)",
      "code_ref": "packages/db/src/schema/invoices.ts:1",
      "evidence": "grep contract_id in packages/db/src/schema/invoices.ts → 0 matches. generateInvoiceFromContract links via contract.metadata.invoiceId only (contract-invoice.ts:98-100), not invoices.contract_id.",
      "repro": "Generate invoice from contract → invoice row has no contract_id FK.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contracts-esignature-014",
      "severity": "P1",
      "type": "missing",
      "summary": "VIEWED lifecycle status and signatory viewed_at not implemented",
      "spec_ref": "State rules SENT→VIEWED; GET /api/sign/:token records viewed_at, flips contract to VIEWED",
      "code_ref": "packages/db/src/schema/contracts.ts:75",
      "evidence": "contracts.status CHECK only draft|sent|signed|voided (:74-76). contract_signatories has no viewed_at column (schema :85-116). recordContractView only inserts contract_audit_log event 'viewed' (contract-signing.ts:196-212), no status transition.",
      "repro": "Open signing link → contract.status stays 'sent'; signatory viewed_at always null.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contracts-esignature-015",
      "severity": "P1",
      "type": "missing",
      "summary": "Staff UI routes /contracts/new, /contracts/templates*, /contracts/:id/edit not registered",
      "spec_ref": "Routes table — /contracts/new, /contracts/templates, /contracts/:id/edit",
      "code_ref": "apps/zync-app/src/modules/contracts.tsx:16",
      "evidence": "ContractsModule Routes only index (list) and :id (detail). grep 'contracts/new' in apps/zync-app/src/modules → 0 route. TemplateListPage/ContractCreatePage files absent (glob apps/zync-app/**/contracts/** → 5 files, none are templates/new).",
      "repro": "Navigate /contracts/new → SPA 404/unmatched route.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contracts-esignature-016",
      "severity": "P1",
      "type": "missing",
      "summary": "Tiptap variable substitution, content validator, and rich editor integration absent",
      "spec_ref": "Content JSONB Tiptap; substituteVariables; validateContractContent ALLOWED_NODE_TYPES",
      "code_ref": "apps/zync-api/src/routes/contracts/index.ts:63",
      "evidence": "createContractSchema accepts title+content string only (:63-67). contracts.content is text() not jsonb() (schema contracts.ts:45). No validateContractContent on create. grep substituteVariables in apps/packages (excl docs) → 0.",
      "repro": "POST /api/contracts with Tiptap JSON body → stored as opaque string; {{variables}} never substituted server-side.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contracts-esignature-017",
      "severity": "P1",
      "type": "missing",
      "summary": "Lead → Contract integration (Create Contract button, lead_id prefill, activity log) not wired per spec",
      "spec_ref": "Integration Marketing — /contracts/new?lead_id=; contract created/signed activities",
      "code_ref": "apps/zync-api/src/routes/contracts/index.ts:212",
      "evidence": "POST /api/contracts handler accepts only title/content/customerId (:63-67, :224); no lead_id query/body handling. grep lead_id in apps/zync-api/src/routes/contracts → 0. Lead detail features/leads has LeadLinkedEntities but no Create Contract navigation to /contracts/new?lead_id=.",
      "repro": "WON lead detail → no spec-compliant Create Contract entry; POST /api/contracts?lead_id= ignored.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contracts-esignature-018",
      "severity": "P1",
      "type": "missing",
      "summary": "contract_audit_log 'created' events and detail-page audit tab per spec not implemented",
      "spec_ref": "Audit log events created/sent/viewed/signed; Contract Detail audit log tab with IPs",
      "code_ref": "packages/db/src/queries/contracts.ts:154",
      "evidence": "createContract writes global auditLog action 'contract.created' (:154-162), not contract_audit_log. ContractDetailPage uses EntityHistoryTab (operational audit) at :776; grep contract_audit_log in apps/zync-app → 0 matches.",
      "repro": "Create contract → no row in contract_audit_log with event='created'. Detail page has no contract_audit_log timeline.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contracts-esignature-019",
      "severity": "P1",
      "type": "missing",
      "summary": "requireModuleEnabled('contracts') and MODULE_MANIFEST contracts entry absent",
      "spec_ref": "Plan Task 3 — register contracts module; guard all authed routes",
      "code_ref": "apps/zync-api/src/routes/contracts/index.ts:184",
      "evidence": "grep requireModuleEnabled in contracts/index.ts → 0. grep \"id: 'contracts'\" in packages/modules/src/manifest.ts → 0 matches (MODULE_MANIFEST has tasks, projects, customers, etc., no contracts).",
      "repro": "Disable contracts module (once registered) would have no effect — guard not applied.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contracts-esignature-020",
      "severity": "P1",
      "type": "missing",
      "summary": "E2E tests contracts.e2e.test.ts and sign-public.e2e.test.ts not present",
      "spec_ref": "Plan Task 18 — lifecycle, security, state-machine, permission tests",
      "code_ref": "docs/plans/tasks/contracts-esignature.md:471",
      "evidence": "grep contracts.e2e|sign-public.e2e in apps/zync-api/test → 0 files.",
      "repro": "N/A — test files absent.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contracts-esignature-021",
      "severity": "P1",
      "type": "missing",
      "summary": "System starter templates (Service Agreement IL, NDA IL, Fixed-Price Project IL) not seeded per tenant",
      "spec_ref": "System templates — 3 starter templates seeded per tenant on creation",
      "code_ref": "packages/db/src/queries/seed-rbac.ts:40",
      "evidence": "grep seedTenantContractTemplates|seed-templates in packages/apps → 0 files. tenant provisioning uses seedSystemRoles only (seed-rbac.ts), no contract template seed hook.",
      "repro": "Provision new tenant → contract_templates row count 0.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contracts-esignature-022",
      "severity": "P2",
      "type": "divergence",
      "summary": "Contract status enum uses lowercase draft/sent/signed/voided vs spec uppercase DRAFT/SENT/VIEWED/SIGNED/VOIDED",
      "spec_ref": "contracts.status CHECK ('DRAFT','SENT','VIEWED','SIGNED','VOIDED')",
      "code_ref": "packages/db/src/schema/contracts.ts:46",
      "evidence": "Drizzle default 'draft' and CHECK IN ('draft','sent','signed','voided') at :46,:74-76. SignPageData type expects uppercase (packages/types/src/sign.ts:7). UI StatusBadge uses lowercase (ContractListPage.tsx:65-71).",
      "repro": "API returns status:'draft'; spec/UI contract expects 'DRAFT'.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contracts-esignature-023",
      "severity": "P2",
      "type": "divergence",
      "summary": "contracts.content is plain TEXT/markdown, not JSONB Tiptap document with template_id/created_by columns",
      "spec_ref": "Data Model — content JSONB Tiptap; template_id, created_by on contracts",
      "code_ref": "packages/db/src/schema/contracts.ts:45",
      "evidence": "content: text('content') at :45. grep template_id|created_by in contracts schema → 0 columns. Migration 0007_add_contracts.sql creates content text NOT NULL.",
      "repro": "Inspect contracts table — no template_id, created_by, void_reason, voided_by columns from spec DDL.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contracts-esignature-024",
      "severity": "P2",
      "type": "divergence",
      "summary": "contract_audit_log actor_type uses 'staff' not spec 'user'; missing 'created' event in CHECK",
      "spec_ref": "contract_audit_log actor_type IN ('user','signatory','system'); events include created",
      "code_ref": "packages/db/src/schema/contracts.ts:144",
      "evidence": "actorType comment 'staff' at :144; CHECK IN ('staff','signatory','system') at :158-159. event CHECK omits 'created' (:154-155). append paths use actorType:'staff' (contract-signatories.ts:173).",
      "repro": "Audit rows use actor_type='staff'; spec expects 'user' for staff actions.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contracts-esignature-025",
      "severity": "P2",
      "type": "divergence",
      "summary": "Public signing rate limit uses RATE_LIMITER_SIGN 20/min not spec RATE_LIMITER_AUTH 10/min",
      "spec_ref": "Public endpoint security — 10 requests/min per IP via RATE_LIMITER_AUTH",
      "code_ref": "apps/zync-api/src/routes/sign.ts:48",
      "evidence": "checkSignRateLimit calls env.RATE_LIMITER_SIGN (:48). wrangler.toml RATE_LIMITER_SIGN limit=20 period=60 (:128-131). Spec names RATE_LIMITER_AUTH.",
      "repro": "11th request/min to /api/sign/* from same IP → allowed until 21st (spec expects 429 at 11).",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contracts-esignature-026",
      "severity": "P2",
      "type": "divergence",
      "summary": "GET /api/sign/:token omits waitingFor payload for sequential signing order",
      "spec_ref": "Signing order — higher-order signatories see waiting for {name}; SignPageData.waitingFor",
      "code_ref": "apps/zync-api/src/routes/sign.ts:85",
      "evidence": "SignPageData built at :85-106 without waitingFor field. signatory.order hardcoded 0 with comment 'not surfaced' (:95). ContractSigningIsland resolvePageState checks waitingFor (:46) but API never sets it.",
      "repro": "Sequential contract, second signatory opens link → active signing UI instead of waiting message.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contracts-esignature-027",
      "severity": "P2",
      "type": "divergence",
      "summary": "Resend endpoint path differs from spec (/signatories/:sigId/resend vs /resend/:signatoryId)",
      "spec_ref": "API POST /api/contracts/:id/resend/:signatoryId",
      "code_ref": "apps/zync-api/src/routes/contracts/index.ts:517",
      "evidence": "Implemented as POST /:id/signatories/:sigId/resend at :517-518. grep '/resend/:signatoryId' in contracts routes → 0.",
      "repro": "POST /api/contracts/{id}/resend/{sigId} → 404; must use /signatories/{sigId}/resend.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contracts-esignature-028",
      "severity": "P2",
      "type": "divergence",
      "summary": "Contract list API not paginated and lacks customer/date filters from spec",
      "spec_ref": "GET /api/contracts — paginated, filterable by status/customer/date",
      "code_ref": "packages/db/src/queries/contracts.ts:92",
      "evidence": "listContracts hard limit 100 (:107), only optional status filter (:98-100). No customer_id or date range params. Response shape {items} not buildPaginated cursor page.",
      "repro": "GET /api/contracts?customer_id=… → param ignored; no pagination metadata returned.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contracts-esignature-029",
      "severity": "P2",
      "type": "divergence",
      "summary": "Public signing page lives on zync-www Astro, not zync-app /sign/:token as spec routes table states",
      "spec_ref": "Routes — /sign/:token in tenant app; Plan Task 15 apps/zync-app/src/pages/sign/*",
      "code_ref": "apps/zync-www/src/pages/sign/[token].astro:4",
      "evidence": "Signing page at apps/zync-www/src/pages/sign/[token].astro. grep apps/zync-app/src/pages/sign → 0 files. apps/zync-app router has no /sign route.",
      "repro": "Spec staff app URL /sign/{token} on app host — implementation only on www worker.",
      "fix_direction": "spec_review"
    },
    {
      "id": "contracts-esignature-030",
      "severity": "P2",
      "type": "divergence",
      "summary": "voidContract allows voiding DRAFT contracts; spec requires void only for status ≥ SENT",
      "spec_ref": "State rules — DRAFT deletable via DELETE; ≥ SENT exits via void only",
      "code_ref": "packages/db/src/queries/contracts.ts:272",
      "evidence": "voidContract checks only status === 'voided' already (:287-289); no rejection for status==='draft'. UI shows Void for non-voided including draft (ContractDetailPage.tsx:646-649).",
      "repro": "POST /api/contracts/{draft-id}/void → 200 voided (spec: use DELETE for DRAFT).",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contracts-esignature-031",
      "severity": "P3",
      "type": "cosmetic",
      "summary": "Contract list UI missing VIEWED status badge and signatory X-of-Y signed column",
      "spec_ref": "Contract List — Status VIEWED badge; Signatories X of Y signed column",
      "code_ref": "apps/zync-app/src/modules/contracts/ContractListPage.tsx:65",
      "evidence": "StatusBadge variantMap keys draft|sent|signed|voided only (:65-70). Row shows signerEmail not signatory completion ratio.",
      "repro": "Contract in VIEWED state cannot display — status not in enum.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contracts-esignature-032",
      "severity": "P3",
      "type": "cosmetic",
      "summary": "declineSchema allows empty reason; spec requires min 1 char; decline_reason column missing",
      "spec_ref": "declineSchema reason min 1; contract_signatories.decline_reason TEXT",
      "code_ref": "apps/zync-api/src/routes/sign.ts:37",
      "evidence": "declineSchema z.string().max(1000).optional() at :37-39. contract_signatories has declinedAt but no decline_reason column (schema :100-101). Reason stored only in audit metadata (contract-signing.ts:189).",
      "repro": "POST /api/sign/{token}/decline {} → 200; no persisted decline_reason on signatory row.",
      "fix_direction": "code_to_spec"
    }
  ],
  "summary": {
    "P0": 6,
    "P1": 15,
    "P2": 9,
    "P3": 2,
    "total": 32
  }
}
