ServicesTerm

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 XSEBbtmPUzjNCnBCssRe

Over 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: false with chain.verified: true - the scope simply has more events than it did at export time. Expected on any active principal.
  • chain.verified: false with gaps populated - 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:jjj9

The 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.

Reference

  • Term API - export, verify, and signature endpoints in full
  • Back to Term for publishing, consent, and enforcement

On this page