# Le Vérificateur

> Check every claim of a text against passages you send or documents in Cortex, with zero model calls, and get a report signed in the Subsidia proof chain.

Le Vérificateur takes a text from anywhere (an answer your own RAG produced, a note written by a chatbot, a draft letter) and gives **every claim in it a verdict** against a set of passages. The result is a report, signed in the same hash chain as Gateway certificates, that an auditor can recheck without trusting Subsidia.

You do not need Cortex. If you already run your own retrieval (Qdrant, pgvector, Elasticsearch, anything), send the text and the passages your retrieval returned. If the documents live in Subsidia, name the collections instead.

## The four verdicts

A text is cut into claims (one sentence per non-structural line). A claim is judged on its **anchors**: the values a person copies and a model invents, meaning numbers, amounts, dates, references and proper names. Each anchor gets a verdict; the claim takes the worst of its anchors.

| Verdict | Rule | What to do with it |
| --- | --- | --- |
| `verified` | Every anchor of the claim was found in a passage that talks about the same subject. The evidence gives the source, the page, and an exact quote around the value. | Nothing. Show the source next to the claim. |
| `unsupported` | The claim has anchors, and at least one is carried by no passage. | Source it or remove it. Nothing in the passages says this. |
| `contradicted` | A passage on the same subject carries a **different value with the same unit**, and does not also carry the announced one. The anchor result shows the value found in `found`. | Handle before the text goes out. |
| `unverifiable` | The claim has no anchor: reasoning, opinion, a question, a courtesy phrase. | Out of scope. This is information, not a defect. |

> **INFO: Why contradicted is deliberately shy**
> Saying "contradicted" wrongly destroys trust faster than any silence, so the rule is narrow. It only fires when the anchor is numeric **and has a unit**, the passage shares at least two content words with the claim, the passage carries another number of the same unit, and the passage does not also contain the announced value (a table listing both lines is not in conflict with itself). Dates are excluded on purpose: a file legitimately holds many neighbouring dates. When in doubt the verdict is `unsupported`, never `contradicted`.

## Zero model calls, and why

Every verdict is computed deterministically; no AI model is called at any point. A provenance guarantee produced by a model is one you would have to believe. This one gives the same result on two runs, costs no tokens, runs on a machine with no model loaded, and a third party who holds the text and the passages recomputes exactly your verdicts.

It follows that Le Vérificateur:

- **never rewrites** the text and never suggests a correction (suggesting one is generating);
- **never judges substance**: it says where a value comes from, not whether the clause is wise;
- **never gives a percentage or a global score**: you get counts (12 verified, 3 unsupported, 1 contradicted), because a percentage invites nobody to read the detail.

## Where the passages come from

| `coverage.mode` | How you get it | What the report attests |
| --- | --- | --- |
| `provided` | You send `passages`. | This text, checked against **these** passages, gave these verdicts. It does not attest that the passages come from your client's documents: Subsidia never saw those documents, and the report says so. The full set of passages you sent is committed in the certificate (`pool.sha256`), so nobody can claim afterwards that a passage was missing. |
| `exhaustive` | You send `collectionIds` and the collections hold at most 1,500 passages. | Checked against the **whole** content of those collections, read from the documents themselves. |
| `retrieved` | You send `collectionIds` that are too large, or none (the whole workspace). | Checked against what a search returned for each claim (top 4 passages per claim). The report states this: a check whose scope is unknown is not opposable. |

> **INFO: Walls hold**
> In Cortex mode, a collection you name but your user may not read is dropped from the scope, and the report says what it actually covered. You cannot bypass a restricted collection by naming it.

## Controls, not questions

A check calls no model, so it is not billed as a question. One report is **one control**, wherever it comes from (the app or the API), counted per month and shown separately. A key's monthly question ceiling does not count controls. `GET /v1/verify/usage` returns this month's count. For integrators, controls appear on each client statement ([Partner API](https://dev.subsidia.protypa.fr/docs/partner.md)).

> **INFO: Pricing**
> At the time of writing, controls are counted and shown on statements but no price is charged for them. This page will state the price when it is set.

## Authentication and scope

All routes below need a developer API key (`Authorization: Bearer sk_live_...` or `x-api-key: sk_live_...`) carrying the **`proof`** scope. Keys created before scopes existed keep reaching them. A read-only key may call `POST /v1/verify`, since a check changes nothing in your data. See [Authentication](https://dev.subsidia.protypa.fr/docs/authentication.md).

CORS is open on these routes, so an integrator's own web application can call them from the browser (never ship a live key to a browser you do not control).

## Endpoints

### POST /v1/verify

Check a text against passages or Cortex collections; returns a signed report.

Gives every claim of `text` a verdict. Send `passages` (what your own retrieval returned) **or** `collectionIds` (Cortex collections), not both. With neither, the check runs against the whole workspace in `retrieved` mode.

The report is saved, signed and returned with its certificate inline, so one call is enough to get the verdicts and the proof. Limited to 60 requests per minute per key.

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

#### Request body

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `text` | `string` | yes |  | The text to check. Only the first 60,000 characters are analysed (the report then has `coverage.truncated: true`), and at most 150 claims. A text longer than 240,000 characters is refused with 413. |
| `title` | `string` | no |  | Up to 160 characters, shown in the app and in the dossier. Defaults to the first line of the text. It is never written into the certificate. |
| `passages` | `object[]` | no |  | What to check against: 1 to 1,500 passages, each up to 20,000 characters, 3,000,000 characters in total. |
| `passages.source` | `string` | yes |  | The document the passage comes from (up to 300 characters). Shown in the report. |
| `passages.content` | `string` | yes |  | The passage text. |
| `passages.id` | `string` | no |  | Your own passage or chunk id. Defaults to `p1`, `p2`... Must be unique in the request. |
| `passages.sourceId` | `string` | no |  | Your document id. Defaults to `source`. |
| `passages.page` | `integer` | no |  | Page number, returned in the evidence. |
| `passages.section` | `string` | no |  | Section label, returned in the evidence. |
| `collectionIds` | `string[]` | no |  | Cortex mode: the collections to check against. Ignored if `passages` is sent without it; sending both is a 400. |

#### Request examples

_curl_

```bash
curl -X POST https://api.subsidia.protypa.fr/v1/verify \
  -H "Authorization: Bearer $SUBSIDIA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Synthese bail commercial",
    "text": "Le loyer mensuel est de 1 250 €. Le préavis du preneur est de 30 jours.",
    "passages": [
      {
        "id": "qdrant-17",
        "source": "Bail commercial.pdf",
        "page": 3,
        "content": "Le loyer mensuel du lot est de 1 250 €. Le préavis du preneur est de 90 jours."
      }
    ]
  }'
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr/v1/verify', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    title: 'Synthese bail commercial',
    text: answerFromMyRag,
    // the passages your own retrieval returned for that answer
    passages: hits.map((h) => ({
      id: h.id,
      source: h.documentName,
      page: h.page,
      content: h.text,
    })),
  }),
})

if (res.status !== 201) throw new Error('verify failed: ' + res.status)
const { report, certificate } = await res.json()

const bad = report.claims.filter((c) => c.verdict === 'contradicted' || c.verdict === 'unsupported')
console.log(report.counts, 'proof:', report.proofId, 'to review:', bad.length)
```

_Python_

```python
import os, requests

res = requests.post(
    "https://api.subsidia.protypa.fr/v1/verify",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
    json={
        "title": "Synthese bail commercial",
        "text": answer_from_my_rag,
        "passages": [
            {"id": h["id"], "source": h["document"], "page": h.get("page"), "content": h["text"]}
            for h in hits
        ],
    },
)
res.raise_for_status()
data = res.json()
print(data["report"]["counts"], data["report"]["proofId"])
```

#### Responses

**201**: The saved report and its certificate. `certificate` is `null` only if signing failed, in which case `report.proofId` is `null` too: you still get the verdicts, announced as unsigned.

```json
{
  "report": {
    "id": "cm2k9x3a40001abcd1234efgh",
    "title": "Synthese bail commercial",
    "createdAt": "2026-10-09T08:14:03.221Z",
    "counts": { "claims": 2, "verified": 1, "unsupported": 0, "contradicted": 1, "unverifiable": 0 },
    "claims": [
      {
        "index": 1,
        "line": 1,
        "text": "Le loyer mensuel est de 1 250 €.",
        "verdict": "verified",
        "anchors": [
          {
            "text": "1 250 €",
            "verdict": "verified",
            "evidence": {
              "sourceId": "Bail commercial.pdf",
              "sourceName": "Bail commercial.pdf",
              "section": null,
              "page": 3,
              "chunkId": "qdrant-17",
              "quote": "Le loyer mensuel du lot est de 1 250 €. Le préavis du preneur est de 90 jours."
            }
          }
        ]
      },
      {
        "index": 2,
        "line": 1,
        "text": "Le préavis du preneur est de 30 jours.",
        "verdict": "contradicted",
        "anchors": [
          {
            "text": "30 jours",
            "verdict": "contradicted",
            "found": "90 jours",
            "evidence": {
              "sourceId": "Bail commercial.pdf",
              "sourceName": "Bail commercial.pdf",
              "section": null,
              "page": 3,
              "chunkId": "qdrant-17",
              "quote": "Le loyer mensuel du lot est de 1 250 €. Le préavis du preneur est de 90 jours."
            }
          }
        ]
      }
    ],
    "coverage": { "mode": "provided", "chunks": 1, "sources": 1, "collectionIds": [], "truncated": false },
    "proofId": "vr_cm2k9x3a40001abcd1234efgh",
    "payloadHash": "9f2c41d0a7..."
  },
  "certificate": {
    "id": "vr_cm2k9x3a40001abcd1234efgh",
    "chain_index": 41,
    "prev_hash": "5be1c07d33...",
    "payload": { "kind": "verification", "issuer": "subsidia-verifier" },
    "payload_hash": "9f2c41d0a7...",
    "signature": "base64...",
    "algorithm": "Ed25519",
    "hash": "sha256(canonical-json(payload))",
    "created_at": "2026-10-09T08:14:03.240Z"
  },
  "certificate_check_url": "https://subsidia.protypa.fr/certificat"
}
```

**400**: Invalid request. `code` tells which rule failed.

```json
{ "error": "passages[0].source is required (the document the passage comes from)", "code": "invalid_passages" }
```

**413**: Text longer than 240,000 characters (`text_too_large`), or request body over 4 MB.

#### Errors

| Status | Code | When |
| --- | --- | --- |
| 400 | `text_required` | `text` is missing or blank. |
| 400 | `invalid_passages` | `passages` is empty, not an array, has more than 1,500 entries, an entry without `source` or `content`, a duplicate `id`, or exceeds the size limits. |
| 400 | `ambiguous_scope` | Both `passages` and `collectionIds` were sent. |
| 401 |  | Missing, invalid, revoked or expired key. |
| 403 | `scope_denied` | The key does not carry the `proof` scope. |
| 413 | `text_too_large` | The text exceeds 240,000 characters. |
| 429 | `rate_limited` | More than 60 requests per minute from this key, or the key's own requests-per-minute limit. Honour `retry-after`. |

#### Notes

The call is also written to the workspace access journal (action `light.verify`, with `via: api`). Only the verdict counts and the proof id are logged, not the text.

### GET /v1/verify

List the reports of the workspace, newest first.

Returns summaries only (no text, no claims). Fetch one report to get the verdicts.

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

#### Query parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `page` | `integer` | no | `1` | Page number, starting at 1. |
| `pageSize` | `integer` | no | `20` | Between 1 and 100. |

#### Request examples

_curl_

```bash
curl "https://api.subsidia.protypa.fr/v1/verify?page=1&pageSize=20" -H "Authorization: Bearer $SUBSIDIA_API_KEY"
```

_TypeScript_

```typescript
const { reports, total } = await fetch('https://api.subsidia.protypa.fr/v1/verify?pageSize=50', {
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
}).then((r) => r.json())
```

_Python_

```python
import os, requests

data = requests.get(
    "https://api.subsidia.protypa.fr/v1/verify",
    params={"pageSize": 50},
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
).json()
print(data["total"], [r["id"] for r in data["reports"]])
```

#### Responses

**200**: A page of report summaries.

```json
{
  "reports": [
    {
      "id": "cm2k9x3a40001abcd1234efgh",
      "title": "Synthese bail commercial",
      "counts": { "claims": 2, "verified": 1, "unsupported": 0, "contradicted": 1, "unverifiable": 0 },
      "coverage": { "mode": "provided", "chunks": 1, "sources": 1, "collectionIds": [], "truncated": false },
      "proofId": "vr_cm2k9x3a40001abcd1234efgh",
      "payloadHash": "9f2c41d0a7...",
      "createdAt": "2026-10-09T08:14:03.221Z"
    }
  ],
  "total": 1,
  "page": 1,
  "pageSize": 20
}
```

#### Errors

| Status | Code | When |
| --- | --- | --- |
| 401 |  | Missing or invalid key. |
| 403 | `scope_denied` | The key does not carry the `proof` scope. |

### GET /v1/verify/usage

Controls used this calendar month (UTC).

The number of reports created by the workspace since the first of the month, UTC. This is what an integrator will see on the client statement.

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

#### Request examples

_curl_

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

_Python_

```python
import os, requests

usage = requests.get(
    "https://api.subsidia.protypa.fr/v1/verify/usage",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
).json()
print(usage["month"], usage["controls"])
```

#### Responses

**200**: The month and its control count.

```json
{ "month": "2026-10", "controls": 128 }
```

#### Errors

| Status | Code | When |
| --- | --- | --- |
| 403 | `scope_denied` | The key does not carry the `proof` scope. |

### GET /v1/verify/:id

Get one report with its human reviews.

Returns the stored report (text, verdicts, counts, coverage, proof id) and the human reviews recorded against it, oldest first. Reviews are the fifth mention of the [AI register](https://dev.subsidia.protypa.fr/docs/registre.md): who read the report, and what they decided.

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

#### Path parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `id` | `string` | yes |  | The report `id` returned by `POST /v1/verify`. |

#### Request examples

_curl_

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

_TypeScript_

```typescript
const { report, reviews } = await fetch('https://api.subsidia.protypa.fr/v1/verify/' + reportId, {
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
}).then((r) => r.json())
```

_Python_

```python
import os, requests

data = requests.get(
    "https://api.subsidia.protypa.fr/v1/verify/" + report_id,
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
).json()
print(data["report"]["counts"], len(data["reviews"]))
```

#### Responses

**200**: The report and its reviews. A review has `decision` (`accepted`, `modified` or `rejected`), `reviewerEmail`, `note` and `createdAt`.

```json
{ "report": { "id": "cm2k9x3a40001abcd1234efgh", "title": "Synthese bail commercial", "counts": { "claims": 2, "verified": 1, "unsupported": 0, "contradicted": 1, "unverifiable": 0 }, "proofId": "vr_cm2k9x3a40001abcd1234efgh" }, "reviews": [] }
```

**404**: No such report in this workspace.

```json
{ "error": "Report not found" }
```

### GET /v1/verify/:id/certificate

A report's certificate, and its server-side verification.

Returns the signed certificate and the result of checking it: the Ed25519 signature, and the link to the previous entry of the chain. For a check that does not rely on this server at all, see "Check a certificate" below.

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

#### Path parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `id` | `string` | yes |  | The report id (not the proof id). |

#### Request examples

_curl_

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

_Python_

```python
import os, requests

data = requests.get(
    "https://api.subsidia.protypa.fr/v1/verify/" + report_id + "/certificate",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
).json()
assert data["verification"]["valid"]
```

#### Responses

**200**: `valid` is true only when both the signature and the chain link hold.

```json
{
  "certificate": {
    "id": "vr_cm2k9x3a40001abcd1234efgh",
    "chain_index": 41,
    "prev_hash": "5be1c07d33...",
    "payload": {
      "v": 1,
      "kind": "verification",
      "issuer": "subsidia-verifier",
      "subject": { "sha256": "c41f...", "chars": 71 },
      "counts": { "claims": 2, "verified": 1, "unsupported": 0, "contradicted": 1, "unverifiable": 0 },
      "coverage": { "mode": "provided", "chunks": 1, "sources": 1, "collectionIds": [], "truncated": false },
      "claimsSha256": "2a77...",
      "evidence": [{ "sourceId": "Bail commercial.pdf", "chunkId": "qdrant-17", "sha256": "d90b..." }],
      "pool": { "passages": 1, "sha256": "8e13..." }
    },
    "payload_hash": "9f2c41d0a7...",
    "signature": "base64...",
    "algorithm": "Ed25519",
    "hash": "sha256(canonical-json(payload))",
    "created_at": "2026-10-09T08:14:03.240Z"
  },
  "verification": { "id": "vr_cm2k9x3a40001abcd1234efgh", "signature_valid": true, "chain_valid": true, "valid": true, "reason": null },
  "certificate_check_url": "https://subsidia.protypa.fr/certificat"
}
```

**404**: Unknown report, or the report was saved unsigned (`code: "unsigned"`).

### GET /v1/verify/:id/export

The control dossier, as a zip.

The file you hand to a client, a supervisor or an insurer. It contains `rapport.md` (the report in Markdown, written without any model), `texte-controle.txt` (the exact text that was checked), `verdicts.json`, `certificat.json` (when signed) and `relectures.json` (when human reviews exist). The export is written to the access journal.

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

#### Path parameters

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

#### Request examples

_curl_

```bash
curl https://api.subsidia.protypa.fr/v1/verify/$REPORT_ID/export \
  -H "Authorization: Bearer $SUBSIDIA_API_KEY" -o controle.zip
```

_Python_

```python
import os, requests

res = requests.get(
    "https://api.subsidia.protypa.fr/v1/verify/" + report_id + "/export",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
)
open("controle.zip", "wb").write(res.content)
```

#### Responses

**200**: `application/zip`, with `Content-Disposition: attachment; filename="controle-<id>.zip"`.

**404**: No such report in this workspace.

## What the certificate attests

The certificate commits, by SHA-256, to the fingerprint of the text, to the full list of verdicts (`claimsSha256`), to every passage a verdict cites (`evidence[].sha256`), to the counts and to the coverage. With provided passages it also commits to the **whole set** you sent (`pool.sha256`). Changing a verdict afterwards, or claiming a passage was missing, breaks the signature.

It holds fingerprints only: no text, no passage, no title. You can hand it to an auditor as it is, and the auditor learns nothing about the content until you also give them the dossier.

It does **not** attest that provided passages come from your client's documents, nor that they are complete. That is stated by `coverage.mode: "provided"` in the report and the certificate.

## Check a certificate

Three ways, from easiest to most independent:

1. **In a browser, no account.** Paste the certificate on the verification page (the `certificate_check_url` of the response). The browser recomputes the fingerprint and checks the signature; nothing is stored.
2. **Over the API.** `GET /v1/verify/:id/certificate` returns the certificate with `verification`, including the chain link.
3. **Fully offline.** An auditor needs the certificate and the public key from `GET /v1/gateway/public-key` (no credentials required). The signature covers `payload_hash`, the SHA-256 of the canonical JSON of `payload`; the code is in [Gateway, verify offline](https://dev.subsidia.protypa.fr/docs/gateway.md#verify-offline). Reports and Gateway certificates share one chain, so the same code checks both.

## Limits

| Limit | Value | Behaviour |
| --- | --- | --- |
| Text analysed | 60,000 characters | The rest is ignored and `coverage.truncated` is `true`. |
| Text accepted | 240,000 characters | Above that, 413 `text_too_large`. |
| Claims per report | 150 | The rest is ignored and `coverage.truncated` is `true`. |
| Passages per request | 1,500 | 400 `invalid_passages`. |
| One passage | 20,000 characters (3,000,000 in total) | 400 `invalid_passages`. |
| Request body | 4 MB | 413. |
| Rate | 60 requests per minute per key on `POST /v1/verify` | 429 `rate_limited`. |
| Exhaustive Cortex pool | 1,500 passages | Larger scopes fall back to `retrieved` mode, stated in the report. |

> **WARNING: A "not checkable" claim is not a safe claim**
> `unverifiable` means the engine found nothing to compare, because the sentence contains no number, date, reference or name. It does not mean the sentence is true. Do not present a report as "the text is correct": present it as "these values were or were not found in these passages".

## Frequently asked

**Does it catch a wrong statement that contains no number or name?**

No. It checks values (numbers, amounts, dates, references, proper names). A sentence with none of these is `unverifiable`. The limit is by design: anything beyond it would require a model, and then the verdict would be something to believe rather than something to recompute.

**Can I run it on my own RAG without sending Subsidia my documents?**

You send the passages your retrieval returned, because the check needs them. They are used for the check and are not stored. The report keeps the text you checked, the verdicts and the evidence quotes; the certificate keeps fingerprints of the passages.

**Is the result the same if I run it twice?**

Yes, for the same text and the same passages. In `retrieved` mode the passages depend on a search, so the pool can change if the documents change; `exhaustive` and `provided` modes do not have this variance.

**Where do I record that someone read the report?**

In the app, at the bottom of the report. The review (accepted, modified, rejected, plus a note) is returned by `GET /v1/verify/:id` and counted in the [AI register](https://dev.subsidia.protypa.fr/docs/registre.md).
