temper-rb into a Rails app — a web tier serving signed-inclients/temper-rb/spec/. Where a claim rests onApiClient, one Faraday connection, onenet-http-persistent pool. The token is per call, resolved from fiber-local storage at requestA token never escapes its call. Temper.with_tokenrestores the previous value on the way
out — including when the block raises — and a thread spawned inside a token scope sees no
token at all. Do not hand aTemper::Clientto a thread and expect its credential to travel.
| Puma request | Sidekiq / ActiveJob worker | |
|---|---|---|
| Who is acting | a signed-in human | the application itself |
| Class | Temper::Credentials::BearerToken | Temper::Credentials::ClientCredentials |
| Where the token comes from | the user's session — you already hold it | the SDK mints it, client_credentials grant |
| I/O at construction | none | none; the first mint is lazy, on first use |
| Can it refresh? | no — refresh! raises Unauthorized | yes — mints, caches, re-mints |
| Writes land as | the user's profile | the machine's agent profile |
BearerToken does no I/O and cannot refresh — refresh! raises Unauthorized rather thanArgumentError ataudience is the one exception: it is optional, because it is Auth0's, not the protocol's. AbsentClientCredentials works against both. TheAuth0-issued (temper admin machine provision) | Temper-issued (temper admin machine issue) | |
|---|---|---|
| Who holds the secret | your Auth0 tenant | Temper — it is the authorization server |
client_id | the Auth0 M2M application's client id | minted by Temper, prefixed tmpr_ |
client_secret | from Auth0 | printed once at issue; stored only as a hash |
token_url | your Auth0 tenant's /oauth/token | your own instance's /oauth/token |
audience | required — must equal the API's AUTH_AUDIENCE | omit it |
| Rotate the secret with | Auth0 (no Temper action needed) | temper admin machine rotate-secret |
application/x-www-form-urlencoded), which RFC 6749 §4 mandates at the tokenreq.formData() and does not. Form-encoding is the shape both accept.Omit audiencefor a temper-issued credential — do not pass an empty string. Temper's AS mints
with its server-sideAS_AUDIENCEand ignores a request-supplied audience entirely, so the SDK
leaves the parameter off the wire rather than sending a lie. Passingaudience: ''raises at
construction.
tests/contracts/m2m-token-request.json,ClientCredentials is not a thin wrapper over a POST. Three behaviours come with it, each of them a| Behaviour | What it does | Why |
|---|---|---|
| Absolute-expiry cache | caches until 60s before the token's absolute expiry | a duration cannot survive being cached; only an absolute expires_at can |
| Mutex-guarded mint | many threads racing a cold cache mint once | under Puma every in-flight thread hits expiry together |
| Re-mint on 401 | re-mints once and retries the call, then gives up | a job holding a token across a long unit of work outlives it |
BearerToken takes no part in any of this: refresh! raises, so the repair path terminatesUnauthorized propagates out of refresh! itself. One request, one exception.| Where the write happens | Credential | Author |
|---|---|---|
| the Puma request | BearerToken (the user's token) | dana@sdk |
| the Sidekiq job it enqueued | ClientCredentials (the machine) | acme-app@sdk |
Temper::Act renames correlation: to the wire key correlation_id (and invocation: toinvocation_id) for you. The act's keys flatten into the write body — and onto the query stringAct accepts correlation:confidence:, while it refuses reasoning:, rationale:, persona:, or model: withoutAgentAuthorship.confidence is non-optional, and the callX-Temper-Surface: sdk on every request for you — once, on the client, not per@sdk suffix on both author names comes from.Temper::Error carrying #status, #code, #message, and #details.Temper::Error
├─ Temper::TransientError # let it escape → Sidekiq retries
│ ├─ RateLimited (429, #retry_after)
│ ├─ ServerError (5xx)
│ └─ ConnectionError (timeout / refused)
└─ Temper::PermanentError # rescue it → dead-letter
├─ Unauthorized (401)
├─ Forbidden (403)
│ └─ SystemAccessRequired (403 with code SYSTEM_ACCESS_REQUIRED)
├─ NotFound (404)
├─ Conflict (409)
└─ BadRequest (400 / 422)| Call | On a TransientError | On a PermanentError |
|---|---|---|
Idempotent read (show, list, search, whoami) | retried, 3 attempts, backoff 200ms then 400ms | raised on the first attempt |
Write (create, update, delete, assert_relationship, set_facet) | never auto-retried — raised | raised |
Writes are never auto-retried, even on a 503. The SDK classifies; it does not decide to
re-submit. Retrying a write it cannot prove failed is how you get two postmortems. A 401 is the one
exception, and it is not a re-submission — re-minting a token and replaying the call is repairing
authentication, not retrying the operation.
SystemAccessRequired discriminates off error.code == "SYSTEM_ACCESS_REQUIRED", not off theForbidden. It means the caller authenticated but has no reach — theConnectionError is inferred from an ApiError with a nil status. The generated clientTemper::Error and Sidekiq would dead-letter it.{"error":{code,message,details}} envelope — an HTML 502 from a#details rather than raising inside the error path.client_id with the instance:machine client 'tmpr_abc123' is not registered with this instance.
An administrator must run: temper admin machine provision --client-id tmpr_abc123 --label <label>PermanentError, so a Sidekiq workerSystemAccessRequired) on anything you have no reach for. Registration creates the agent profile;--team and --cogmap — and is never inferred from --owner-team, which records who owns the--team <ref>[:role] gives the machine team membership (role defaults to member) for read--cogmap <ref>[:ro] applies a cogmap write grant on that map (:ro for read-only).This is not necessarily an operator ticket. Minting is authorized by is_system_adminor
ownership of the team that will own the machine — so a team owner can register their own team's
machine with no admin in the loop, bounded to reach they could already confer on a human. A
teamless machine (no--owner-team) is admin-only, because the empty owning team fails closed.
The full rule, and what a team owner may and may not confer, is
standing-up-a-machine-credential.
Unauthorized naming the client id. AForbidden / SystemAccessRequired naming theerror.details — you get the diagnosis, not a bare 4xx.rotate-secret | rebind | |
|---|---|---|
| What changes | the secret — same client_id | a new client_id, same agent profile |
| For | a temper-issued (tmpr_) credential | rotating the external IdP application behind an Auth0 machine |
| Who may run it | admin, or owner of the machine's team | system admin only |
| Downtime | none — a grace window (default 24h) keeps the previous secret valid | none if you pass --no-revoke-old for an overlap |
| What you redeploy with | the new TEMPER_M2M_CLIENT_SECRET | a new client_id and secret |
client_id is unchanged, soclient_id) needs rebind, which transplants the existing agentrebind alone keeps the system-admin bar.connection_pool (>= 2.4, whichnet-http-persistent pools through) drops pooled connections from a Process._fork hook, and thesluggify(title)-<uuid> form.Temper.parse_ref is available directly and never touches the network. Unparseable input raisesPUT /api/cognitive-maps/{id} takes a pre-embeddedchunks_packed is a required, client-computed 768-dimension BGEcognitive_maps.author, assert_relationship, set_facet) is fully supported, and on every one of/oauth/token a temper-issued credential mints against.