ADR: Unified Signature Model
Status
Accepted — implemented in migration 20260617120000_unify_signatures.
Context
Three parallel signing mechanisms existed:
PnpSignature— SHA-256 hash, IP/UA, immutableissuedAt, assignment-bound.AgreementVendor.signedAt— nullable timestamp only; no hash, no metadata, no re-sign guard.- Generic
Document+Signature— weak hash (docId-userId-timestamp), no audit metadata.
This divergence created an inconsistent security posture and made it impossible to enforce uniform audit requirements across document types.
Decision
Replace all three with a single polymorphic Signature table backed by DocumentVersion:
-
DocumentVersion(document_versions) — one row per uploaded version of a signable subject.subjectType+subjectId+versionNumberuniquely identifies it. Signatures bind to a version, so they survive subject edits without becoming stale. -
Signature(signatures) — unified table for all attestations. Polymorphic onsubjectType(POLICY_PROCEDURE|BUSINESS_ASSOCIATE_AGREEMENT) and polymorphic signer viasignerType+userId/vendorId(mirroringAuditLog.userId/actorVendorId).
Key invariants:
| Invariant | Enforcement |
|---|---|
| File-byte hash | contentHash = SHA-256(fileBytes) stored on every new signature |
| Signature hash | hash = SHA-256(subjectType:subjectId:versionId:signerType:signerId:contentHash:issuedAt) — tamper-evident |
| No re-sign | Two partial unique indexes (signatures_subject_user_uq, signatures_subject_vendor_uq) enforce one signature per signer per subject |
| Immutability | issuedAt is an ISO string frozen at write time; Signature has no updatedAt |
| "Exactly one signer" | Enforced at service layer on insert (not a DB CHECK, which conflicts with onDelete: SetNull) |
| Cascade delete | Signature.versionId → DocumentVersion CASCADE; subjects must manually deleteMany from document_versions before deletion (enforced in PnpDocumentsRepository.delete and AgreementsService.delete) |
The generic Document signing flow (POST /documents/:id/sign, GET /documents/:id/signatures) was vestigial and has been removed.
Consequences
- Positive: one place to audit all signatures; uniform hash/metadata standard; re-sign blocking enforced at DB level.
- Positive: vendor-signed BAA now has full audit trail (IP, UA, hash) matching PnP quality.
- Negative: polymorphic FK means no automatic cascade from subjects to versions — deletion must be done explicitly in service layer.
- Legacy data: migrated with
algorithm='legacy'and preserved original hashes/timestamps where available.