Verifying signed decisions

Every AI Agent Guardrails authorization decision is logged and, when signing keys are configured, signed with ML-DSA-65 (NIST FIPS 204). The signature is attestation only — it is produced after the deterministic decision and never influences it. If signing is unavailable the decision is still made and logged, with the signature fields left null.

Canonical payload

What gets signed is an exact JSON string with a fixed key order — stored verbatim as signed_payload. Always verify against that stored string; never rebuild it from the individual columns.

{
  "schema": "guard.decision.v1",
  "user_id": "...",
  "agent": "payments-agent",
  "action": "create_payout",
  "resource": "acct:1234",
  "context": { "amount_usd": 2500 },
  "decision": "deny",
  "reason": "No policy grants this action (default-deny).",
  "obligations": [],
  "matched_policy_id": null,
  "decided_at": "2026-01-01T00:00:00.000Z"
}

Fetch the public verification key

curl https://akrum.io/api/public/v1/guard/verification-key

Returns { suite: "mldsa65-v1", public_key_b64 }. The public key is safe to publish; the signing key never leaves AKRUM.

Export the decision log

curl "https://akrum.io/api/public/v1/guard/decisions?limit=-n">100" \
  -H "Authorization: Bearer $AKRUM_KEY"

-c"># verify a single decision server-side
curl -X POST https://akrum.io/api/public/v1/guard/decisions/<decision_id>/verify \
  -H "Authorization: Bearer $AKRUM_KEY"

Both endpoints require the guard scope and only ever return your own rows. Each exported decision carries its signed_payload, signature, signature_suite, message_sha256 and signed_at, so the export is independently verifiable without calling AKRUM again.

Offline verification

import { ml_dsa65 } from "@noble/post-quantum/ml-dsa.js";

const b64 = (s: string) => Uint8Array.from(Buffer.from(s, "base64"));

class="tk-c">// publicKeyB64 from /v1/guard/verification-key
class="tk-c">// decision from /v1/guard/decisions
const valid = ml_dsa65.verify(
  b64(decision.signature),
  new TextEncoder().encode(decision.signed_payload), class="tk-c">// the stored string, byte for byte
  b64(publicKeyB64),
);

console.log(valid ? "authentic" : "tampered or unsigned");

Change a single character of signed_payload and verification fails — that is what makes the log tamper-evident. An unsigned row (no signature) verifies as { valid: false, reason: "unsigned" }.