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.mdforge-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#
| Instance | Mechanism | What it cannot express |
|---|---|---|
| versable-runner | one 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 empty | who called, for which customer, with what budget; revoking one caller without rotating everyone; any of it from a caller outside GCP in IAM mode |
| speedway | opaque 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 callback | anything machine-to-machine; a session is a person in a browser |
| walmart-mvp | bearer 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 all | an 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
metatag 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 herebudget optional hard ceiling for this caller at this moduleexpires_at when this credential stops workingand 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
jtidenylist 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 withaud,env,tenants, andscopesclaims 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.mdfixes), 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_passwordis 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:
| Verifier | Backed by | Use |
|---|---|---|
jwks | the issuer's public keys | option b, the default in dev and prod |
keys | the module's own key table | option a |
remote | a central key service asked over HTTP, verdict cached for a bounded TTL | contender F in ../guides/03-machine-identity.md; conforming once its verdict carries the full context |
local | one static token from config | in-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-devruns asrunner-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"))