Audit Trail and Signed Exports
Every acceptance and revocation appends an immutable event to a per-user hash
chain. The chain is what makes the record defensible: each event carries the hash
of the one before it, so removing or editing an event after the fact breaks every
link that follows. Exports package a scoped slice of that log with an HMAC
signature, and verification recomputes both. Exporting and verifying require a
platform admin token or the legal / compliance capability.
The event log
Consent responses return the events they appended. Each one pins the exact document version, language, and content hash the user accepted:
{
"event_id": "GCrstYFYHPCnKfcUAZJb",
"user_id": "docs-wave",
"event_type": "accepted",
"document_id": "doc_docs4-tos_v1_0_0_en_US",
"document_name": "docs4-tos",
"document_version": "1.0.0",
"document_hash": "sha256:aaa1",
"language": "en-US",
"event_hash": "sha256:0dd127cf28d9fd3e74632c28cc9efb2592ba3dfe4fd588fb3088db865feb3e2e",
"created_at": "2026-08-28T22:54:46.467056Z"
}event_type is one of accepted, revoked, superseded, admin_correction,
or exported. Corrections are new events - nothing in the log is ever rewritten,
which is why a re-accept or a redundant revoke still appends a row. Inside an
export each event also carries previous_event_hash, which is null for the
first event in a user's chain.
Generating an export
snug term export --user docs-wave
snug term export --user docs-wave --document-name docs4-tos
snug term export --bundle XSEBbtmPUzjNCnBCssReOver HTTP the same call adds from and to for a time window, which the CLI
does not expose:
curl -H "Authorization: Bearer $ADMIN_TOKEN" \
"http://localhost:4000/api/v1/term/export?user_id=docs-wave&from=2026-01-01T00:00:00Z"The summary carries the signature, a per-document event count, and the full event list:
{
"export_id": "WxyzNwEYZhPvmmWBYKyT",
"user_id": "docs-wave",
"report_hash": "sha256:4e6b0acf483a71200d19576845cddd63a8edfca9861c8e0dda687df0c1ff3198",
"signature": "hmac-sha256:db8927935f9519ec753e85b22613e95e50729dee7f2a93743e7b24e520df548e",
"signature_algorithm": "hmac-sha256",
"chain": { "verified": true, "event_count": 15, "gaps": [], "complete_chain": true },
"documents": { "docs4-tos": 3, "docs4-privacy": 4, "docs4-commercial": 6, "docs4-licence": 2 },
"events": [ ... ]
}The events array is the archival artifact - keep it. Only export_id,
report_hash, signature, and the scope are persisted server-side.
Verifying an export
snug term verify-export --export-id WxyzNwEYZhPvmmWBYKyT{
"export_id": "WxyzNwEYZhPvmmWBYKyT",
"report_hash": "sha256:4e6b0acf483a71200d19576845cddd63a8edfca9861c8e0dda687df0c1ff3198",
"signature_valid": true,
"recomputed_report_hash": "sha256:4e6b0acf483a71200d19576845cddd63a8edfca9861c8e0dda687df0c1ff3198",
"chain": { "verified": true, "event_count": 15, "gaps": [], "complete_chain": true },
"event_count": 15
}Verification does not re-read the export. It re-runs the stored scope against the live log, recomputes the report hash from whatever that returns, and compares the signature in constant time.
So signature_valid: false usually means "the user consented to something
since", not "someone tampered". The export above verified clean; one further
acceptance by docs-wave was enough to turn it into signature_valid: false
with event_count: 16 and recomputed_report_hash moved on, while
chain.verified stayed true. Read the two together:
signature_valid: falsewithchain.verified: true- the scope simply has more events than it did at export time. Expected on any active principal.chain.verified: falsewithgapspopulated - the chain itself is broken. That is the tampering signal.
Only a scope that cannot grow re-verifies stably. A closed from/to window
does: the same export still reports signature_valid: true and an unchanged
event_count after further acceptances. But closing the window is exactly what
sets complete_chain: false, so no single export gives you both a durable
signature and a chain assertion - pick which one the evidence needs, and archive
the export's own events array either way. An unknown id is
400 invalid_request.
What chain.verified actually means
This is the part most people read wrong. The hash chain is one gapless sequence
per user spanning every document, so adjacency is only meaningful when the events
in hand are a complete prefix of that chain. verified is therefore asserted
only for an export scoped by user or not scoped at all.
Add document_name, bundle_id, from, or to and you get a subset whose
neighbours are not neighbours in the real chain. Term does not pretend otherwise:
the same user's export, scoped to one document name, returns
{ "verified": false, "event_count": 3, "gaps": [], "complete_chain": false }complete_chain: false is the flag that tells you verified: false means "not
checked", not "tampering detected". Real tampering shows up as
complete_chain: true with verified: false and the offending
{user}:{event_id} pairs listed in gaps. If you need a chain assertion over a
narrower slice, take a user-scoped export and filter the returned events
yourself rather than scoping the export.
Visual signatures
Where a jurisdiction wants a drawn signature, store the image in Blob and register its metadata here:
snug term signature --user docs-wave --document doc_docs4-tos_v2_0_0_en_US \
--blob-id docs4-blob-x --signature-hash sha256:jjj9The document id is validated - an unknown one is 404 document_not_found.
Capture metadata (device, geometry, stroke counts) is free-form JSON on the
POST /api/v1/term/signatures body, capped at 32 KB. To bind the signature to
the acceptance itself, pass the returned signature_id as visual_signature_id
on the consent body - an HTTP-only field, with no CLI flag on term accept.