Agent docs

ADR-002: App-to-module identity is a shared issuer on a JWKS seam

Status: accepted, 2026-08-17 (owner ruling: "let's do b, can have a blueprint for a in the…

Status: accepted, 2026-08-17 (owner ruling: "let's do b, can have a blueprint for a in the meanwhile"); amended 2026-08-18: multi-contender by owner ruling. A is an idea for the V6 app to validate and plan (../plans/issuer-a-better-auth.md); B has a plan for later (../plans/issuer-b-dedicated.md); C stays for some modules under ../contracts/caller-keys.md; D is rejected; E may come later on top of A or B. Guidebook: ../guides/03-machine-identity.md.

Context#

Every instance authenticates callers differently and none can express "app A on Render, on behalf of org X, calling module B on GCP, with a budget": one shared password (versable-runner), a session cookie (speedway), a user JWT with no machine path (walmart-mvp). versable-builder ADR-002 (2026-07-07) already decided user auth: better-auth in a Next BFF, JWTs with JWKS, Python services verifying statelessly and reading {sub, org, roles[]}, and rejected a standalone auth service for user sessions.

Decision#

Modules verify a bearer JWT against a JWKS, offline, and read sub (caller), aud (module), env, tenants, scopes, exp, jti. Tenant travels on the request and is checked against tenants. Users never reach a module; attribution is carried as claimed data. The verifier is a port with four implementations: jwks (default), keys (per-module long-lived keys, the option-a blueprint, same request shape), remote (a central key service asked over HTTP with a bounded verdict cache, contender F in guides/03, observed in services-api 2026-08-18), local (static token, refuses to start on a public URL). Full text: ../canon/02-identity-and-tenancy.md.

Who mints the machine tokens is left open between two shapes that the module side cannot distinguish: better-auth itself, if its API-key or client-credentials facility can carry the machine claims, or one small dedicated issuer on GCP with a client table and its own JWKS. The claims contract on the module side must be reconciled with auth-kit's user claims so a Python service verifying both reads one shape.

Options for who mints the machine tokens (laid out 2026-08-18, owner to pick when the first module ships)#

Correction recorded 2026-08-18 from vb-fable: versable-builder ADR-002 is a plan-era decision with zero implementation anywhere (no better-auth deployment, no auth-kit, no JWKS, no claims contract in code). Option A therefore depends on a build that has not started; option B is consistent with everything shipped; C is the floor either way.

The module side is fixed by this ADR: verify a bearer JWT against a JWKS, read sub, aud, env, tenants, scopes. What varies is who mints, and that is one deployment decision with five real options. The request shape never changes, so the choice can be revisited without touching a caller.

OptionWhat mintsBuysCostsFits when
A. better-auth as the issuer (extends versable-builder ADR-002)the better-auth instance already planned in the Next BFF, using its API-key or organization plugin (or a definePayload extension) to mint tokens for server-side clients with aud, env, tenants, scopes claims, published on the same JWKS Python services already verify againstone identity system, one JWKS, one admin surface, no new deployable; user and machine tokens verified by the same code pathbetter-auth's machine-token facility has to be checked against these exact claims (an unverified assumption today); the app's auth deployment becomes call-time infrastructure for every module on every cloud; a better-auth outage blocks new module tokens; the claims contract must be reconciled with auth-kit's {sub, org, roles[]}the console's auth is live before the first module ships, and better-auth's plugin can carry the claims
B. A small dedicated issuer (the shape drawn in canon/02)one Cloud Run service on GCP with one client table (id, hashed secret, tenants, audiences, scopes, budget) and its own JWKS; client-credentials grant, fifteen-minute tokens, jti denylistindependent of any app's auth; a Foundry-conforming module itself; simplest possible claims; can ship before better-auth existsone more service to run and secure; a bootstrap secret; two identity systems (users in better-auth, machines here) sharing a claims vocabulary by discipline rather than by constructionmodules ship before the console's auth, or the team wants machine identity isolated from user auth
C. Per-module keys (option a in canon/02)each module issues and stores its own long-lived keys per caller, hashed, with the caller context in its own tableno shared infrastructure at all; the first module can ship tomorrow; the request shape is identical to A and BN×M credentials to issue, rotate, and revoke; no single audit log; revocation is per modulethe first one or two modules, as the stepping stone the ADR already keeps; not the end state
D. Cloud provider identity (Cloud Run IAM, Google-signed ID tokens with the module URL as audience)GCP mints; the module trusts platform verification (versable-runner's IAM-locked mode)zero code, zero secrets for GCP-to-GCP callsGCP-only: Vercel and Render callers need workload identity federation, which is real setup, and service-account keys are banned in this estate; no tenants or scopes claims without a proxynever as the cross-cloud answer; fine as an extra lock on internal routes
E. A hosted IdP for machines (Auth0 M2M, Clerk, WorkOS, etc.)a vendor mints client-credentials tokens on its JWKSmature, audited, nothing to runreintroduces the vendor coupling ADR-002 rejected for users, a per-token or per-client bill, and a third identity system if better-auth stays for usersonly if A and B both prove unworkable

Recommendation, held loosely: start on C for the first module (it costs nothing and the shape is final), decide between A and B before the second module, and prefer A if better-auth's plugin can carry the four machine claims on the shared JWKS, otherwise B. D stays as an internal-route lock on GCP; E is not planned. Whichever wins, the claims contract on the module side and auth-kit's user claims are reconciled in one document so a Python service verifying both reads one shape.

Rejected alternatives#

  • One shared credential per module. Cannot revoke a caller, attribute cost, or scope. The lapse being designed away.
  • Cloud Run IAM-locked mode as the answer. GCP-only; Vercel and Render callers would need workload identity federation, and service-account keys are banned in this estate.
  • Per-module keys as the end state. N×M credentials to manage and revoke; kept only as the same-shape stepping stone.

Consequences#

  • One shared piece of call-time infrastructure every module depends on; it gets the deployment care of a module.
  • A bootstrap problem (the issuer's own admin credential) solved once with a real secret store.
  • Owner decision needed, batched: better-auth-as-issuer vs a dedicated issuer, at the time the first module ships.
@versable-git/ui · reference, canon, and method, read in place