Agent docs

Guide: machine identity, a multi-contender guidebook

Who mints the credential an app presents to a module, and how the estate supports more than one…

Who mints the credential an app presents to a module, and how the estate supports more than one answer at once without inviting free-for-all. The module side is fixed (canon/02, adr/002): a bearer credential resolved by a verifier port into a caller context of caller_id, env, tenants, scopes, budget. Everything in this guide is about the other side of that port, and the owner's ruling of 2026-08-18 is that it stays open with a recommendation per option rather than one winner: A is an idea for the V6 app to validate and plan; B gets a plan for later; C is the loosest and gets a contract so "loose" is not "unsafe"; D is rejected; E may come later on top of A or B once the premise has been exercised.

Audience: anyone issuing a credential to a caller, wiring an app to a module, or deciding which issuer a new module trusts.

The seam every option must respect#

caller ──── bearer credential ────► verifier port ────► caller context
(app, tool, agent) jwks | keys | remote | local {caller_id, env, tenants, scopes, budget}
┌─────────┴──────────┐
A better-auth JWKS B dedicated issuer JWKS
C per-module key table (D rejected) (E later, on top)

Whatever mints, the module verifies through the same port and produces the same context, every job carries the same caller_id and env, every usage event and log line inherits them, callbacks are signed with a per-caller callback secret that is not the credential, and environments never share a credential. An option that cannot produce that context is not an option (canon/02, "The rule").

The contenders#

A. better-auth as the machine issuer (idea for V6, validate then plan)#

Extend the better-auth instance versable-builder ADR-002 plans for user auth (unimplemented as of 2026-08-18: no better-auth, auth-kit, JWKS, or claims contract exists in any repo, per vb-fable's grep; treat the ADR as intent) so that, when it is built, it also mints server-side client tokens carrying aud, env, tenants, scopes on the same JWKS Python services verify against. One identity system, one JWKS, no new deployable, user and machine tokens verified by one code path.

Owner ruling: keep as an idea for the V6 app; validate and plan properly, including compatibility with GCP and the existing services; do not build now. Validation checklist and plan outline: ../plans/issuer-a-better-auth.md. The unverified assumption is the whole decision: whether better-auth's API-key or organization plugin, or a definePayload extension, can carry those four claims for a non-user principal. If yes, A is the strongest option; if no, A collapses into B behind the same JWKS.

How it seams: the console is one client per environment; walmart-mvp and speedway would become clients too; modules on GCP and Render verify against a JWKS hosted wherever the BFF is (Vercel), which means the app's auth deployment becomes call-time infrastructure for every module on every cloud, and its availability is now a module concern.

B. A small dedicated issuer (plan written, build later)#

One service with one client table (id, hashed secret, tenants, audiences, scopes, budget, env), a client-credentials grant, fifteen-minute tokens, its own JWKS, a jti denylist for emergencies. Independent of any app's auth, a Foundry-conforming module itself, the simplest possible claims, and it can exist before the console's auth does.

Owner ruling: write the plan with detail, including migration and adoption, for later. Plan: ../plans/issuer-b-dedicated.md.

How it seams: same as A from the module's side; on the caller's side, one more place to register a client, and two identity systems (users in better-auth, machines here) sharing a claims vocabulary by discipline. If A later proves workable, B's clients migrate to A by re-issuing credentials and switching the JWKS URL in module config; the request shape does not change.

C. Per-module keys (the loosest, kept, and now under contract)#

Each module issues its own long-lived keys per caller and verifies them from its own table. No shared infrastructure, ships tomorrow, the same request shape as A and B. It is the stepping stone for the first one or two modules and, the owner rules, something "we'll have to keep for some": modules that must run with no dependency on an issuer, third-party-hosted deployments, and the debugging path.

Because it will persist, it is not left loose. ../contracts/caller-keys.md fixes the minimum: key format and prefix, hashed at rest, per caller, per env, with tenants, scopes, and budget attached, expiry mandatory, rotation by overlap, revocation by row delete, header-only transport, never logged, and the same conformance rows as any verifier. A module on C is conforming; a module with a shared password is not.

How it seams: the verifier port's keys implementation, and nothing else in the module knows the difference. The cost is N×M credentials and no single audit log, which is why C is the floor and not the target.

D. Cloud-provider identity (rejected)#

Cloud Run IAM with Google-signed ID tokens as the caller credential. Rejected 2026-08-18: GCP-only, Vercel and Render callers would need workload identity federation, service-account keys are banned in this estate, and it cannot carry tenants or scopes without a proxy that would itself be B. It stays only as an extra lock on a module's internal routes (canon/02, "Internal routes"), never as caller identity. Recorded in ../adr/002.

E. A hosted machine IdP (later, on top of A or B)#

Auth0 M2M, WorkOS, or similar minting client-credentials tokens on a vendor JWKS. Mature and nothing to run; reintroduces the vendor coupling versable-builder ADR-002 rejected for users, a bill, and a third identity system. Owner ruling: possible, but built on top of A or B even if they succeed, as a finalization once the basic premise has been exercised. Not planned; if it comes, it is one more JWKS in the verifier chain below and nothing else changes.

F. A central key service asked over HTTP (observed in the estate, 2026-08-18)#

services-api's own answer, shipped in ~/Code/Versable/services-api commit f46d894 after this guide was written: opaque vsk_<key_id>_<secret> keys minted, stored and revoked in prompt-mgmt, and a module that verifies a presented key by asking that service over HTTP, caching each verdict for a bounded TTL (60 s by default). Not JWKS, not module-issued: a keys-shaped credential with one central issuer, so the audit log C lacks exists and the per-module table C requires does not. Evidence and the divergence table: ../evidence/20260818-services-api-git-source.md.

Ruling (brains lane, owner to confirm): F is a real contender and the estate's most mature module chose it, so it is written down rather than argued away. It seams the same way as C, as one more implementation of the verifier port, remote beside keys and jwks. It is conforming when its verdict produces the caller context in "The seam every option must respect" above (caller_id, env, tenants, scopes, budget) and the key is bound to one env; today the verdict carries {valid, key_id, user_id, name} and no env, so services-api is on a recorded divergence, not on the contract. Two of its choices are adopted for every keys-shaped verifier regardless (../contracts/caller-keys.md, "The verifier"): an issuer outage is never cached as a rejection, and a credential that is not key-shaped never reaches the network.

Supporting more than one at once#

The estate will run C for a while, then A or B, and possibly both A and B during a migration, and possibly E later. The rule is that the module never cares, and the way that is true is a verifier chain:

  • a module's config lists the issuers it trusts, in order: zero or more JWKS URLs (each with the iss it expects and the env it is for) and, if enabled, its own keys table; local only in env: local
  • a bearer credential is routed by shape and iss: a JWT goes to the JWKS whose iss matches; a prefixed key goes to the key table; anything else is 401
  • every accepted credential resolves to the same caller context, and the context records issued_by (which issuer), so usage, logs, and stats can say which path a call took
  • /health/deep and the manifest's identity block list the issuers in use

Policy on top of the mechanism, so that "supports many" does not become "anything goes":

  • the module's manifest names which issuers it accepts and marks one as preferred; a caller on a non-preferred path is allowed and visible
  • a new issuer, or a module accepting an issuer not on the estate list, is an ADR entry (../adr/002 amendments), not a config edit
  • C credentials carry an expiry by contract, so a module on C alone cannot drift into permanent shared secrets
  • deviations are allowed with a stated reason and a review date, filed as bespoke (../01-system-classes.md), never silently

Migration between them#

FromToWhat changesWhat does not
CA or Bthe caller gets a client credential at the issuer, exchanges it for a token, and stops sending the key; the module adds the JWKS to its chain; the key row expiresrequest shape, tenant header, caller context, callbacks, usage, logs
BAclients re-issued at better-auth; module config swaps the JWKS URL; B's JWKS stays in the chain until every token has expired, then is removedsame
A or BEE's JWKS is added to the chain; clients migrate as their credentials rotatesame
shared password (versable-runner today)Cthe password becomes one key row with tenants: "*" and a short expiry; per-caller keys are issued; the row is deleted when unused (../patterns/03, step 1)callers keep working throughout

Recommendation, held loosely#

Start on C for the first module, under the contract, so nothing waits. A exists only if the console builds better-auth first, which nothing has yet; validate A the day that build starts, and if better-auth can carry the four claims on its JWKS, adopt A before the second module and skip B. If the console's auth is not built by then, or cannot carry the claims, build B from its plan; B is consistent with everything shipped today. Keep D as an internal-route lock on GCP only. Consider E only after A or B has run for real. Whatever the sequence, the verifier chain means no module and no caller rewrites for any of it.

@versable-git/ui · reference, canon, and method, read in place