Export signed evidence
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 (bundle, base64), 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.
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.pemThe 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.pemThe verifiers below check either, and refuse an algorithm they don't know (see An algorithm the verifier doesn't know).
-
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:Terminal window openssl genpkey -algorithm ed25519 | base64 | gcloud secrets versions add $N-export-signing-key --data-file=-For a P-256 key, put
openssl 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):Terminal window az keyvault key create --vault-name <vault> -n <name> --kty EC --curve P-256 --ops sign verifyThe 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 "Authorization: Bearer $KINDGI_API_TOKEN"Each key has an id derived from it (ex_…), so the same key keeps its id across restarts, and a fingerprint (sha256:…) to compare by eye.
Export
Section titled “Export”kindgi approvals export 0b6e3c1e-2f4f-4a51-9d0e-7a1f2c3d4e5f > approval.jsonkindgi 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:
Terminal window curl -X POST "$KINDGI_API_URL/v1/compliance/evidence/export" \-H "Authorization: Bearer $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”kindgi exports verify approval.json --from-runtime--from-runtime trusts the keys the runtime lists. An auditor without access checks against the key you gave them: --trust=export-signing.pub.pem (repeat it for several). 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.
pip install 'kindgi[verify]'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()].
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 (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 (the file, the secret's version, or the KMS key version) and restart. 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 "Authorization: Bearer $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.
See Provenance and Compliance in the HTTP API.