# Export signed evidence

> Export an approval's audit bundle, a run's provenance or compliance evidence, signed by your deployment, and check it anywhere.

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

- **A runtime,** such as `kindgi dev`, with Kindgi 0.1's CLI.
- **`KINDGI_API_URL` and `KINDGI_API_TOKEN`** set in your shell, for the HTTP requests.
- **`openssl`,** to make a key to keep. `kindgi dev` signs 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

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:

```sh
openssl genpkey -algorithm ed25519 -out export-signing.pem && chmod 600 export-signing.pem
```

It 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:

```sh
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out export-signing.pem && chmod 600 export-signing.pem
```

It prints nothing either.

The verifiers in [Check an export](https://docs.kindgi.com/v0.1.10/guides/observability/export-signed-evidence.md#check-an-export) check either. They refuse an algorithm they don't know, as [An algorithm the verifier doesn't know](https://docs.kindgi.com/v0.1.10/guides/observability/export-signed-evidence.md#an-algorithm-the-verifier-doesnt-know) shows.

- **`kindgi dev`** signs 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, set `KINDGI_EXPORT_SIGNING_KEY_PATH` to the absolute path of a key file on your machine, in the shell or the pack's `.env`. `kindgi dev` mounts 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. `$N` is the module's `name_prefix`:

  ```sh
  openssl genpkey -algorithm ed25519 | base64 | gcloud secrets versions add $N-export-signing-key --data-file=-
  ```

  `gcloud` prints the version it created. For a P-256 key, put `openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256` in place of the first command. The rest is the same.

  Or set `export_signing = "kms"` and `export_signing_kms_key` to a key version: the module grants the server's service account `roles/cloudkms.signerVerifier` and `roles/cloudkms.publicKeyViewer` on its key.

- **On Azure, a Key Vault key:** set `KINDGI_EXPORT_SIGNING_KMS_KEY` to 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 (`EC` or `EC-HSM`) and allowed to sign, so exports are `ecdsa-p256-sha256`. Key Vault has no Ed25519. `<vault>` is your Key Vault's name, and `<name>` the key's:

  ```sh
  az keyvault key create --vault-name <vault> -n <name> --kty EC --curve P-256 --ops sign verify
  ```

  `az` prints the new key as JSON, with its version in `key.kid`.

  The runtime signs as its managed identity: `KINDGI_AZURE_CLIENT_ID` names 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:

```sh
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

Export an approval by its id, or a run's provenance by the run's id. Each prints the signed export, and `>` saves it:

- **TypeScript**

  ```sh
  pnpm exec kindgi approvals export 0b6e3c1e-2f4f-4a51-9d0e-7a1f2c3d4e5f > approval.json
  pnpm exec kindgi provenance export 11a79b0a-ebdd-4d21-9e66-e0cacaea50e8 > provenance.json
  ```

- **Python**

  ```sh
  uv run kindgi approvals export 0b6e3c1e-2f4f-4a51-9d0e-7a1f2c3d4e5f > approval.json
  uv run kindgi provenance export 11a79b0a-ebdd-4d21-9e66-e0cacaea50e8 > provenance.json
  ```

- **Java**

  ```sh
  ./kindgiw approvals export 0b6e3c1e-2f4f-4a51-9d0e-7a1f2c3d4e5f > approval.json
  ./kindgiw provenance export 11a79b0a-ebdd-4d21-9e66-e0cacaea50e8 > provenance.json
  ```

- **Scala**

  ```sh
  ./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-messages` adds 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 `filter` keeps one run's events:

  ```sh
  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

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:

- **CLI**

  ```sh
  pnpm exec kindgi exports verify approval.json --from-runtime
  ```

  In 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.

- **TypeScript**

  `verify-export.ts`

  ```ts
  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`.

- **Python**

  Install the client with its verifier:

  ```sh
  pip install 'kindgi[verify]'
  ```

  It installs the client and `cryptography`, which the verifier uses. It prints the packages it installed.

  `verify_export.py`

  ```python
  import json
  import sys
  from pathlib import Path

  from kindgi import exports
  from 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()]`.

- **Java**

  Not in the Java SDK. Check an export with the CLI, `./kindgiw exports verify <file> --from-runtime` in a Java pack (the CLI tab).

- **Scala**

  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

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`:

```text
$ 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

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:

```text
$ 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

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:

```sh
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.pem
```

Then 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-keys` lists the active key first and the retired ones after it, with `active: false`. So `kindgi exports verify --from-runtime` trusts 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](https://docs.kindgi.com/v0.1.10/reference/api/operations/tags/approvals.md), [Provenance](https://docs.kindgi.com/v0.1.10/reference/api/operations/tags/provenance.md), [Compliance](https://docs.kindgi.com/v0.1.10/reference/api/operations/tags/compliance.md) and [Export signing keys](https://docs.kindgi.com/v0.1.10/reference/api/operations/tags/export-signing-keys.md) in the HTTP API have every operation.

## Next

- [Trace an answer and its cost](https://docs.kindgi.com/v0.1.10/guides/observability/trace-an-answer.md)
- [Decide an approval](https://docs.kindgi.com/v0.1.10/guides/approvals/decide-an-approval.md)
- [Keep secrets in your own secret manager](https://docs.kindgi.com/v0.1.10/deploy/self-hosted/secret-manager.md)
