Term
Policy documents and consent tracking for compliance-sensitive flows: publish versioned Terms of Service, Privacy Policy, or commercial-use documents with a content hash, record who accepted which version in which language, and ask one endpoint whether a user may perform a gated action. Acceptance lands in an append-only, hash-chained event log that a signed export can later prove was not altered.
When to reach for it: mandatory Terms of Service updates, age-gated or region-restricted features, commercial-use policies, parental-consent routing, anything where "did this user agree, to which text, and when" must survive audit.
When not to: turning a feature on or off for a cohort is
Remote Config, not a policy gate, and
deciding what a user is allowed to do by identity or role belongs to
Auth. The document body lives in
Blob; Term stores a content_uri
pointer and the hash, never the text.
Concepts
- A document is identified by name, version, and language. The id is derived,
not chosen:
doc_{name}_v{version}_{language}with dots and dashes replaced by underscores, sodocs4-tos1.0.0 en-US becomesdoc_docs4-tos_v1_0_0_en_US. Publishing that triple twice is409 document_already_exists- versions are immutable, and each language edition is its own document. - You check actions, not documents. A document declares the actions it gates
in
machine_constraints.requires_acceptance_for;GET /term/checkcollects every in-force document gating that action and requires all of them. - Consent is an append-only event log plus a bit matrix. Each acceptance
appends a hash-chained event (the audit record) and sets a bit (the hot path
checkreads). Nothing is mutated - a revocation is a new event. - In force is a time window between
effective_atandexpires_at; outside it a document neither gates nor accepts consent. - Caller context is not stored. Age and region are evaluated from what the
caller sends on each
check; Term remembers acceptance, never the user's age.
Publishing documents
Publishing, bundles, and exports require a platform admin token or one carrying
the legal or compliance capability. Reading, accepting, and checking do not.
snug term publish --name docs4-licence --version 1.0.0 --language en-US \
--title "End User Licence" --content-hash sha256:kkk10 --mandatory \
--action docs4.install
snug term documents list --name docs4-licence --status published
snug term documents get --document-id doc_docs4-licence_v1_0_0_en_US
# a new version retires the old one by naming it
snug term publish --name docs4-licence --version 2.0.0 --content-hash sha256:kkk11 \
--mandatory --action docs4.install --supersedes doc_docs4-licence_v1_0_0_en_US--version must be real semver - three numeric parts, so v2 is rejected with
400 invalid_document. Constraints the CLI does not expose (regions,
expires_at, effective_at) go through machine_constraints on the
POST /api/v1/term/documents body.
A superseded document stops gating actions and refuses further consent with
400 invalid_document. Users who accepted it now show it under superseded
with mandatory_update_required: true.
Recording consent
snug term accept --user docs-wave --document doc_docs4-licence_v2_0_0_en_US \
--document-hash sha256:kkk11 --language en-US --surface signup-modal
snug term accept --user docs-wave --document doc_docs4-commercial_v1_0_0_en_US --age 21
snug term revoke --user docs-wave --action commercial.use --reason user_request
snug term batch-accept --file consents.json
# a bundle accepts several documents in one call, one event per member
snug term bundle create --name docs4-core \
--documents doc_docs4-licence_v2_0_0_en_US,doc_docs4-privacy_v1_0_0_en_US --mandatory
snug term accept --user docs-wave --bundle XSEBbtmPUzjNCnBCssRe--document-hash and --language are optional assertions that the user saw what
you think they saw: a mismatch is 400 invalid_hash or 400 invalid_request
rather than a silently wrong audit record. Both are skipped when the request
resolves to more than one document, so they apply to single-document accepts, not
bundles.
Every bundle member must already exist; an unknown id fails the create with
404 document_not_found. Bundles hold 10 documents by default. Users act on
themselves: accepting, checking, or reading status for someone else is
403 insufficient_permissions unless the token is admin, legal, or compliance.
The enforcement check
GET /term/check is what product code calls before a gated action. It returns a
decision, the documents still missing, and where to send the user:
curl -H "Authorization: Bearer $SNUG_API_TOKEN" \
"http://localhost:4000/api/v1/term/check?user_id=docs-wave&action=commercial.use"{
"status": 200,
"msg": "OK",
"data": {
"user_id": "docs-wave",
"allowed": false,
"action": "commercial.use",
"required_documents": [
{ "document_id": "doc_docs4-commercial_v1_0_0_en_US", "name": "docs4-commercial",
"version": "1.0.0", "language": "en-US", "status": "missing" }
],
"mandatory_update_required": false,
"age_gate": { "required": true, "minimum_age": 18, "route": "age_verification" },
"route": "age_verification",
"reason": "Age gate has not been satisfied for this action."
}
}route is the screen to send the user to - age_verification or
region_unsupported, both configurable. mandatory_update_required separates
"never accepted" from "accepted a version since superseded", which is what drives
a forced-update interstitial.
Per-user status
Where check answers one action, status is the whole picture for one person -
active, revoked, and superseded acceptances plus missing, every in-force
mandatory document they still owe across all actions:
snug --output json term status docs-wave{
"active": [
{ "document_id": "doc_docs4-tos_v2_0_0_en_US", "name": "docs4-tos", "version": "2.0.0",
"language": "en-US", "document_hash": "sha256:aaa2",
"accepted_at": "2026-08-28T22:57:05.388473Z" }
],
"mandatory_update_required": true,
"missing": [
{ "document_id": "doc_docs4-tos_v2_0_0_fr_FR", "name": "docs4-tos", "version": "2.0.0",
"language": "fr-FR", "status": "missing" }
],
"revoked": [ ... ],
"superseded": [ ... ],
"user_id": "docs-wave"
}Behaviors and gotchas
- Age and region are re-supplied on every check. A user who accepted an
age-gated document still gets
allowed: falsefrom acheckthat omitsage, with an emptyrequired_documentsandroute: age_verification.regionsbehaves the same way: a document restricted to["US","CA"]blocksregion=DEand passes whenregionis omitted entirely. - Publishing a translation makes it required. Adding an
fr-FRedition puts both editions inrequired_documentsfor every user, as themissingbucket above shows. Thelocalequery parameter does not narrow this. - Acceptance is not idempotent. Re-accepting appends another event and
refreshes
accepted_at. Re-revoking appends one too, but reportscleared_bits: 0because the bit was already clear. completeis scoped to the request. It means the documents named in that call are now accepted, not that the user has satisfied everything - acheckcan still befalseimmediately after acomplete: trueaccept.- Batch consent is fail-fast, not atomic.
POST /term/consents/batchapplies entries in order and stops at the first failure; entries before it stay applied. The cap is 500 (400 batch_too_large), and an empty array is rejected. - Future-dated documents are inert. Publishing with
effective_atin the future returnspublishedand the document is listed, but it does not gatecheck, does not appear inmissing, and refuses consent with400 invalid_documentuntil its date arrives - that is how you stage a rollout. - Adding
actionto a document listing changes the default page size from 25 to 100, because that filter is applied outside the index.
Limits and configuration
Ten documents per bundle, 500 consents per batch, 128-byte content hashes, and
32 KB of signature metadata by default, along with the age-gate and
unsupported-region route names - all set through the TERM_* variables in the
Term CONFIG reference.
TERM_EXPORT_SIGNING_SECRET ships with a development default and must be
overridden in production, or exports are signable by anyone running the code.
Reference
- Term API - every endpoint, callable
- Audit trail and signed exports - the hash chain, compliance exports, and what verification does and does not prove
- Related: Blob for document bodies and signature images, Auth for the identities Term authorizes against, Remote Config for feature gating that is not a legal question