Binding Electronic Signatures to Excursion Records
When a quality reviewer dispositions a temperature excursion, the signature they apply must attach to that exact excursion and nothing else — not the batch in general, not a later revision of the record, not a similar event on another pallet. This guide binds a signature to a single excursion record by computing it over the record’s SHA-256 hash, applying the signing controls established in implementing 21 CFR Part 11 electronic signatures. The result is a disposition that carries its own proof of which excursion it belongs to, so it cannot be lifted off one event and dropped onto another.
Regulatory hook
21 CFR Part 11 §11.70 requires that electronic signatures be linked to their respective records to ensure they cannot be excised, copied, or otherwise transferred to falsify a record. A signature stored merely as a row referencing an excursion ID satisfies none of this: the reference can be re-pointed, the excursion payload can be revised beneath it, and nothing breaks. Binding the signature over the excursion’s content hash makes the link intrinsic — change the excursion and the signature no longer verifies; move the signature and it verifies against nothing. That intrinsic linkage is precisely the §11.70 guarantee, and because the excursion’s disposition drives batch release, the binding is what makes the release attributable and defensible.
The excursion case is where a weak binding does the most damage. A single shipment can generate dozens of near-identical events — the same product, the same 2–8 °C envelope, similar durations — differing only in which pallet and which time window they cover. A signature linked by a mutable reference could be attached to the wrong one of these look-alikes through nothing more than an ordering bug or a careless copy, and the error would be invisible because every field would still look plausible. A hash-bound signature removes that class of error entirely: it commits to the exact content of one excursion, so a mismatch is not a subtle inconsistency an auditor must notice but a verification failure the system raises on its own.
Prerequisites
- Python 3.11 or newer. Signing uses only the standard library.
pip install pytest # validation suite only; hashing and HMAC are stdlib
- Excursion record: a structured disposition produced by the detection stack — for example an event from duration-based scoring for temperature excursions carrying its
excursion_id,batch_id, scored state, and the disposition decision. - Signer identity: a verified, unique individual with at least two authentication components, as required for the signing authority.
- Signing key: held in a KMS or HSM, supplied to the process by reference, never inline.
- Access control: only reviewers holding the disposition role may invoke the signing path; the role check runs before the signature is offered.
Step-by-Step Implementation
Step 1 — Freeze the excursion record before signing
A signature over a mutable record is meaningless. Snapshot the exact fields being dispositioned into an immutable structure, so the hash the signature commits to cannot shift after the fact.
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass, asdict
@dataclass(frozen=True)
class ExcursionDisposition:
# §11.70: the signature will bind to THIS frozen content; a later edit to any
# field changes the hash and invalidates the signature by construction.
excursion_id: str
batch_id: str
scored_state: str # e.g. QUARANTINE from the scoring engine
disposition: str # REJECT / RELEASE / INVESTIGATE
disposition_reason: str
def _canonical(obj: dict) -> bytes:
# Deterministic serialization so signer and verifier hash identical bytes.
return json.dumps(obj, sort_keys=True, separators=(",", ":")).encode("utf-8")
def excursion_hash(record: ExcursionDisposition) -> str:
# §11.70 record linking: the digest of the exact disposition being signed.
return hashlib.sha256(_canonical(asdict(record))).hexdigest()
Verify the hash is stable for identical content and changes on any edit:
d = ExcursionDisposition("EXC-88213", "BATCH-2026-0417", "QUARANTINE", "REJECT", "MKT exceeded")
assert excursion_hash(d) == excursion_hash(d)
d2 = ExcursionDisposition("EXC-88213", "BATCH-2026-0417", "QUARANTINE", "RELEASE", "MKT exceeded")
assert excursion_hash(d) != excursion_hash(d2) # decision change breaks the digest
Step 2 — Sign over the excursion hash with signer and meaning
The signature body embeds the excursion hash together with the signer’s verified identity and declared meaning, then seals all three with a keyed HMAC. The two-component gate runs first, so an under-authenticated signer never reaches the signing operation.
import hmac
import os
from datetime import datetime, timezone
class SignatureError(RuntimeError):
pass
def sign_excursion(
record: ExcursionDisposition,
signer_id: str,
printed_name: str,
auth_components: int,
meaning: str,
signing_key: bytes,
key_version: str,
) -> dict:
# §11.200(a)(1): refuse to sign without at least two authentication components.
if auth_components < 2:
raise SignatureError("two-component authentication required (§11.200)")
# §11.50(a)(2): the meaning must be an explicit, controlled value.
if meaning not in {"APPROVAL", "REVIEW", "RESPONSIBILITY"}:
raise SignatureError("unrecognized signing meaning (§11.50)")
body = {
"excursion_hash": excursion_hash(record), # §11.70 binds to this excursion
"signer_id": signer_id,
"signer_printed_name": printed_name, # §11.50(a)(1)
"signing_meaning": meaning, # §11.50(a)(2)
"signed_at": datetime.now(timezone.utc).isoformat(), # §11.50(a)(3)
"key_version": key_version,
}
# §11.70: keyed HMAC over the body makes the identity, meaning, and excursion
# inseparable — none can be altered or transplanted without detection.
body["signature_value"] = hmac.new(
signing_key, _canonical(body), hashlib.sha256
).hexdigest()
return body
Step 3 — Verify the binding rejects a transplanted signature
Verification recomputes the excursion hash from the presented record and the keyed signature, and confirms both match. A signature offered against a different excursion must fail.
def verify_excursion_signature(
record: ExcursionDisposition, sig: dict, signing_key: bytes
) -> bool:
# §11.70: reject if the presented record does not hash to the signed excursion.
if sig.get("excursion_hash") != excursion_hash(record):
return False
body = {k: sig[k] for k in (
"excursion_hash", "signer_id", "signer_printed_name",
"signing_meaning", "signed_at", "key_version",
)}
expected = hmac.new(signing_key, _canonical(body), hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig["signature_value"]) # constant-time
Prove the transplant is caught:
key = os.environb.get(b"PART11_SIGNING_KEY") or b"k" * 32 # dev fallback only
d = ExcursionDisposition("EXC-88213", "BATCH-2026-0417", "QUARANTINE", "REJECT", "MKT exceeded")
sig = sign_excursion(d, "qa.014", "Dana Okafor", 2, "APPROVAL", key, "v3")
assert verify_excursion_signature(d, sig, key) is True
other = ExcursionDisposition("EXC-90001", "BATCH-2026-0417", "QUARANTINE", "REJECT", "MKT exceeded")
assert verify_excursion_signature(other, sig, key) is False # cannot be transplanted
Step 4 — Append the signature to the record’s tamper-evident trail
The signed disposition joins the same append-only chain the audit stack maintains, so the signing event itself becomes a non-obscuring, time-stamped record.
def append_signed_disposition(sig: dict, prev_hash: str) -> dict:
# §11.10(e): chain the signing event so it cannot be edited after the fact.
entry = dict(sig)
entry["prev_hash"] = prev_hash
entry["entry_hash"] = hashlib.sha256(_canonical(entry)).hexdigest()
return entry
Step 5 — Confirm the disposition flows to batch state under signature
A signed REJECT disposition is what authorizes a batch hold; the batch workflow must accept the state change only when a valid signature accompanies it.
# The batch-state service verifies the signature before honouring the disposition.
python -m coldchain.qms.apply_disposition --excursion EXC-88213 --require-signature || exit 1
Enforcing the signature at the state-change boundary, rather than trusting an upstream caller to have obtained one, is what keeps the release attributable end to end. If the batch service applied a hold on the strength of a disposition record alone, an unsigned or wrongly signed decision could still move product, and the audit trail would record a state change with no accountable human behind it. Requiring the service to re-verify the bound signature before it acts means every batch transition traces to a named, authenticated reviewer who asserted a specific meaning over a specific excursion — the attributability that lets a release decision withstand scrutiny months later. The verification is inexpensive and deterministic, so making it a hard precondition costs nothing at runtime while removing an entire category of undocumented action.
Compliance Validation Checklist
Run these as part of computerized-system validation; each is something an auditor can independently confirm for this control.
Troubleshooting
| Symptom | Root cause | Fix |
|---|---|---|
| Signature verifies against the wrong excursion | Signature bound to an ID, not the content hash | Sign over excursion_hash; include the digest in the signed body |
| Valid signature fails after a record edit | Disposition mutated after signing | Treat the excursion as frozen; a needed change is a new signed disposition, not an edit |
| Signing succeeds for a single-factor user | Two-component gate not enforced on this path | Run auth_components < 2 check before any signing operation (§11.200) |
| Meaning stored but ambiguous | Free-text meaning accepted | Constrain to a controlled enumeration and reject others (§11.50) |
| Batch held without an attributable signer | State change accepted without signature verification | Require and verify the signature in the batch-state service before applying the hold |
Related
- Implementing 21 CFR Part 11 electronic signatures — the signing authority and controls this guide applies.
- Enforcing signature meaning and non-repudiation — making the declared meaning enforceable and undeniable.
- Duration-based scoring for temperature excursions — the source of the excursion records being signed.
- Audit trail export for regulatory submissions — packages the signed dispositions for inspection.
For architectural context, this guide sits under implementing 21 CFR Part 11 electronic signatures.