Verdict Verification
Every Thoth-ATO cycle emits a cryptographically signed verdict using an Ed25519 key stored in Google Cloud KMS. Customers verify any verdict independently — Thoth-ATO never asks you to trust our infrastructure.
This page covers the verdict structure, the canonicalization rules, the public-key endpoint, and reference verification implementations in four languages.
Why verifiable verdicts matter
Section titled “Why verifiable verdicts matter”A normal AI assistant’s output is unverifiable. The vendor logs some trace. The customer believes some of it. The auditor sees none of it.
A signed verdict reverses this. The Judge produces a self-contained JSON document. The signature is over the canonical form of that document. Anyone with the public key — auditor, regulator, internal compliance, customer’s customer — can verify the verdict was issued by Thoth-ATO and has not been tampered with. We can’t repudiate it. You can’t be fooled by a forgery.
This is the foundation of evidence for:
- EU AI Act Art. 12 record-keeping
- EU AI Act Art. 15 accuracy + cybersecurity
- Colorado AI Act § 6-1-1704 impact assessment durability
- SOC2 CC7.1 + CC8.1 detect/log security events and change records
- ISO 27001 A.8.16 monitoring activities
See Compliance for the full mapping.
The verdict structure
Section titled “The verdict structure”A verdict is a JSON document with the following shape:
{ "cycleId": "cyc_01HXXXX", "kmsKeyVersion": "projects/p/locations/l/keyRings/r/cryptoKeys/judge/cryptoKeyVersions/1", "principles": { "security": 0.92, "cost": 0.88, "performance": 0.95, "scalability": 0.90, "antiFragility": 0.87, "extensibility": 0.91 }, "constitutionRef": "sha256:abc...", "deliverableHash": "sha256:def...", "issuedAt": "2026-05-16T12:04:30Z", "verdictHash": "BASE64_ED25519_SIGNATURE_OVER_CANONICAL_PAYLOAD"}Which fields are signed
Section titled “Which fields are signed”The signature covers the entire object minus verdictHash itself, canonicalized via JCS. All of the following are included in the signed payload:
| Field | Purpose | Tampering effect |
|---|---|---|
cycleId | Unique cycle identifier (ULID) | Signature breaks if cycleId is changed. |
kmsKeyVersion | KMS key resource path that signed this verdict | Signature breaks; verifier also fails to find the matching public key. |
principles | Scores per principle (0.0–1.0 floats) | Signature breaks — auditors cannot “improve” scores after the fact. |
constitutionRef | Hash of the constitution the Judge enforced | Signature breaks — proves which rules the Judge was applying. |
deliverableHash | SHA-256 of the deliverable bundle | Signature breaks — binds the verdict to this exact deliverable. |
issuedAt | RFC 3339 timestamp | Signature breaks — proves the issuance time. |
JSON Canonicalization Scheme (JCS, RFC 8785)
Section titled “JSON Canonicalization Scheme (JCS, RFC 8785)”RFC 8785 defines a deterministic byte-exact serialization of a JSON value. The signing party serializes via JCS; the verifying party deserializes, removes verdictHash, re-serializes via JCS, and verifies. As long as both ends agree on JCS rules, the byte stream is identical.
Why RFC 8785 specifically
Section titled “Why RFC 8785 specifically”Naive JSON.stringify is not deterministic across implementations:
- Object key ordering varies (insertion order vs. lexicographic).
- Numeric serialization differs (
1.0vs1vs1e0). - Unicode escape choices differ.
JCS pins every ambiguous case:
| Rule | JCS requirement |
|---|---|
| Object key order | Lexicographic sort on UTF-16 code units. |
| Numbers | IEEE 754 double serialized via ECMAScript Number.prototype.toString. Integers within ±2^53 emit no decimal point. |
| Strings | UTF-8, no unnecessary escaping. Only required escapes (\", \\, control chars). |
| Whitespace | None. Compact separators (, and :). |
| Booleans / null | true, false, null. |
Edge cases worth knowing
Section titled “Edge cases worth knowing”- Floats: Principle scores are floats in [0, 1].
0.5serializes as"0.5", not"5e-1".0.1 + 0.2would serialize as"0.30000000000000004"— Thoth-ATO rounds principle scores to 4 decimal places before signing to make the canonical form stable. - Unicode: Constitution refs are ASCII (SHA-256 hex). The metadata block (if present) may contain user-supplied UTF-8; JCS handles this correctly.
- Object ordering: The fact that we sort keys means it doesn’t matter how your JSON parser ordered them on disk. Verification is robust to round-tripping.
The public-key endpoint
Section titled “The public-key endpoint”GET https://api.thothato.io/api/v1/judge/public-keys/{kmsKeyVersion}This endpoint is unauthenticated — anyone can fetch a public key to verify a verdict.
Example:
curl https://api.thothato.io/api/v1/judge/public-keys/projects/p/locations/l/keyRings/r/cryptoKeys/judge/cryptoKeyVersions/1Response:
{ "kmsKeyVersion": "projects/p/locations/l/keyRings/r/cryptoKeys/judge/cryptoKeyVersions/1", "publicKey": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA...\n-----END PUBLIC KEY-----", "algorithm": "Ed25519", "createdAt": "2026-01-01T00:00:00Z", "rotatedAt": null}Verification — three steps
Section titled “Verification — three steps”- Fetch the public key for the verdict’s
kmsKeyVersion. - Canonicalize the verdict object minus its
verdictHashfield via JCS. - Verify the Ed25519 signature over the canonical bytes.
Node.js
Section titled “Node.js”import crypto from "node:crypto";import canonicalize from "canonicalize"; // npm i canonicalizeimport fs from "node:fs";
async function fetchPublicKey(kmsKeyVersion) { const res = await fetch( `https://api.thothato.io/api/v1/judge/public-keys/${kmsKeyVersion}`, ); if (!res.ok) throw new Error(`fetch public key failed: ${res.status}`); return (await res.json()).publicKey;}
function verifyVerdict(verdict, publicKeyPem) { const { verdictHash, ...payload } = verdict; const canonicalJson = canonicalize(payload); const publicKey = crypto.createPublicKey(publicKeyPem); const signature = Buffer.from(verdictHash, "base64"); return crypto.verify( null, // Ed25519: algorithm must be null Buffer.from(canonicalJson), publicKey, signature, );}
const verdict = JSON.parse( fs.readFileSync(".thoth/verdicts/cyc_01HXXXX.json", "utf8"),);const publicKeyPem = await fetchPublicKey(verdict.kmsKeyVersion);const ok = verifyVerdict(verdict, publicKeyPem);console.log(ok ? "verdict valid" : "VERDICT TAMPERED");Python
Section titled “Python”import base64, json, urllib.requestfrom cryptography.hazmat.primitives.serialization import load_pem_public_key
def canonical(obj): # JCS — RFC 8785. sort_keys + no whitespace + ensure_ascii=False approximates JCS # for the field set we use. For production-grade JCS, use the `jcs` PyPI package. return json.dumps( obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False ).encode("utf-8")
def fetch_public_key(kms_key_version): url = f"https://api.thothato.io/api/v1/judge/public-keys/{kms_key_version}" with urllib.request.urlopen(url) as resp: return json.loads(resp.read())["publicKey"]
def verify_verdict(verdict, public_key_pem): sig = base64.b64decode(verdict["verdictHash"]) payload = {k: v for k, v in verdict.items() if k != "verdictHash"} public_key = load_pem_public_key(public_key_pem.encode("utf-8")) try: public_key.verify(sig, canonical(payload)) return True except Exception: return False
with open(".thoth/verdicts/cyc_01HXXXX.json") as f: verdict = json.load(f)pem = fetch_public_key(verdict["kmsKeyVersion"])print("verdict valid" if verify_verdict(verdict, pem) else "VERDICT TAMPERED")For strict RFC 8785 compliance install jcs and replace canonical() with jcs.canonicalize(obj).
package main
import ( "crypto/ed25519" "crypto/x509" "encoding/base64" "encoding/json" "encoding/pem" "fmt" "io" "net/http" "os"
"github.com/cyberphone/json-canonicalization/go/src/webpki.org/jsoncanonicalizer")
type publicKeyResponse struct { PublicKey string `json:"publicKey"`}
func fetchPublicKey(kmsKeyVersion string) (ed25519.PublicKey, error) { url := "https://api.thothato.io/api/v1/judge/public-keys/" + kmsKeyVersion resp, err := http.Get(url) if err != nil { return nil, err } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) var pk publicKeyResponse if err := json.Unmarshal(body, &pk); err != nil { return nil, err } block, _ := pem.Decode([]byte(pk.PublicKey)) pub, err := x509.ParsePKIXPublicKey(block.Bytes) if err != nil { return nil, err } return pub.(ed25519.PublicKey), nil}
func verifyVerdict(verdictBytes []byte, pub ed25519.PublicKey) (bool, error) { var verdict map[string]any if err := json.Unmarshal(verdictBytes, &verdict); err != nil { return false, err } sigB64 := verdict["verdictHash"].(string) delete(verdict, "verdictHash") payload, _ := json.Marshal(verdict) canonical, err := jsoncanonicalizer.Transform(payload) if err != nil { return false, err } sig, _ := base64.StdEncoding.DecodeString(sigB64) return ed25519.Verify(pub, canonical, sig), nil}
func main() { b, _ := os.ReadFile(".thoth/verdicts/cyc_01HXXXX.json") var v map[string]any json.Unmarshal(b, &v) pub, _ := fetchPublicKey(v["kmsKeyVersion"].(string)) ok, _ := verifyVerdict(b, pub) fmt.Println(ok)}// Cargo.toml:// ed25519-dalek = "2"// serde_json = "1"// base64 = "0.22"// reqwest = { version = "0.12", features = ["blocking", "json"] }
use ed25519_dalek::{Signature, Verifier, VerifyingKey};use serde_json::Value;use std::fs;
fn fetch_public_key(kms_key_version: &str) -> anyhow::Result<VerifyingKey> { let url = format!( "https://api.thothato.io/api/v1/judge/public-keys/{}", kms_key_version ); let body: Value = reqwest::blocking::get(&url)?.json()?; let pem_str = body["publicKey"].as_str().unwrap(); // Strip PEM headers, base64-decode the SPKI body, take the trailing 32 bytes // (Ed25519 SPKI = 12-byte algorithm prefix + 32-byte raw public key). let b64: String = pem_str .lines() .filter(|l| !l.starts_with("-----")) .collect(); let spki = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64)?; let raw: [u8; 32] = spki[spki.len() - 32..].try_into()?; Ok(VerifyingKey::from_bytes(&raw)?)}
fn canonical(v: &Value) -> Vec<u8> { // Production: use the `serde_jcs` crate for full RFC 8785 compliance. serde_jcs::to_vec(v).unwrap()}
fn verify_verdict(verdict_path: &str) -> anyhow::Result<bool> { let raw = fs::read_to_string(verdict_path)?; let mut v: Value = serde_json::from_str(&raw)?; let sig_b64 = v["verdictHash"].as_str().unwrap().to_string(); let kms = v["kmsKeyVersion"].as_str().unwrap().to_string(); v.as_object_mut().unwrap().remove("verdictHash"); let canonical = canonical(&v); let sig_bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, sig_b64)?; let sig = Signature::from_slice(&sig_bytes)?; let pk = fetch_public_key(&kms)?; Ok(pk.verify(&canonical, &sig).is_ok())}
fn main() { println!("{}", verify_verdict(".thoth/verdicts/cyc_01HXXXX.json").unwrap());}Operational notes
Section titled “Operational notes”- Verification is offline-capable. Once you’ve fetched and cached the public key for a
kmsKeyVersion, verification needs no network access. Auditors typically snapshot all relevant public keys at audit time. - Key rotation is transparent. Old verdicts continue to verify because the historical KMS key version is preserved.
- Tampering surface area is small. Anything the customer hands to an auditor — verdict JSON, deliverable bundle, constitution snapshot — is hash-bound to the signature. Any byte flip anywhere fails verification.
What’s next?
Section titled “What’s next?”- API reference — fetch verdicts programmatically.
- Compliance — map verdict properties to specific regulatory clauses.
- CLI reference — where verdicts are written on disk after a local cycle.