Skip to content

Term

Try the interactive example ↓

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 the version's dots and the language's dashes replaced by underscores (the name is kept verbatim), 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 /api/v1/term/check collects every in-force document gating that action and requires all of them.
  • Consent has an append-only audit log and a current decision. Each acceptance or revocation writes an event, updates the current decision, and advances the user’s chain head in one PostgreSQL transaction. check reads the indexed current decision; it does not scan the event history.
  • 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.

Storage and tenant boundaries

Term stores policy documents, bundles, consent events, current decisions, chain heads, signature metadata, and export records in PostgreSQL through Rent. It has no Redis consent cache or dual-write path. Document bodies and signature images remain in Blob.

Every record belongs to the active tenant (an organization or personal tenant). Policies are visible across that tenant so members can discover and accept them; they do not become private workspace resources. Being an organization owner does not automatically grant the legal/compliance capability. Self-service consent and privileged operations keep the authorization rules described below. Tenant removal also reclaims that tenant's Term records.

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 --filter 'name[eq]=docs4-licence' --filter 'status[eq]=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 /api/v1/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 consent was already inactive. The API retains this field name; its value counts previously active decisions revoked.
  • 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 /api/v1/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.
  • Action and text filters run before pagination in PostgreSQL. A filtered listing follows the same shared pagination contract as other Rent-backed lists.

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

Identifiers

Bundle, signature, export and consent-event IDs are opaque UUIDv7 strings. Document IDs retain their version/language business-locator format.

Interactive example

Publish versioned terms and record each person’s acceptance. Gate an action on the versions currently required by your app.

An illustrative example that runs in your browser. No account or API calls required.