Skip to main content

What are attestations?

An attestation is a token the QWED server signs to show it reached a verdict. It:
  • Uses ES256 (ECDSA P-256) signatures
  • Is formatted as a JWT, with header typ qwed-attestation+jwt
  • Can be verified by anyone who holds the server’s public keys, published at GET /.well-known/jwks.json
A token covers only what it signs: the verdict’s status, the answer’s proof_ref, the statement’s hash and, on a route that decides one, the admission. It doesn’t cover other fields of the response, such as agent_message.

Getting an attestation

Every VERIFIED answer carries attestation. You don’t need to ask for it:
The maths, code, SQL, and natural-language routes, the agent route, and their batch items decide an admission, and their tokens sign it as qwed.admission. Routes that decide none (logic, fact, stats, image, RAG, process, consensus) sign none. A Verification Context document carries its own attestation, of type qwed-context+jwt, which signs every other field of the document. Verify it with verify_document_attestation(), described on the Python SDK page.

Verifying attestations

With the client

The client fetches the server’s JWKS, caches it for five minutes, and checks the token against the answer:
Treat a missing attestation, or one that doesn’t verify, as a refusal. Never act on the unchecked result. verify_attestation() returns the verified claims, or raises AttestationError. It checks:
  • The signature, with an ES256 key in the JWKS, and that the verified header’s kid names that key.
  • The issuer, and that the token has not expired (nor is used before any nbf).
  • That it attests a VERIFIED result.
  • That its qwed.proof_hash is the answer’s proof_ref, so it’s this answer’s token.
  • When you pass query, that its qwed.query_hash is the SHA-256 of that statement. For a natural-language answer, pass the translated expression the answer reports (translated_expression in its developer fields), not your question: the verdict covers that expression.
  • On a route that decides an admission, that the token signs the answer’s admission.
If no cached key verifies the token, the client fetches the keys once more, at most once every 30 seconds, to pick up a rotated key. If it can’t fetch them, it raises KeysUnavailableError, a subclass of AttestationError. KeyMismatchError means no key in the JWKS verifies the token. The async client has the same method: await client.verify_attestation(result, query=...).

Offline, or against keys you pinned

Fetching the JWKS from the server you asked trusts that server’s TLS. Pass a JWKS you pinned to verify against a known key.

Other SDKs

The TypeScript, Go, and Rust SDKs make the same checks, with the same key caching: Each SDK’s README has the details: TypeScript, Go, Rust.
A token says the server reached the verdict and, where it signs one, the admission. Act on the admission: a verified BLOCKED is still BLOCKED.

Signing keys

The server signs with one ES256 key.
  • Configured key. Set QWED_SIGNING_KEY (PEM text) or QWED_SIGNING_KEY_FILE (a path to a PEM file). The key survives restarts and every replica shares it. Its kid is <issuer>#<RFC 7638 thumbprint>, the same on every restart and replica. A key that can’t be loaded stops the server at startup.
  • Generated key. Without either variable, the server generates a key per process and logs attestation.ephemeral_key at startup. Its tokens stop verifying after a restart, and on another replica.
Tokens last QWED_ATTESTATION_TTL_SECONDS (24 hours by default, from 60 seconds to 30 days). Rotating the key stops old tokens from verifying against the server’s new key. Callers that still hold the old public key, cached for up to five minutes or pinned, can verify those tokens until they expire, so rotation is not an immediate revocation. Revocation is held per process and is not shared, so a token’s lifetime is what bounds its use. AttestationService.get_issuer_info() reports the key’s lifecycle: key_continuity_policy ("ephemeral" or "configured"), key_generated_at (null for a configured key), and key_loaded_at. QWED logs attestation.key_generated on every new key generation.

Attestation structure

Payload

qwed.admission is present only on routes that decide an admission. It is ADMIT or BLOCKED, and the server signs ADMIT only for a VERIFIED result that is verified and bound to its proof.

Issuing attestations

This section is for code that runs QWED’s engines in-process and signs its own results.

Fail-closed contract

create_verification_attestation() never returns None. It always returns an AttestationResult with status set to ISSUED, BLOCKED, or UNVERIFIABLE. Check result.is_issued before you treat the attestation as valid. A missing or failed attestation must hard-block the verification path. Never downgrade it to VERIFIED.

AttestationResult

AttestationStatus
required
Lifecycle state of the attestation. One of ISSUED, BLOCKED, or UNVERIFIABLE.
string | None
Signed JWT string. Present only when status == ISSUED; None otherwise.
string | None
Machine-readable failure code, or None on success. See the table below.
string | None
Human-readable failure detail. None on success.
bool
Property. True only when status is AttestationStatus.ISSUED. You must check this flag before you use token.

AttestationStatus values

Caller pattern

Issuance-side proof enforcement

create_verification_attestation() won’t sign a VERIFIED verdict that has no proof. If you pass verified=True with neither proof_data nor proof_hash, it returns:
A caller can’t obtain a signed attestation for a claim it has no proof of. UNVERIFIABLE and BLOCKED verdicts are unaffected. Pass the diagnostic’s proof_ref as proof_hash, or the JSON string your engine hashed into it as proof_data, so the token’s qwed.proof_hash claim matches the result on the consuming side.

Server-side verification

Inside the server, AttestationService.verify_attestation(token) returns (is_valid, claims, error) and checks the token against the server’s own key.

Uniform rejection error

Every rejection returns the same "Invalid token" message. The result never says why a token was rejected: expired, revoked, malformed, oversized, untrusted-issuer, and unsupported-external-issuer tokens all look the same from the outside.
Telling "Attestation has expired" apart from "Untrusted issuer: did:...:X" or "Attestation has been revoked" would let an attacker enumerate the trusted-issuer registry, probe token expiry, and confirm revocation state from the response text. The reason goes only to the server’s debug log.
The signature is verified before the issuer is checked. An unknown-issuer token that isn’t signed by the server’s own key is rejected on signature grounds first, so the issuer check never runs and can’t be used to probe the trust list. Order your own custom validation the same way if you extend verify_attestation.

Enforce the trust boundary

Release gates and any code path that admits a VERIFIED result MUST route through enforce_trust_decision() before acting on it. It is the single consumption-side entry point that binds a verification result to its attestation token and fails closed on any mismatch. Don’t consume DiagnosticResult.status == VERIFIED directly.

Parameters

DiagnosticResult
required
The verification result returned by the engine. enforce_trust_decision passes through any already-fail-closed status (BLOCKED, UNVERIFIABLE) unchanged, and only applies attestation checks to VERIFIED.
string | None
The JWT emitted by create_verification_attestation(). May be None, but if require_attestation=True and the result is VERIFIED, a missing token blocks the decision.
bool
default:"True"
When True (default, mandatory policy), a VERIFIED result without a valid token is downgraded to BLOCKED. When False (advisory policy), attestation is best-effort: a missing token still passes, but a present-and-invalid token still blocks.
list[str] | None
Optional list of trusted issuer DIDs. Defaults to the service’s own issuer. The server verifies only tokens signed by its own key: external issuers are not supported.
string | None
Optional statement the verdict covers. For a natural-language verdict, that’s the translated expression, not the user’s question. When provided, the token’s qwed.query_hash claim must equal sha256(query), otherwise the decision is blocked. Without it, query binding is not checked.
string | None
Optional admission, ADMIT or BLOCKED. When provided, the token must sign this admission as qwed.admission, otherwise the decision is blocked.

Detached result snapshot

enforce_trust_decision() always returns a new DiagnosticResult, never the reference you passed in. Before running any validation, it takes an isolated snapshot of developer_fields (recursive rebuild admitting only immutable scalars, AdvisoryCheck, and JSON-safe containers), so:
  • The value you validate is the value you return. Concurrent code holding the original result reference can’t mutate developer_fields between the enforcement check and your admission decision, closing a TOCTOU window that frozen=True alone did not cover.
  • The returned result does not alias caller data. Downstream code can safely add breadcrumbs to decision.developer_fields without leaking into the original object.
If snapshotting fails, for example when developer_fields contains an unsupported value type or an object whose __deepcopy__ refuses to copy, enforce_trust_decision() fails closed with a BLOCKED result carrying constraint_id="trust_gate.diagnostic_snapshot_failed". Only the exception type is recorded in the audit log; caller-supplied error text is never surfaced.
Keep developer_fields values to JSON-safe types (strings, numbers, booleans, None, lists, dicts) plus AdvisoryCheck. Anything else, such as custom class instances, generators, or open file handles, is rejected by the snapshotter and blocked at the trust boundary.

Claims binding

When a token is present, enforce_trust_decision checks these bindings against the result before admitting it: Every block decision is logged at WARNING with a structured trust_gate.blocked event (constraint_id, reason, policy) so auditors can replay the enforcement trail.

Fail-closed matrix

The server’s own routes enforce attestation as mandatory. In your own code, you can start with require_attestation=False while engines are still being migrated to emit proof, then flip it to True once every code path that emits VERIFIED signs with a proof. That gives you the audit trail without breaking existing gates on day one.

Use cases

  1. Audit trails: keep the token with the answer to show the server verified it.
  2. Compliance: a signed, time-bounded record of each verdict and admission.
  3. Downstream checks: services that receive an answer verify it against the server’s JWKS before acting on it.