Agent docs

Identity and tenancy

> Evidence: 10 file:line witnesses as of 2026-08-18.

Evidence: 10 file:line witnesses as of 2026-08-18. Confidence: high, most rules here have a named witness. What changes it: the first module built against this doc (../PLAN.md forge-1) and its instance breakdown.

Who is calling a module, on whose behalf, with what permission, and how the module knows. This is the concern where the estate is weakest and where the contract designs forward instead of documenting backward. Owner ruling 2026-08-17: build the shared issuer (option b), and keep a blueprint for per-module keys (option a) that uses the same request shape, so a caller never notices which one is behind the door.

Audience: anyone exposing a module, or wiring an app to call one.

What the instances do today, and why none of it carries forward#

InstanceMechanismWhat it cannot express
versable-runnerone shared password for every caller, X-API-Key or Authorization: Bearer, hmac.compare_digest against RUNNER_API_PASSWORD (src/services-api/app/auth.py:30-41); Cloud Tasks OIDC on the internal routes, verified against the service's own URL as audience (:44-61); an IAM-locked mode when the password is emptywho called, for which customer, with what budget; revoking one caller without rotating everyone; any of it from a caller outside GCP in IAM mode
speedwayopaque session cookie in Firestore, hand-rolled password auth, org membership checked per workspace (app/lib/auth/session.server.ts:37-163, access.server.ts:43-65); a static X-Tasks-Secret on the Cloud Tasks callbackanything machine-to-machine; a session is a person in a browser
walmart-mvpbearer JWT (HS256, two-week expiry) for the SPA, X-Org-Id header validated against membership rows (backend/app/security.py:40-58, routes/_common.py:94-106); no inbound API-key scheme at allan app or an agent calling it; the JWT is a user

Two of the three have no machine-to-machine path, and the one that does has a single credential for the world. None can say "app A on Render, on behalf of org X, calling module B on GCP, with this budget", which is the sentence every cross-cloud call has to be able to say.

The rule#

Every request to a module carries a verified caller, a tenant, and scopes, and the module never sees a user. Four routes are the stated exemption because they exist to be read before a caller has anything: /health, /build-info, /manifest, and /guide are unauthenticated and carry no tenant (contracts/module-surface.md); everything else is under the rule.

  • Caller is the identity of the thing making the call: an app (per environment), a service, a developer's tool, an agent. It is verified, not declared. It has scopes and can be revoked on its own.
  • Tenant is the org or workspace the work is for. It travels on the request (header or job envelope), and the module checks it is one the caller is allowed to act for. Every job, outcome, usage event, and log line inherits it. Nothing in a module is ever unscoped by tenant.
  • Scopes are what the caller may do at this module: submit, read, cancel, read stats, administer. Roles (owner, admin, member) are an app concept and stay in the app; the app translates a role into scopes when it calls.
  • Attribution is who the app says it is acting for (a user id, a workflow run id, an agent name). It is carried, stored, and searchable, and it is labelled as claimed, not verified. This is what versable-runner's meta tag actually is; the contract keeps the idea and stops pretending it is identity.
  • Users never reach a module. Sessions, passwords, invites, 2FA, and SSO belong to the app. A module that verifies a user has crossed the line.

The shape on the wire#

Same for both options, so callers cannot tell them apart:

Authorization: Bearer <credential>
X-Tenant-Id: <tenant id> (or `tenant` in the job envelope; header wins on conflict)
X-Attribution: user=u_123; run=wr_456 (optional, claimed, stored verbatim)

The module resolves the bearer credential into a caller context:

caller_id who this is (verified)
env which environment the caller is (verified)
tenants which tenant ids it may act for, or "*"
scopes what it may do here
budget optional hard ceiling for this caller at this module
expires_at when this credential stops working

and rejects the request if the tenant on the request is not in tenants, if the scope for the route is missing, or if the credential is expired or revoked. Every job created carries caller_id, env, tenant, and the attribution string, and every downstream record inherits them.

Option b, the ruled default: one issuer, short-lived tokens#

A small identity service, itself a Foundry-conforming module, mints short-lived signed tokens for every other module.

app (any cloud) issuer module (any cloud)
│ client_id + secret │ │
│ + wanted audience/tenant │ │
├────────────────────────────►│ check the client row, │
│ ◄── signed JWT (15 min) ───┤ its tenants, its scopes │
│ │ │
│ Authorization: Bearer <JWT> ──────────────────────────► │ verify signature against
│ X-Tenant-Id: t_1 │ the issuer's public keys
│ │ (cached JWKS, no round trip)
│ │ check aud, exp, tenant, scope
  • Client credentials. Each app, per environment, is a client row at the issuer: id, hashed secret, allowed tenants, allowed audiences (modules), scopes, optional budget. Revoke the row and the caller is out within one token lifetime.
  • Tokens. Signed JWT, RS256 or EdDSA, short expiry (fifteen minutes is the default; long enough to submit a big job, short enough that revocation is real). Claims: iss, sub (caller id), aud (module id), env, tenants, scopes, exp, jti. The tenant for a given request still goes on the request; the token says which tenants are allowed.
  • Verification is offline. Modules fetch the issuer's public keys once and cache them; a request costs no network call to the issuer. The issuer being down stops new tokens, not in-flight work.
  • Emergency revocation of a single token before expiry is a jti denylist the modules poll; rarely needed, cheap to have.
  • Where it runs, and what it is. versable-builder's ADR-002 (2026-07-07) chose better-auth issuing JWTs with a JWKS endpoint, verified statelessly by Python services reading {sub, org, roles[]}, and rejected a standalone auth service for user sessions. That ADR is unimplemented as of 2026-08-18: no repo has better-auth, an auth-kit, a JWKS, or that claims contract in code (vb-fable, grep across versable-builder, speedway, walmart-mvp); it is intent, not surface, and the module seam must not depend on it existing. The module side of this design is the same seam: verify against a JWKS, read claims. So the issuer for machine callers is either better-auth itself, if its API-key or client-credentials facility can mint tokens with aud, env, tenants, and scopes claims for an app's server-side client, or one small dedicated service on GCP with one client table that publishes its own JWKS. The module cannot tell which; it verifies the same way. Which one is a deployment decision to take when the first module ships (owner's call, batched), and the claims contract on the module side (sub, aud, env, tenants, scopes) has to be reconciled with auth-kit's user claims contract (sub, org, roles[]) so a Python service that verifies both reads one shape. Either way it is the one piece of shared infrastructure every module depends on at call time, so it gets the deployment care of a module and nothing more exotic. See ../adr/002.

What this buys over per-module keys: one place to add a caller, one place to revoke, one audit log of who was issued what, and modules that need no credential storage of their own. What it costs: one more service to run, and a bootstrap problem (the issuer's own admin credential) that has to be solved once with a real secret store.

Cloud Run's IAM-locked mode (Google-signed ID tokens with the module URL as audience) is the GCP-only shortcut for GCP-to-GCP calls and stays valid as a deployment option, but it cannot serve Vercel or Render callers without workload identity federation, and service-account keys are banned in this estate. It is not the cross-cloud answer.

Option a, the blueprint kept in reserve: per-module keys#

Each module issues its own long-lived API keys, one per caller, and verifies them itself. Same request shape; the bearer credential is a key instead of a JWT.

  • Keys are random, prefixed so they can be identified in logs (smk_<env>_…, the format ../contracts/caller-keys.md fixes), stored hashed, and carry the same caller context (tenants, scopes, budget, expiry) in the module's own table.
  • Revocation is deleting the row. Rotation is issuing a second key before deleting the first.
  • versable-runner's require_api_password is the degenerate case of this: one row, no context. Upgrading it to option a is a table and a lookup, and the request shape does not change.

When to use it: a module that must run with no dependency on the issuer (an air-gapped or third-party-hosted deployment), or the first module shipped before the issuer exists. Because the caller context and the request shape are identical, a module can start on option a and move to option b by swapping its verifier, and no caller changes a line.

The verifier is a port#

A module has one credential verifier behind one interface: bearer string in, caller context or rejection out. Four implementations, selected by config:

VerifierBacked byUse
jwksthe issuer's public keysoption b, the default in dev and prod
keysthe module's own key tableoption a
remotea central key service asked over HTTP, verdict cached for a bounded TTLcontender F in ../guides/03-machine-identity.md; conforming once its verdict carries the full context
localone static token from configin-process and local runs only; refuses to start if the module believes it is on a public URL

The local verifier is what makes "runs with zero env on a laptop" true without shipping an unauthenticated service by accident. The refusal is the guard. Its caller context is fixed so the runner's own conformance run can pass under it: caller_id: "local", env: "local", tenants: ["*"], every scope the manifest lists, no expiry, issued_by: local.

Internal routes are a different trust boundary#

A module that pushes work to itself through a queue (versable-runner's /internal/tasks/*, speedway's /tasks/module) has a second ingress that the platform calls, not a caller. Rules:

  • Internal routes verify platform identity, never the caller credential: Cloud Tasks OIDC with the module URL as audience and the queue's service account as the expected subject (versable-runner does this correctly, app/auth.py:48-66); arq trusts its Redis network; a Pub/Sub push has its own signed token.
  • A static shared secret on an internal callback (speedway's X-Tasks-Secret) is a lapse: it is a password with no expiry that lives in two places. Acceptable in the in-process mode, where the caller and callee are one process; not on a public URL.
  • The caller context of the originating job is loaded from the job record inside the internal handler, not re-sent on the callback.

Rate, budget, and attribution#

The module enforces a hard ceiling per caller (from the caller context) and per tenant (from config), as a floor against runaway cost. It does not know about plans, included bands, or overage. The app enforces those, using the usage events the module emits (08-usage-and-credits.md). Every usage event carries caller, env, tenant, and attribution, which is what makes a per-SKU-through-workflow price computable (../evidence/20260817-source-docs-skeptical-read.md §D).

Environments#

Every caller credential is per environment, and a module rejects a credential from a different environment even if the signature is valid (env claim vs the module's own env). Dev sharing prod's identity, as runner-service-dev does today, is banned. 12-deployment-and-environments.md carries the rest.

Do-nots#

  • Do not issue one credential for all callers. (versable-runner app/auth.py:32-45)
  • Do not treat a self-declared tag as identity or tenancy. Store it as attribution and label it claimed. (versable-runner meta)
  • Do not put a static shared secret on an internal callback that is reachable from a public URL. (speedway app/routes/tasks/module.tsx:4-9)
  • Do not reuse the caller's API key as the callback signing key. The issuer hands out a separate per-caller callback secret, so authentication and signature verification rotate independently and holding the API key does not let you forge a callback. (the extractor signs with config.extractorApiKey, speedway/app/lib/extractor.server.ts:8-9,252)
  • Do not let a module verify users, sessions, or passwords. That is the app's job. (speedway's session auth is correct for an app and would be wrong in a module)
  • Do not let one environment's identity be valid in another. (runner-service-dev runs as runner-service@)
  • Do not create service-account keys to let a non-GCP caller in; use the issuer. (estate rule, CLAUDE.md)
  • Do not skip tenant on any request, job, outcome, usage event, or log line.
  • Do not accept a credential in a query string. It lands in access logs, browser history, and referrer headers. Header only. (the extractor webserver, automation/router.py:35, request.query_params.get("key"))
@versable-git/ui · reference, canon, and method, read in place