# Proof certificates

> Every call returns an Ed25519-signed, hash-chained certificate that commits to the request, what left toward the model and the response, without containing any content.

A proof certificate is the receipt of one AI call. It answers, for an auditor who was not there: what was asked, what actually left toward the model provider after personal data was masked, what came back, which provider and model answered, and in what order this happened relative to every other certificate.

Three properties make it worth keeping next to the business record it supports:

- **It holds no content.** Prompts and answers are committed by SHA-256 only, so a certificate can be sent to a client, an insurer or a regulator as is.
- **It cannot be forged or edited.** The document is signed with an Ed25519 key held by the Gateway. Changing one character changes the hash and breaks the signature.
- **It cannot be silently removed.** Each certificate contains the hash of the previous one in a single chain, so a deleted certificate leaves a hole that every verifier can see.

**Lifecycle of a certificate. It is written to storage before the response completes, so the id you receive can be fetched immediately.**

Flow: Your call -> Hash request -> Mask + hash egress -> Model answers -> Hash response -> Sign + chain -> Stored -> x-pulse-proof

## Getting the proof id

Every successful call on `POST /v1/chat/completions` and `POST /v1/messages` carries the id in the **`x-pulse-proof`** response header. It has the form `pf_` followed by 32 hex characters. On the OpenAI dialect the same id also appears in the completion id (`chatcmpl-pf_...`) and in the `pulse.proof` field of the body.

On a streamed response the header is sent with the first byte, before any token, so you have the id even if the stream is interrupted. The certificate itself is persisted before the final `[DONE]` event (OpenAI dialect), so it is fetchable the moment your stream iterator returns.

A proof failure never fails a completion that was already produced. In the rare case where the certificate could not be written, the header is present but the fetch returns 404; treat that as an incident to report rather than as a client error.

> **TIP: Streamed equals signed**
> The response hash is computed over the full text that was streamed to you, after personal data was restored. What your user saw is what was hashed. There is no separate non-streamed version that the certificate describes.

## What a certificate contains

`GET /v1/proofs/:id` returns the envelope below. The `payload` is the signed document; the other fields are the signature material and bookkeeping.

| Envelope field | Type | Meaning |
| --- | --- | --- |
| `id` | string | The proof id, same as the `x-pulse-proof` header. |
| `chain_index` | integer | Position in the chain, from 0. Also inside the signed payload as `chainIndex`. |
| `prev_hash` | string | `payload_hash` of the previous certificate, or `GENESIS` for index 0. Also inside the payload as `prevHash`. |
| `payload` | object | The signed document, described below. |
| `payload_hash` | string | Lower-case hex SHA-256 of the canonical JSON of `payload`. |
| `signature` | string | Base64 Ed25519 signature over the UTF-8 bytes of the `payload_hash` hex string. |
| `algorithm` | string | Always `Ed25519`. |
| `hash` | string | The literal description `sha256(canonical-json(payload))`. |
| `created_at` | string | ISO 8601 time the row was stored. Not signed; use `payload.issuedAt` for the signed time. |

| Payload field | Type | Meaning |
| --- | --- | --- |
| `v` | integer | Certificate format version, currently `1`. |
| `id` | string | The proof id. |
| `issuer` | string | Always `subsidia-gateway`. |
| `issuedAt` | string | ISO 8601 time of issue, from the Gateway clock. |
| `chainIndex`, `prevHash` | integer, string | The position and previous link, inside the signed bytes so a certificate cannot be replayed elsewhere in the chain. |
| `request.model` | string | The exact `model` string you sent, flags included. See [Model addressing](https://dev.subsidia.protypa.fr/docs/model-addressing.md). |
| `request.sha256` | string | SHA-256 of the canonical JSON of your request (see below for what exactly is hashed). |
| `address` | object | The parsed address: `kind` (`auto`, `agent`), `agent`, `cortex`, and `knowledge: false` / `memory: false` when `+nocortex` / `+nomemory` switched them off. |
| `grounding[]` | object[] | Present only when knowledge passages were used (agent turns and `+cortex`). Each item: `source`, `chunkId`, `page` (or null), `sha256` of the passage text. |
| `egress.provider`, `egress.model` | string | Who answered: for example `ollama` and a local model, or an external provider and its model. |
| `egress.sha256` | string | SHA-256 of the system prompt and messages as they leave toward the provider, after masking. |
| `egress.pii.masked` | integer | How many personal values were masked. |
| `egress.pii.categories` | string[] | Which categories (`EMAIL`, `PHONE`, `IBAN`...). Never the values. |
| `response.sha256` | string | SHA-256 of the final answer text delivered to you. |
| `response.finishReason` | string | OpenAI dialect: `stop`, `length` or `tool_calls`. Anthropic dialect: `end_turn` or `tool_use`. |
| `response.usage` | object | `promptTokens`, `completionTokens`, `totalTokens`. |
| `routing.taskType` | string | The task Synapse resolved (`simple`, `complex`, `agent`, `sensitive`...). |
| `routing.reason` | string | A human-readable explanation of the routing decision. See [Synapse](https://dev.subsidia.protypa.fr/docs/synapse.md). |
| `trace` | object | Present only on agent turns that ran tools: every step committed by hash (tool name, hash of arguments, hash of result), plus the hash of the whole list. Absent otherwise. |

**A real-shaped certificate (hashes shortened)**

```json
{
  "id": "pf_a059713a5f1c4e0b8d7a3c21e9b64f10",
  "chain_index": 4182,
  "prev_hash": "90a8ecd956f1...",
  "payload": {
    "v": 1,
    "id": "pf_a059713a5f1c4e0b8d7a3c21e9b64f10",
    "issuer": "subsidia-gateway",
    "issuedAt": "2026-10-09T08:12:44.210Z",
    "chainIndex": 4182,
    "prevHash": "90a8ecd956f1...",
    "request": { "model": "pulse-auto+cortex", "sha256": "b2205bba91c4..." },
    "address": { "kind": "auto", "cortex": true },
    "grounding": [
      { "source": "lease-martin.pdf", "chunkId": "ck_8f31", "page": 4, "sha256": "11d10642ac07..." }
    ],
    "egress": {
      "provider": "ollama",
      "model": "qwen2.5:7b",
      "sha256": "876be0679d3e...",
      "pii": { "masked": 0, "categories": [] }
    },
    "response": {
      "sha256": "ae792e49c5b2...",
      "finishReason": "stop",
      "usage": { "promptTokens": 912, "completionTokens": 64, "totalTokens": 976 }
    },
    "routing": { "taskType": "complex", "reason": "task=complex -> configured model (ollama/qwen2.5:7b)" }
  },
  "payload_hash": "5d41a0c3f7e2...",
  "signature": "MEUCIQDx...==",
  "algorithm": "Ed25519",
  "hash": "sha256(canonical-json(payload))",
  "created_at": "2026-10-09T08:12:44.233Z"
}
```

## How the commitments are built

| Hash | Input | Detail |
| --- | --- | --- |
| `request.sha256`, plain calls | Canonical JSON of `{ "model": <your model string>, "messages": <your messages array> }` | Exactly the array you sent, before any masking. Anyone holding your original request can recompute it (see the walkthrough). |
| `request.sha256`, `agent:` calls | Canonical JSON of `{ "agent": <agent id>, "message": <last user message> }` | The agent pipeline binds the certificate to the message the agent answered, not to the whole array. |
| `egress.sha256` | Canonical JSON of `{ "systemPrompt", "messages": [{ "role", "content" }] }` after masking | Computed by the Gateway with the active shield mode. See [Synapse](https://dev.subsidia.protypa.fr/docs/synapse.md) for what is masked. |
| `response.sha256` | The answer text as UTF-8, personal data restored | What you received. |
| `grounding[].sha256` | The text of each retrieved passage | Lets you check the passage against the source document. |
| `payload_hash` | Canonical JSON of the whole `payload` | What the signature covers. |

### Canonical JSON

Verifying anything requires reproducing the exact bytes that were hashed. The rule is short:

1. Strings, numbers, booleans and `null` are serialised with standard JSON (`JSON.stringify` semantics).
2. Arrays keep their order: `[a,b,c]` with no spaces.
3. Objects have their keys **sorted** in ascending order, recursively, and any key whose value is `undefined` is dropped: `{"a":1,"b":2}` with no spaces.
4. The result is encoded as UTF-8 before hashing.

In Python, `json.dumps(value, sort_keys=True, separators=(',', ':'), ensure_ascii=False)` produces the same bytes for the integers, strings and nulls that certificates contain. `ensure_ascii=False` matters as soon as an agent name or a source file name has an accent.

## Signature and chain

**Signature.** The Gateway signs the 64-character hex `payload_hash` string (as UTF-8 bytes, not the raw digest) with an Ed25519 private key generated on first start and stored with the installation. The matching public key is served openly at `GET /v1/gateway/public-key` in SPKI PEM form.

**Chain.** Every attestation the engine issues, a completion, a [verification report](https://dev.subsidia.protypa.fr/docs/verify.md), a [register export](https://dev.subsidia.protypa.fr/docs/registre.md), is appended to one sequence. Each document includes its `chainIndex` and the `payload_hash` of the previous one as `prevHash`. Two consequences worth knowing:

- The chain is **global to the installation**, not per workspace. Your own certificates are interleaved with others', so consecutive ids of yours do not have consecutive indexes. You can only rebuild a link offline when you hold both neighbouring certificates.
- A certificate you are not allowed to read (another workspace's) is indistinguishable from a missing one. The server-side verification below checks the link for you, because it can see the whole chain.

## Endpoints

### GET /v1/proofs/:id

Fetch a proof certificate.

Returns the signed certificate for a proof id. Certificates are scoped to the workspace of the key: another workspace's proof returns the same 404 as an unknown id, so ids cannot be probed.

Store the certificate next to the record the answer fed. The server keeps it, but a copy in your own archive is what lets an auditor verify without any access to your account.

- **Authentication:** API key (Bearer or x-api-key)
- **Scopes:** `engine`

#### Path parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `id` | `string` | yes |  | The value of the `x-pulse-proof` header: `pf_` followed by 32 hex characters. |

#### Request examples

_curl_

```bash
curl https://api.subsidia.protypa.fr/v1/proofs/pf_a059713a5f1c4e0b8d7a3c21e9b64f10 \
  -H "Authorization: Bearer $SUBSIDIA_API_KEY" \
  -o certificate.json
```

_TypeScript_

```typescript
const proofId = 'pf_a059713a5f1c4e0b8d7a3c21e9b64f10' // from the x-pulse-proof header

const res = await fetch('https://api.subsidia.protypa.fr/v1/proofs/' + proofId, {
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
})
if (res.status === 404) throw new Error('unknown proof, or it belongs to another workspace')
const certificate = await res.json()
console.log(certificate.chain_index, certificate.payload.egress.provider)
```

_Python_

```python
import os, requests

proof_id = "pf_a059713a5f1c4e0b8d7a3c21e9b64f10"  # from the x-pulse-proof header

res = requests.get(
    "https://api.subsidia.protypa.fr/v1/proofs/" + proof_id,
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
)
res.raise_for_status()
certificate = res.json()
print(certificate["chain_index"], certificate["payload"]["egress"]["provider"])
```

#### Responses

**200**: The certificate envelope described above.

```json
{
  "id": "pf_a059713a5f1c4e0b8d7a3c21e9b64f10",
  "chain_index": 4182,
  "prev_hash": "90a8ecd956f1...",
  "payload": { "v": 1, "issuer": "subsidia-gateway", "...": "..." },
  "payload_hash": "5d41a0c3f7e2...",
  "signature": "MEUCIQDx...==",
  "algorithm": "Ed25519",
  "hash": "sha256(canonical-json(payload))",
  "created_at": "2026-10-09T08:12:44.233Z"
}
```

**404**: Unknown id, or the certificate belongs to another workspace (`proof_not_found`).

```json
{ "error": { "message": "proof not found", "type": "invalid_request_error", "code": "proof_not_found", "param": null } }
```

#### Errors

| Status | Code | When |
| --- | --- | --- |
| 401 |  | No key, wrong key, revoked or expired key. |
| 403 | `scope_denied` | The key does not carry the `engine` scope. |
| 404 | `proof_not_found` | Unknown id or another workspace. |

### GET /v1/proofs/:id/verify

Verify a certificate on the server.

Recomputes the payload hash, checks the Ed25519 signature against the Gateway's key, and checks that the previous record in the chain still exists with the hash this certificate points to. It is the quick check. Because it runs on the server it asks you to trust the server; for an independent check use the offline walkthrough below.

- **Authentication:** API key (Bearer or x-api-key)
- **Scopes:** `engine`

#### Path parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `id` | `string` | yes |  | Certificate id. |

#### Request examples

_curl_

```bash
curl https://api.subsidia.protypa.fr/v1/proofs/$PROOF_ID/verify \
  -H "Authorization: Bearer $SUBSIDIA_API_KEY"
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr/v1/proofs/' + proofId + '/verify', {
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
})
const verdict = await res.json()
if (!verdict.valid) console.error('Do not rely on this certificate:', verdict.reason)
```

_Python_

```python
import os, requests

verdict = requests.get(
    "https://api.subsidia.protypa.fr/v1/proofs/" + proof_id + "/verify",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
).json()
if not verdict["valid"]:
    print("Do not rely on this certificate:", verdict["reason"])
```

#### Responses

**200**: `valid` is true only when both the signature and the chain link hold. `reason` is null when valid, otherwise a short explanation.

```json
{
  "id": "pf_a059713a5f1c4e0b8d7a3c21e9b64f10",
  "signature_valid": true,
  "chain_valid": true,
  "valid": true,
  "reason": null
}
```

**404**: Unknown id or another workspace (`proof_not_found`).

#### Errors

| Status | Code | When |
| --- | --- | --- |
| 401 |  | No key, wrong key, revoked or expired key. |
| 403 | `scope_denied` | The key does not carry the `engine` scope. |
| 404 | `proof_not_found` | Unknown id or another workspace. |

#### Notes

Reasons you may see: `payload hash mismatch — document was altered`, `signature does not verify against the gateway public key`, `previous chain record missing or altered`. A certificate with `chain_index` 0 has no previous record to check.

### GET /v1/gateway/public-key

Public key for offline verification.

Open route, no credentials required, on purpose: an auditor holding only a certificate and this key can validate the certificate with no access to your account. Fetch it once and archive it with the certificates, so verification keeps working with no network access.

- **Authentication:** None

#### Request examples

_curl_

```bash
curl https://api.subsidia.protypa.fr/v1/gateway/public-key
```

_TypeScript_

```typescript
const { public_key } = await fetch('https://api.subsidia.protypa.fr/v1/gateway/public-key').then((r) => r.json())
// public_key is a PEM string: "-----BEGIN PUBLIC KEY-----..."
```

_Python_

```python
import requests

public_key_pem = requests.get("https://api.subsidia.protypa.fr/v1/gateway/public-key").json()["public_key"]
```

#### Responses

**200**: The key in SPKI PEM format.

```json
{ "algorithm": "Ed25519", "format": "spki-pem", "public_key": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----\n" }
```

## Verify a certificate offline

This is the check an auditor runs. It needs two files and no network, no account and no trust in the server: the certificate JSON and the public key PEM. It proves that the payload is byte-for-byte what the Gateway signed.

1. **Collect the two files**

   Save the certificate from `GET /v1/proofs/:id` as `certificate.json` and the key from `GET /v1/gateway/public-key` as `public-key.pem` (the `public_key` field). The second call needs no credentials.

   ```bash
   curl -s https://api.subsidia.protypa.fr/v1/proofs/$PROOF_ID \
     -H "Authorization: Bearer $SUBSIDIA_API_KEY" -o certificate.json
   
   curl -s https://api.subsidia.protypa.fr/v1/gateway/public-key \
     | python3 -c "import sys, json; sys.stdout.write(json.load(sys.stdin)['public_key'])" > public-key.pem
   ```

2. **Recompute the payload hash**

   Serialise `payload` as canonical JSON (keys sorted, no spaces), SHA-256 it, and compare with `payload_hash`. A mismatch means the document was altered after signing.

3. **Verify the signature**

   Verify the Ed25519 signature over the **hex string** `payload_hash` (its UTF-8 bytes, not the 32 raw bytes) with the public key. Reject the certificate if either step 2 or step 3 fails.

4. **Optionally bind it to your own data**

   If you kept the request you sent, recompute `request.sha256` and the response hash and compare them with the payload. That proves the certificate is about your exchange and not another.

**Offline verifier**

_Node.js_

```typescript
// node verify.mjs certificate.json public-key.pem
import fs from 'node:fs'
import crypto from 'node:crypto'

const [certPath, keyPath] = process.argv.slice(2)
const cert = JSON.parse(fs.readFileSync(certPath, 'utf8'))
const publicKeyPem = fs.readFileSync(keyPath, 'utf8')

// Keys sorted recursively, undefined dropped, no whitespace.
function canonicalJson(value) {
  if (value === null || typeof value !== 'object') return JSON.stringify(value)
  if (Array.isArray(value)) return '[' + value.map(canonicalJson).join(',') + ']'
  const entries = Object.entries(value)
    .filter(([, v]) => v !== undefined)
    .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
  return '{' + entries.map(([k, v]) => JSON.stringify(k) + ':' + canonicalJson(v)).join(',') + '}'
}

const sha256 = (text) => crypto.createHash('sha256').update(text, 'utf8').digest('hex')

// 1. The payload must hash to payload_hash.
const hashOk = sha256(canonicalJson(cert.payload)) === cert.payload_hash

// 2. The signature covers the hex string payload_hash.
const sigOk =
  hashOk &&
  crypto.verify(
    null, // Ed25519 takes no separate digest algorithm
    Buffer.from(cert.payload_hash, 'utf8'),
    crypto.createPublicKey(publicKeyPem),
    Buffer.from(cert.signature, 'base64'),
  )

// 3. The envelope fields must agree with the signed payload.
const envelopeOk =
  cert.payload.id === cert.id &&
  cert.payload.chainIndex === cert.chain_index &&
  cert.payload.prevHash === cert.prev_hash

console.log('payload hash :', hashOk ? 'match' : 'MISMATCH (document altered)')
console.log('signature    :', sigOk ? 'valid' : 'INVALID')
console.log('envelope     :', envelopeOk ? 'consistent' : 'INCONSISTENT')
console.log('egress       :', cert.payload.egress.provider + '/' + cert.payload.egress.model, '| masked:', cert.payload.egress.pii.masked)
process.exit(hashOk && sigOk && envelopeOk ? 0 : 1)

// Optional 4. Bind to the request you sent (plain calls, not agent: calls):
//   sha256(canonicalJson({ model: cert.payload.request.model, messages: yourMessagesArray }))
//   must equal cert.payload.request.sha256
```

_Python_

```python
# pip install cryptography
# python verify.py certificate.json public-key.pem
import base64
import hashlib
import json
import sys

from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.serialization import load_pem_public_key


def canonical_json(value) -> str:
    # Keys sorted, no whitespace, non-ASCII kept as is (JSON.stringify semantics).
    return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)


def sha256_hex(text: str) -> str:
    return hashlib.sha256(text.encode("utf-8")).hexdigest()


cert_path, key_path = sys.argv[1:3]
with open(cert_path, encoding="utf-8") as f:
    cert = json.load(f)
with open(key_path, "rb") as f:
    public_key = load_pem_public_key(f.read())

# 1. The payload must hash to payload_hash.
hash_ok = sha256_hex(canonical_json(cert["payload"])) == cert["payload_hash"]

# 2. The signature covers the hex string payload_hash (its UTF-8 bytes).
sig_ok = False
if hash_ok:
    try:
        public_key.verify(base64.b64decode(cert["signature"]), cert["payload_hash"].encode("utf-8"))
        sig_ok = True
    except InvalidSignature:
        sig_ok = False

# 3. The envelope fields must agree with the signed payload.
payload = cert["payload"]
envelope_ok = (
    payload["id"] == cert["id"]
    and payload["chainIndex"] == cert["chain_index"]
    and payload["prevHash"] == cert["prev_hash"]
)

print("payload hash :", "match" if hash_ok else "MISMATCH (document altered)")
print("signature    :", "valid" if sig_ok else "INVALID")
print("envelope     :", "consistent" if envelope_ok else "INCONSISTENT")
egress = payload["egress"]
print("egress       :", egress["provider"] + "/" + egress["model"], "| masked:", egress["pii"]["masked"])
sys.exit(0 if (hash_ok and sig_ok and envelope_ok) else 1)
```

_Bind to your request_

```typescript
import crypto from 'node:crypto'
// canonicalJson as above

const messages = [{ role: 'user', content: 'Summarise the termination clause for Jean Dupont.' }]
const requestHash = crypto
  .createHash('sha256')
  .update(canonicalJson({ model: cert.payload.request.model, messages }), 'utf8')
  .digest('hex')

console.log(requestHash === cert.payload.request.sha256 ? 'this certificate is about that request' : 'different request')

// The answer you received, personal data restored:
const answerHash = crypto.createHash('sha256').update(answerText, 'utf8').digest('hex')
console.log(answerHash === cert.payload.response.sha256)
```

> **INFO: Checking the chain offline**
> A single certificate proves itself. To prove the chain around it you need the certificate whose `chain_index` is one less, then check that its `payload_hash` equals this one's `prev_hash`. Because the chain is shared by the whole installation, you will usually hold only your own certificates and not their neighbours. Use `GET /v1/proofs/:id/verify`, which sees the whole chain.

> **TIP: The Gateway repository ships the same verifier**
> `proof/verify.mjs` is a standalone script that does steps 2 and 3 above (`node verify.mjs certificate.json public-key.pem`). The end-to-end demos next to it fetch a certificate, recompute its hash, check the signature and the chain link.

## Two proof levels

| Level | When | What it adds |
| --- | --- | --- |
| 1. Pipeline | Every call | Request, masked egress, provider and model, response, usage and routing reason. |
| 2. Grounding | `agent:` calls and calls with `+cortex` | A `grounding` array committing to the exact knowledge passages the answer drew on. |
| 2+. Trace | `agent:` turns that ran tools | A `trace` committing to every tool step by hash. |

## What a proof does not prove

A certificate is strong evidence about the pipeline, and says nothing beyond it. State the limits when you show one to someone:

- **Not that the answer is correct.** It proves what was answered, not that it is true. To check an answer against your documents, use [Le Vérificateur](https://dev.subsidia.protypa.fr/docs/verify.md).
- **Not the content.** The certificate holds hashes. Whoever holds the original text can confirm a match; whoever holds only the certificate learns the shape of the exchange, not its substance.
- **Not independent of the Gateway.** The signing key belongs to the Gateway. The certificate proves the document was issued by this installation and not edited since; it cannot prove that the installation recorded the truth, for example that the provider received exactly the masked text hashed in `egress.sha256`. That hash is computed by the Gateway, which is the party being audited.
- **Not the provider's behaviour.** Retention, training use and logging on the provider side are governed by your contract with the provider. The certificate tells you which provider and model answered, and whether the call stayed local.
- **Not that the answer used the grounding faithfully.** `grounding` shows which passages were supplied to the model, not that the model quoted them accurately.
- **Not a trusted timestamp.** `issuedAt` comes from the Gateway clock and is signed, but it is not countersigned by an independent time authority. The chain proves order, not wall-clock time.
- **Not completeness of your own records.** A call that was never made through the Gateway has no certificate. The chain exposes deleted certificates, not calls made elsewhere.
- **Not cost.** Billing in questions is not part of the document. Usage tokens are included for transparency.

## Frequently asked

**Is the header present on errors?**

No. A call rejected before any model work (a 400, 402, 403, 404, 429) produces no certificate and no `x-pulse-proof`. Mid-stream failures after the headers went out can leave a proof id with no stored certificate, because the certificate is written only after the answer completes.

**How long are certificates kept?**

They are stored with the installation and fetchable by id for your workspace. Archive the JSON next to your own record if you need a retention period you control.

**Why sign the hex string and not the raw digest?**

It is how the Gateway signs: the Ed25519 message is the 64 ASCII characters of `payload_hash`. If you verify with a library that expects the raw digest you will get a false negative. Pass the string's UTF-8 bytes, as in the examples.

**My canonical JSON does not match in Python.**

Check three things: `sort_keys=True`, `separators=(',', ':')` and `ensure_ascii=False`. Parse the certificate and re-serialise `payload`; never hash the raw text of the file, whose spacing is not the canonical form.

**Can I share a certificate publicly?**

It contains no prompt or answer text, but it does reveal the model string you used, the names of knowledge sources in `grounding`, the provider, and timing. Review those before publishing.

**Do agent turns and Gateway calls produce the same certificate format?**

Yes, one format and one chain. Agent turns add `trace` when tools ran, and record the agent identifier the pipeline holds internally. See [Model addressing](https://dev.subsidia.protypa.fr/docs/model-addressing.md#where-the-address-shows-up-in-a-certificate).
