Agent docs

Contract: per-module caller keys (option C)

The minimum a module must meet when it issues its own long-lived caller credentials instead of…

The minimum a module must meet when it issues its own long-lived caller credentials instead of trusting an issuer's JWKS. Option C in guides/03-machine-identity.md is the loosest way to identify a caller and the owner rules it stays for some modules, so this contract exists to make "loose" mean "no shared infrastructure", never "no standard". A module on this contract is conforming; a module with one shared password is not.

Audience: anyone implementing the keys verifier, or issuing a key.

The key#

PropertyRequirement
formatsmk_<env>_<random>: literal prefix, the environment (local, dev, staging, prod), then at least 32 bytes of cryptographic randomness, base16 or base62
shownonce, at issue; never retrievable afterwards
storedhashed (argon2id or scrypt; SHA-256 with a per-module pepper is the floor) with a short lookup prefix stored in clear so the row can be found without scanning
bound toexactly one caller (caller_id) and one environment; a key from dev is rejected by a prod module even if the hash matches
carriestenants (list or "*"), scopes, optional budget, expires_at, created_at, created_by, label
expirymandatory; default 90 days, maximum 1 year; a key with no expiry is a contract violation
rotationissue the replacement, run both for the overlap window, revoke the old; the module accepts any non-expired, non-revoked key for the caller
revocationdelete or mark the row; takes effect on the next request; no cache longer than 60 s
auditissue, rotate, revoke, and first-use events written to the module's log with caller_id, never the key

Transport#

  • Authorization: Bearer smk_… only. Not a query string, not a cookie, not a custom header. (The extractor's request.query_params.get("key") is the named lapse, automation/router.py:35.)
  • The tenant travels on the request as X-Tenant-Id or in the body, and is checked against the key's tenants (canon/02).
  • Compared in constant time against the hash; the module never logs, echoes, or stores the presented value.
  • The callback signing secret is a separate per-caller value handed out with the key, never the key itself.

The verifier#

The keys implementation of the verifier port (canon/02, "The verifier is a port"): bearer string in, caller context out, or 401 for every credential refusal (unknown, expired, revoked, and a key from another env, so a probe learns nothing about a key's validity elsewhere), 403 only for a valid credential that lacks the tenant or scope. Same context shape as the jwks verifier, with issued_by: keys:<module> set so downstream records say which path the call took. A module may run keys beside jwks in the verifier chain (guides/03, "Supporting more than one at once").

Two rules for any keys-shaped verifier, module-issued or remote (learned from services-api's apikeys/client.py:38 and app/auth.py:46): a credential that is not key-shaped is rejected before any lookup and never reaches a network; and when the verifier depends on another service, that service being unreachable is a 503 to the caller and is never cached as a rejection, so an issuer outage cannot lock a valid key out for the TTL. Verdict caches are bounded by the 60 s revocation ceiling below and are not env-settable past it.

Issuing#

  • A key is issued by a module operator through an admin scope, never by the caller self-serving; the operator names the caller, env, tenants, scopes, budget, expiry, and a label.
  • The module keeps the count of active keys per caller small (default cap 2, for rotation) and per env; more is a config decision with a reason.
  • Every module on this contract exposes GET /health/deep with the number of active keys and the soonest expiry, so an expiring key is visible before it fails.

Conformance rows#

Added to contracts/conformance.md for any module whose manifest lists a keys verifier:

  • a key from another env is rejected with 401 (never 403, which would confirm the key is valid somewhere)
  • an expired key is rejected with 401 within one request of expiry
  • a revoked key is rejected within 60 s
  • the presented key never appears in logs (grep the log after a request)
  • a key in a query string is rejected with 401 and logged as a misuse
  • /health/deep reports active-key count and soonest expiry

Banned#

  • A key without an expiry.
  • One key shared by more than one caller.
  • A key valid in more than one environment.
  • A key stored in clear, or logged.
  • A key accepted anywhere except the Authorization header.
  • Reusing the key as the callback signing secret.
  • Sharing the key store itself with another service. Copy the pattern, keep the tables separate: a schema change in the other service's key table silently changes this module's auth surface, and the two stop being able to deploy independently. (evidence/20260820-internal-dashboard-reuse.md)
@versable-git/ui · reference, canon, and method, read in place