Export signed evidence
This guide gives your deployment a key to sign exports with, exports signed evidence, and checks an export.
A deployment can sign three exports, for an auditor or for your own records:
- an approval's audit bundle: who decided a human review, when, why, and the evidence it was shown;
- a run's provenance: the graph of what ran, with each model call's usage;
- compliance evidence: the audit events a filter matches, by run, agent, flow, kind or time.
Each is one JSON file. It holds the exact bytes that were signed, base64, as bundle. It also holds a signature over them, the algorithm it was made with, and the public key that signed. Anyone can check it, with no access to your deployment.
Before you start
Section titled “Before you start”- A runtime, such as
kindgi dev, with Kindgi 0.1's CLI. KINDGI_API_URLandKINDGI_API_TOKENset in your shell, for the HTTP requests.openssl, to make a key to keep.kindgi devsigns with a throwaway key without one.- A decided approval, or the run of an agent turn that completed, to export.
Give the deployment its key
Section titled “Give the deployment its key”The runtime signs with one key, its export signing key. Set exactly one of:
| Setting | What it holds |
|---|---|
KINDGI_EXPORT_SIGNING_KEY_PATH |
The path of a private key file: Ed25519 or EC P-256 (PKCS#8 PEM, mode 0600). |
KINDGI_EXPORT_SIGNING_KEY |
The same PEM file, base64: for a platform that gives secrets as environment variables, such as Cloud Run with Secret Manager. |
KINDGI_EXPORT_SIGNING_KMS_KEY |
A KMS key version, so the private key never leaves the KMS: a Cloud KMS key version (EC_SIGN_ED25519 or EC_SIGN_P256_SHA256), or an Azure Key Vault key version (https://<vault>.vault.azure.net/keys/<name>/<version>, an EC P-256 key). Optional. |
Make a key for this alone:
openssl genpkey -algorithm ed25519 -out export-signing.pem && chmod 600 export-signing.pemIt prints nothing. The key is in export-signing.pem, readable only by you.
The key's type sets the export's algorithm. An Ed25519 key signs ed25519, the default. An EC P-256 key signs ecdsa-p256-sha256, for a key store that has no Ed25519:
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out export-signing.pem && chmod 600 export-signing.pemIt prints nothing either.
The verifiers in Check an export check either. They refuse an algorithm they don't know, as An algorithm the verifier doesn't know shows.
-
kindgi devsigns with a key the runtime makes at startup, so exports work with nothing set. That key is a throwaway, though. To keep one across restarts, setKINDGI_EXPORT_SIGNING_KEY_PATHto the absolute path of a key file on your machine, in the shell or the pack's.env.kindgi devmounts that file into the runtime's container, read-only. -
On Cloud Run, set
export_signing = "secret"in the Terraform module, then add the key's version.$Nis the module'sname_prefix:Terminal window openssl genpkey -algorithm ed25519 | base64 | gcloud secrets versions add $N-export-signing-key --data-file=-gcloudprints the version it created. For a P-256 key, putopenssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256in place of the first command. The rest is the same.Or set
export_signing = "kms"andexport_signing_kms_keyto a key version: the module grants the server's service accountroles/cloudkms.signerVerifierandroles/cloudkms.publicKeyVieweron its key. -
On Azure, a Key Vault key: set
KINDGI_EXPORT_SIGNING_KMS_KEYto the key version's URL,https://<vault>.vault.azure.net/keys/<name>/<version>. The version is required, so exports keep the key id of the version that signs them. The key must be EC P-256 (ECorEC-HSM) and allowed to sign, so exports areecdsa-p256-sha256. Key Vault has no Ed25519.<vault>is your Key Vault's name, and<name>the key's:Terminal window az keyvault key create --vault-name <vault> -n <name> --kty EC --curve P-256 --ops sign verifyazprints the new key as JSON, with its version inkey.kid.The runtime signs as its managed identity:
KINDGI_AZURE_CLIENT_IDnames a user-assigned one, else it's the system-assigned one. Give that identity Key Vault Crypto User on the key. The private key never leaves Key Vault. -
Without a key, outside development, the exports answer
404 signing-not-configured.
The runtime publishes its key's public half. Pin it, so you can tell your deployment's exports from anyone else's:
curl "$KINDGI_API_URL/v1/export-signing-keys" -H @<(printf 'Authorization: Bearer %s\n' "$KINDGI_API_TOKEN")It answers with the keys as JSON, the active one first. Each key has an id derived from it, ex_…, so the same key keeps its id across restarts. Its fingerprint, sha256:…, is there to compare by eye, and publicKeyPem is its public half.
Export
Section titled “Export”Export an approval by its id, or a run's provenance by the run's id. Each prints the signed export, and > saves it:
pnpm exec kindgi approvals export 0b6e3c1e-2f4f-4a51-9d0e-7a1f2c3d4e5f > approval.jsonpnpm exec kindgi provenance export 11a79b0a-ebdd-4d21-9e66-e0cacaea50e8 > provenance.jsonuv run kindgi approvals export 0b6e3c1e-2f4f-4a51-9d0e-7a1f2c3d4e5f > approval.jsonuv run kindgi provenance export 11a79b0a-ebdd-4d21-9e66-e0cacaea50e8 > provenance.json./kindgiw approvals export 0b6e3c1e-2f4f-4a51-9d0e-7a1f2c3d4e5f > approval.json./kindgiw provenance export 11a79b0a-ebdd-4d21-9e66-e0cacaea50e8 > provenance.json./kindgiw approvals export 0b6e3c1e-2f4f-4a51-9d0e-7a1f2c3d4e5f > approval.json./kindgiw provenance export 11a79b0a-ebdd-4d21-9e66-e0cacaea50e8 > provenance.json-
An approval must be decided: approved, rejected, escalated, expired or withdrawn. An open one answers
409 approval-not-decided.--include-messagesadds the conversation of the approval's run. -
Compliance evidence is exported over HTTP. With no body, it covers every kind the classifier marks exportable. Here
filterkeeps one run's events:Terminal window curl -X POST "$KINDGI_API_URL/v1/compliance/evidence/export" \-H @<(printf 'Authorization: Bearer %s\n' "$KINDGI_API_TOKEN") \-H 'content-type: application/json' \-d '{ "filter": { "runId": "11a79b0a-ebdd-4d21-9e66-e0cacaea50e8" } }' > evidence.json
Every export is recorded as an export-signed audit event: who exported what, with which key, and the SHA-256 of the signed bytes. That ties a file someone hands you to the moment it was made. If the event can't be recorded, the export isn't handed out.
Check an export
Section titled “Check an export”To check one from your code, ask your coding agent: "I want to check that an export was signed by my deployment." For reference, the command and the code look like this:
pnpm exec kindgi exports verify approval.json --from-runtimeIn a Python project that's uv run kindgi …, and in Java or Scala ./kindgiw ….
--from-runtime trusts the keys the runtime lists. An auditor without access checks against the key you gave them, with --trust=export-signing.pub.pem. Repeat it for several keys. It prints the check as JSON: valid, and signingKeyId, the id of the key that signed. It exits 0 when the export checks out and 1 when it doesn't, saying why.
import { readFile } from 'node:fs/promises';
import { createClient, verifySignedExport } from '@kindgi/sdk/client';
const kindgi = createClient(); // KINDGI_API_URL, KINDGI_API_TOKEN, or the running kindgi dev
const exported = JSON.parse(await readFile(process.argv[2] ?? 'approval.json', 'utf8'));const keys = await kindgi.exportSigningKeys.list();const checked = await verifySignedExport(exported, { trustedKeys: keys.map((key) => key.publicKeyPem),});if (!checked.valid) throw new Error(checked.issues?.join('; '));console.log(`signed by ${checked.signingKeyId}`);It uses Web Crypto, so it runs in Node and in a browser. An auditor without access to your deployment passes the PEM you gave them as trustedKeys.
Install the client with its verifier:
pip install 'kindgi[verify]'It installs the client and cryptography, which the verifier uses. It
prints the packages it installed.
import jsonimport sysfrom pathlib import Path
from kindgi import exportsfrom kindgi.client import Kindgi
kindgi = Kindgi() # KINDGI_API_URL, KINDGI_API_TOKEN, or the running kindgi dev
exported = json.loads(Path(sys.argv[1] if len(sys.argv) > 1 else "approval.json").read_text())keys = [key.public_key_pem for key in kindgi.export_signing_keys.list().data]checked = exports.verify_signed_export(exported, trusted_keys=keys)if not checked.valid: raise SystemExit("; ".join(checked.issues))print(f"signed by {checked.signing_key_id}")An auditor without access to your deployment passes the PEM you gave them: trusted_keys=[Path("export-signing.pub.pem").read_text()].
Not in the Java SDK. Check an export with the CLI, ./kindgiw exports verify <file> --from-runtime in a Java pack (the CLI tab).
Not in the Scala SDK. Check an export with the CLI, ./kindgiw exports verify <file> --from-runtime in a Scala pack (the CLI tab).
An algorithm the verifier doesn't know
Section titled “An algorithm the verifier doesn't know”An export whose algorithm the verifier doesn't know is refused, never checked another way. Here, an export whose algorithm was edited to ecdsa-p384-sha384:
$ kindgi exports verify approval.json --trust=export-signing.pub.pem{ "valid": false, "kind": "audit-bundle", "signingKeyId": "ex_sD1K1gmwO-S3BJxR", "checkedAgainst": "trusted-keys", "issues": [ "algorithm \"ecdsa-p384-sha384\" isn't one this verifier knows (ed25519, ecdsa-p256-sha256): a newer verifier may check it" ]}It exits 1. The TypeScript and Python verifiers give the same line in issues. A file signed with a newer algorithm needs a newer verifier. A file someone edited is refused either way.
Checked without trusted keys, a valid export only proves its bytes weren't changed: anyone could have signed it with a key of their own. Checking against your deployment's keys is what tells you it's yours.
An audit bundle made with @kindgi/api 0.1.4
Section titled “An audit bundle made with @kindgi/api 0.1.4”The 0.1.4 runtime didn't sign exports: its three export routes answered 404 signing-not-configured. But an app that embedded @kindgi/api 0.1.4 with its own key could export audit bundles. Those verify. That version stamped a bundle's envelope exportedAt separately from the signed one, so the two can be a millisecond apart. The envelope's copy isn't signed, so the verifiers report the signed time in notes and don't compare the two:
$ kindgi exports verify approval.json --trust=export-signing.pub.pem{ "valid": true, "signingKeyId": "acme-export-key-1", "checkedAgainst": "trusted-keys", "notes": [ "made by Kindgi 0.1.4, which stamped the envelope's exportedAt separately: the signed export time is 2026-10-08T13:12:21.532Z (the envelope says 2026-10-08T13:12:21.533Z)" ]}It exits 0. The TypeScript and Python verifiers give the same line in notes. Only 0.1.4's audit bundle format, with an envelope bundleSchemaVersion of 1, is checked this way. In every later export, an envelope exportedAt that isn't the signed one fails the check.
Rotate the key
Section titled “Rotate the key”Replace the key and restart: the file, the secret's version, or the KMS key version. New exports carry the new key's id.
An export signed before still checks out against the old public key wherever you pinned it. For --from-runtime to keep trusting it, list the old key as retired. Save its public half before you replace it:
curl -s "$KINDGI_API_URL/v1/export-signing-keys" -H @<(printf 'Authorization: Bearer %s\n' "$KINDGI_API_TOKEN") \ | jq -r '.data[] | select(.active) | .publicKeyPem' >> export-signing-retired.pemThen give the runtime the file, in one of these:
| Setting | What it holds |
|---|---|
KINDGI_EXPORT_SIGNING_RETIRED_PUBLIC_KEYS_PATH |
The path of a file of retired keys' public halves: PEM (-----BEGIN PUBLIC KEY-----), Ed25519 or EC P-256, concatenated. |
KINDGI_EXPORT_SIGNING_RETIRED_PUBLIC_KEYS |
The same file, base64 (base64 < export-signing-retired.pem): for a platform that gives settings as environment variables. |
- What it lists:
GET /v1/export-signing-keyslists the active key first and the retired ones after it, withactive: false. Sokindgi exports verify --from-runtimetrusts what they signed. - They never sign: a request that asks for a retired key's id is
404 signing-key-not-found. A private key in the file stops the runtime at boot, and so does a block it can't read. - Any signing key: it works whatever signs now: a file, a secret or a KMS key version.
Approvals, Provenance, Compliance and Export signing keys in the HTTP API have every operation.