# Le Registre

> The register of AI usage in your workspace (model, data, date, reviewer, human change), listable through the API and exportable as a signed zip whose attestation commits to the filters.

The AI Act expects an organisation that deploys AI to be able to say, for any output: **which model, on which data, when, validated by whom, and with which human changes**. Le Registre assembles that answer from what Subsidia already records, and exports it as a file an inspector, an insurer or a client can open.

It does not add a second system of record. Four of the five mentions come from data that exists anyway (every model call, every Gateway certificate, the access journal). The fifth, human review, is captured where it is honest to capture it: at the bottom of a [Vérificateur](https://dev.subsidia.protypa.fr/docs/verify.md) report, in the app.

## The five mentions

| Mention | Where it comes from | Field in a row |
| --- | --- | --- |
| Which model | Every model call writes a log line: provider, model, task, tokens, estimated cost. | `provider`, `model`, `taskType`, `promptTokens`, `completionTokens`, `totalTokens`, `estimatedCostUsd` |
| On which data | For calls through the [Gateway](https://dev.subsidia.protypa.fr/docs/gateway.md), the proof certificate commits to a hash of every passage the answer drew on. | `proofId`, `grounding` (number of passages committed) |
| When, by whom | The log line carries the time, the user and the surface; personal data masked before leaving is counted. | `at`, `userId`, `userEmail`, `source`, `piiCount` |
| Validated by whom | Human reviews recorded in the app: decision `accepted`, `modified` or `rejected`, reviewer and note. | `reviews[].decision`, `reviewerEmail`, `note`, `at` |
| Which human changes | The `modified` decision and its note. | `reviews[].decision = "modified"`, `reviews[].note` |

> **WARNING: What the register does not claim**
> "On which data" is complete only for calls that went through the Gateway, whose certificate hashes each passage. For calls made inside the app the register knows the surface (`source`) and the turn, not the list of documents read, and `proofId` and `grounding` are `null`. The attestation says so in plain text. A register that pretended otherwise would be worse than none.

## Access, plan and scope

The routes need an API key with the **`proof`** scope (keys created before scopes existed also work). The register is a plan feature: it is included in the **Cabinet** plan, and a workspace on another plan gets `402` with `code: "PLAN_UPGRADE_REQUIRED"`; the key does not bypass it. Every export is written to the workspace access journal.

Within the app, reading the register is a right of every member of the workspace, for the same reason as the access journal: a register only the manager can read is not a counterweight.

## Endpoints

### GET /v1/registre

List register rows, newest first.

One row per model call of the workspace, with the proof reference and the human reviews attached. Filters combine with AND.

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

#### Query parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `from` | `string` | no |  | Start of the period, ISO 8601 (`2026-09-01` or a full timestamp). Inclusive. |
| `to` | `string` | no |  | End of the period, ISO 8601. Inclusive. A bare date means midnight at the start of that day. |
| `model` | `string` | no |  | Exact model name, for example `gpt-4o-mini`. |
| `provider` | `string` | no |  | Exact provider name, for example `openai` or `ollama`. |
| `source` | `string` | no |  | The surface that made the call, as recorded in the register (the `source` field of a row). |
| `review` | `string` | no |  | Keep only rows that have, or have not, at least one human review. Applied to the page after it is read: `total` still counts all rows matching the other filters. One of: `reviewed`, `unreviewed`. |
| `page` | `integer` | no | `1` | Page number, from 1. |
| `pageSize` | `integer` | no | `50` | Between 1 and 500. |

#### Request examples

_curl_

```bash
curl "https://api.subsidia.protypa.fr/v1/registre?from=2026-09-01&to=2026-10-01&provider=openai&pageSize=100" \n  -H "Authorization: Bearer $SUBSIDIA_API_KEY"
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/v1/registre' + '?from=2026-09-01&to=2026-10-01&provider=openai&pageSize=100', {
  method: 'GET',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
})
const data = await res.json()
console.log(res.status, data)
```

_Python_

```python
import os, requests

res = requests.get(
    "https://api.subsidia.protypa.fr" + "/v1/registre" + "?from=2026-09-01&to=2026-10-01&provider=openai&pageSize=100",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
)
print(res.status_code, res.json())
```

#### Responses

**200**: A page of rows.

```json
{
  "rows": [
    {
      "id": "slog_7c1e90",
      "at": "2026-09-18T14:02:11.000Z",
      "provider": "openai",
      "model": "gpt-4o-mini",
      "taskType": "chat",
      "source": "gateway",
      "userId": "usr_21a",
      "userEmail": "claire@cabinet.example",
      "promptTokens": 1840,
      "completionTokens": 312,
      "totalTokens": 2152,
      "estimatedCostUsd": 0.000738,
      "piiCount": 3,
      "turnId": "pf_a059713a5f1c4e0b8d7a3c21e9b64f10",
      "proofId": "pf_a059713a5f1c4e0b8d7a3c21e9b64f10",
      "grounding": 4,
      "reviews": [
        { "decision": "modified", "reviewerEmail": "paul@cabinet.example", "note": "Montant corrige", "at": "2026-09-18T15:40:00.000Z" }
      ]
    }
  ],
  "total": 1284,
  "page": 1,
  "pageSize": 100
}
```

#### Errors

| Status | Code | When |
| --- | --- | --- |
| 401 |  | Missing or invalid key. |
| 402 | `PLAN_UPGRADE_REQUIRED` | The workspace plan does not include the proof journal. |
| 403 | `scope_denied` | The key does not carry the `proof` scope. |

#### Notes

The `source` strings are the surfaces recorded by the platform (for example `agent_chat` for agent conversations). Read them from your own rows rather than hard-coding a list.

### GET /v1/registre/summary

Totals for the same filters.

What a supervisor reads first: how many calls, how many went to an external provider, how much personal data was masked, how many reviews exist. `externalCalls` counts calls to `openai`, `anthropic`, `mistral`, `groq` and `azure`; everything else ran on the machine.

- **Authentication:** API key
- **Scopes:** `proof`

#### Query parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `from` | `string` | no |  | Start of the period, ISO 8601 (`2026-09-01` or a full timestamp). Inclusive. |
| `to` | `string` | no |  | End of the period, ISO 8601. Inclusive. A bare date means midnight at the start of that day. |
| `model` | `string` | no |  | Exact model name, for example `gpt-4o-mini`. |
| `provider` | `string` | no |  | Exact provider name, for example `openai` or `ollama`. |
| `source` | `string` | no |  | The surface that made the call, as recorded in the register (the `source` field of a row). |

#### Request examples

_curl_

```bash
curl "https://api.subsidia.protypa.fr/v1/registre/summary?from=2026-09-01&to=2026-10-01" \n  -H "Authorization: Bearer $SUBSIDIA_API_KEY"
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/v1/registre/summary' + '?from=2026-09-01&to=2026-10-01', {
  method: 'GET',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
})
const data = await res.json()
console.log(res.status, data)
```

_Python_

```python
import os, requests

res = requests.get(
    "https://api.subsidia.protypa.fr" + "/v1/registre/summary" + "?from=2026-09-01&to=2026-10-01",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
)
print(res.status_code, res.json())
```

#### Responses

**200**: The summary.

```json
{
  "summary": {
    "calls": 1284,
    "totalTokens": 3150012,
    "estimatedCostUsd": 1.92,
    "piiMasked": 407,
    "externalCalls": 212,
    "byModel": [
      { "model": "qwen2.5:7b", "provider": "ollama", "calls": 1072, "totalTokens": 2480110 },
      { "model": "gpt-4o-mini", "provider": "openai", "calls": 212, "totalTokens": 669902 }
    ],
    "bySource": [
      { "source": "gateway", "calls": 900 },
      { "source": "agent_chat", "calls": 384 }
    ],
    "reviewed": 96
  }
}
```

#### Errors

| Status | Code | When |
| --- | --- | --- |
| 402 | `PLAN_UPGRADE_REQUIRED` | The workspace plan does not include the proof journal. |
| 403 | `scope_denied` | The key does not carry the `proof` scope. |

#### Notes

`reviewed` counts human reviews recorded in the period, not rows.

### GET /v1/registre/export

The signed export, as a zip.

The file to hand to an inspector. It holds up to 5,000 rows matching the filters (narrow the period for larger volumes and export in several parts: each part carries its own filters in its attestation).

- **Authentication:** API key
- **Scopes:** `proof`

#### Query parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `from` | `string` | no |  | Start of the period, ISO 8601 (`2026-09-01` or a full timestamp). Inclusive. |
| `to` | `string` | no |  | End of the period, ISO 8601. Inclusive. A bare date means midnight at the start of that day. |
| `model` | `string` | no |  | Exact model name, for example `gpt-4o-mini`. |
| `provider` | `string` | no |  | Exact provider name, for example `openai` or `ollama`. |
| `source` | `string` | no |  | The surface that made the call, as recorded in the register (the `source` field of a row). |
| `review` | `string` | no |  | Keep only reviewed or unreviewed rows. One of: `reviewed`, `unreviewed`. |

#### Request examples

_curl_

```bash
curl "https://api.subsidia.protypa.fr/v1/registre/export?from=2026-09-01&to=2026-10-01" \n  -H "Authorization: Bearer $SUBSIDIA_API_KEY" \n  -o registre.zip
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/v1/registre/export' + '?from=2026-09-01&to=2026-10-01', {
  method: 'GET',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
})
const buffer = Buffer.from(await res.arrayBuffer())
await writeFile('registre.zip', buffer)
```

_Python_

```python
import os, requests

res = requests.get(
    "https://api.subsidia.protypa.fr" + "/v1/registre/export" + "?from=2026-09-01&to=2026-10-01",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
)
open('registre.zip', 'wb').write(res.content)
```

#### Responses

**200**: `application/zip`, named `registre-usage-ia-<date>.zip`, with the four files described below.

#### Errors

| Status | Code | When |
| --- | --- | --- |
| 402 | `PLAN_UPGRADE_REQUIRED` | The workspace plan does not include the proof journal. |
| 403 | `scope_denied` | The key does not carry the `proof` scope. |

## What is in the zip

| File | Content |
| --- | --- |
| `registre.csv` | One row per call. Semicolon-separated with a UTF-8 byte-order mark so a French Excel opens it with accents intact. Columns: `date`, `fournisseur`, `modele`, `tache`, `surface`, `utilisateur`, `jetons_entree`, `jetons_sortie`, `jetons_total`, `cout_usd`, `entites_masquees`, `certificat`, `passages_engages`, `relecture`, `relu_par`, `note`. |
| `registre.json` | The same rows as machine-readable JSON, with the filters that produced them and the summary. |
| `attestation.txt` | The attestation in plain French for a reader who does not know what a hash is: period, row count, external calls, masked entities, reviews, and what the register does and does not establish. |
| `attestation.json` | The signed record: `payload`, `payloadHash`, `signature`. |

> **INFO: If the signature could not be issued**
> The zip is still produced, but `attestation.txt` says that the extraction is **not signed** and there is no `attestation.json`. The data is accurate; nothing proves it was not altered afterwards.

## The attestation and why it commits to the filters

The attestation is signed with the same Ed25519 key and chained in the same hash chain as [Gateway proof certificates](https://dev.subsidia.protypa.fr/docs/proofs.md): each one embeds the hash of the previous record, so removing one leaves a visible hole.

Its payload commits to the exact fingerprint of `registre.csv` and `registre.json` (`csvSha256`, `jsonSha256`), to the row count, to a short summary, **and to the filters** (`from`, `to`, `model`, `provider`, `source`, `review`). The filters are in the signed part on purpose. Without them, an extract limited to September, to one model, or to reviewed rows only could be presented as the whole register. With them, the recipient can read in a signed document exactly what the extract is a slice of.

**attestation.json (abridged)**

```json
{
  "payload": {
    "v": 1,
    "id": "rg_3f9a1c0b7d2e4a6851be90cd",
    "issuer": "subsidia-registre",
    "kind": "usage-register",
    "issuedAt": "2026-10-09T09:05:00.000Z",
    "chainIndex": 58,
    "prevHash": "e07b...",
    "workspaceId": "ws_...",
    "issuedBy": "usr_21a",
    "filters": { "from": "2026-09-01T00:00:00.000Z", "to": "2026-10-01T00:00:00.000Z", "model": null, "provider": null, "source": null, "review": null },
    "rowCount": 1284,
    "summary": { "calls": 1284, "totalTokens": 3150012, "externalCalls": 212, "piiMasked": 407, "reviewed": 96 },
    "csvSha256": "5c1d...",
    "jsonSha256": "b82e..."
  },
  "payloadHash": "sha256(canonical-json(payload))",
  "signature": "base64 Ed25519 over payloadHash"
}
```

## Verify an export

Three checks, none of which needs an account. Fetch the public key once from `GET /v1/gateway/public-key` (open route).

1. The SHA-256 of each file equals `csvSha256` and `jsonSha256`: the files were not edited.
2. The SHA-256 of the canonical JSON of `payload` equals `payloadHash`: the attestation was not edited.
3. The Ed25519 signature over `payloadHash` verifies with the public key: Subsidia issued it.

Then read `payload.filters` and `payload.rowCount` to know what the extract covers.

_Node.js_

```typescript
import crypto from 'node:crypto'
import { readFileSync } from 'node:fs'

function canonicalJson(v: unknown): string {
  if (v === null || typeof v !== 'object') return JSON.stringify(v)
  if (Array.isArray(v)) return '[' + v.map(canonicalJson).join(',') + ']'
  const entries = Object.entries(v as Record<string, unknown>)
    .filter(([, x]) => x !== undefined)
    .sort(([a], [b]) => (a < b ? -1 : 1))
  return '{' + entries.map(([k, x]) => JSON.stringify(k) + ':' + canonicalJson(x)).join(',') + '}'
}
const sha256 = (s: string) => crypto.createHash('sha256').update(s, 'utf8').digest('hex')

// files extracted from the zip into the current folder
const csv = readFileSync('registre.csv', 'utf8')
const json = readFileSync('registre.json', 'utf8')
const att = JSON.parse(readFileSync('attestation.json', 'utf8'))
const publicKeyPem = readFileSync('public-key.pem', 'utf8') // from GET /v1/gateway/public-key

const filesOk = sha256(csv) === att.payload.csvSha256 && sha256(json) === att.payload.jsonSha256
const payloadOk = sha256(canonicalJson(att.payload)) === att.payloadHash
const signatureOk = crypto.verify(
  null,
  Buffer.from(att.payloadHash, 'utf8'),
  crypto.createPublicKey(publicKeyPem),
  Buffer.from(att.signature, 'base64'),
)

console.log({ filesOk, payloadOk, signatureOk, filters: att.payload.filters, rows: att.payload.rowCount })
```

_Python_

```python
import base64, hashlib, json
from cryptography.hazmat.primitives.serialization import load_pem_public_key


def canonical(v):
    if isinstance(v, dict):
        items = sorted(v.items())
        return "{" + ",".join(json.dumps(k, ensure_ascii=False) + ":" + canonical(x) for k, x in items) + "}"
    if isinstance(v, list):
        return "[" + ",".join(canonical(x) for x in v) + "]"
    return json.dumps(v, ensure_ascii=False, separators=(",", ":"))


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


csv_text = open("registre.csv", encoding="utf-8", newline="").read()
json_text = open("registre.json", encoding="utf-8", newline="").read()
att = json.load(open("attestation.json", encoding="utf-8"))
public_key = load_pem_public_key(open("public-key.pem", "rb").read())

files_ok = sha256(csv_text) == att["payload"]["csvSha256"] and sha256(json_text) == att["payload"]["jsonSha256"]
payload_ok = sha256(canonical(att["payload"])) == att["payloadHash"]
public_key.verify(base64.b64decode(att["signature"]), att["payloadHash"].encode("utf-8"))  # raises if invalid
print(files_ok, payload_ok, att["payload"]["filters"], att["payload"]["rowCount"])
```

> **TIP: Read files without altering them**
> The hash covers the exact characters of the files, byte-order mark included. Read them as UTF-8 text without newline translation and do not open and re-save them in a spreadsheet before checking.

## Recording a human review

Reviews are recorded in the app, at the bottom of a Vérificateur report, with the reviewer taken from the session and never from a request field: a review that can be signed in someone else's name is worthless. They are returned by `GET /v1/verify/:id` and appear in the `reviews` array of register rows and in the export. There is no API route to write a review.

## Frequently asked

**Is the register the same as the access journal?**

No. The access journal records who or what read which client file. The register records AI calls: model, volume, masking, proof, review. They share the same philosophy (open to every member, AI reads and human reads distinguished) but answer different questions.

**Does the register contain the prompts or answers?**

No. It holds metadata and references: the proof id and the number of committed passages, not their content, and not the question or the answer.

**Why are token counts and estimated cost in it?**

They are what the platform measured for the call and are useful to an auditor to size usage. They are not billing amounts: customers are billed in questions, see [Rate limits and quotas](https://dev.subsidia.protypa.fr/docs/rate-limits.md).
