Signed verdicts¶
The same judge as the API (the same server and code), answering with a status, a decision (admitted),
findings, and a receipt signed with Ed25519.
CAGE compatibility. The wire format follows the pattern of an independent HTTP verifier in the open governance engine CAGE (google/cybernetic-agent-governance-engine, Apache-2.0), as read in that repository on 2026-10-10: tri-state answers, a receipt signed over RFC 8785 JSON, keys published separately, health checks, refusal by default. It keeps ZTL's own status words, so a CAGE deployment would need an adapter to map them; none is published. One call answers and signs at once. ZTL is not part of CAGE and is not affiliated with or endorsed by Google.
POST https://api.ztljudge.com/signed/v1/verify |
judge a document, answer and sign |
GET https://api.ztljudge.com/signed/v1/keys |
the public key(s), as a JWKS |
GET https://api.ztljudge.com/signed/v1/health/live |
200 while the process answers |
GET https://api.ztljudge.com/signed/v1/health/ready |
200 only when the signing key is loaded |
GET https://api.ztljudge.com/signed/v1/terms |
terms of use and privacy note |
The same service also answers at /cage/v1/* (its first address, kept for clients already connected): the same code,
the same key, the same receipts. The format name inside every receipt stays ztl-cage-1.
A request¶
The document is the same table as for the API: rows with verified, refuted, unverified or defined, and a claim.
The judge refuses a ground with spaces in it: a ground names one document (bank-statement-1030).
curl -s -X POST https://api.ztljudge.com/signed/v1/verify \
-H 'Content-Type: application/json' \
-d '{"protocolVersion": "ztl-cage-1",
"document": {"rows": [
{"name": "p", "means": "the invoice is paid", "status": "verified", "ground": "bank-statement-1030"},
{"name": "q", "means": "the goods were delivered", "status": "unverified"}],
"claim": "p & q"},
"nonce": "example-1"}'
The answer (from the live server on 2026-10-30; digests and the signature shortened here, so this copy does not verify):
{
"status": "OPEN",
"admitted": false,
"needsHumanReview": true,
"findings": [
{"code": "ztl.open", "severity": "review",
"message": "NOT established: it could still turn either way; do not report it as false"},
{"code": "ztl.to_check", "severity": "review", "message": "verify: q"}
],
"attestation": {"provider_name": "ztl", "attestation_type": "ZTL_JUDGMENT", "status": "UNVERIFIED", "...": "..."},
"receipt": {
"receiptVersion": "ztl-receipt-1",
"judge": {"studioCommit": "5886c32"},
"requestDigest": "2acd979f7aa76eaa…",
"status": "OPEN", "verdict": "F", "disposition": "OPEN",
"attests": "the verdict follows, by ZTL, from the grounds the document declares; the grounds themselves are the caller's statements and were not verified by ZTL",
"restsOn": [{"name": "p", "status": "verified", "ground": "bank-statement-1030"}],
"nonce": "example-1",
"issuedAt": "2026-10-30T10:48:46Z",
"receiptDigest": "1035e4d5b9e234a7…",
"signature": {"alg": "Ed25519", "kid": "ztl-c6e5de87c91ad73b", "value": "Pn36TtAe8g8hmRUx…"}
}
}
Statuses¶
status |
judge's disposition | admitted |
human review |
|---|---|---|---|
EARNED |
EARNED | yes | no |
REFUTED |
REFUTED | no | no |
EARNED_IF_STABLE |
EARNED IF STABLE | no | yes |
REFUTED_IF_STABLE |
REFUTED IF STABLE | no | yes |
ON_CREDIT |
ON CREDIT | no | yes |
OPEN |
OPEN | no | yes |
NO_VERDICT |
no claim to judge | no | yes |
What each disposition means is on the judge. findings lists, under ztl.to_check, the names the
judge says to verify (at most 20). settleByChecking is false when the judge reports that no checking can settle the
claim, as for a paradox. NO_VERDICT means there was no claim to judge.
admitted: true means only that the claim follows from the grounds as declared. Nobody has checked the grounds;
see the next section before acting on it.
Refusals carry no receipt: HTTP 400 for a body that is not JSON, a wrong protocolVersion, a bad nonce, text that is
not valid Unicode, or a document the judge cannot read (with its issues); 503 when no signing key is loaded (then
/signed/v1/health/ready is 503 too); 500 for a disposition the endpoint does not know, rather than a signature under a guessed
status; 429 above the rate limit. Treat any response without a valid receipt as "no verdict".
What a signature attests — and what it does not¶
ZTL signs the inference, not the grounds. Anyone may send rows marked verified with any ground, and the judge
will earn a claim that follows from them. The receipt says this in attests. restsOn lists every row the document
declares verified, refuted or defined, with its status and ground as declared (names cut at 100 characters,
grounds at 200; at most 64 rows, and a truncated marker when more were left out — the verdict may then rest on rows
not listed). Rows declared unverified are not listed: they have no ground to check, and the names to verify are in
findings. A consumer that needs the grounds verified must check restsOn against its own sources.
Only receipt is signed. status, admitted, needsHumanReview, findings and attestation sit outside it.
Act on the signed receipt.status and receipt.disposition, or check that the top-level status equals them.
The attestation's status is always UNVERIFIED. In CAGE's own definition, VERIFIED is set by the consumer once it
has checked the signature, not claimed by a provider about itself. ZTL's answer is in metadata.ztl_status.
Checking a receipt¶
- Take
receiptwithoutreceiptDigestandsignature. - Canonicalize it with RFC 8785 (JCS). Today the receipt holds only strings, booleans and nulls, inside objects and arrays.
- Compute SHA-256 of those bytes and compare it with
receiptDigest(lowercase hex). That field is not signed; never trust it unchecked. - Check
signature.value(base64url, no padding) as a pure Ed25519 signature over the same bytes.signature.kidis outside the signed bytes too: use it only to find the key, fromGET /signed/v1/keys.
A second copy of the key, outside the server, is in
SIGNING-KEYS.md in the ZTL repository, with the
rules for retired and revoked keys. It is a different system with different credentials but the same owner, so it does
not protect against a compromise of the owner. Fetch it again from time to time — a revocation reaches only those who
do — and treat a disagreement between the two copies as a sign of compromise. Pinning the key does not protect against
a takeover of the server, because the key lives on it.
requestDigest is SHA-256 of the exact bytes received: keep them, because a re-serialized body will not match. The
optional nonce (1–128 characters) is echoed inside the signed receipt. Send an unpredictable one and check that it
comes back: then an old receipt cannot be passed off as an answer to your request. Without a nonce, an old receipt for
the same body can be shown again as if new. issuedAt is the server's own clock, not a trusted time. Only a 200 response carries a
signature; treat anything else as "no verdict".
Terms¶
A free research preview, as is, with no service-level agreement; a verdict is not legal, financial, medical, safety or
compliance advice. Do not send personal data, others' confidential information, or classified or export-controlled
information. Request metadata is kept for up to 30 days; the body of /verify is not stored. The full Terms
(GET /signed/v1/terms) prevail over this summary.