Signatures
A signature is a row in signatures that binds a person, a meaning derived by the server from the route, a
verbatim statement and the hash of the versioned content of one record. Signatures are reserved for approve
routes (protocols, reports) and for requirement-set acceptance; approving an agent proposal in the console is
not a signature. The web app labels the dialog with the fixed phrase “Decision signature (non-GxP pilot)”
and prints “Signature valid” or “Record changed since signature” on the record page. Nothing here is a
compliance claim; the controls are described so they can be inspected.
Record (SignatureOut)
| Field | Meaning |
|---|---|
signer_user_id | not null: agents are not users and cannot sign |
printed_name | the signer’s display name at signing time |
meaning | authored, reviewed, approved, acknowledged; derived by the server from the route (approve -> approved, requirement decision -> approved) and never accepted from the client |
statement | the exact sentence the signer confirmed, stored verbatim: I, <printed name>, approve <record label> (record <first 16 hex of record_hash>). Decision signature (non-GxP pilot). |
target_type, target_id, target_version | protocol, report or requirement_set and the version signed |
record_hash | sha256 of RFC 8785 canonical JSON of the versioned content fields (below) |
signed_at, auth_method, auth_evidence | idp_reauth with auth_time and a uti hash, or dev_token with a jti; the hash carries a unique index so one token signs once |
reason | the rationale from the request |
valid | recomputed on read: true when the current record hash equals record_hash |
The row is immutable: BEFORE UPDATE OR DELETE triggers raise, the application role holds SELECT and
INSERT only, and every foreign key (including signer_user_id) is ON DELETE NO ACTION, so users are
deactivated, never deleted.
Record hash per target type
| Target | Fields hashed |
|---|---|
protocol | {id, version, title, body_md, format, plan_id, sop_version_ids} |
report | {id, version, title, body_md, plan_id} |
requirement_set | {sop_version_id, requirements} |
record_hash is sha256(JCS(fields)) with the same canonical JSON function the audit envelope uses
(api/app/services/audit.py canonical_json, twin of the SQL trovensa_jcs). After a signature the service
refuses update on the target with 409 record_signed; a change is a new version with supersedes_id, and
the previous version keeps its signature and shows valid = true against its own content.
Requests that sign
| Route | Body | Meaning |
|---|---|---|
POST .../plans/protocols/{protocol_id}/approve | ApproveRequest {rationale, reauth_token, statement_ack} | approved |
POST .../plans/reports/{report_id}/approve | ApproveRequest | approved |
POST .../review/sop-versions/{version_id}/requirements/decision | RequirementSetDecision {decision, requirements[], rationale, reauth_token, statement_ack} | approved when decision = accepted |
POST .../progress/approvals/{approval_id}/decision | ApprovalDecision (with a signature when the target is a protocol or report) | approved; approvals.signature_id links the row |
statement_ack must equal the statement the API showed before re-authentication; a mismatch is rejected.
Re-authentication
- The dialog persists
pending_signature {interrupt_id | approval_id, statement_hash, return_path, started_at}insessionStorage, then calls the identity provider’s step-up (prompt: 'login', redirect or popup) and resumes on return. - The API verifies
reauth_tokenthrough the same issuer, audience and JWKS path as session tokens, and requiresoidandtidequal to the session principal,auth_timewithin 300 s ofsigned_atand single use (utihash). - A token without
auth_timefails closed in deployed environments with401 reauth_required; the optionalauth_timeclaim is enabled on thetrovensa-consoleregistration. - Development mode:
uv run python scripts/dev_token.py --user a@b.c --org demo --reauthmints a 300 s token withauth_time = iatand ajti; the signature recordsauth_method = dev_token. Deployed environments refuse dev tokens.
Where signatures appear
GET .../signatures/{signature_id}(program.audit.read) returns the row withvalid.protocols.signature_id,reports.signature_id,sop_versions.signature_idandapprovals.signature_idlink records to their signature;audit_events.signature_idlinks the audit row of the signing mutation.- Record pages print “Agent-generated draft; approved by
<name>on<date>(approved). Signature valid.” for an agent-drafted protocol approved by a person; a later content change flips the line to “Record changed since signature”. - Audit exports carry
signature_idbesideactor_kind,approved_by_user_id,interrupt_id,proposal_hashandapproved_hash.
Source: api/app/db/models/signatures.py, api/app/services/signatures.py, api/app/services/audit.py, api/app/auth/jwt.py, api/app/schemas/signatures.py, api/scripts/dev_token.py, api/alembic/versions/0002_console.py, web/lib/api/types.ts, web/lib/content.ts (consoleCopy), PRODUCT-CONTRACT.md section 10.4, decisions/0014-alcoa-envelope-and-append-only-audit.md