HMAC seam pattern
_shared/hmac-seam-pattern
| Kind | shared |
|---|---|
| Applies to | backend |
| State | written |
| Maturity | prod |
| Reusable | yes |
| Stack | python-3.12, stdlib-hmac-sha256, fastapi, pydantic-2, httpx-0.28 |
| Depends on | _shared/fastapi-project-layout, _shared/error-envelope, _shared/test-layout |
| Reference implementation |
|
| Measured on | prima feature/per-model-pricing cd2c248 2026-09-11 |
| Also measured on |
|
| Version | 1 |
Purpose
Two services that share no code, no database and no deploy can act on a request from each other, because every call between them carries an HMAC-SHA256 signature over one fixed canonical string and the receiver recomputes it from the raw bytes it was sent. The invariant that makes it trustworthy is that each direction and each capability has its own credential, the verifier fails closed on anything missing or malformed, and replay is neutralised by the receiver's own database rather than by the signature, so a captured request can be re-sent a thousand times and change the world once.
This foundation fixes the canonical string, the three headers, the skew window, the credential-per-direction rule and the boot check, so every domain recipe that says "signed with the internal scheme" means exactly one thing. The seams it was measured on are the money-in seam (Labs grants credit into Prima's ledger, decision D2), the key-sync seam (a key minted in Labs becomes valid on Prima, ADR-0026) and the balance-alert seam (Prima tells Labs a tenant crossed a threshold, ADR-0036). Prima is the reference for the gateway side (decision D3); the in-Labs services/primaapi copy of the gateway is legacy, and its prima_key_sig.py is cited here only as the signer of the key-sync seam.
When to use
Include it from any backend recipe where one of our own services calls another over HTTP with no user in the loop. Trigger phrases: "Labs has to tell Prima", "an internal endpoint", "service-to-service", "how does Prima know this came from Labs", "which credential does this seam use", "can I reuse the read-seam credential for this", "add a nonce cache", "the outbox posts to".
Use the neighbouring recipe instead when: the caller is a person or a customer's SDK (a session, a prima_pk_ key: _shared/auth-dependency-pattern); the call has to survive the other side being down (that is the durable half, _shared/outbox-pattern, which uses this recipe for the delivery step); the signature is a third party's own scheme arriving at a webhook (the Payrexx settlement webhook in commerce-payments, the calendar webhook in analytics-tracking) where the provider's verification rules apply, not this one; or the request comes from a browser, where a signature in the page is a credential in the page.
Stack assumptions
Standard library only for the scheme: hmac, hashlib, time, uuid. No signing library on either side. Python 3.12 or newer (prima/pyproject.toml at cd2c248, requires-python = ">=3.12"). Pinned around it, measured 2026-09-11: Prima fastapi==0.115.6, sqlalchemy==2.0.36, httpx==0.28.1 (requirements.txt at cd2c248; no pydantic line of its own, the seam models run on the pydantic that fastapi pulls in, resolved to 2.13.4 in the checkout at cd2c248); Labs fastapi==0.118.2, SQLAlchemy==2.0.43, httpx==0.28.1, pydantic==2.12.0 (backend/requirements.txt at fd8558f).
Foundations assumed: _shared/fastapi-project-layout (S4) for where a router mounts and the rule that the internal prefix is not the gateway prefix; _shared/error-envelope (S7) for the 401 and 503 shapes each app returns; _shared/outbox-pattern (S11) is the caller and depends on this recipe, not the reverse (which is why it is absent from depends_on); it re-signs on every attempt; _shared/test-layout (S9) for where the known-answer test lives.
Environment names. The same variable name exists on both sides of a seam and carries one value; the signer reads it to sign, the verifier reads it to verify.
| Direction | Endpoint | Variable (same name both sides) | Signer module | Verifier module | Skew | State at the measured commits |
|---|---|---|---|---|---|---|
| Labs to Prima | POST /internal/credits/grant, POST /internal/credits/reverse | PRIMA_CREDIT_GRANT_SECRET | alchemy_labs/backend/services/commerce/prima_credit_sig.py | prima/prima/api/internal.py with prima/prima/internal_sig.py | 300 s | built on both sides, deployed |
| Labs to Prima | POST /internal/keys/sync | PRIMA_KEY_SYNC_SECRET | alchemy_labs/backend/services/primaapi/prima_key_sig.py | prima/prima/api/keys_sync.py with internal_sig.py | 300 s | built on both sides, deployed (ADR-0026, 2026-08-04) |
| Prima to Labs | POST /internal/sparks/low-balance-alert | PRIMA_LOW_BALANCE_ALERT_SECRET | prima/prima/api/labs_events.py (branch w2-balance-threshold-event, unmerged) | alchemy_labs/backend/services/commerce/prima_inbound_sig.py and api/routers/prima_inbound_router.py | 300 s | verifier deployed and never called; signer unmerged |
| Prima to Labs (reads) | the four reads of prima/seams/labs_internal.py | PRIMA_LABS_INTERNAL_SECRET | not built | not built | design says 60 s | a Protocol and a docstring; build_labs_internal() raises NotImplementedError |
Fail behaviour of an unset variable: on the verifier, every request is refused with 503; Prima raises it with a distinct code (grant_not_configured, key_sync_not_configured) that reaches the request_failed log event and not the wire, because the internal routes are off the gateway prefix and answer S7's Shape E, {"detail": <message>, "request_id": <hex>} (prima/app.py lines 56 to 72); the Labs receiver answers 503 in S7's Shape B, {"error": "Low-balance alert seam not configured", "code": "SRV_001", "request_id", "timestamp", "details": null}, because prima_inbound_router.py raises fastapi.HTTPException (line 25 import, lines 65 to 67) and http_exception_handler (app_fastapi.py lines 724 to 788) reshapes every one off the gateway prefix, with SRV_001 for a 503 since error_code_map has no entry for it (S7 Gotchas, 2026-09-11); there is no fallback to another credential. On the signer, nothing is sent and the outbox row stays pending (S11). Prima additionally refuses to parse a value shorter than 16 characters (prima/config.py lines 414 to 419) and refuses to boot when any two of its seam credentials are equal (lines 959 to 990: three pairs at cd2c248, six pairs at 82ba0d1). Labs has no equivalent boot check at fd8558f (Gotchas).
Data model
The scheme owns no table. What it stores: the credential, in the process environment only, typed SecretStr on Labs (core/config.py lines 719, 740, 765) and wrapped on Prima in the config.py type whose .reveal() is the only way to read the value, called at verification time and nowhere else. What it deliberately does not store: the signature (never a column on the outbox row, because every attempt re-signs with a fresh timestamp and nonce and a stored signature is dead after the skew window); the nonce (no nonce table and no nonce cache on any built verifier); the response body of the other side (S11 records a class name or an HTTP status, never a body).
The replay guard is the receiver's own idempotency, in SQL, and it is stronger than a nonce cache because it holds across restarts and across replicas:
-- prima/prima/models/credits.py lines 223 and 262 to 277 (cd2c248)
CONSTRAINT uq_prima_credit_entries_idempotency_key UNIQUE (idempotency_key);
CREATE UNIQUE INDEX uq_prima_credit_entries_provider_event
ON prima_credit_entries (provider, provider_event_id) WHERE provider_event_id IS NOT NULL;
CREATE UNIQUE INDEX uq_prima_credit_entries_topup_order
ON prima_credit_entries (order_id) WHERE order_id IS NOT NULL AND kind = 'topup';
-- prima/prima/api/keys_sync.py lines 476 to 491: the predicate, not a SELECT
INSERT INTO prima_api_keys (...) VALUES (...)
ON CONFLICT (key_hash) DO UPDATE SET ...
WHERE prima_api_keys.sync_seq < EXCLUDED.sync_seq;
-- alchemy_labs/backend/models/sparks_billing.py line 123: the Labs receiver's dedup
CONSTRAINT uq_sparks_low_balance_alerts_tenant_period UNIQUE (tenant_id, period_key);
prima_api_keys.sync_seq is a BIGINT NOT NULL DEFAULT 0 (prima/auth/models.py line 232, migration 20260804_0001_key_sync_seq.py); Labs' outbox starts at 1 and bumps on every state change, so a backfilled row at 0 always loses to the first real message.
Every body field on these seams is a UUID, a number (integers; the Labs alert body's percent_remaining is a float bounded to 0.0 to 1.0), a Literal (bucket), a boolean (is_active), a fixed-length hex digest (key_hash, _HASH_PATTERN ^[0-9a-f]{64}$, keys_sync.py line 190), a bounded token (prima/content_free.py TOKEN_PATTERN, applied to provider, provider_event_id, issuer, external_sub, key_prefix, every scope; currency is a plain str capped at three characters, internal.py line 251, and the handler refuses anything but CHF) or a timestamp. No free text travels, so a signed body can be logged as an event without a scrubber.
API surface
The three built receivers, measured 2026-09-11. Auth dependency, tenancy, status codes and error codes per route. Prima's internal routes are off the gateway prefix (/internal/credits, internal.py line 79; /internal/keys, keys_sync.py line 181), so a 4xx or 503 is S7's Shape E, {"detail": <message>, "request_id": <hex>} (prima/app.py lines 56 to 72), with no code on the wire; the code names below are what the handler raises and what the request_failed log event records (app.py line 93), and the seam tests assert on detail (tests/test_credit_grant_seam.py line 100, tests/auth/test_key_sync_seam.py lines 104 to 106). Labs answers FastAPI's {"detail": ...}.
| Route | Auth dependency | Tenancy | 200 | 4xx | 503 |
|---|---|---|---|---|---|
POST /internal/credits/grant (Prima) | LabsOnly = Depends(require_labs_signature), internal.py lines 108 to 174; no CurrentIdentity anywhere in the module | tenant_id from the signed body; no session, no workspace header | {"granted", "duplicate", "tenant_id", "bucket", "amount_micro", "idempotency_key", "balance_micro", "allowance_micro", "allowance_expires_at", "currency"}; a duplicate is 200 with granted: false, duplicate: true | 400, raised with code unsupported_currency or amount_out_of_range (log only; the wire carries the message in detail), or 400 from the app's validation handler (app.py lines 111 to 127: detail is "Request body failed validation.", code invalid_request log only) for a bucket and amount field that do not match; 401, raised with code invalid_api_key (log only) for missing headers, a malformed or out-of-window timestamp, or a signature that does not verify | 503, raised with code grant_not_configured (log only) when the variable is unset |
POST /internal/credits/reverse (Prima) | same dependency, same credential | tenant_id and order_id from the body; carries no amount | {"tenant_id", "order_id", "reversed", "duplicate", "credited_micro", "recovered_micro", "shortfall_micro", "balance_micro"} (the handler at line 482 spreads credits.reverse_topup's dict, prima/credits.py lines 1320 to 1327 and 1444 to 1450) | 401 as above | 503, raised with code reversal_contended (log only) when the claw-back lost a race with a debit; grant_not_configured as above |
POST /internal/keys/sync (Prima) | LabsOnly, keys_sync.py lines 209 to 283, a separate function reading a separate credential on purpose | tenant_id from the body, written verbatim as prima_tenants.id | {"applied", "superseded", "key_present", "is_active", "sync_seq", "subject_id", "tenant_id"}; superseded: true is a success | 400, raised with code unknown_issuer, unrepresentable_scopes, not_a_gateway_key or subject_suspended (log only; detail carries the message); 401 as above | 503, raised with code key_sync_not_configured (log only) |
POST /internal/sparks/low-balance-alert (Labs) | none from the framework: mounted surface_exempt("public", ...) (app_fastapi.py line 887), the handler calls prima_inbound_sig.verify itself (prima_inbound_router.py lines 63 to 82) | tenant_id from the body; the handler passes it to notify_low_balance, which dedups per calendar month | {"ok": true, "email_sent": <bool>} | 401 Shape B {"error": "Invalid signature", "code": "AUTH_001", "request_id", "timestamp", "details": null} (a fastapi.HTTPException at line 82, rendered by http_exception_handler, S7) for any missing header, bad prefix, non-integer or out-of-window timestamp, or a signature that does not verify; 422 from Pydantic's own list for a body outside the model (unhandled on Labs, S7 step 8) | Shape B {"error": "Low-balance alert seam not configured", "code": "SRV_001", ...} |
The wire, identical on every seam:
X-Prima-Timestamp: <unix seconds, integer, as a string>
X-Prima-Nonce: <opaque; the built signers send uuid4().hex, 32 characters>
X-Prima-Signature: v1=<64 lowercase hex characters>
Content-Type: application/json
base = f"{timestamp}\n{nonce}\n{METHOD}\n{path}\n{sha256(body).hexdigest()}"
signature = "v1=" + hmac_sha256(credential_utf8, base_utf8).hexdigest()
METHOD is upper-cased by canonical_string on both sides; path is the real request path as sent, with no prefix template and no query string (the built routes are static, request.url.path on Prima, the module constant on Labs); body is the exact bytes that leave the socket. Five newline-separated parts, always, because the body is hashed before it enters the string (test_the_body_is_hashed_not_concatenated, Labs). Nothing else is signed: not the host, not the content type, not a request id.
Application logic
Signer, in order (prima_credit_sig.signed_headers lines 63 to 84, prima_key_sig.signed_headers lines 77 onward, labs_events._headers on w2):
- Serialise the body once, compactly:
json.dumps(payload, separators=(",", ":")).encode(). Keep thatbytesobject; it is both what gets signed and what gets sent. A second serialisation for the send is a signature over something the receiver never got. - Take
timestamp = str(int(time.time()))andnonce = uuid.uuid4().hexnow, on this attempt. A retry an hour later runs these two lines again. - Build the canonical string from
(timestamp, nonce, method, path, body)and sign it with the credential of this direction, read from the process settings, never from a module constant. - Send with the three headers and
Content-Type: application/json, a short timeout (10 s on the Labs outboxes, 5 s on Prima's), and hand the response code to S11's state machine: 200 is done, including a body that saysduplicateorsuperseded; 5xx, 408, 429 and any transport error retry; every other 4xx parks the row asstuckbecause a bad signature does not fix itself.
Verifier, in order. This is the order Prima's dependency runs it (prima/api/internal.py lines 108 to 171, keys_sync.py lines 209 to 280, attached as dependencies=[LabsOnly] on the route, so FastAPI resolves it before it validates the body). The Labs receiver (prima_inbound_router.py lines 63 to 82 with prima_inbound_sig.verify lines 70 to 102) runs steps 1 to 5 inside the handler, but declares body: LowBalanceAlertRequest as an endpoint parameter (line 53), so Pydantic validates the body before the handler starts: a body outside the model is 422 whether the request is signed, unsigned or the seam is unconfigured. A new receiver follows Prima's order (Gotchas).
- Read the credential from the settings the process booted with.
Noneis 503 and an ERROR log event; on Prima the error is raised with a distinct code that therequest_failedevent records while the wire carries onlydetailandrequest_id(S7 Shape E), and the request is not inspected further. - Read the three headers. Any one missing is 401. Compare header names case-insensitively: the framework hands them over lowercased (
x-prima-timestamp), sorequest.headers.get("X-Prima-Timestamp")works because Starlette'sHeadersmatches without regard to case (Prima reads them that way,internal.pylines 136 to 138), while a lookup indict(request.headers)or a scan ofscope["headers"]against the names as spelled in the wire block refuses every signed request with the missing-headers 401 (the 2026-09-12 rebuild did exactly that until the lookup was lowercased). - Parse the timestamp as an integer; a parse failure is 401. Compare
abs(now - timestamp)against 300 s; outside the window is 401. Prima gives that case a distinguishable message ("Internal signature timestamp is outside the window.") because clock skew on a trusted caller is an operations fault, not an attack; the status is the same. - Read the raw body with
await request.body(). On Prima this happens before Pydantic sees the body: the dependency isasyncon an otherwise synchronous service for exactly this reason, andRequest.body()caches, so the endpoint's model parse re-reads the same bytes that were hashed. On the Labs receiver the same cache works the other way round: Pydantic has already consumed the bytes, andrequest.body()at line 71 returns them unchanged, so the hash is still over what was sent. - Recompute the signature over
(timestamp, nonce, request.method, request.url.path, raw_body)and compare withhmac.compare_digestover the whole header value, prefix included, so av2=from a caller announcing a version that does not exist fails here rather than being treated as v1. - Only now parse the body, apply the endpoint's own validation (issuer asserted against the deployment's own issuer, amounts bounded, currency fixed, scopes representable), and write through the idempotent statement. A signature proves the caller holds the credential; it proves nothing about the body.
- Answer 200 for "done" and for "already done"; the caller is a retrying worker and a 4xx is the one answer it can do nothing useful with.
The mount on a Labs-style service (S4): an HMAC receiver is surface_exempt("own_auth", "<why>"), because its dependency or handler is the authenticator and the gate has nothing to add. The reference mounts prima_inbound_router as surface_exempt("public", ...) (app_fastapi.py line 887), and public is not a no-op: it delegates to a gate over all four surfaces so a presented credential is still checked (S5; surface_gate.py lines 240 to 252), which means a seam call that carries a stray Authorization: Bearer for a surface its owner is not granted is 403 at the gate before the signature is read. own_auth is the true no-op. The public mount is recorded as a deviation of the reference (Reference implementation); a new receiver is mounted own_auth.
Invariants
- One canonical string in the estate:
f"{ts}\n{nonce}\n{METHOD}\n{path}\n{sha256hex(body)}", withv1=and hex. Four copies of the function exist (prima/internal_sig.py78 to 81,prima_credit_sig.py49 to 52,prima_key_sig.py63 to 66,prima_inbound_sig.py53 to 56) and a known-answer test pins each pair; a change to one is a wire break the twin test catches. - One credential per direction and per capability, with the same variable name on both sides. A credential the least powerful side holds must not be able to do the most powerful thing: the read credential Prima holds cannot mint a key or add money; the alert credential can only cause one deduped email.
- The verifier fails closed: 503 when unconfigured, 401 for anything malformed or wrong, never a fallback credential, never a default value.
- The internal routes keep the plain envelope (S7 Shape E,
{"detail", "request_id"},tests/test_app.py::test_non_gateway_errors_keep_the_plain_envelope); a code is a log field, not a wire field. A new receiver never emits the gateway's error object on an internal route. - Every attempt is signed afresh; there is no signature column and no stored nonce on any side.
- Replay changes nothing because the receiver's write is idempotent at the database (a partial UNIQUE, a UNIQUE key, or a
sync_seqpredicate), not because a nonce was remembered. - The method and the path are inside the signed string, so a signature for
/grantis not a signature for/reverse(test_a_grant_signature_is_not_a_reverse_signatureon Prima,tests/test_credit_grant_seam.pyline 173;test_a_signature_for_grant_is_not_a_signature_for_reverseon Labs,tests/prima_credits/test_credit_grant_client.pyline 86). - The body is hashed, never concatenated, so a newline inside a JSON string cannot move a field boundary.
- The comparison is constant-time and covers the whole header value.
- A signed internal route is never on the gateway prefix and has no gateway identity dependency in its call graph; a valid customer key is refused with 401 (
test_a_customer_gateway_key_cannot_reach_the_grant_endpoint,test_a_customers_gateway_key_is_not_a_way_in). - The process refuses to boot when two seam credentials carry the same value (built on Prima; required of a new Labs-side service by this recipe).
Frontend
No page renders a signature and none should. The surfaces that make this recipe visible:
billing-credits/ui/billing-outbox-admin-console(F30, planned): the table per outbox where a signature failure shows up as astuckrow withlast_error = "HTTP 401"on the signer's side. Until it exists, the debug surface is the stuck query in_shared/outbox-patternrun from a database prompt.- On the verifier, the ERROR log events
credit_grant_signature_rejected,key_sync_signature_rejected,credit_grant_not_configured,key_sync_not_configured(Primaprima/logging.pyevent) and the Labs log line "Prima inbound: invalid signature on low-balance alert". A monitoring recipe inmonitoring-opsthat watches for these is the minimum alerting; none exists at the measured commits (Deploy & ops).
Security & tenancy
- Fail direction: CLOSED, on every seam, in both roles. This is the money and access-control class of the inventory's fail-direction rule (§2), not the inference-meter class.
- Auth dependency and tenant derivation: the dependency is the signature check and nothing else;
tenant_idcomes from the signed body. The verifier still validates what the body claims:keys_syncassertsissueragainst the deployment's ownjwt_issuer(lines 353 to 359) because a valid signature does not entitle the caller to file a subject under any issuer it likes;grant_creditrefuses a non-CHF currency and any amount aboveMAX_GRANT_MICRO(line 91, a blast radius rather than a business rule) or at or below zero; a message that only deactivates a key is never refused, so turning a key off always lands. - Blast radius per credential, as the code enforces it at cd2c248 and fd8558f:
PRIMA_LABS_INTERNAL_SECRETcan at most read four Labs endpoints (none built);PRIMA_KEY_SYNC_SECRETcan create a working gateway key for any tenant;PRIMA_CREDIT_GRANT_SECRETcan add money to a balance;PRIMA_LOW_BALANCE_ALERT_SECRETcan trigger one deduped email. ADR-0036 §6 (76c29d9) records the last one as "one deduped email and one deduped, capped charge the tenant already opted into"; the charge is the W1 extension, unmerged, and on the deployed line the email is all the credential can do. The pairwise boot check on Prima is what stops one value from wearing several of these hats after a copy-paste in the env heredoc. - Secrets at rest: environment only, wrapped types on both sides (
SecretStron Labs, the.reveal()wrapper on Prima), revealed at the point of use; a Prima config error names the two variable names and never a value (test_the_alert_secret_never_appears_in_a_config_error, w2). Never a repo file, never a log line, never a recipe. - Attacker-controlled inputs and what bounds them: the body (hashed; every field a bounded token, a number, a UUID or a timestamp; undeclared fields are ignored on both sides, Pydantic 2's default, since none of
GrantRequest,ReverseRequest,KeySyncRequestorLowBalanceAlertRequestsetsextra; what bounds the body is the declared fields' types and patterns, and a new receiver that wants refusal addsmodel_config = ConfigDict(extra="forbid")and says so); the path (signed); the timestamp (integer parse, 300 s window: a signature lifted from a log a week later is dead on arrival); the prefix (compared as part of the value); the nonce (signed, so two different requests never share a signature, but not remembered). - Replay: a captured request stays valid for at most 300 s of clock, and within that window a replay hits the receiver's UNIQUE index or
sync_seqpredicate and comes back asduplicate: trueorsuperseded: truewith nothing changed.internal_sig.pylines 37 to 50 record why there is no nonce cache: Prima has no Redis, and the money path should not gain a dependency for defence-in-depth over an invariant that is already a unique index. The Labs receiver's dedup is one email per tenant per calendar month, so a replayed alert sends nothing. - Reachability: the
/internal/*prefixes are not on the gateway prefix, are expected to be bound to the internal interface and are not routed by the public ingress. That is defence in depth; the control is the signature (internal.pydocstring). The Labs base URL variable's own description says the public gateway host does not route/internal/*at all. - Rate limits: none on these routes at the measured commits. The only caller is an outbox worker with backoff (S11); a new seam adds a limit only if its caller is not one.
- Workspace scoping: these routes carry no
X-Workspace-IDand no workspace is ever set for them. On Prima there is no workspace middleware. On Labs the route is not onWorkspaceMiddleware.EXCLUDED_PATHS(middleware/workspace_middleware.pylines 36 to 44, all/api/...prefixes plus the docs and health paths), so the middleware does run; it sets nothing because the request carries no workspace header and_requires_workspace(lines 371 to 390) matches only/api/...prefixes, which the route deliberately does not have (app_fastapi.pyline 883 comment).surface_exempt("public", ...)bypasses the surface gate (api/surface_gate.pylines 259 to 274), which is S4's concern, not S6's.
Tests
All hermetic; the Prima seam tests mock the session and the Labs signer tests monkeypatch httpx.post. No Playwright gate: nothing here is user-facing.
alchemy_labs/backend/tests/prima_credits/test_credit_grant_client.py(needs no database; run with--confcutdir=tests/prima_creditsso the shared conftest stays out):test_the_canonical_string_is_exactly_five_newline_separated_parts,test_the_signature_matches_the_prima_vector,test_the_body_is_hashed_not_concatenated(a body containing\n<ts>\n<nonce>still yields four newlines in the base string),test_a_signature_for_grant_is_not_a_signature_for_reverse,test_every_attempt_gets_a_fresh_nonce_and_timestamp.alchemy_labs/backend/tests/prima_keys/test_prima_key_sync.py:test_the_signature_matches_the_prima_vector(the vector below),test_every_attempt_is_signed_afresh,test_the_payload_carries_a_hash_and_never_a_key.alchemy_labs/backend/tests/sparks/test_prima_inbound_sig.py, eleven tests (counted 2026-09-11 at fd8558f): three on the scheme (five newline-separated parts,v1=hex output, a fresh signature verifies) and eight on the verifier (wrong credential, tampered body, tampered path, outside the skew window, inside a custom window, fails closed on missing headers, never raises on a malformed timestamp, rejects a signature without thev1=prefix).prima/tests/test_credit_grant_seam.py: unset credential is 503 and the route is still mounted (not 404), a gateway key is 401, a Labs bearer is 401, no headers is 401, wrong credential, tampered body, grant signature refused on/reverse, stale timestamp, malformed timestamp refused rather than crashing,test_the_known_answer_vector,test_verify_is_a_constant_time_compare_of_the_whole_header(v2=fails), the credential may not equal the read-seam value, is unset by default, and a short value is refused.prima/tests/auth/test_key_sync_seam.py:test_unset_secret_refuses_everyone_with_503_not_404, unsigned, wrong credential, different body, stale timestamp, gateway key is not a way in,test_the_canonical_string_matches_the_labs_vector,test_the_seam_is_not_on_the_gateway_prefix,test_the_key_path_still_cannot_reach_labs.prima/tests/test_seams_are_declared_not_built.py:test_labs_internal_declares_exactly_four_endpoints,test_no_seam_imports_anything_from_labs,test_key_path_has_no_import_route_to_labs.prima/tests/test_low_balance_event.py(w2, 82ba0d1):test_the_signed_bytes_are_the_sent_bytes,test_every_attempt_gets_a_fresh_nonce_and_timestamp,test_the_alert_secret_may_not_equal_any_other_seam_secret,test_all_six_pairs_are_actually_checked.
The known-answer vector both repos assert for the key-sync seam (a test value, not a credential; no identifier in the body):
credential shared-secret
timestamp 1785500000
nonce abc123
method POST
path /internal/keys/sync
body {"hello":"world"}
signature v1=2ede58bc4616041070f1df52184ef19317ddefce428af811b23994c00a42ad6b
A new seam pins its own vector the same way, in a test on each side, before either side is deployed. The credit seam's vector lives in test_credit_grant_client.py and test_credit_grant_seam.py (KAT_* constants) and is not repeated here because its body carries a placeholder tenant id.
Deploy & ops
Config keys per app, as the heredoc in S13 renders them, one master value per variable rendered under the same name into every app block that needs it (ADR-0036 §6 records that as the rule; the only named exception is the PSP credential, which is never shared between the two Labs environments):
- Labs (
backend/core/config.py):PRIMA_INTERNAL_BASE_URL(line 752, one variable for both Labs to Prima seams; unset means rows queue and are never delivered),PRIMA_CREDIT_GRANT_SECRET(740),PRIMA_KEY_SYNC_SECRET(719),PRIMA_KEY_SYNC_ISSUER(712, must equal Prima's issuer or every mint raises rather than queuing a row that can only be stuck),PRIMA_LOW_BALANCE_ALERT_SECRET(765), plusPRIMA_*_TIMEOUT_SECONDSandPRIMA_*_MAX_ATTEMPTSfor S11. - Prima (
prima/config.py):PRIMA_LABS_INTERNAL_SECRET(_required, lines 506 to 512, 16 characters or more),PRIMA_KEY_SYNC_SECRETandPRIMA_CREDIT_GRANT_SECRET(_optional, unset means 503),PRIMA_JWT_ISSUER,PRIMA_LABS_INTERNAL_BASE_URL; on w2 alsoPRIMA_LOW_BALANCE_ALERT_SECRET(unset means the drain loop is not started and one ERROR is logged at boot).
Migrations: none belong to the scheme. Scheduler jobs: none; the callers are S11's drains. Argus checks: none exist for these seams at the measured commits; the minimum this recipe asks for is one check per outbox on the stuck query (S11) and one on the verifier's *_signature_rejected event rate.
Rotation and rollback, read from the code paths: both sides read the value at boot, and between the two restarts every signed call answers 401, which the signer's deliver treats as a non-retryable 4xx and parks as stuck (prima_credit_grants.py lines 332 to 347, prima_key_sync.py lines 427 to 442, sparks_grants.py lines 405 to 414, labs_events.py _RETRYABLE_STATUSES). So a rotation is done with the drains quiet (deploy both apps in one window) or followed by a reset of the rows it stranded: UPDATE <outbox> SET status = 'pending', attempts = 0, next_attempt_at = now(), last_error = NULL WHERE status = 'stuck' AND last_error = 'HTTP 401'. Rolling back a code change on one side is a normal deploy; the vector test is what tells you the other side still agrees.
Gotchas
- 2026-08-04 (ADR-0026, wiki main d4f5b95): the key-sync seam exists because after the 2026-08-04 cutover a key minted in Labs answered 401 on the gateway with no symptom anywhere; only a hand-backfilled key worked. The same ADR records the stock-SDK gap found the same day (a bearer carrying a
prima_pk_prefix is retried through the key path), which is a gateway matter, not a seam one. - 2026-09-11 (measured, Labs fd8558f): no boot-time collision check for
PRIMA_KEY_SYNC_SECRET,PRIMA_CREDIT_GRANT_SECRETandPRIMA_LOW_BALANCE_ALERT_SECRET;core/config.pyhas twomodel_validators (lines 417 and 510) and neither is this. Prima has the check (three pairs at cd2c248, six at 82ba0d1). ADR-0036 §6 assigns the Labs half to W1, which is unmerged and failed verification (inventory B66). A new Labs-side service built to this recipe adds the check on day one, and not as a pydanticmodel_validator: a refusal raised inside one is wrapped inValidationError, whose text carriesinput_value={...}, the raw environment dict, so the shared value is printed in the traceback (measured 2026-09-12 with pydantic 2.12.0;SecretStron the fields does not help, because the input dict is the unparsed source). Prima's check is a plain comparison in the settings dataclass that raisesConfigErrorwith the two names and neither value (config.pylines 984 to 989). On aBaseSettingsservice the same check is a plain function run on the constructed instance aftersettings = Settings()at the bottom ofcore/config.py, raising the service's ownConfigError; never a validator. - 2026-09-11 (measured, Prima cd2c248): the read direction's design text in
prima/seams/labs_internal.pylines 45 to 48 says "+/-60s skew, nonce replay cache in Redis for 120s"; nothing implements it (build_labs_internalraises, lines 221 to 225) and every built seam uses 300 s and no cache (internal_sig.pylines 69 to 75 give the reason). The inventory's S10 row says 300 s and matches the code; the 60 s figure is a design that never shipped. - 2026-09-11 (measured, code against the wiki): ADR-0036 §2 describes additive fields on the alert body (
crossing_seq,balance_micro,allowance_micro,threshold_micro,occurred_at) and a per-crossing dedup table on Labs. At fd8558fLowBalanceAlertRequest(prima_inbound_router.pylines 40 to 47) has two fields, Pydantic ignores the extras Prima's w2 sender emits, and the only dedup isuq_sparks_low_balance_alerts_tenant_period. The code wins: on the deployed line a delivered alert can produce one email per month and nothing else. The sender itself is unmerged (w2-balance-threshold-event82ba0d1). - 2026-09-11 (measured, Labs fd8558f): a docstring or a mount-line banner that says "verified by HMAC" is not a verifier; the proof is a call to a
verifyfunction over the raw bytes and the three headers, and a twin test on the signer's side. The inventory's D8 list carries one Labs webhook handler under item B156 for exactly this gap; its state lives in that entry, not here. A new receiver copies the verifier in this recipe, not a handler that only claims one. - 2026-09-11 (measured, Labs fd8558f):
api/routers/admin_router.pyline 2930POST /notifyis gated by an admin session, not by a signature; its D8 defect (B122) is a hard-coded mail transport, recorded atapi/routers/mail_router.pylines 19 to 21, and belongs to the messaging domain. Listed here only because the wave 1 plan asked S10 to name it: it is not an HMAC seam and must not become one by copy. - 2026-09-11 (measured, Prima cd2c248):
tests/auth/test_key_sync_seam.pyline 253 names the Labs twin test asbackend/tests/prima_keys/test_prima_key_sig.py; the Labs file isbackend/tests/prima_keys/test_prima_key_sync.py(prima/api/keys_sync.pydoes not name the file). The vector is identical (recomputed 2026-09-11,v1=2ede58bc...); the path in the test's docstring is stale. - 2026-09-11 (measured, Prima cd2c248):
PRIMA_LABS_INTERNAL_SECRETis_required(config.pyline 506) although no Prima module outsideconfig.pyreads it and the four reads it would sign are not built. It still takes part in the collision check, so it needs a real, distinct value on every deployment. - 2026-09-11 (measured): the two verifier styles differ in where they fail.
prima_inbound_sig.verifyreturnsFalseon anything malformed and the router turns that into 401; Prima's dependency raises 401 itself with a distinct message for a stale timestamp. Both are 401 on the wire; a new verifier picks one style and keeps the whole-header constant-time compare either way. - 2026-09-11 (measured, Labs fd8558f): the Labs receiver validates the body before it verifies the signature.
low_balance_alert(prima_inbound_router.pylines 50 to 58) takesbody: LowBalanceAlertRequestas a parameter, so FastAPI answers 422 for a body outside the model before the handler's 503 (line 63) and 401 (line 72) can run; an unauthenticated caller learns the model's shape. Prima's verifier is a route dependency and runs first. A new receiver either verifies in a dependency the way Prima does or takesRequestonly, verifies overawait request.body(), and callsModel.model_validate_jsonon the same bytes afterwards. - 2026-09-11 (measured, both repos): the credential-per-direction rule was argued three times, once per seam, in module docstrings.
internal.py(line 26) andkeys_sync.py(line 30) each say a further endpoint on their prefix is a design-review trigger;labs_events.py(82ba0d1, lines 35 to 41) argues it as a different module, a different prefix and a different credential under the same discipline, and leaves the reviewable count toseams/labs_internal.py's four reads. ADR-0036 was that review for the threshold write (PUT /internal/credits/threshold, w2, same credential as/grant, unmerged). The next one needs its own ADR, not a copy of the paragraph.
Reference implementation
Paths are relative to each repo's root; commits as in measured_on and also_measured_on.
prima(feature/per-model-pricing, cd2c248; D3 makes this the gateway reference):prima/internal_sig.py:MAX_SKEW_SECONDS = 300(75),canonical_string(78 to 81),sign(84 to 91),verify(94 to 113); the module docstring lines 37 to 50 on why there is no nonce cache.prima/api/internal.py:MAX_GRANT_MICRO(91),require_labs_signature(108 to 171),LabsOnly(174),GrantRequestwith the bucket validator (182 to 299),grant_credit(302),ReverseRequest(435),reverse_credit(449); mounted atprima/app.pyline 364.prima/api/keys_sync.py:_HASH_PATTERN(190),require_labs_signature(209 to 280),KeySyncRequest(291 to 335),sync_key(339 to 543) with the issuer assertion (349 to 359), the scope refusals (361 to 396), the subject and tenantON CONFLICT DO NOTHINGinserts and the key upsert with thesync_seqpredicate (476 to 491); mounted atprima/app.pyline 362.prima/seams/labs_internal.py: the read direction as aProtocol(193 to 218),INTROSPECTION_KEYS(162 to 168),build_labs_internalraising (221 to 225).prima/config.py:_secret(414 to 419), thePRIMA_LABS_INTERNAL_SECRETdeclaration (506 to 512), the two optional seam credentials (801 to 833), the pairwise collision check (959 to 990).prima/models/credits.pylines 223 and 262 to 277: the three UNIQUE guards the grant relies on.
prima(w2-balance-threshold-event, 82ba0d1, unmerged):prima/api/labs_events.py_headers(fresh nonce and timestamp per attempt, signing throughinternal_sig.sign),_payload(compact bytes signed and sent);prima/config.pywith the fourth credential and six pairs;tests/test_low_balance_event.py.alchemy_labs(d1a-token-source, fd8558f; the backend tree is unchanged fromfeat/service-deskb1089b6):backend/services/commerce/prima_credit_sig.py:canonical_string(49 to 52),sign(55 to 60),signed_headers(63 to 84).backend/services/primaapi/prima_key_sig.py: the same three functions (63 to 66, 69 to 74, 77 onward); the docstring lines 31 to 48 on why a third credential.backend/services/commerce/prima_inbound_sig.py:DEFAULT_SKEW_SECONDS = 300(50),canonical_string(53 to 56),sign(59 to 67, kept for the known-answer test),verify(70 to 102).backend/api/routers/prima_inbound_router.py:LowBalanceAlertRequest(40 to 47), the handler (50 to 87);backend/app_fastapi.pyline 887 for thesurface_exemptmount.backend/core/config.pylines 700 to 765: the five seam variables and their descriptions.backend/tests/prima_credits/test_credit_grant_client.py,backend/tests/prima_keys/test_prima_key_sync.py,backend/tests/sparks/test_prima_inbound_sig.py.
- Wiki
philosophers_stone(main d4f5b95):operations/prima-modules-inventory.md§1.1 row S10, §2 (fail direction, idempotency in the database);operations/decisions/0026-labs-to-prima-key-propagation.md. Branchadr-0036-prepaid-auto-topup(76c29d9):operations/decisions/0036-prepaid-credits-auto-topup-and-balance-events.md§2 (the alert wire) and §6 (which credential signs which direction, the boot rule).platform/prima-sparks-pricing.md(updated 2026-08-20): the operator path that signs a one-off grant withprima_credit_sig.signed_headers.
Deviations of the references from this recipe, all dated 2026-09-11: Labs lacks the boot-time collision check; the Labs receiver is mounted surface_exempt("public") where an HMAC receiver is own_auth (Application logic); the read direction is a design with a different skew and a nonce cache that never shipped; the Labs alert receiver dedups per month, reads two fields while the ADR and the unmerged sender describe seven, and validates the body before it verifies the signature; no Prima seam model refuses undeclared fields; the Labs twin-test path in Prima's test_key_sync_seam.py docstring is stale; the Prima to Labs sender exists only on an unmerged branch.
Acceptance checks
Run without the author, from each repo's root at the commit named above. Expected observations are exact.
- 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. Both repos' vector tests assert that value. - Prima:
pytest -q tests/auth/test_key_sync_seam.py tests/test_credit_grant_seam.py tests/test_seams_are_declared_not_built.pypasses; the namestest_the_canonical_string_matches_the_labs_vector,test_the_known_answer_vector,test_verify_is_a_constant_time_compare_of_the_whole_headerandtest_labs_internal_declares_exactly_four_endpointsare among them. - Labs,
backend/:pytest -q tests/prima_credits/test_credit_grant_client.py -p no:cacheprovider --confcutdir=tests/prima_creditspasses with no database;pytest -q tests/sparks/test_prima_inbound_sig.py --confcutdir=tests/sparkspasses;grep -c "def test_" tests/sparks/test_prima_inbound_sig.pyprints11;pytest -q tests/prima_keys/test_prima_key_sync.py -k prima_vectorpasses. - Both repos:
grep -n "MAX_SKEW_SECONDS: Final\[int\] =" prima/internal_sig.pyprints line 75, ending in= 300;grep -n "DEFAULT_SKEW_SECONDS =" backend/services/commerce/prima_inbound_sig.pyprints line 50, ending in= 300;grep -n "60s" prima/seams/labs_internal.pyprints the design line 48 andgrep -rn "60s" prima/api/prints nothing. - Prima, boot rule: with a valid environment where
PRIMA_KEY_SYNC_SECRETandPRIMA_CREDIT_GRANT_SECRETare set to the same 16-plus-character value,python3 -c "import os; from prima.config import load_settings; load_settings(dict(os.environ))"exits non-zero with aConfigErrorwhose message names both variables and neither value. The same on a new service; a traceback that prints the value is the pydantic-validator trap in Gotchas. - Prima, unset means 503:
pytest -q tests/test_credit_grant_seam.py -k "unset_secret_disables or mounted_even_when_disabled"passes (503, and the route is not 404). - Prima, no gateway route in:
pytest -q tests/auth/test_key_sync_seam.py -k "not_on_the_gateway_prefix or gateway_key_is_not_a_way_in"passes;grep -n "CurrentIdentity" prima/api/internal.py prima/api/keys_sync.pyprints only docstring lines, no import and noDepends. - Labs:
grep -n "surface_exempt" backend/app_fastapi.py | grep prima_inboundprints line 887 (apublicmount, the deviation in Application logic; on a new service the same grep over its receiver printsown_auth);grep -n "verify(" backend/api/routers/prima_inbound_router.pyprints line 72. - Every signer re-signs:
grep -n "uuid.uuid4().hex" backend/services/commerce/prima_credit_sig.py backend/services/primaapi/prima_key_sig.pyprints one line per file;grep -in "signature" backend/models/prima_credit_grant.py backend/models/prima_key_sync.py backend/models/sparks_billing.py | grep -i columnprints nothing. - Labs, the receiver's order:
grep -n "body: LowBalanceAlertRequest" backend/api/routers/prima_inbound_router.pyprints line 53, above theverify(call that check 8 finds at line 72. Prima, the reference order:grep -n "dependencies=\[LabsOnly\]" prima/api/internal.pyprints lines 301 and 448, andgrep -n "model_config\|extra=" prima/api/internal.py prima/api/keys_sync.pyprints nothing (no seam model refuses undeclared fields).
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.