Operate
The signing boundary
A signature is what turns this pipeline’s opinion into something another party can check. Until the signing boundary is configured, this pipeline has opinions and no way to state them, and every surface that depends on a signature refuses to answer.
Six values switch it on. All six, together, or the boundary reports itself unconfigured.
What must be set
Section titled “What must be set”| Variable | Requirement | Error if malformed |
|---|---|---|
AGREEMENT_KEY_ID |
Matches /^[A-Za-z0-9._:-]{1,128}$/ |
AGREEMENT_KEY_ID contains unsupported characters |
AGREEMENT_PRIVATE_KEY_HEX |
64 hex characters, decoding to exactly 32 bytes | AGREEMENT_PRIVATE_KEY_HEX must be a 32-byte key |
TANDEM_PARSER_COMMIT |
40 hex characters | TANDEM_PARSER_COMMIT must be 20-byte lowercase hex |
TANDEM_INDEXER_COMMIT |
40 hex characters | TANDEM_INDEXER_COMMIT must be 20-byte lowercase hex |
TANDEM_PARSER_BINARY_SHA256 |
64 hex characters | TANDEM_PARSER_BINARY_SHA256 must be 32-byte lowercase hex |
TANDEM_INDEXER_BINARY_SHA256 |
64 hex characters | TANDEM_INDEXER_BINARY_SHA256 must be 32-byte lowercase hex |
AGREEMENT_PUBLIC_KEY_HEX is optional and is covered below. Every hex value in that table is lowercased before it is validated, so uppercase hex is accepted and normalised. That is not true of the trusted key maps, where an uppercase public key is a boot failure.
The key id is the exception: it is taken exactly as given, case included, and it has to match the trust map entry byte for byte later on. Its character set is narrower than it looks. Letters, digits, dot, underscore, colon and hyphen are permitted, and nothing else. No spaces, no slashes, no @, no +. Something like pipeline-a.signer.2026-08 is fine.
The four release values are not decoration. They are carried inside every tuple this pipeline signs, they are preserved separately for each pipeline in a verified response, and they are never compared between pipelines. Set them from the artifacts that are actually running, not from a branch name or a build label. See release identity for what each one commits to.
The validation that surprises people
Section titled “The validation that surprises people”Setting either agreement key without a key id fails startup with AGREEMENT_KEY_ID is required when an agreement key is configured. That one is loud.
The public key check is quiet, and it is the one to know about. If AGREEMENT_PUBLIC_KEY_HEX is set, it must equal the public key derived from AGREEMENT_PRIVATE_KEY_HEX. A mismatch does not throw at boot. The process starts, the configuration looks complete, and the signing boundary simply reports itself unconfigured. You will see it as agreement_signer_unavailable on the readiness snapshot and nowhere else.
Use that deliberately. Setting the public key alongside the private one converts a mispasted key into a visible, fail-closed readiness failure instead of a signature that nothing will trust. Derive it once, store it beside the key id, and let the process check your work on every boot.
What happens while it is unconfigured
Section titled “What happens while it is unconfigured”Nothing degrades gracefully here, by design.
- Readiness fails. The tenth gate reports
agreement_signer_unavailable, soGET /readyandGET /tandem/readinessboth return HTTP 503 with the full snapshot as the body. - Every verified route is 503. The gateway requires a ready pipeline A at a canonical height before it signs anything, so all twelve routes under
/tandem/verifiedreturn{"status":"verification_unavailable","error":"verification_unavailable"}. - Signed agreements fail at an existing checkpoint.
GET /tandem/agreement/:heightlooks up the checkpoint row first, so a height with no row still returns 404checkpoint not found. When the row does exist, the request reaches the signer and throwsagreement release identity is not configured, oragreement signing boundary is not configuredif the four release values are present but the key is not. Neither is an HTTP exception, so both surface as HTTP 500.
That last one is worth internalising. A 500 from the agreement route on a height you know exists is almost always an unconfigured boundary, not a database problem.
What is actually signed
Section titled “What is actually signed”The tuple, and only the tuple. Its fourteen fields are canonicalized with RFC 8785 JSON Canonicalization Scheme, the resulting bytes are signed with Ed25519, and the signature is emitted as 128 lowercase hex characters. The envelope wrapped around it carries schema, key_id and signature outside the signed bytes, so none of those three are authenticated by the signature they sit next to.
Verification runs in strict RFC 8032 mode rather than the permissive ZIP-215 rules, which means small-order public keys and non-canonical encodings are rejected rather than tolerated. Signing is deterministic, which is what allows the gateway to compare its two resolutions of the same height byte for byte.
Configuring the key here is only half of it. Pipeline A’s own envelope is verified through the same trust map machinery as pipeline B’s, so this key id and its derived public key must also appear in PIPELINE_A_TRUSTED_KEYS_JSON. Skip that and the verified surface stays closed with pipeline A key is not trusted in the log. Trust registries covers both maps.
Once this pipeline can sign, the next question is whose signatures it will accept, which is trust registries.