Module kit / Identity and auth
Labs JWT federation door
identity-auth/labs-jwt-federation-door
| Kind | backend |
|---|---|
| Domain | identity-auth |
| Maturity | scaffold |
| Reusable | extract |
| Stack | fastapi, cryptography, httpx, hmac-sha256 |
| Depends on | _shared/auth-dependency-pattern, _shared/hmac-seam-pattern, _shared/error-envelope, _shared/test-layout |
| Frontends | identity-auth/ui/developer-portal-auth-pages |
| Reference implementation |
|
| Measured on | prima feature/per-model-pricing cd2c248 2026-09-12 |
| Also measured on |
|
| Version | 1 |
Purpose
A person signed in to Labs presents their Labs access token to the standalone Prima gateway and is served as the tenant the token names, without the gateway calling Labs on the request path: the signature is checked offline against a disk-cached JWKS, the subject's grants and workspaces are asked once per session (jti) and cached in prima_sessions, and revocation arrives on a pulled feed instead of a cache expiry. The invariant that makes it trustworthy is that authentication, authorisation and freshness are three channels that cannot be collapsed into one: no module on the API-key path can import the Labs client (tests/auth/test_import_graph.py), so a Labs outage is a login outage for Labs humans and never an outage for a paying key holder.
This recipe is the consumer half, built in prima on 2026-08-03 (7e240c9, the seam's own commit; seams/identity.py line 2 records STATUS: BUILT, 2026-08-03 and line 849 "SEAM 6 is built"), carried through the FIND-011 suspension fixes of 2026-08-08 (c14f552, 9555ded) that status_for_external in step 10 relies on, and measured here at cd2c248 (2026-08-10, the head of feature/per-model-pricing, itself a credits commit), and the contract of the producer half, which Labs has not built: at fd8558f Labs publishes the JWKS and nothing else, so the door answers 503 identity_provider_unavailable to every first request of a Labs session (measured 2026-09-12, Gotchas). Maturity is scaffold for that reason and is not upgraded until a measured-on entry names the Labs commit that ships POST /internal/prima/introspect and GET /internal/prima/revocations.
When to use
Read it before letting any Prima surface (developer.prima.li, the chat server, a desktop client) send a Labs Bearer to api.prima.li, before building the Labs producer endpoints, before adding a second token issuer, and before "simplifying" the session cache into a per-request introspection call. Trigger phrases: "the portal gets 503 on whoami", "identity_provider_unavailable", "Retry-After 30", "why does Prima not just call Labs", "the balance read is blocked", "unknown kid", "feed lag", "which issuer minted this", "can Prima verify a Labs token".
Not this recipe: the API-key door (identity-auth/gateway-api-key-auth, B11: X-API-Key, prima_api_keys, the revenue path), the signing side and the JWKS document itself (identity-auth/ed25519-jwt-and-jwks, B3, and _shared/auth-dependency-pattern S5 for the v2 claim set), the key-sync outbox in the other direction (identity-auth/key-sync-outbox-labs-to-prima, B12), and the per-session key the chat server mints instead of forwarding a Labs token (identity-auth/prima-chat-session-and-gateway-key, B21). Where the tenant comes from once the door is open is _shared/workspace-scoping (S6), whose Prima half is resolve_tenant in this recipe's claims module.
Stack assumptions
Prima at cd2c248: FastAPI with synchronous dependencies on a synchronous SQLAlchemy Session (prima/api/auth.py lines 108 to 141), cryptography for Ed25519 with no JWT library anywhere on the auth path (test_no_auth_module_imports_a_jwt_library, tests/auth/test_import_graph.py line 149; python-jose is not a declared dependency, line 133), httpx imported lazily inside the JWKS fetcher only (prima/auth/jwks.py line 268), daemon threads for the two background loops because the request path is synchronous end to end (jwks.py lines 299 to 325, wiring.py lines 239 to 287). Foundations assumed: S5 for what Labs puts in the token (iss, aud, ver: 2, type, jti, iat, exp, sub, tids_n, tid when tids_n == 1), S10 for the HMAC transport the three authenticated producer endpoints use, S7 for the OpenAI-shaped envelope every refusal here is rendered in.
Configuration read by this door (prima/config.py, names only; the DECLARED list at lines 480 to 540 and the attribute map at 1050 to 1064):
| Name | Required | Behaviour |
|---|---|---|
PRIMA_JWT_ISSUER | yes | the one iss the registry trusts; compared by set membership, never by pattern (claims.py 133 to 135) |
PRIMA_JWKS_URL | yes | Labs' /.well-known/prima-jwks.json; must be https:// outside PRIMA_ENV=dev (config 1010 to 1020) |
PRIMA_JWKS_CACHE_PATH | yes | the disk copy, mode 600, loaded before the network at boot; "not a temp file and must survive a restart" |
PRIMA_JWKS_REFRESH_SECONDS | no, default 300, bounds 30 to 86400 | background refresh interval; a failed refresh keeps the last set indefinitely |
PRIMA_LABS_INTERNAL_BASE_URL | yes | where the three authenticated producer endpoints would live; nothing calls it at cd2c248 |
PRIMA_LABS_INTERNAL_SECRET | yes | the HMAC key for those calls; boot refuses when it equals PRIMA_KEY_SYNC_SECRET or PRIMA_CREDIT_GRANT_SECRET (config 946 to 990), because this one is read-only and the other two can mint a key or money |
PRIMA_PUBLIC_BASE_URL | yes | self_issuer on identities resolved through the key door (wiring.py line 213); the empty second issuer seat |
Fail behaviour: a missing required key refuses boot (ConfigError); a missing or corrupt JWKS cache is not fatal and means no Labs JWT can be verified until the first successful refresh, while API-key traffic is unaffected (jwks.py 189 to 223).
Data model
Prima's identity tables (prima/auth/models.py, migration migrations/versions/20260803_0001_auth_seam.py, revision prima_auth_001, revises prima_credits_0002), the two this door writes:
-- the once-per-jti introspection cache, durable on purpose (models.py lines 255 to 288)
prima_sessions (
jti varchar(64) PRIMARY KEY, -- CHECK: a bounded token, prima/content_free.py token_check
subject_id uuid NOT NULL REFERENCES prima_subjects(id) ON DELETE RESTRICT,
tenant_id uuid NULL REFERENCES prima_tenants(id) ON DELETE RESTRICT, -- written NULL at cd2c248
surfaces varchar(64)[] NOT NULL, -- the introspected grants
workspaces uuid[] NOT NULL, -- the introspected memberships
exp timestamptz NOT NULL,
introspected_at timestamptz NOT NULL,
revoked_at timestamptz NULL -- a tombstone; set once, never cleared
);
CREATE INDEX ix_prima_sessions_subject ON prima_sessions(subject_id);
CREATE INDEX ix_prima_sessions_exp ON prima_sessions(exp); -- purge_expired is a range scan
-- where the feed poller got to: one row by primary key (models.py 291 to 307)
prima_revocation_cursor (
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton), -- ck_prima_revocation_cursor_singleton
cursor bigint NOT NULL DEFAULT 0, -- a sequence, never a timestamp
fetched_at timestamptz NOT NULL
);
prima_subjects (one row per federated or native caller, UNIQUE (issuer, external_sub), status defaulting to 'active', CHECK (is_native OR email IS NULL) so a federated subject can never carry an address; lines 142 to 160) is read by this door for suspension (FIND-011) and is owned by B11. Every ON DELETE on these tables is RESTRICT; there is no cascade in the identity half.
In-process state (prima/api/auth.py AuthRuntime, lines 30 to 44, built once per process): the JwksStore (a dict kid -> Ed25519PublicKey, a fetched_at, and a global unknown-kid cooldown, jwks.py 117 to 137), the IssuerRegistry with exactly one Issuer(iss, jwks, native=False, realm="labs") (wiring.py 177 to 181), and the FeedState(started_at, last_success_at, cursor, consecutive_failures) (sessions.py 109 to 126). The disk cache is a JSON envelope {"fetched_at": <epoch float>, "document": <the JWKS as fetched>} written by atomic replace (_persist, 225 to 250).
What is NOT stored: the raw token (introspection sends it to Labs once and nothing writes it), any email, username, role or company (SubjectFacts and Identity are PII-free by construction, sessions.py 143 to 158, seams/identity.py 284 to 313), a per-kid refresh budget (global on purpose, jwks.py 27 to 29), and a TTL on key material (there is none; jwks.py lines 3 to 14).
What the producer must store and Labs does not have at fd8558f: a durable prima_revocation_events table, written in the same transaction as the logout, password reset, deactivation, grant revocation or membership removal that caused it, with a bigint sequence as the cursor (seams/labs_internal.py 95 to 124). The recipe's shape for it:
prima_revocation_events (
seq bigserial PRIMARY KEY, -- the cursor; timestamps tie at a page boundary
kind varchar(40) NOT NULL CHECK (kind IN ('jti_revoked', 'subject_deactivated', 'grant_revoked', 'workspace_membership_revoked')),
payload jsonb NOT NULL, -- {jti, exp} | {sub} | {sub, surface} | {sub, tid}
created_at timestamptz NOT NULL DEFAULT now()
);
Removals only. subject_reactivated and grant_granted are deliberately absent: a re-grant is picked up at the next introspection, and a feed that can grant is a feed that can be replayed to grant (sessions.py 415 to 425).
API surface
The consumer (Prima, cd2c248), all under /api/prima/v1, OpenAI-shaped envelope per S7:
| Route | Auth dep | Tenancy | Codes | Notes |
|---|---|---|---|---|
| any gateway route | CurrentIdentity (prima/api/auth.py 144) | resolve_tenant from tid, tids_n and X-Workspace-ID (claims.py 195 to 252) | Surfaced to the caller: 200; 401 invalid_api_key (the uniform authentication error, prima/errors.py 108 to 111, for every refusal raised before the subject is known); 401 credential_revoked; 401 token_expired only from a cached session row (sessions.py 303 to 306); 403 surface_not_granted; 400 workspace_invalid, workspace_ambiguous; 403 workspace_forbidden; 409 no_workspace; 503 identity_provider_unavailable with Retry-After: 30. Internal, logged and never on the wire: token_expired and token_not_yet_valid and token_lifetime_excessive from the claim floor, token_type_invalid, token_version_unsupported, iss_untrusted, claim_*_invalid, claim_tid_unexpected (claims.py 125 to 192), and the first-pass audience codes aud_missing, aud_ambiguous, aud_unknown, aud_not_issuable, insufficient_surface (surfaces.py 93 to 136) | every one of the internal codes is raised before the subject is known and converted to _Undecodable (seams/identity.py 699 to 732: except ClaimError at 712, except PrimaError at 729), so the caller sees either the key-door answer, when an X-API-Key or a key-shaped Bearer is present, or authentication_error() (resolve, 578 to 618); a Bearer that does not decode falls through to X-API-Key (S5) |
GET /api/prima/v1/whoami | CurrentIdentity | as above | 200 | subject_id, tenant_id, issuer, auth_method, aud, scopes, surfaces, cached_until, degraded, and identity_provider: {federation_enabled, jwks_keys, jwks_age_seconds, revocation_lag_seconds, feed_state} (auth.py 154 to 184); no PII, so it can be pasted into a support conversation |
The producer (Labs; the contract from prima/seams/labs_internal.py lines 65 to 152, exactly four reads, one direction). Status at fd8558f in the last column:
| Route | Auth | Response | Built |
|---|---|---|---|
GET /.well-known/prima-jwks.json | none, public, Cache-Control: public, max-age=120 | {"keys": [{"kty": "OKP", "crv": "Ed25519", "use": "sig", "alg": "EdDSA", "kid": <thumbprint>, "x": <base64url>}]} | yes: backend/api/routers/jwks_router.py, _MAX_AGE_SECONDS = 120 (line 52), mounted before the catch-all (B3) |
POST /internal/prima/introspect | S10's canonical string ts\nnonce\nMETHOD\npath\nsha256hex(body) and headers X-Prima-Timestamp, X-Prima-Nonce, X-Prima-Signature: v1=<hex>, signed by internal_auth_headers (wiring.py 97 to 128). Window and replay guard: the design text in labs_internal.py lines 45 to 48 and the signer's docstring (wiring.py 114 to 115) ask for 60 s skew and a 120 s Redis nonce cache; every built S10 verifier uses 300 s and SQL idempotency with no nonce cache (_shared/hmac-seam-pattern.md Data model and verifier step 3; S10 Gotchas dated 2026-09-11 record the 60 s figure as a design that never shipped). The producer implements S10 as built, 300 s and no cache, so the shared HMAC vector in Tests is the S10 vector; a re-verified token is the only replay guard introspection needs, because a replayed introspection reads and writes nothing | body {"token": <the JWT as presented>, "token_type_hint": "access_token"}; active: exactly the ten keys of INTROSPECTION_KEYS (lines 162 to 165), typed as the consumer parses them (IntrospectionResult 171 to 182, IntrospectionAnswer sessions.py 203 to 216): active bool, the presented token is live and unrevoked; iss string, the issuer, must equal the token's; sub string, the subject UUID as text, must equal the token's; aud one string, never a list; jti string, must equal the token's; exp integer epoch seconds, becomes the session row's exp; is_active bool, the subject is not deactivated; surfaces array of surface names from the registry vocabulary (surfaces.py 64 to 86); workspaces array of workspace UUID strings, an unparseable one dropped by _uuids (391 to 401); checked_at integer epoch seconds. Inactive: {"active": false} and nothing else | no (`grep -rln introspect backend/ |
GET /internal/prima/revocations?since=<cursor>&limit=1000 | HMAC as above | {"cursor": "<seq>", "has_more": bool, "as_of": <integer epoch seconds>, "events": [{"seq", "kind", ...payload}]}; labs_internal.py 95 to 112 leaves as_of untyped ("as_of":...), the recipe fixes it as integer epoch seconds to match exp and checked_at; seq integer, kind one of the four strings under Data model, the payload keys per kind | no |
POST /internal/prima/keys/lookup | HMAC as above | {"found": true, "sub", "tid", "scopes": ["ai"], "is_active", "expires_at"} or {"found": false}; cutover only, gated by PRIMA_LABS_KEY_LOOKUP_UNTIL where unset means disabled | no, and deliberately not wired on the consumer either (seams/identity.py 630 to 637; the variable is absent from prima/config.py on purpose) |
The producer must re-verify the presented token before answering, so it only answers about tokens the caller demonstrably holds and a compromised Prima cannot enumerate subjects (lines 80 to 82); {"active": false} carries no reason and no timing signal (RFC 7662 section 2.2). These endpoints bind to the internal interface and are blocked by the ingress on every public host (lines 55 to 56).
Application logic
One Labs Bearer on the gateway (GatewayIdentityProvider._resolve_jwt, prima/seams/identity.py 691 to 808; everything before the subject is known raises _Undecodable and falls through):
peek_kid(token)(jwt_ed25519.py138): the unverified header'skid, bounded byMAX_TOKEN_BYTES = 8192(line 82). Nokidis undecodable._key_for(kid)(810 to 826): the registry's stores are asked in sorted issuer order; a miss denies this one token and, at most once perUNKNOWN_KID_REFRESH_INTERVAL_S = 300across everykid, logsjwks_unknown_kid(should_refresh_for_unknown_kid,jwks.py166 to 173, stamps the cooldown and returns a bool; the caller atidentity.py821 to 824 logs on True and nothing else). The out-of-band refresh the comment at 817 to 820 and thejwks.pydocstring describe is not performed at cd2c248: nothing signals the refresh thread and nothing callsrefresh_onceoff the request path (Gotchas, 2026-09-12). The key set changes only when the background loop's next scheduledrefresh_oncelands.key_fornever fetches (141 to 144).verify(token, public_key)(jwt_ed25519.py190):algmust equalEdDSA, no dispatch table; a bad signature is undecodable.parse_claims(payload, issuers, now)(claims.py125 to 192):issin the allowlist,type == "access",ver >= 2,suba UUID,jtiat most 64 characters,exp > now - 60,iat <= now + 60,nbfhonoured when present,exp - iat <= 86400,tids_n >= 0,tidpresent exactly whentids_n == 1(atidwith any othertids_nisclaim_tid_unexpected: the issuer resolved an ambiguity it was told not to resolve). Every refusal is aClaimErrorand, at this step, a fallthrough.- The key that verified must belong to the issuer the token names (719 to 727): with one issuer unreachable, with two the exact confusion
issexists to prevent. enforce_audience(aud, allowed, granted=None)first pass (surfaces.py93 to 136): a real surface, issuable (neverprimapvs), permitted on this route (GATEWAY_SURFACESis every issuable surface;MANAGEMENT_SURFACESis{prima_dev}for key management). The grant half waits for step 7.Federation.facts_for(raw_token, jti, subject_id, issuer, native=False)(sessions.py267 to 388), the one step that may cost a Labs call, in order:feed_healthatDENY(lag at or pastDENY_LABS_AFTER_S = 3600) refuses every Labs token with 503; a cachedprima_sessionsrow answers locally (revoked is401 credential_revoked, expired is401 token_expired, otherwise the facts, markeddegradedwhen the feed isDEGRADED, lag at or pastDEGRADED_AFTER_S = 900); an unseenjtiwith a stale feed or withintrospect is Noneis 503identity_provider_unavailablewithRetry-After: 30; else introspect once, and an answer withactivefalse oris_activefalse (sessions.pyline 344:if not answer.active or not answer.is_active, so a live token whose subject is deactivated is tombstoned the same way as{"active": false}) writes a tombstone row (revoked_at = now,exp = now) so Labs is never asked about thatjtiagain; an answer whosejti,suborissdisagrees with the token is denied as revoked (identity_introspection_mismatch); a good answer is written as the session row withexpfrom the answer and returned withdegraded=False.enforce_audience(aud, allowed, granted=facts.surfaces)second pass: the claim said which door, the introspected grants say whether the subject holds it;surface_not_grantedis 403, exactly where Labs' gate fails closed.resolve_tenant(claims, workspace_header, workspaces=facts.workspaces):tids_n == 1usestidand refuses a mismatching header with 403;tids_n > 1requires the header and checks it against the introspected set;tids_n == 0is 409. Zero Labs calls in the common case.- Suspension, read fresh on this request through
status_for_external(iss, sub)(repository.pyline 97), by the Labs id and not the local one (the comment at 754 to 771 says why a lookup byidwould silently admit every suspended Labs subject). authorize_gateway(Identity(...))(459 onward): the single gate both doors funnel through; the identity carriesauth_method="jwt",scopes=frozenset(),surfaces=facts.surfaces,cached_until=exp,aud,degraded,subject_active.
Off the request path, per process (prima/api/auth.py init_auth, 53 to 69, called from the app lifespan):
build_jwks_store:load_from_disk()before any network (wiring.py169 to 174; eventjwks_startupwithjwks_age_seconds).start_identity_background(settings, jwks, feed, poll=None): the JWKS refresh thread runsrefresh_onceeveryPRIMA_JWKS_REFRESH_SECONDS; every successful fetch is installed and persisted, every failure keeps the last set (jwks.py279 to 296). Withpoll=Nonethe revocation poller is not started and the warningidentity_federation_disabled reason=phase_a_not_shippedis logged once at boot (wiring.py261 to 265). With a poller,poll_revocations_once(290 to 324) pullsclient.revocations(feed.cursor)everyREVOCATION_POLL_INTERVAL_S = 5, applies the batch throughapply_revocations(sessions.py409 to 446: four kinds map to fourUPDATE ... SET revoked_at WHERE revoked_at IS NULLshapes inSqlSessionStore,repository.py181 to 206; an unknown kind is skipped and logged, a malformed payload is logged and skipped), and advances the cursor only after a successful apply; a transport failure incrementsconsecutive_failuresand is a lag increase, never an exception.
Invariants
- The JWKS fetch can never fail a request: disk is loaded before the network, a refresh failure keeps the last set with no TTL, and
key_forperforms no I/O (test_a_refresh_failure_never_expires_the_key_set,test_key_for_never_fetches,test_disk_is_loaded_before_the_network_is_ever_touched,tests/auth/test_jwks.pylines 55, 148, 40). - An unknown
kiddenies that one token, never fetches inline, and is granted a refresh budget at most once per 300 s across allkidvalues (test_the_rate_limit_is_global_not_per_kid, line 128;test_an_unknown_kid_denies_only_that_token,test_labs_down.pyline 308). At cd2c248 the granted budget produces onejwks_unknown_kidlog line and no fetch; the key set is refreshed by the 300 s background loop alone (Gotchas). The invariant a rebuild must add, and test, is that the granted budget triggers exactly onerefresh_onceoff the request path. - Labs is asked at most once per
jti, on the first request of a session, and never on the API-key path (test_a_first_request_introspects_once_and_never_again,test_resolver.pyline 214;test_an_unknown_key_is_401_and_does_not_read_through_to_labs, line 92). - No module on the key path can reach
prima.seams.labs_internal, exactly one module imports it, and that module really holds the edge (test_import_graph.pylines 87, 100, 115). - A revocation is terminal:
puton a tombstonedjtireturns without writing (repository.py150 to 154); applying the same event twice affects zero rows; a tombstone is kept until the token it denies has expired (purge_expired, 208 to 216). - Ambiguity denies and never synthesises a grant: a mismatched introspection answer, a native
jtiwith no row, a list-valuedaud, atidwithtids_n != 1(test_ambiguity_always_denies_and_never_synthesises_a_grant,test_labs_down.pyline 324). - The feed only ever removes access; the four kinds are the whole vocabulary and a fifth is ignored, not fatal.
- A cold start is not an outage: lag is measured from
started_atuntil the first successful poll (test_a_cold_start_is_not_a_sixty_minute_outage, line 280). - The two ceilings are the numbers they were argued from: 900 s equals Labs'
_REVOCATION_ACCESS_TTL_MINUTESand 3600 s equals Labs' access-token lifetime, so "deny everything Labs-issued" never outlives a token that would have expired anyway (test_the_ceilings_match_the_numbers_they_were_argued_from, line 212). - The producer's revocation write raises inside the causing transaction; a best-effort
try/exceptthere means revoked sessions served with no symptom (labs_internal.py120 to 124). - The producer's introspection answer has exactly ten keys, asserted by equality and not containment, so the seam cannot start carrying PII unnoticed (lines 162 to 168).
Frontend
The modules that render this door, by id:
identity-auth/ui/developer-portal-auth-pages F7, interim: the portal holds the prima_dev Bearer that hits this door;
a Labs Bearer sent to the standalone gateway at cd2c248 gets the 503
until the producer ships (the scaffold state, Gotchas; portal 4428767
itself recorded /usage answering 200 with a session Bearer, measured
2026-08-03 against the in-Labs gateway, src/lib/prima-api.ts 630)
billing-credits/ui/developer-portal-konto F27, unmerged (W6): the balance read is the first customer-visible
consumer; blocked by this row's 503 until 2026-09-11, when decision
A-2 (a) had the portal mint a per-session ai key at sign-in and send
it as X-API-Key to GET /api/prima/v1/usage (w6-konto c594dfc,
src/lib/gateway-key.ts 1 to 12, src/lib/billing.ts 857 to 861: the
identity_provider_unavailable state is removed there), mirroring B21;
a Labs Bearer still cannot be used directly; linked once the stub exists
billing-credits/ui/billing-outbox-admin-console F30, planned: the admin table for every outbox, where the revocation
feed's cursor and lag belong
Minimum admin and debug surface: GET /api/prima/v1/whoami (which door, which tenant, federation_enabled, jwks_keys, jwks_age_seconds, revocation_lag_seconds, feed_state); the structured events by name, jwks_startup, jwks_loaded_from_disk, jwks_cache_unusable (with check one of oversize, corrupt, no_keys; jwks.py 201, 208, 213), jwks_entries_rejected (111), jwks_installed, jwks_refresh_failed, jwks_refresh_rejected, jwks_unknown_kid, jwks_cache_write_failed, identity_federation_disabled, identity_new_session_refused, identity_feed_deny, identity_introspection_failed, identity_introspection_mismatch, identity_resolved (with subject_realm, kid, degraded), revocation_poll_failed, revocations_applied, revocation_event_unknown, revocation_event_malformed, revocation_cursor_rewind_refused (prima.logging.event, bounded tokens and integers only); and SELECT count(*) FILTER (WHERE revoked_at IS NOT NULL) FROM prima_sessions as the tombstone count.
Security & tenancy
Auth dependency: current_identity (prima/api/auth.py 108 to 141) reads Authorization, X-API-Key and X-Workspace-ID as typed headers, builds the provider per request against the request's session, and passes introspector=None at cd2c248. There is no cookie branch and there will not be one (_bearer, seams/identity.py 828 to 836; test_there_is_no_cookie_branch, test_resolver.py line 189): a Labs cookie is set on the Labs origin and never reaches api.prima.li.
Workspace derivation: tid when tids_n == 1, the header checked against the introspected workspaces[] when tids_n > 1, 409 when zero; never a server-side default (S6, and the docstring at seams/identity.py 123 to 137: a default "silently reattributes a multi-workspace customer's spend").
Fail direction, per seam: signature, claim floor, issuer, audience closed; unknown kid closed for that token, open for everyone else; introspection transport failure closed for the new session with 503, open for cached sessions; feed lag past 900 s closed for new sessions, past 3600 s closed for every iss=labs token, open for keys and native tokens throughout (the asymmetry is the design); an unparseable workspace id in the introspection answer is dropped, not fatal, because it grants nothing and raising would let one bad row lock a person out (_uuids, sessions.py 391 to 401); JWKS persistence failure open (in-memory set is already good, jwks.py 225 to 231).
Credentials at rest: Prima holds no material that can mint a Labs token, which is why the internal seam is HMAC and not a bearer JWT (labs_internal.py 50 to 53); the JWKS cache holds public keys only, mode 600; PRIMA_LABS_INTERNAL_SECRET is pairwise distinct from the two write-direction keys or boot refuses.
Rate limits: the unknown-kid budget (one per 300 s, global; at cd2c248 it bounds the jwks_unknown_kid log line, not a fetch, because no fetch is wired to it, Gotchas); the JWKS refresh loop (one fetch per PRIMA_JWKS_REFRESH_SECONDS, the only fetch there is); the feed poll (one per 5 s); the JWKS document bounded at MAX_DOCUMENT_BYTES = 65536 and MAX_KEYS = 20; no per-client limiter on the door itself (the gateway's limiter is B18's).
Attacker-controlled inputs: the JOSE header (kid looked up in a dict, at most 64 characters when it came from the JWKS, and never scanned or fetched inline); the token bytes (8192 cap before base64); every claim (typed, bounded, floors and ceilings above); the JWKS response (size, count, per-entry parse with one bad entry rejected and a document with no usable entry refused, jwks.py 82 to 114); the introspection answer (must agree with the token; ten keys); every feed event (unknown kind skipped, malformed payload skipped); the X-Workspace-ID header (a UUID compared against a set).
Tests
Prima (tests/auth/, run 2026-09-12 at cd2c248 with PYTHONPATH=. python -m pytest tests/auth -q -o addopts=""): 232 passed, 21 skipped in 5.96s; the skips are test_repository_live.py, which needs the database. The door's own files, run together the same day: test_labs_down.py, test_import_graph.py, test_jwks.py, test_resolver.py, test_claims_and_surfaces.py print 104 passed.
tests/auth/test_labs_down.py: the degradation table row by row.test_a_paying_customer_is_served_no_matter_how_long_labs_has_been_down(line 101, parametrised over lag),test_the_key_path_is_served_with_no_jwks_at_all(117),test_a_cached_labs_session_is_served_while_labs_is_down(149),test_a_new_labs_session_is_refused_with_retry_after(167: codeidentity_provider_unavailable,Retry-Afterequal to"30"),test_with_federation_not_yet_shipped_new_labs_sessions_are_refused(181:introspector=None, the live configuration),test_feed_health_thresholds(202),test_degraded_serves_cached_sessions_and_marks_them(225),test_degraded_refuses_a_new_session_even_when_labs_answers(242),test_beyond_the_hard_ceiling_every_labs_token_is_denied_but_keys_are_not(259),test_jwks_serves_from_disk_indefinitely_while_labs_is_down(297).tests/auth/test_resolver.py: the once-per-jtirule (214), the tombstone (244), the mismatch denial (259), the revoked session answered without asking Labs (277), the cached session without the grant as 403 and not a fallthrough (292), the four fallthrough cases (128, 146, 156, 173).tests/auth/test_jwks.py: 16 cases on the store; known answers include the persisted envelope being JSON with a fetch time (171) and the cache file not being world-readable (35).tests/auth/test_claims_and_surfaces.py: 28 cases on the claim floor and the audience rules; the tenant table (149 to 198, eight cases fromtest_single_workspace_no_header_costs_zero_labs_callstotest_a_malformed_workspace_header_is_400) is S6's known-answer set; 116 to 139 are thetidand required-claim floor cases.tests/auth/test_repository_live.py(database required, 21 cases):test_session_round_trip(464),test_revoking_a_grant_kills_only_sessions_on_that_surface(474),test_revoking_membership_uses_the_workspace_array(487),test_a_revocation_is_terminal_and_reintrospection_cannot_undo_it(502),test_applying_the_same_revocation_twice_is_free(514),test_a_denied_session_is_denied_by_the_resolver_after_the_feed_applies_it(525),test_the_cursor_is_monotonic(549).- Known-answer vector for the signature primitive: the seed,
kid, payload and 466-character compact token recorded in S5 Tests (computed 2026-09-12 againstcore/jwt_ed25519.pyat fd8558f and verified withprima/auth/jwt_ed25519.py::verifyat cd2c248). A rebuilt verifier must accept it byte for byte; a rebuilt signer must produce it.
What the producer adds when it is built (the twin files, per S10's rule that signer and verifier live in different repos pinned by one shared known-answer test): a Labs test that POST /internal/prima/introspect answers exactly INTROSPECTION_KEYS for an active token and {"active": false} for anything else; a Labs test that every revocation-causing path writes a prima_revocation_events row in the same transaction and that a failed write raises; a Prima test that a canned feed page from Labs' serialiser applies through apply_revocations with the expected affected count; one HMAC vector (timestamp, nonce, method, path, body hash, signature hex) shared by both repos.
Hermetic versus live: everything above is hermetic except the 21 live repository tests; nothing reaches a deployed host. Playwright gate: none; the door has no page of its own (F7's spec covers the portal's sign-in, F27's will cover the balance read).
Deploy & ops
Config keys per app heredoc (S13): on Prima PRIMA_JWT_ISSUER byte-equal to Labs' JWT_ISSUER, PRIMA_JWKS_URL pointing at the API host's /.well-known/prima-jwks.json and not the SPA host (S5 Deploy & ops records the trap), PRIMA_JWKS_CACHE_PATH on a volume that survives a container replace, PRIMA_JWKS_REFRESH_SECONDS left at 300, PRIMA_LABS_INTERNAL_BASE_URL and PRIMA_LABS_INTERNAL_SECRET set even though nothing calls them yet (boot requires both); on Labs the same HMAC key under a name of its own once the producer exists, and JWT_ED25519_PUBLISHED_PUBLIC_KEYS for rotation (B3).
Rotation contract on the producer: publish the new public key at least one full PRIMA_JWKS_REFRESH_SECONDS before signing with it (jwks.py 31 to 36). At cd2c248 the loop interval is the whole bound: the docstring's out-of-band refresh on an unknown kid is not wired (Gotchas, 2026-09-12), so a key that signs before the next scheduled refresh fails every token it signs, with the uniform 401, for up to PRIMA_JWKS_REFRESH_SECONDS, and the operator sees one jwks_unknown_kid line per 300 s while it lasts. Once the refresh is wired the out-of-band fetch shortens that window to one request plus one fetch, and the interval becomes the ceiling rather than the rule. Labs' max-age=120 is under the 300 s refresh on purpose (B3).
Migrations order: prima_auth_001 after prima_credits_0002 on Prima's own chain (S8's prima_alembic_version); on Labs the prima_revocation_events table lands with the producer, before any code writes it.
Scheduler jobs: two daemon threads per Prima process (prima-jwks-refresh, prima-revocation-poll when a poller is passed), one shutdown callable returned to the lifespan (wiring.py 283 to 287). Under --workers N each process refreshes and polls on its own; the feed apply is idempotent so N pollers are wasteful and harmless. No housekeeping job calls purge_expired at cd2c248 (Gotchas).
Argus checks: whoami with a known key returns 200 and identity_provider.jwks_keys >= 1; jwks_age_seconds under 900 on a host where Labs is up (a value climbing past that is the refresh loop failing, not a request failure); once the poller exists, revocation_lag_seconds under 30 and feed_state == "fresh". Today, with poll=None, feed_state is a function of process uptime and not of Labs: fresh for the first 900 s after boot, degraded until 3600 s, deny from then on, and revocation_lag_seconds equals the uptime (Gotchas, 2026-09-12); an alert on either is meaningless until the poller is wired. Rollback: configuration only; a Prima rolled back to before prima_auth_001 cannot serve any Labs JWT, which is today's state.
Gotchas
- 2026-09-12 (measured at cd2c248 and fd8558f): the door is a scaffold.
current_identitypassesintrospector=None(prima/api/auth.pyline 130) andinit_authpassespoll=None(line 65), so every first request of a Labs session is503 identity_provider_unavailablewithRetry-After: 30, cached sessions are never written, andwhoamihard-codesfederation_enabled: false(line 178). On Labs,grep -rln introspect backend/ | grep -v testshits onlyservices/primalayered, and no route under/internal/prima/exists. Only the API-key door is complete end to end. F27's balance read was blocked on this row until 2026-09-11; at w6-konto c594dfc the portal mints a per-sessionaikey at sign-in and reads/usagewithX-API-Key(src/lib/gateway-key.ts1 to 12, decision A-2 (a);src/lib/billing.ts857 to 861 removes theidentity_provider_unavailablestate because the portal no longer sends a Bearer there). The 503 remains the reason a Labs Bearer cannot be used directly by any Prima surface. - 2026-09-12 (measured at cd2c248): the out-of-band JWKS refresh on an unknown
kidis described and not built.jwks.pylines 23 to 25 and 35 to 36, the comment atseams/identity.py817 to 820 and the design table at 891 to 893 all say a miss "asks for" or "triggers ONE out-of-band refresh"; what the code does isshould_refresh_for_unknown_kid()stamping_last_unknown_kid_refreshand returning True once per 300 s (jwks.py166 to 173), and_key_forloggingjwks_unknown_kidon True and returning(None, None)(821 to 825).refresh_onceis called from the background loop only (start_background_refresh, 299 to 325,stop.wait(interval_s)with no wake event);grep -rn "refresh_once\|should_refresh_for_unknown_kid" prima/shows no other caller, and noEvent, queue or fetcher reachesGatewayIdentityProviderorIssuerRegistry(identity.py334 to 388, 534 to 566). The tests that touch it (test_jwks.py119 to 125 and 128 to 147,test_labs_down.py308) assert the budget arithmetic and the denial, never a fetch. Effect: the only bound on a too-early rotation is the 300 s loop. The fix, when code changes are in scope: give the refresh thread athreading.Eventthat_key_forsets when the budget is granted, with the loop waiting on that event withinterval_sas its timeout instead ofstop.wait(interval_s)(or arequest_refresh()onJwksStorethe loop honours), so the granted budget triggers onerefresh_onceoff the request path and never inline; plus a test that an unknownkidcauses exactly one fetch within the cooldown. Until then this recipe's Application logic, Invariants, Rate limits and rotation contract describe the log line, not a fetch. - 2026-09-12 (measured at cd2c248): the scaffold's feed lag climbs from boot. With
poll=Nonenothing ever setsFeedState.last_success_at, solag_secondsmeasures fromstarted_at(sessions.py124 to 126;prima/api/auth.py60 to 65 buildsFeedState(started_at=now)), and on a healthy key-serving processwhoami.identity_provider.feed_statereadsdegradedafter 900 s of uptime anddenyafter 3600 s (feed_health, 129 to 135;whoami181 to 182). A Labs Bearer presented after the first hour takes theidentity_feed_denyERROR branch (facts_for, 284 to 295) instead of theidentity_new_session_refusedWARNING branch (327 to 334); the answer is the same 503 withRetry-After: 30, so check 6 holds either way, but the log carries an ERROR per Labs Bearer for the life of the process. When the poller is wired the first successful poll resets the baseline; until then readfeed_stateas uptime. - 2026-09-12 (measured at cd2c248): the module docstrings still describe Labs as signing HS256 and say none of the four endpoints exists (
prima/auth/wiring.pylines 9 to 34,prima/seams/labs_internal.pylines 50 to 53 and 204 to 205,prima/auth/claims.pylines 34 to 36). Labs has signed Ed25519 with akidand published/.well-known/prima-jwks.jsonsince ca227b9 (2026-08-03, B3). One of the four exists; the argument for HMAC over a bearer JWT on the internal seam still holds for a different reason (Prima should hold nothing that mints a Labs token, and an HMAC key scoped to four reads cannot), so the transport does not change. - 2026-09-12 (measured at cd2c248):
wiring.pyline 25, the degradation table atseams/identity.py866 to 867 and thetest_labs_down.pyheader table (lines 17 to 18) all say the unavailable-provider answer is 401;_unavailable()(sessions.py223 to 231) builds a 503, andtest_a_new_labs_session_is_refused_with_retry_afterasserts the code and the header, not the status. The code wins: 503 withRetry-After: 30. - 2026-09-12 (measured at cd2c248):
SqlCursorStore(repository.py219 to 245, read at boot and written per poll, monotonic) exists and is exercised bytest_the_cursor_is_monotonic, butpoll_revocations_oncereads and writesfeed.cursoron the in-memoryFeedStateand nothing constructs aSqlCursorStoreoutside that test (grep -rn SqlCursorStore prima/hits the definition and its import only). When the poller is wired,FeedState.cursormust be seeded fromSqlCursorStore.read()at boot andwrite()called after each successful apply, or a restart replays the feed from zero (harmless but slow) and a long outage loses nothing only because the cursor never advanced in the first place. - 2026-09-12 (measured at cd2c248):
purge_expired(repository.py208 to 216) has no caller;prima_sessionsgrows by one row per Labs session for the life of the table. Wire it as a daily job under S12 once sessions are being written; the index onexpis already there. - 2026-09-12 (measured at cd2c248):
IssuerRegistry's docstring (seams/identity.py367 to 368) saystests/auth/test_issuer_registry.pyasserts the issuer count; no such file exists (ls tests/auth). The single-issuer fact is pinned indirectly bybuild_issuer_registryregistering one and by the tests that construct a registry by hand. A second issuer is still a decision, not an accident, but the guard the docstring names is not there. - 2026-09-12 (measured at cd2c248):
prima_sessions.tenant_idis declared and writtenNULLon every path (sessions.py351 and 380); the tenant is resolved from the claims per request and never cached. The column is reserved, not used. - 2026-09-12 (measured at fd8558f): the revocation feed presupposes
revoke_jtiwriting durable state; today it writes Redis only whenJWT_DENYLIST_ENABLEDis on, which it is not on the estate, and fails open on a Redis error (S5 Gotchas). Building the producer'sprima_revocation_eventswrite is an improvement to Labs' own posture independent of this door, and the inventory (row B13) andlabs_internal.py114 to 124 both say so. - 2026-09-12 (wiki 261be0e,
platform/primaapi.md"Open items"): the page still says Labs' half of the seam does not exist "in any branch" and lists the JWKS among the missing pieces; the JWKS is built (B3). The other two pieces are missing as the page says. ADR-0030 (Infomaniak OIDC as the estate's identity provider, status "proposed" as of 2026-08-08) would add a third trust root to the picture; it does not change this door, because a Labs token stays a Labs token whichever provider signed the person in. - 2026-08-03 (cd2c248, recorded in
seams/identity.pylines 20 to 39): the design assumedpython-josecould verify EdDSA; it cannot, so the verifier is written againstcryptographydirectly and the service has no algorithm-dispatch table. A rebuild that reaches for a JWT library reintroduces the table. - 2026-08-03 (cd2c248,
seams/identity.py27 to 30 andjwks.py27 to 29): the unknown-kidrefresh limit was first per-kid; an attacker picks a freshkidper request and each one gets its own budget. Global or nothing. - 2026-08-03 (cd2c248,
sessions.py109 to 117):FeedStatewithoutstarted_atdenied every Labs token from boot until the first poll returned, a self-inflicted outage on every deploy.
Reference implementation
Paths are relative to each repo's root; commits as in measured_on and also_measured_on.
prima(feature/per-model-pricingat cd2c248):prima/auth/jwks.py:MAX_KEYS(69),UNKNOWN_KID_REFRESH_INTERVAL_S(72),MAX_DOCUMENT_BYTES(75),_parse(82 to 114),JwksStore(117 to 250:key_for141,age_seconds154,should_refresh_for_unknown_kid166,install177,load_from_disk189,_persist225),http_fetcher(258 to 276),refresh_once(279 to 296),start_background_refresh(299 to 325).prima/auth/jwt_ed25519.py:ALG(69),MAX_TOKEN_BYTES(82),InvalidToken(85),jwk_to_public_key(112),peek_kid(138),_split(149),verify(190).prima/auth/claims.py:MIN_CLAIM_VERSION(58),CLOCK_SKEW_SECONDS(61),MAX_TOKEN_LIFETIME_SECONDS(66),ACCESS_TOKEN_TYPE(72),Claims(84 to 100),parse_claims(125 to 192),resolve_tenant(195 to 252).prima/auth/sessions.py:DEGRADED_AFTER_S(92),DENY_LABS_AFTER_S(96),RETRY_AFTER_S(100),FeedState(109 to 126),feed_health(129 to 135),SubjectFacts(143 to 158),SessionRecord(161 to 177),SessionStore(180 to 187),IntrospectorandIntrospectionAnswer(190 to 216),_unavailable(223 to 231),_revoked(234 to 239),Federation.facts_for(267 to 388),_uuids(391 to 401),apply_revocations(409 to 446).prima/auth/wiring.py:REVOCATION_POLL_INTERVAL_S(89),internal_auth_headers(97 to 128),LabsIntrospectorAdapter(136 to 161),build_jwks_store(169),build_issuer_registry(177),build_identity_provider(184 to 215),start_identity_background(239 to 287),poll_revocations_once(290 to 324).prima/seams/labs_internal.py: the four-endpoint contract (65 to 152),INTROSPECTION_KEYS(162),IntrospectionResult(171),RevocationEvent(185),LabsInternal(193 to 218),build_labs_internalraisingNotImplementedError(221 to 225).prima/seams/identity.py:Identity(284 to 313),Issuer(334 to 346),IssuerRegistry(349 to 388),_Undecodable(396 to 410),authorize_gateway(459),GatewayIdentityProvider.resolve(567 to 619),_resolve_jwt(691 to 808),_key_for(810 to 826),_bearer(828 to 836), the degradation table (856 to 898).prima/auth/surfaces.py: the vocabulary (64 to 86),enforce_audience(93 to 136).prima/api/auth.py:AuthRuntime(30 to 44),init_auth(53 to 69),current_identity(108 to 141),whoami(154 to 184).prima/auth/models.py:PrimaSession(255 to 288),PrimaRevocationCursor(291 to 307);prima/auth/repository.py:SqlSubjectRepository.status_for_external(97),SqlSessionStore(125 to 216),SqlCursorStore(219 to 245);migrations/versions/20260803_0001_auth_seam.py(prima_sessions183 to 205,prima_revocation_cursor208 to 216).- Tests as listed under Tests.
alchemy_labs(d1a-token-sourceat fd8558f, backend identical tofeat/service-deskb1089b6):backend/api/routers/jwks_router.py(the one producer endpoint that exists;_MAX_AGE_SECONDS52,JWKS_PATH57);backend/api/routers/prima_inbound_router.pyandbackend/services/commerce/prima_inbound_sig.py(the nearest existing pattern for an HMAC-verified receiver in Labs, fail-closed, mountedsurface_exempt("public", ...)atapp_fastapi.py887);backend/services/auth_service.pyrevoke_session(856 to 876) andrevoke_jti(the sites theprima_revocation_eventswrite attaches to). The introspection and revocation endpoints do not exist; the nearest existing pattern for the verifier they need isprima/api/keys_sync.py::require_labs_signature(cd2c248, lines 209 to 270) mirrored into Labs. The legacy in-Labs gateway copy has its own door for a Labs session,backend/services/primaapi/identity.py::resolve_gateway_identity(194 to 230 at fd8558f:auth_method: "jwt" | "api_key"at 66 to 67, the session decoded in-process byauth_service.decode_token_with_surface, the tenant from the workspace header through_resolve_tenant_for_jwt_user), with no JWKS, no introspection and no feed because it runs inside Labs; it is whatapi.prima.liserved before the standalone gateway, is legacy per D3, and is not this recipe. A reader diagnosing "which door served this Labs session" on a host still routed to Labs is looking at that file.- Wiki
philosophers_stone(261be0e):platform/primaapi.md("The auth seam, as built", "Open items"),operations/decisions/0022-prima-extraction.md,operations/decisions/0030-infomaniak-as-identity-provider.md,operations/prima-modules-inventory.md(rows B13, F27, F30; wave 2 note on B13).
Deviations of the reference from this recipe, all dated 2026-09-12: the producer absent (introspect, revocations, prima_revocation_events); introspector=None and poll=None at the composition root; federation_enabled hard-coded false; the unknown-kid refresh budget granted and never spent on a fetch; the feed lag measured from boot until a poller exists; the cursor store and purge_expired unwired; the stale HS256 and 401 docstrings; the missing test_issuer_registry.py; prima_sessions.tenant_id always NULL.
Acceptance checks
Run from the prima repo root at cd2c248 unless stated. Expected observations are exact.
PYTHONPATH=. python -m pytest tests/auth -q -o addopts=""prints232 passed, 21 skipped;PYTHONPATH=. python -m pytest tests/auth/test_labs_down.py tests/auth/test_import_graph.py tests/auth/test_jwks.py tests/auth/test_resolver.py tests/auth/test_claims_and_surfaces.py -q -o addopts=""prints104 passed.grep -rlE "^(from|import) prima\.seams(\.labs_internal| import labs_internal)" prima/prints exactly one path,prima/auth/wiring.py(the one permitted edge,wiring.pyline 66;test_exactly_one_module_imports_labs_internal,test_import_graph.pyline 100, pins the same fact). A grep on the bare stringlabs_internalis not this check: it prints 13 paths at cd2c248, because docstrings, comments and thelabs_internal_secretsetting name carry the word.grep -n "introspector=None" prima/api/auth.pyprints one line, 130, andgrep -n "poll=None" prima/api/auth.pyprints two, 62 (the comment) and 65 (the call); the scaffold state, and all three lines change when the producer ships, this recipe'smaturitywith them.grep -n "DEGRADED_AFTER_S = \|DENY_LABS_AFTER_S = \|RETRY_AFTER_S = " prima/auth/sessions.pyprints15 * 60,60 * 60and30on lines 92, 96 and 100;grep -n "UNKNOWN_KID_REFRESH_INTERVAL_S = \|MAX_KEYS = " prima/auth/jwks.pyprints two lines in file order,69:MAX_KEYS = 20then72:UNKNOWN_KID_REFRESH_INTERVAL_S = 300.grep -c 'kind == "' prima/auth/sessions.pyprints4(the four branches ofapply_revocations, lines 429 to 437, one per kind), andgrep -n "subject_reactivated\|grant_granted" prima/auth/sessions.pyprints two lines, 417 and 418, both inside the sentence saying the two kinds are deliberately absent (one name per line; there is no code branch for either). Agrep -con the four kind names is not this check: it prints5, because the docstring at line 31 mentionsis_jti_revoked().python -c "from prima.seams.labs_internal import INTROSPECTION_KEYS; print(sorted(INTROSPECTION_KEYS))"prints['active', 'aud', 'checked_at', 'exp', 'is_active', 'iss', 'jti', 'sub', 'surfaces', 'workspaces'], ten names and noemail.- With the service running and no Labs reachable:
curl -s -i -H "Authorization: Bearer $LABS_TOKEN" $PRIMA_BASE/api/prima/v1/whoamishowsHTTP/1.1 503,retry-after: 30and"code":"identity_provider_unavailable";curl -s -H "X-API-Key: $PRIMA_KEY" $PRIMA_BASE/api/prima/v1/whoamishows200,"auth_method":"api_key","federation_enabled":falseand"jwks_keys":1or more. Both hold at cd2c248 whether Labs is up or not (the scaffold state). - Stop the JWKS host, restart Prima: the boot log carries
jwks_startup ok=truewith a non-zerojwks_age_seconds,whoamiwith the key still answers 200, andjwks_keysis unchanged;ls -l $PRIMA_JWKS_CACHE_PATHshows mode-rw-------. - Present a token whose header
kidis a fresh random string: the response is 401 with the uniform authentication error ("code":"invalid_api_key"), the log carries exactly onejwks_unknown_kidevent, a second such token within 300 s logs none, and nojwks_installedorjwks_refresh_failedevent follows either token before the loop's next scheduled tick (the unwired refresh, Gotchas; a rebuild that wires it observes exactly one fetch after the first token and none after the second). - In
alchemy_labs/backendat fd8558f:grep -rln "internal/prima" api/ services/ | wc -lprints0andcurl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8000/.well-known/prima-jwks.jsonprints200(one of four built). When the producer lands, the first grep prints at least1, and check 6's first call is200with"auth_method":"jwt"and"federation_enabled":true. npm run checkin this repo prints no line for this recipe.
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.