Skip to main content

31. Onboarding agreements & e-signatures (hybrid)

Date: 2026-06-14

Status

Accepted

Context

Onboarding collected profile + documents but the contract-signing step was a placeholder (OnboardingWizard showed "Contract e-signature (DocuSeal) will be added here before go-live"). The contractor_documents.docuseal_submission_id column existed but nothing used it. Contractors are Philippine independent contractors signing a small set of simple agreements (IC, NDA); the owner wanted in-app signing that's fully under our control, while keeping the door open to a formal audited envelope (DocuSeal) for anything heavier later.

Decision

  • Hybrid e-signature. Build native in-app signing for the simple PH agreements against an immutable signature ledger; keep DocuSeal available (the retained column) for formal envelopes later. No third-party dependency is required for the common path.
  • Immutable ledger (onboarding_signatures, migration 0027). One row per (contractor, kind, party). The contractor signs as party='contractor'; an authorized admin counter-signs as party='company'. RLS is SELECT-only for clients (admins see their company, a contractor sees their own) — there is no insert/update/delete policy, so the ledger is immutable except via service-role server actions. Every row is audited (app.audit_row).
  • Hash what was actually signed. Each signature stores agreement_sha256 = sha256(mergedBody \n signedName \n signedDate) (src/lib/agreements/hash.ts). The body is merged server-side at sign time from the template + the contractor's data (src/lib/agreements/merge.ts, {{token}} substitution) — the client never supplies the hashed text — so a later template edit can never change what a contractor already agreed to.
  • Templates with a safe default. agreement_templates (per company, per kind) holds the body + merge tokens, edited by admins. When a company has no template for a required kind yet, the flow falls back to a built-in [PLACEHOLDER] default so onboarding is never blocked; the admin editor persists the real legal text before go-live.
  • Typed or drawn. SignaturePad captures a typed name (rendered cursive) or a drawn PNG (bounded data URI); a scroll-to-bottom gate enables signing. method + optional signature_data are recorded. IP/User-Agent are captured server-side from request headers.
  • Document review workflow. Admins review uploaded docs (approve / waive / defer / needs-replacement) with a reason and an optional issued_on (e.g. NBI freshness); columns added to contractor_documents. Counter-signing is gated by profiles.can_countersign.

Consequences

  • Onboarding can require the contractor to sign the configured agreements before submitting; the admin reviews documents and counter-signs, then activates.
  • The signature ledger is court-defensible-ish (hash + name + date + IP/UA, immutable) without a third party; if a formal audited envelope is later required for a specific agreement, DocuSeal slots in via the retained column without disturbing the ledger.
  • Editing a template does not alter past signatures (they keep their own hash + the body is re-derivable from the template version they signed against — future work: snapshot the body per signature if templates start changing frequently).
  • New tables (agreement_templates, onboarding_signatures) + contractor_documents review columns
    • profiles.can_countersign are added to src/db/types.ts; regenerate after further changes.