ServicesTerm

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, so docs4-tos 1.0.0 en-US becomes doc_docs4-tos_v1_0_0_en_US. Publishing that triple twice is 409 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/check collects 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 check reads). Nothing is mutated - a revocation is a new event.
  • In force is a time window between effective_at and expires_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.

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: false from a check that omits age, with an empty required_documents and route: age_verification. regions behaves the same way: a document restricted to ["US","CA"] blocks region=DE and passes when region is omitted entirely.
  • Publishing a translation makes it required. Adding an fr-FR edition puts both editions in required_documents for every user, as the missing bucket above shows. The locale query parameter does not narrow this.
  • Acceptance is not idempotent. Re-accepting appends another event and refreshes accepted_at. Re-revoking appends one too, but reports cleared_bits: 0 because the bit was already clear.
  • complete is scoped to the request. It means the documents named in that call are now accepted, not that the user has satisfied everything - a check can still be false immediately after a complete: true accept.
  • Batch consent is fail-fast, not atomic. POST /term/consents/batch applies 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_at in the future returns published and the document is listed, but it does not gate check, does not appear in missing, and refuses consent with 400 invalid_document until its date arrives - that is how you stage a rollout.
  • Adding action to 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

On this page