Module kit / Identity and auth
Key-sync outbox, Labs to Prima
identity-auth/key-sync-outbox-labs-to-prima
| Kind | backend |
|---|---|
| Domain | identity-auth |
| Maturity | prod |
| Reusable | yes |
| Stack | fastapi, sqlalchemy-sync, postgres, httpx, apscheduler, hmac-sha256 |
| Depends on | _shared/fastapi-project-layout, _shared/error-envelope, _shared/outbox-pattern, _shared/hmac-seam-pattern, _shared/scheduler-and-background-work, _shared/alembic-migrations, _shared/test-layout |
| Frontends | identity-auth/ui/api-keys-settings-and-developer-dashboard |
| Reference implementation |
|
| Measured on | alchemy_labs d1a-token-source fd8558f 2026-09-12 |
| Also measured on |
|
| Version | 1 |
Purpose
A key minted in Labs works on Prima seconds later, and a key revoked in Labs stops working on Prima, whatever order the messages arrive in and however many times they are retried. The invariant that makes it trustworthy is that the obligation to tell Prima is a row written in the same transaction as the key, carrying the key's whole desired state and a per-key counter, and Prima applies a message only where prima_api_keys.sync_seq < EXCLUDED.sync_seq, so a replay changes nothing and a stale "active" that arrives after a revocation changes nothing (alchemy_labs/backend/services/primaapi/prima_key_sync.py 42 to 58 and prima/prima/api/keys_sync.py 475 to 504, read 2026-09-12 at fd8558f and cd2c248).
When to use
Use it wherever a credential is minted in one service and validated in another that must not call back: it is S11's outbox and S10's signed seam applied to ai-scoped keys, and it is what closed the 2026-08-04 defect where every self-serve key 401ed on the gateway (ADR-0026). Trigger phrases: "the key works in Labs and not on api.prima.li", "stuck row", "pending for too long", "sync_seq", "a revoked key still works", "superseded", "Prima refused the key with 400", "the drain", "reconcile the pre-seam keys", "PRIMA_KEY_SYNC_SECRET".
Do not use it for the pattern itself (_shared/outbox-pattern, S11, which names this outbox as one of four and fixes the shared shape; _shared/hmac-seam-pattern, S10, which fixes the canonical string and the credential rules), for the mint route (identity-auth/developer-api-keys-and-security-policy, B10, the caller), for how Prima validates the row afterwards (identity-auth/gateway-api-key-auth, B11), for the money seam (billing-credits, the credit-grant outbox with the same shape and a different credential), or for the other direction (Prima reading Labs, B13, a design with a death date).
Stack assumptions
Pinned as measured at fd8558f and cd2c248 (2026-09-12): Labs as S4 (SQLAlchemy sync sessions, httpx for the one outbound call, APScheduler's IntervalTrigger on the leader, S12), Prima as the S7 and S10 reference (an async signature dependency in front of a sync handler so the raw body can be awaited, keys_sync.py 209 to 227). Foundations assumed: S8 for the two migrations, S9 for the hermetic test layout on both sides. The scheme is the money-IN seam's byte for byte (prima_key_sig.py 18 to 29; internal_sig.py 14 to 26).
Environment names and their fail behaviour:
| Side | Name | Default | Behaviour |
|---|---|---|---|
Labs (core/config.py) | PRIMA_KEY_SYNC_ISSUER | None (712) | falls back to JWT_ISSUER (_issuer, prima_key_sync.py 322 to 338), whose default is https://labs.alchemy.zuerich (113 to 116), so the raise at 332 to 337 is reachable only when JWT_ISSUER is set to an empty string; unset on a deployment whose Prima federates a different issuer, every mint queues a row that parks as stuck with HTTP 400 (Gotchas); must equal Prima's PRIMA_JWT_ISSUER byte for byte or Prima answers 400 unknown_issuer |
| Labs | PRIMA_KEY_SYNC_SECRET | None, SecretStr (719) | unset: deliver records SyncNotConfigured and the row stays pending (374 to 384); never the read-seam value, never the credit-grant value (prima_key_sig.py 31 to 43) |
| Labs | PRIMA_INTERNAL_BASE_URL | None (752) | one variable for both Labs-to-Prima seams; a loopback address on the estate, never the public ingress, which does not route /internal/*; unset behaves as the line above |
| Labs | PRIMA_KEY_SYNC_TIMEOUT_SECONDS | 10.0 (720) | per attempt |
| Labs | PRIMA_KEY_SYNC_MAX_ATTEMPTS | 24 (721) | after which _fail parks the row as stuck |
Prima (prima/config.py) | PRIMA_KEY_SYNC_SECRET | unset, optional (903) | unset: require_labs_signature answers 503 key_sync_not_configured to everyone and logs at ERROR (229 to 243); at least 16 characters when set; refused at boot when equal to PRIMA_LABS_INTERNAL_SECRET or PRIMA_CREDIT_GRANT_SECRET (959 to 989) |
| Prima | PRIMA_JWT_ISSUER | required | compared with the payload's issuer (353 to 359) |
Data model
Labs, the outbox (S11's shape, one row per key, mutated in place):
-- models/prima_key_sync.py 85 to 169; migrations/versions/20260804_0001_prima_key_syncs.py (prima_key_sync_001, revises prima_magic_link_tpl_001).
-- The stored CHECK names carry the ck_<table>_ prefix from core/database.py's naming convention (S11 Gotchas).
prima_key_syncs (
id uuid PRIMARY KEY,
api_key_id uuid NOT NULL REFERENCES api_keys(id) ON DELETE RESTRICT, -- fk_prima_key_syncs_api_key; the row is evidence
key_hash varchar(64) NOT NULL, -- sha256 hex, CHECK (key_hash ~ '^[0-9a-f]{64}$'): a plaintext key cannot be inserted
key_prefix varchar(32) NOT NULL, -- api_keys.key_prefix[:32], trailing dots and spaces stripped, 'prima_pk_' when that is empty (199, 277), so Prima's token pattern accepts it
tenant_id uuid NOT NULL, -- api_keys.workspace_id copied, no FK; used verbatim as prima_tenants.id
external_sub varchar(255) NOT NULL, -- users.id as a string, the JWT sub
issuer varchar(255) NOT NULL,
scopes jsonb NOT NULL, -- the sorted, lowercased list; only lists containing "ai" are ever enqueued
desired_active boolean NOT NULL DEFAULT true, -- state, not an event
expires_at timestamptz NULL,
sync_seq bigint NOT NULL DEFAULT 1, -- CHECK (sync_seq > 0); +1 on every state change
status varchar(16) NOT NULL DEFAULT 'pending', -- CHECK IN ('pending','synced','stuck')
attempts integer NOT NULL DEFAULT 0,
next_attempt_at timestamptz NULL,
last_error text NULL, -- an exception class name or 'HTTP <n>', never a body
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
synced_at timestamptz NULL,
UNIQUE (api_key_id) -- uq_prima_key_syncs_api_key: one row per key, enforced by the database
);
CREATE INDEX ix_prima_key_syncs_key_hash ON prima_key_syncs(key_hash);
CREATE INDEX ix_prima_key_syncs_tenant ON prima_key_syncs(tenant_id);
CREATE INDEX ix_prima_key_syncs_status ON prima_key_syncs(status);
CREATE INDEX ix_prima_key_syncs_next_attempt ON prima_key_syncs(next_attempt_at);
CREATE INDEX ix_prima_key_syncs_due ON prima_key_syncs(status, next_attempt_at); -- the drain's query
Prima, the target (B11 owns the tables; this seam adds one column):
-- migrations/versions/20260804_0001_key_sync_seq.py (prima_key_sync_001, revises prima_auth_001)
ALTER TABLE prima_api_keys ADD COLUMN sync_seq bigint NOT NULL DEFAULT 0;
-- 0 = written by scripts/backfill_api_key.py before the seam; every seam message starts at 1 and wins.
-- Written by the seam, with ON CONFLICT: prima_subjects (issuer, external_sub) DO NOTHING; prima_tenants (id) DO NOTHING;
-- prima_api_keys (key_hash) DO UPDATE ... WHERE prima_api_keys.sync_seq < EXCLUDED.sync_seq.
The wire payload (_payload, prima_key_sync.py 346 to 357; KeySyncRequest, keys_sync.py 291 to 335), compact JSON with no spaces:
{"issuer": "<token, <=255>", "external_sub": "<token, <=255>", "tenant_id": "<uuid>", "key_hash": "<64 hex>",
"key_prefix": "<token, <=32>", "scopes": ["<token, <=64>", "..."], "is_active": true, "expires_at": "<iso or null>", "sync_seq": 1}
TOKEN_PATTERN is ^[A-Za-z0-9_.:/@+-]+$ (prima/content_free.py 126); scopes is 1 to 16 entries; sync_seq >= 1. There is no field for a plaintext key, no email, no name, no workspace name (test_the_plaintext_key_has_no_field_to_travel_in). What is not stored on either side: the raw key, a signature (recomputed on every attempt with a fresh nonce and timestamp, prima_key_sig.py 45 to 48), a Prima response body.
API surface
| Route | Auth dep | Tenancy | Codes | Notes |
|---|---|---|---|---|
POST /internal/keys/sync (Prima, keys_sync.py 338 to 543, mounted unconditionally at prima/app.py 362, off the gateway prefix: INTERNAL_PREFIX = "/internal/keys", 181) | LabsOnly = Depends(require_labs_signature) (209 to 283): X-Prima-Timestamp, X-Prima-Nonce, X-Prima-Signature over the raw body; never CurrentIdentity | tenant_id from the body, inserted verbatim | 200 {applied, superseded, key_present, is_active, sync_seq, subject_id, tenant_id}; 503 key_sync_not_configured when the credential is unset; 401 for missing headers, a malformed or out-of-window timestamp (300 s, internal_sig.MAX_SKEW_SECONDS, 75), or a bad signature; 400 unknown_issuer, unrepresentable_scopes, not_a_gateway_key, subject_suspended; 400 invalid_request with detail Request body failed validation. on the Pydantic bounds (Prima maps every RequestValidationError to 400 and never echoes the input, prima/app.py 110 to 128) | off the gateway prefix the wire body is S7's Shape E, {"detail": <message>, "request_id": <hex>} (prima/app.py 72 to 74); the code names above, and the param naming the failed field (126), are on the PrimaError and in the request_failed log, and neither reaches the caller; the tests assert on detail |
The caller's contract, deliver (prima_key_sync.py 360 to 448): 200 is success whether applied is true or superseded is true (409 to 425); any 4xx other than 408 and 429 is stuck at once with last_error = 'HTTP <n>' (427 to 441); 408, 429 and every 5xx schedule a retry (443 to 448); a transport exception records its class name and schedules a retry (400 to 407). Signature headers are built by signed_headers (prima_key_sig.py 77 to 96): X-Prima-Timestamp (unix seconds), X-Prima-Nonce (uuid4().hex, 32 characters), X-Prima-Signature (v1= plus HMAC-SHA256 hex over ts\nnonce\nPOST\n/internal/keys/sync\nsha256hex(body)), Content-Type: application/json.
There is no Labs route for this outbox: no admin endpoint lists, resets or drains it, and reconcile_existing_keys and stuck_syncs have no caller outside the module and its tests (grep 2026-09-12 at fd8558f). The admin console the inventory names for it (F30) is planned.
Application logic
Enqueue, inside the caller's transaction, never committing (prima_key_sync.py 134 to 319):
enqueue_mint(db, api_key)fromcreate_api_key(developer_router.py173) and fromcreate_api_only_account(identity.py358, B14):is_gateway_key(116 to 126) is"ai" in scopes; a key withoutaireturnsNoneand writes nothing. A gateway key withworkspace_idNoneraisesValueErrorand fails the mint (155 to 162). A wildcard among the scopes is logged at ERROR and enqueued anyway so Prima's 400 parks it asstuck(164 to 177).db.flush()whenapi_key.idis stillNone(179 to 189, the first live mint failed here). Insert the row withsync_seq = 1,status = 'pending',attempts = 0,next_attempt_at = now, under a savepoint; anIntegrityErroron the UNIQUE rolls back only the savepoint and returns the existing row (211 to 232).enqueue_revocation(db, api_key)fromrevoke_api_key(developer_router.py310): lock the key's rowFOR UPDATE(blocking, 251 to 255); with a row, setdesired_active = False,sync_seq += 1, and resetstatus,attempts,last_error,next_attempt_atas one unit (307 to 315); with no row and a gateway key (pre-seam or backfilled), insert adesired_active = Falserow atsync_seq = 1under a savepoint (257 to 304); with no row and noai, returnNone.- The caller commits once; the key row and the outbox row are one atomic unit.
Deliver, after the commit:
- Inline,
deliver_for_key(db, api_key_id)(519 to 547): select the key'spendingrowFOR UPDATE SKIP LOCKED,deliver, commit; any exception becomesUnexpectedInlineErroron the row and never fails the completed mint. Called under atryby both routes (developer_router.py185 to 191, 316 to 323) and by the API-only signup (identity.py368). - Scheduled,
run_prima_key_sync_drain(scheduled_jobs.py631 to 665) every minute (IntervalTrigger(minutes=1),coalesce=True,max_instances=1, 322 to 329) callsdrain_once(db, limit=50)(474 to 516): rowspendingwithnext_attempt_atnull or past, oldest first,FOR UPDATE SKIP LOCKED, onedeliverand one commit per row so one bad row cannot take its neighbours back topending. deliver(db, row)(360 to 448): unconfigured is_fail(row, "SyncNotConfigured")before the attempt counter moves (372 to 384, Gotchas); otherwise serialise_payload(row)compactly, sign,attempts += 1,httpx.postwith the timeout; then the status table above._fail(451 to 466) setslast_error, and eitherstuckwithnext_attempt_at = NULLwhenattempts >= PRIMA_KEY_SYNC_MAX_ATTEMPTS, orpendingwithnext_attempt_at = now + _BACKOFF_SECONDS[min(attempts - 1, 4)]over(5, 15, 60, 180, 600)(96 to 107): 24 attempts span about 3.2 hours beforestuck(computed 2026-09-12).
Receive, sync_key on Prima (keys_sync.py 338 to 543), one transaction:
require_labs_signature: credential unset is 503; the three headers must be present;abs(now - timestamp) <= 300;internal_sig.verifyrecomputesv1=<hmac>over the raw awaited body and compares withhmac.compare_digestover the whole header value (internal_sig.py94 to 113), so av2=prefix fails rather than being read as v1.issuermust equalPRIMA_JWT_ISSUERwhen that is set (353 to 359): a signature proves who sent the message, not that it may name any issuer.- Normalise scopes (sorted, lowercased); refuse an intersection with
WILDCARD_SCOPESwith 400unrepresentable_scopes, which in practice catchesadminandfull(367 to 381); refuse a list withoutaiwith 400not_a_gateway_key(382 to 396). Both logkey_sync_refusedat ERROR with the tenant id and the reason.*never reaches this step: it is not aTOKEN_PATTERNcharacter, so thescopesfield validator (327 to 335) refuses it first and theRequestValidationErrorhandler answers 400invalid_requestwithdetailRequest body failed validation.and nokey_sync_refusedevent. Both refusals are 400s, and a 400 is terminal for the outbox (step 6), which is the property that matters; which layer caught it is visible only in the wording. - Insert the subject
(gen_random_uuid(), issuer, external_sub, is_native=false, email=NULL, status='active') ON CONFLICT (issuer, external_sub) DO NOTHING, then read itsidandstatus(402 to 415). - FIND-011:
is_activetrue for a subject whose status is notactiveis 400subject_suspended(432 to 447); a deactivation always lands. - Insert the tenant
(tenant_id, issuer, is_internal=false) ON CONFLICT (id) DO NOTHING(454 to 460): the endpoint can create a tenant and can never renumber one. - Upsert the key:
INSERT ... ON CONFLICT (key_hash) DO UPDATE SET subject_id, tenant_id, key_prefix, scopes, is_active, expires_at, sync_seq = EXCLUDED.* WHERE prima_api_keys.sync_seq < EXCLUDED.sync_seq RETURNING id, sync_seq(475 to 503);applied = row is not None; commit; re-read the current row; logkey_syncwith ids andapplied; answer 200 withapplied,superseded = not applied, and the currentis_active,sync_seq,subject_id,tenant_id(506 to 543).subject_idis in theSETso the first genuine message re-points a backfilled key from its placeholder subject onto the real one (469 to 474).
Invariants
- The obligation is as durable as the key:
enqueue_*never commits and never catches anything but the UNIQUE collision, and the callers write the outbox row before their single commit (developer_router.py153 to 176;prima_key_sync.py139 to 148). - One row per key, enforced by
uq_prima_key_syncs_api_key, carrying whole desired state includingis_active; there is no second row for a revocation (models/prima_key_sync.py35 to 55;test_the_payload_carries_state_not_an_event). sync_seqis a counter, bumped on every state change, and Prima applies onlyWHERE sync_seq < EXCLUDED.sync_seq; a replay isapplied: false, superseded: trueand a 200 (keys_sync.py490;test_a_superseded_answer_is_a_success; live:test_the_same_message_twice_is_idempotent,test_a_revoked_key_is_never_resurrected_by_a_stale_retry).- Only
ai-bearing keys are enqueued (is_gateway_key;test_only_ai_scoped_keys_are_gateway_keys), and Prima refuses any other with 400 (test_a_key_without_the_ai_scope_is_refused), so astuckrow is always a customer's gateway key and never a Labs read key. - A 4xx is terminal for the worker and a 5xx is not (
deliver427 to 448;test_a_4xx_goes_straight_to_stuck_rather_than_retrying_forever,test_a_transient_status_is_retriedover 408, 429, 500, 502, 503, 504); nothing ever deletes a row (test_exhausting_attempts_parks_the_row_as_stuck_and_never_deletes_it). - Every attempt is signed afresh; no signature is stored (
test_every_attempt_is_signed_afresh); the canonical string is identical on both sides and pinned by a known-answer vector (Tests). - No key material and no PII cross:
key_hashis checked as 64 hex on both sides (the Labs CHECK, Prima's_HASH_PATTERN190),last_erroris a class name or a status (test_last_error_is_a_class_or_a_status_never_a_response_body), and the request model has no field for a name or an email (test_the_plaintext_key_has_no_field_to_travel_in). - The seam is not on the gateway prefix and a customer's key cannot reach it (
test_the_seam_is_not_on_the_gateway_prefix,test_a_customers_gateway_key_is_not_a_way_in); the key path still cannot import Labs after the seam was added (test_the_key_path_still_cannot_reach_labs). tenant_idisworkspaces.idverbatim, copied onto the outbox row at enqueue and insertedDO NOTHING; the seam never renumbers a tenant (live:test_the_tenant_id_is_never_renumbered).- A suspended subject's replacement key is refused and its deactivation is not (live:
test_a_replacement_key_is_refused_for_a_suspended_subject,test_a_deactivation_still_applies_even_for_a_suspended_subject).
Frontend
The frontend modules that render this layer, by id (the same set as frontend_modules above, 2026-09-12):
identity-auth/ui/api-keys-settings-and-developer-dashboard interim: the mint and revoke buttons whose outcome this
outbox propagates; the card shows nothing of the sync
(no status, no "works on Prima yet"), and a stuck row is
invisible to the developer who holds the key
billing-credits/ui/billing-outbox-admin-console (planned) the inventory's frontend for this row (B12 names only
F30): one table per outbox with status, attempts,
next_attempt_at, last_error, created_at, the key prefix
and the tenant; a filter that is exactly stuck_syncs();
a reset action that is exactly the statement under
Deploy & ops; read-only for non-admins (S11 Frontend);
not a file at 58f13a0, so not in the frontmatter yet
A symptom site, not a renderer, and so not in frontend_modules:
identity-auth/ui/developer-portal-keys-page the portal mints an ai key at sign-in and then calls
GET /api/prima/v1/usage on it; a 401 there within the
first seconds is this outbox's inline delivery having
failed and the drain not yet run
Minimum admin and debug surface until F30 exists: the stranded-key query from the module docstring (prima_key_sync.py 25 to 34: SELECT s.id, s.api_key_id, s.tenant_id, s.key_prefix, s.desired_active, s.status, s.attempts, s.last_error, s.created_at, s.updated_at, u.email FROM prima_key_syncs s JOIN api_keys k ON k.id = s.api_key_id JOIN users u ON u.id = k.user_id WHERE s.status <> 'synced' ORDER BY s.created_at), stuck_syncs(db, older_than_minutes=5) from a shell (589 to 617), the Labs log lines prima key sync: queued mint ..., ... delivered (seq=.. active=.. applied=..), ... REFUSED by Prima with <n>, ... is STUCK after <n> attempts, SCHEDULER: Prima key sync drain with the due and delivered counts (the source line carries a dash the house rule forbids here), and on Prima the events key_sync (ok), key_sync_refused (ERROR, with reason), key_sync_signature_rejected (ERROR), key_sync_not_configured (ERROR).
Security & tenancy
Auth dependency: on Prima, require_labs_signature, a separate function reading a separate credential from the credit seam's near-twin on purpose (keys_sync.py 220 to 225); on Labs, none (the outbox has no route). Workspace derivation: the outbox row's tenant_id, copied from api_keys.workspace_id at enqueue so a later change in Labs' workspace resolution cannot move a queued key (models/prima_key_sync.py 57 to 64). Fail direction: closed on Prima (unset credential is 503, bad signature 401, unrepresentable key 400, suspended subject 400); on Labs a delivery failure is a visible pending or stuck row, never an exception in the mint, and an enqueue failure is a failed mint, never a silent key.
Blast radius of the credential: PRIMA_KEY_SYNC_SECRET lets its holder write a working ai key for any tenant id it names, which is impersonation of any customer on the gateway (keys_sync.py 34 to 54). That is why it is neither the read-seam value Prima holds nor the credit-grant value, why Prima refuses to boot when two of the three are equal, and why Labs at fd8558f, which has no such boot check (S10 Gotchas), is the side to be careful with in the heredoc. The issuer check (step 8) is the one field a compromised or buggy caller could otherwise use to file a subject where the revocation feed would never find it.
Secrets at rest: the credential in each service's environment; key_hash on the row and on the wire; nothing else. Rate limits: none on the endpoint; the caller is one outbox worker with a 10 s timeout and a 50-row drain.
Attacker-controlled inputs, on the assumption that the endpoint is reachable: the three headers (timestamp parsed as int, skew bounded, nonce bounded by nothing but the canonical string, signature compared constant-time over the whole value); the body (Pydantic bounds on every field; tenant_id is a UUID; scopes are tokens of at most 64 characters; key_hash is 64 hex, so no string that could be a key passes); replay of a captured request within 300 s, which is a no-op at the database because the same sync_seq fails the predicate (internal_sig.py 37 to 50 gives the same argument for the money seam). On the estate the endpoint is bound to a loopback address and the public ingress does not route /internal/* (core/config.py 752).
Tenancy of the message: one key, one tenant, one subject; the subject is (issuer, external_sub) and is never a copy of the Labs user (no email by CHECK); the tenant is the workspace id and a fresh tenant row is created on first sight. Prima's tenant cannot be re-keyed by this seam, and the metering chain depends on that (keys_sync.py 151 to 157).
Tests
Labs (alchemy_labs/backend/tests/prima_keys/test_prima_key_sync.py, run 2026-09-12 at fd8558f with python -m pytest ... -q --no-cov -o addopts="" -p no:cacheprovider): 26 passed in 0.63s, no database. The wire contract: test_the_signature_matches_the_prima_vector (40), test_every_attempt_is_signed_afresh (73), test_the_payload_carries_a_hash_and_never_a_key (107), test_the_payload_carries_state_not_an_event (121); the retry policy: test_backoff_is_front_loaded_and_clamped (137), test_a_retryable_failure_schedules_another_attempt (144), test_exhausting_attempts_parks_the_row_as_stuck_and_never_deletes_it (155), test_last_error_is_a_class_or_a_status_never_a_response_body (173); the delivery outcomes: test_a_200_marks_the_row_synced (233), test_a_superseded_answer_is_a_success (252), test_a_4xx_goes_straight_to_stuck_rather_than_retrying_forever (261), test_a_transient_status_is_retried (275, parametrised), test_a_transport_failure_leaves_a_pending_row_not_an_exception (283), test_unconfigured_stalls_visibly_rather_than_raising (298); the gateway rule: test_only_ai_scoped_keys_are_gateway_keys (332, six cases), test_the_wildcard_set_matches_primas (340).
Prima (prima/tests/, run 2026-09-12 at cd2c248 with .venv/bin/python -m pytest <file> -q -o addopts=""): tests/auth/test_key_sync_seam.py 20 passed in 0.33s, hermetic through the HTTP layer with the credential set on a test settings object: test_unset_secret_refuses_everyone_with_503_not_404 (94), test_an_unsigned_request_is_refused (109), test_a_signature_from_the_wrong_secret_is_refused (115), test_a_signature_over_a_different_body_is_refused (127), test_a_stale_timestamp_is_refused (149), test_a_customers_gateway_key_is_not_a_way_in (156), test_a_wildcard_scope_is_refused_rather_than_narrowed (177, parametrised over *, admin, full), test_a_key_without_the_ai_scope_is_refused (197), test_an_unknown_issuer_is_refused (206), test_a_key_hash_that_is_not_a_sha256_is_refused (217, parametrised), test_a_zero_sync_seq_is_refused (225), test_the_plaintext_key_has_no_field_to_travel_in (233), test_the_canonical_string_matches_the_labs_vector (251), test_the_seam_is_not_on_the_gateway_prefix (285), test_the_key_path_still_cannot_reach_labs (293). tests/test_key_sync_live.py is opt-in on PRIMA_TEST_DATABASE_URL and 8 skipped without it: test_a_mint_creates_subject_tenant_and_key (166), test_the_same_message_twice_is_idempotent (182), test_revocation_propagates (203), test_a_revoked_key_is_never_resurrected_by_a_stale_retry (214), test_a_backfilled_subject_is_healed_rather_than_duplicated (237), test_a_replacement_key_is_refused_for_a_suspended_subject (308), test_a_deactivation_still_applies_even_for_a_suspended_subject (352), test_the_tenant_id_is_never_renumbered (369). Those eight are the ones that prove properties of the ON CONFLICT ... WHERE predicate rather than of an if, and they are the ones a rebuilt seam cannot skip.
Known-answer vector, asserted on both sides (test_prima_key_sync.py 40 to 70; test_key_sync_seam.py 251 to 282; recomputed 2026-09-12): timestamp 1785500000, nonce abc123, method POST, path /internal/keys/sync, body {"hello":"world"}, canonical string 1785500000\nabc123\nPOST\n/internal/keys/sync\n followed by the SHA-256 hex of the body, HMAC key shared-secret, signature v1=2ede58bc4616041070f1df52184ef19317ddefce428af811b23994c00a42ad6b. A rebuilt signer or verifier that prints anything else is a wire break.
Hermetic versus live: every test above is hermetic except the eight opt-in live cases; no test reaches a deployed host. ADR-0026 records the live proof on 2026-08-04 (mint, use, revoke, 401). Playwright gate: none; the seam has no browser.
Deploy & ops
Config keys per app heredoc (S13): Labs PRIMA_KEY_SYNC_ISSUER, PRIMA_KEY_SYNC_SECRET, PRIMA_INTERNAL_BASE_URL, optionally PRIMA_KEY_SYNC_TIMEOUT_SECONDS and PRIMA_KEY_SYNC_MAX_ATTEMPTS; Prima PRIMA_KEY_SYNC_SECRET (the same value as Labs' and different from Prima's other two), PRIMA_JWT_ISSUER (equal to Labs' issuer). Both sides read the values at boot.
Migrations order: Labs 20260804_0001_prima_key_syncs.py after prima_magic_link_tpl_001; Prima 20260804_0001_key_sync_seq.py after prima_auth_001. Prima first, then Labs, then the Labs config: a Labs mint with ai inserts into prima_key_syncs inside the mint transaction, so Labs must have the table before any ai mint, and Prima must have sync_seq before the first delivery or every message is a 500 that retries. The downgrade drops the outbox with every undelivered obligation; do not run it on a database that has minted.
Scheduler jobs (S12): prima_key_sync_drain, every minute on the leader. The day the seam ships, once: reconcile_existing_keys(db) from a shell (550 to 586), which queues a row for every ai key that has none; deliberately not scheduled, so a later non-zero count is a fact a human notices.
Argus checks: none exist at the measured commits (S10 Deploy & ops names the four log events a watcher would key on). The minimum: SELECT count(*) FROM prima_key_syncs WHERE status = 'stuck' OR (status = 'pending' AND created_at < now() - interval '5 minutes') is 0, and Prima's key_sync_not_configured and key_sync_signature_rejected events are absent from the last hour.
Reset and rollback: a stranded row is replayed by UPDATE prima_key_syncs SET status = 'pending', attempts = 0, next_attempt_at = now(), last_error = NULL WHERE id = :id (or WHERE status = 'stuck' AND last_error = 'HTTP 401' after a credential rotation); the next drain sends it and Prima's predicate makes a duplicate harmless. A credential rotation with the drain running parks every in-flight row as stuck with HTTP 401, so rotate both apps in one window and then reset. Rolling back the Labs code to before 24f16ef removes the enqueue; keys minted in that window never reach Prima and reconcile_existing_keys is what catches them up afterwards. Rolling back Prima to before prima_key_sync_001 makes every delivery a 500 that retries until the column returns.
Gotchas
- 2026-09-12 (measured at fd8558f, D8, fix needed; S11's step 7 describes the branch without this consequence): the unconfigured branch of
deliverruns beforerow.attempts += 1(374 to 384 precede 392), so_failseesattemptsunchanged, never reachesPRIMA_KEY_SYNC_MAX_ATTEMPTS, and reschedules with_backoff(1)= 5 s. On a host withPRIMA_KEY_SYNC_SECRETorPRIMA_INTERNAL_BASE_URLunset, every row retries on every drain forever and none parks asstuck, sostuck_syncs()still finds them only through its five-minutependingclause. The fix is to count the unconfigured attempt. - 2026-09-12 (measured at fd8558f):
reconcile_existing_keystakes the first 500api_keysrows with a workspace, in no defined order, and only then skips the ones with a row or withoutai(569 to 581). A table with more than 500 such rows can hide pre-seam gateway keys past the slice on every run. A rebuilt reconcile filters on the missing outbox row in SQL, or orders and pages. - 2026-09-12 (measured at fd8558f, D8, fix needed):
PRIMA_KEY_SYNC_ISSUER's description atcore/config.py712 promises "Unset = every mint raises rather than queuing a key that can only ever be stuck";_issuer(prima_key_sync.py329 to 331) falls back toJWT_ISSUER, which is astrwith the defaulthttps://labs.alchemy.zuerich(113 to 116), so the raise at 332 to 337 fires only whenJWT_ISSUERis set to an empty string. On a deployment whose Prima federates a different issuer, an unsetPRIMA_KEY_SYNC_ISSUERproduces the outcome the description says it prevents: every mint succeeds, every row is refused with 400unknown_issuerand parks asstuckwithHTTP 400. The fix is either to drop theJWT_ISSUERfallback or to giveJWT_ISSUERno default; until then the heredoc setsPRIMA_KEY_SYNC_ISSUERexplicitly on every host. - 2026-09-12 (measured at fd8558f):
stuck_syncsandreconcile_existing_keyshave no caller outside the module and its tests, and the drain job logs counts, not ids. Until F30 exists, finding a stranded key is a shell or a psql prompt, and the inventory rule "no headless outbox" is unmet here on the shipping line. - 2026-09-12 (measured at fd8558f):
enqueue_mintenqueues a wildcard-bearingaikey on purpose and lets Prima's 400 park it (164 to 177); the 4xx path then writeslast_error = 'HTTP 400'without thecodePrima raised (unrepresentable_scopes,not_a_gateway_key,unknown_issuer,subject_suspended), because a response body is never copied onto the row. The reason is on Prima's side, in thekey_sync_refusedevent'sreasonfield. No route at fd8558f can mint such a key; the branch is for hand-written rows. - 2026-09-12 (measured at fd8558f): revocation of a key that was never a gateway key returns
Noneand writes nothing (305), and revocation of a gateway key withworkspace_idNULLraises (269 to 272), which fails theDELETEwith a 500 after the soft delete is rolled back. Every route-mintedaikey has a workspace, so the branch is reachable only through a hand edit. - 2026-09-12 (measured at cd2c248):
tests/auth/test_key_sync_seam.pyline 253 names its Labs twin astests/prima_keys/test_prima_key_sig.py; the Labs file istests/prima_keys/test_prima_key_sync.py. The vector agrees; the path in the docstring is stale (S10 Gotchas carries the same). - 2026-09-12 (measured at cd2c248): the 400s and the 503 leave
codeoff the wire because the internal routes are off the gateway prefix (Shape E,prima/app.py72 to 74). Labs seesHTTP 400and a human sees the reason only in Prima's log. A rebuilt receiver that wants the code on the wire renders it in Shape E'sdetailor adds acodekey; the outbox must then still write only the status. - 2026-09-12 (measured at fd8558f and cd2c248): the backoff table is
(5, 15, 60, 180, 600)here and(5, 15, 60, 300, 900, 3600)on the credit and Sparks outboxes (S11 Gotchas); with 24 attempts this outbox gives up after about 3.2 hours, the others after about 18.4 hours. Deliberate: the thing waiting here is a customer who cannot make a first call. - 2026-08-04 (measured,
alchemy_labs24f16ef; S11 carries it): the first live mint failed insideenqueue_mintwith aNULLapi_key_idbecauseAPIKey.idis a Python-side default applied at flush; thedb.flush()at 179 to 189 is the fix and the comment is the record. - 2026-08-04 (ADR-0026): the founder's key was backfilled by hand before the seam existed and its subject row carried a placeholder
external_sub; thesubject_idin the upsert'sSETandsync_seq = 0on the backfilled row are what let the first real message heal it (keys_sync.py469 to 474;20260804_0001_key_sync_seq.py30 to 38). The live testtest_a_backfilled_subject_is_healed_rather_than_duplicatedis the regression guard.
Reference implementation
Paths are relative to each repo's root; commits as in measured_on and also_measured_on, read 2026-09-12.
alchemy_labs(d1a-token-source, fd8558f):backend/services/primaapi/prima_key_sync.py: the docstring with the stranded-key SQL (1 to 64),SYNC_PATH,AI_SCOPE,WILDCARD_SCOPES(84 to 94),_BACKOFF_SECONDSand_backoff(102 to 107),_scopes_of(110 to 113),is_gateway_key(116 to 126),enqueue_mint(134 to 237),enqueue_revocation(240 to 319),_issuer(322 to 338),_payload(346 to 357),deliver(360 to 448),_fail(451 to 466),drain_once(474 to 516),deliver_for_key(519 to 547),reconcile_existing_keys(550 to 586),stuck_syncs(589 to 617).backend/services/primaapi/prima_key_sig.py: the scheme and the credential rule (18 to 48),canonical_string(63 to 66),sign(69 to 74),signed_headers(77 to 96).backend/models/prima_key_sync.py: the design docstring (1 to 65),SYNC_STATUSES(82),PrimaKeySync(85 to 175).backend/migrations/versions/20260804_0001_prima_key_syncs.py:revision,down_revision(55 to 56), the table (62 to 111), the downgrade (114 to 120).backend/services/scheduled_jobs.py: the registration (315 to 329),run_prima_key_sync_drain(631 to 665).backend/core/config.py:PRIMA_KEY_SYNC_ISSUER(712),PRIMA_KEY_SYNC_SECRET(719),PRIMA_KEY_SYNC_TIMEOUT_SECONDS(720),PRIMA_KEY_SYNC_MAX_ATTEMPTS(721),PRIMA_INTERNAL_BASE_URL(752).backend/api/routers/developer_router.py: the enqueue and inline delivery call sites (153 to 191, 299 to 323);backend/services/primaapi/identity.py: the API-only signup's call sites (337 to 368).backend/tests/prima_keys/test_prima_key_sync.py.
prima(feature/per-model-pricing, cd2c248):prima/api/keys_sync.py: the argument for the second direction and the credential (1 to 158),INTERNAL_PREFIX(181),_HASH_PATTERN(190),require_labs_signature(209 to 283),KeySyncRequest(291 to 335),sync_key(338 to 543).prima/internal_sig.py:MAX_SKEW_SECONDS(75),canonical_string(78 to 81),sign(84 to 91),verify(94 to 113).prima/app.py: the router include (362), Shape E (72 to 74).prima/config.py:key_sync_secret(903), thePRIMA_KEY_SYNC_SECRETmap entry (1088), the pairwise refusal (959 to 989).prima/auth/models.py:PrimaApiKey.sync_seq(223 to 232);migrations/versions/20260804_0001_key_sync_seq.py(55 to 74).prima/content_free.py:TOKEN_PATTERN(126).tests/auth/test_key_sync_seam.py,tests/test_key_sync_live.py.
- Wiki
philosophers_stone(6322fa3):operations/decisions/0026-labs-to-prima-key-propagation.md(the decision, the live proof),platform/primaapi.md("Self-serve signup mints a key Prima cannot see"),askedquestions/2026-08-21-prima-user-provisioning-and-spark-grants.md,operations/prima-modules-inventory.mdrows B12, F30, S10, S11.
Deviations of the reference from this recipe, all dated 2026-09-12: the unconfigured branch never reaches stuck; the issuer fallback that makes the config description false; the reconcile slice of 500 in no order; no route, job or page reads stuck_syncs; the refusal code stays on Prima's side of the wire; the stale twin-test path in Prima's docstring; no Labs boot check that the three seam credentials differ (S10).
Acceptance checks
Run from alchemy_labs/backend at fd8558f and from prima at cd2c248, or from a new pair built to this recipe. $PRIMA is a running Prima with the credential set, $SECRET_VALUE the shared value (never echoed). Expected observations are exact.
- Labs:
python -m pytest tests/prima_keys/test_prima_key_sync.py -q --no-cov -o addopts="" -p no:cacheproviderprints26 passed. Prima:python -m pytest tests/auth/test_key_sync_seam.py -q -o addopts=""prints20 passed;python -m pytest tests/test_key_sync_live.py -q -o addopts=""prints8 skippedwithout a database and8 passedwithPRIMA_TEST_DATABASE_URLset to a scratch database. - Anywhere with Python 3:
python3 -c "import hashlib,hmac; b=b'{\"hello\":\"world\"}'; base='1785500000\nabc123\nPOST\n/internal/keys/sync\n'+hashlib.sha256(b).hexdigest(); print('v1='+hmac.new(b'shared-secret',base.encode(),hashlib.sha256).hexdigest())"printsv1=2ede58bc4616041070f1df52184ef19317ddefce428af811b23994c00a42ad6b. - Labs:
grep -n "prima_key_sync_drain" services/scheduled_jobs.py | grep -c "id="prints1;grep -c "with_for_update(skip_locked=True)" services/primaapi/prima_key_sync.pyprints2andgrep -c "with_for_update()" services/primaapi/prima_key_sync.pyprints1(the blocking lock inenqueue_revocation). - Labs, with the
:5434Postgres:python -c "from services.primaapi.prima_key_sync import _BACKOFF_SECONDS, _backoff; print(_BACKOFF_SECONDS, [_backoff(n).total_seconds() for n in (0,1,2,5,6,99)])"prints(5, 15, 60, 180, 600) [5.0, 5.0, 15.0, 600.0, 600.0, 600.0]. - Labs, end to end with the app running and B10's checks 2 and 4 done (an
aikey minted from a session):psql -c "SELECT status, attempts, sync_seq, desired_active, last_error FROM prima_key_syncs ORDER BY created_at DESC LIMIT 1"printssynced | 1 | 1 | t |when Prima is reachable, orpending | 1 | 1 | t | ConnectError(or another class name) when it is not; never a row with a plaintext key in any column, andSELECT count(*) FROM prima_key_syncs WHERE key_hash !~ '^[0-9a-f]{64}$'prints0. - Labs: revoke that key through
DELETE /api/developer/keys/{id}; the sameSELECTnow printssync_seq2,desired_activef, andstatussyncedonce delivered; on Primapsql -c "SELECT is_active, sync_seq FROM prima_api_keys WHERE key_hash = :h"printsf | 2. - Prima, a replay: re-send the seq-1 message by hand (sign the payload from check 5 with the shared value and
POST $PRIMA/internal/keys/sync): the response is 200 with"applied":false,"superseded":true,"is_active":false,"sync_seq":2; the key stays revoked. - Prima:
curl -s -i -X POST -H 'Content-Type: application/json' -d '{}' $PRIMA/internal/keys/syncshows401and"detail":"Missing internal signature headers."; with a timestamp 400 s old and an otherwise valid signature,401and"detail":"Internal signature timestamp is outside the window."; withPRIMA_KEY_SYNC_SECRETunset on a scratch instance,503and adetailcontainingnot configured; with a validprima_pk_key inX-API-Keyand no signature,401. - Prima: a signed message whose
scopesis["read"]answers 400 withdetailstartingA gateway key must carry the 'ai' scope;["ai", "admin"](or["ai", "full"]) answers 400 withdetailstartingThis key carries a scope this service does not define;["ai", "*"]answers 400 withdetailRequest body failed validation., because*failsTOKEN_PATTERNin the field validator before the wildcard branch runs (test_a_wildcard_scope_is_refused_rather_than_narrowedaccepts either wording for that reason); anissuerother thanPRIMA_JWT_ISSUERanswers 400 withdetailThis deployment does not federate that issuer.;sync_seq: 0answers 400 withdetailRequest body failed validation.(test_a_zero_sync_seq_is_refusedasserts the 400). - Labs:
grep -rn "reconcile_existing_keys\|stuck_syncs" --include='*.py' . | grep -v "tests/\|services/primaapi/prima_key_sync.py"prints exactly one line at fd8558f, the docstring mention atservices/scheduled_jobs.py:651, and no call; on a build with F30 or an admin route it prints a call site as well.
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.