# CheckThisFile Integrity API v1

Register the SHA-256 of final file bytes and later compare a copy, without uploading,
analyzing or storing the document in CheckThisFile. This is **not** a reviewed or
approved certificate. No fabricated reviews, reviewers, approval or elapsed review time.

Contract: `/api/integrity-openapi` (OpenAPI 3.1, version 1.0.0).
Existing review certificate routes and signed schemas are unchanged.

## What is actually attested

An authenticated organization declared this hash and we received it at `receivedAt`.
`generatedAt`, MIME type and size are declarations of the origin system. Our clock is
not an independent trusted timestamp. We have not received the file and cannot prove
its existence, possession, authorship, truth, review or legal compliance.

Public verification separates file match, trusted signature and current version state.
Opening the link without comparing bytes returns `FILE_NOT_COMPARED`, never verified.
A metadata-only edit changes bytes and hash. A mismatch does not identify who changed
the file, its meaning, or whether it belongs to another legitimate version.

## Authentication and companies

Use HTTPS, Bearer API keys on the server only. Never in JavaScript bundles, PDF/QR,
URLs, logs, Git or browser storage. Key expiry, revocation, creator status and current
membership are always rechecked. No invalid-key fallback to cookies. Integrity MCP
uses API keys, not the optional external OAuth adapter of the older read-only MCP.

Organization comes from the credential, never from the request body. Within that
organization create opaque companies, then explicitly grant each key its companies.
A key with `integrity:write` and no company grant accesses nothing. A gestoría key
gets only authorized companies. Management is owner/admin with `integrity:admin`;
do not give that scope to a routine document emission worker.

Panel: `/integrity-records`; key creation: `/settings/api`.
Recommended DECA worker scopes: `integrity:write`, `integrity:read`.
Use a separate owner/admin key with `integrity:revoke` only when needed.
Existing keys are NOT silently elevated with these new scopes or grants.

Setup through the API (an admin key is needed):

1. `POST /api/v1/integrity/companies` with `{"reference":"company_7f92"}`.
2. `POST /api/v1/integrity/companies/{companyId}/access` with
   `{"keyId":"YOUR_WORKER_KEY_ID","enabled":true}`.
3. Optionally authorize a trusted webhook destination via
   `POST /api/v1/integrity/companies/{companyId}/webhooks` with
   `{"webhookId":"YOUR_ENDPOINT_ID","enabled":true}`.

Python setup with an administrator credential (not the emission worker credential):

```python
admin = IntegrityClient(settings.CTF_ORIGIN, settings.CTF_ADMIN_KEY)
try:
    company = await admin.create_company("company_7f92")
    await admin.grant_key(company["id"], settings.CTF_WORKER_KEY_ID)
    # Optional: await admin.grant_webhook(company["id"], trusted_endpoint_id)
finally:
    await admin.close()
```

References use `[A-Za-z0-9][A-Za-z0-9._:-]{0,127}`. Use random opaque identifiers,
not names, license plates, tax identifiers, routes, passenger or customer data.
Same reference in a different organization is unrelated. Companies are internal
integration partitions, not independently identity-verified legal entities.

## Emitir stays a single button

DECA owns document generation, storage, delivery, permissions and its durable outbox.
CheckThisFile must never be a prerequisite for emitting or downloading a DECA.

1. Allocate stable company/document/version references in DECA.
2. Reserve an optional extra verification link before closing the PDF bytes.
3. If the service is unavailable, omit the optional external link for this emission
   rather than blocking delivery. Do not modify an already-finalized PDF later to add it.
4. Embed the reserved link, then finalize the PDF and calculate its SHA-256.
5. In one **DECA database transaction**, save the final PDF/reference and an outbox
   item containing the immutable declaration. Serve/download the PDF immediately.
6. A durable worker registers the declaration and records the response/evidence.

The principal DECA inspection QR remains a direct PDF download from DECA, with no
intermediate CheckThisFile screen or authentication. This is an integration design,
not our independent legal validation of DECA requirements.

Reservation request (no registration consumption):

```json
{
  "companyReference": "company_7f92",
  "source": "gestionar-deca",
  "documentReference": "doc_9b81",
  "version": 1
}
```

`POST /api/v1/integrity/reservations` returns `publicId`, `verificationUrl`,
`expiresAt`, `status` and `consumption.applied=0`. The public ID is unpredictable
and can safely be put in the extra PDF link. A pending reservation is not evidence.
Repeat the same identity to renew without changing its URL. Active reservations
last 7 days; the identity itself is retained. Finalization also renews it. Technical
caps: 1,000 active pending and 10,000 total unfinalized identities per organization;
10,000 companies. Contact the operator before exceeding those limits.

Registration request, JSON at most 8 KiB with Content-Length:

```json
{
  "companyReference": "company_7f92",
  "source": "gestionar-deca",
  "documentReference": "doc_9b81",
  "version": 1,
  "previousVersion": null,
  "sha256": "REPLACE_WITH_64_HEX_CHARACTERS",
  "sizeBytes": 12345,
  "mimeType": "application/pdf",
  "generatedAt": "2026-10-01T12:00:00Z",
  "reservationId": "REPLACE_WITH_RESERVED_PUBLIC_ID"
}
```

`POST /api/v1/integrity/records`: 201 for a new committed version; 200 for a replay.
Reservation is optional when the PDF contains no external link. New response includes
`id`, `publicId`, hash/references/version, both dates, status, verification and evidence
URLs, `consumption`, organization usage and company contribution.

## Permanent duplicate protection and version order

Natural identity is organization + company + source + documentReference + version.
No Idempotency-Key is required: this identity is permanently unique in SQLite, not a
24-hour response cache. Same normalized declaration returns the existing record with
`replayed=true` and `applied=0`, even after quota exhaustion. A different hash, size,
format, declared generation time or predecessor for that identity is `409 VERSION_CONFLICT`.
Equivalent ISO timestamps normalize to UTC; hexadecimal hash case normalizes lowercase.

Record, registration consumption (the inserted row), audit, durable change feed and
eligible webhook outbox are committed in one IMMEDIATE transaction. A lost response
does not justify a new document/version or another charge.

`GET /api/v1/integrity/lookup?companyReference=...&source=...&documentReference=...&version=1`
recovers it by origin reference. Lookup is restricted to authorized companies.

Versions are positive bounded integers; DECA must assign them monotonically for each
document. Highest accepted number wins regardless of arrival order or timestamps.
If v3 arrives before v2, later v2 is stored as historical, not made current. Revoking
v3 does not resurrect v2. A future accepted v4 can become current. `previousVersion`
is a declared lower version and may arrive later; it is NOT a validated complete chain
or evidence of what was modified. Do not reuse a version after withdrawal.

## States and public comparison

`GET /api/public/integrity/{publicId}?sha256=...` is free, requires no account and
receives only a hash. `/integrity/{publicId}` and `/en/integrity/{publicId}` calculate
SHA-256 locally in the browser; they never upload the selected file (browser limit
512 MiB). Selecting a file obtains a fresh online status, not just cached page state.

| Result | Meaning |
|---|---|
| MATCH_CURRENT | Exact hash match, trusted signature, current version |
| MATCH_HISTORICAL | Exact match to a signed historical version, subsequently replaced |
| HASH_MISMATCH | Does not match this reference; could be another version or changed metadata |
| RECORD_REVOKED | Hash matches, but this record was explicitly withdrawn |
| FILE_NOT_COMPARED | No client hash supplied; viewing a record alone is not file verification |
| SIGNATURE_INVALID | Signature/trust/binding not validated; comparison must not use its hash |
| REGISTRATION_PENDING | Reserved link, no signed registration yet |
| RECORD_NOT_FOUND | Unknown public ID (HTTP 404) |
| HTTP 429/503 or connection error | Temporarily unable to check; not a mismatch |

Do not collapse these into one red/green result. `verified=true` requires all three
positive conditions; `hashMatches=null` means unestablished, not false. Retired signing
keys remain trusted when independently anchored; compromised/revoked keys do not.

## Portable evidence and independent verification

Authenticated export: `GET /api/v1/integrity/records/{publicId}/evidence`.
Response is a raw signed envelope, not data/error/meta. Save it, BOTH JSON schemas,
trusted public keys and verifiers outside CheckThisFile. This is useful after cancelling.

The receipt contains immutable public payload, Ed25519 signature, keyId, public JWK and
SPKI SHA-256 fingerprint. Payload uses RFC 8785 canonical JSON. It commits to private
company/source/document/version references with SHA-256 of their canonical JSON;
the private export supplies the references so the consumer can verify the binding.
Public page/download does not reveal those references, document names or people.

The private export also contains a **separately signed dated state observation**.
It asserts status at `observedAt`, not present status. Offline verification proves the
conserved signature and optional exact file match, not current online vigency.

Downloadable Python verifier `/developers/verify_integrity.py`:

```sh
python -m pip install cryptography rfc8785 jsonschema
python verify_integrity.py receipt.json independently-trusted-keys.json document.pdf
```

The key file is an array of `{keyId,publicKeyJwk,fingerprintSha256}` or the pinned
`keys` from `/.well-known/editwitness-keys.json`. Authenticate and pin that material
through a trusted channel BEFORE relying on it. A key embedded in a receipt cannot
authenticate itself. Do not fetch arbitrary `schemaUrl`/key URLs from untrusted files.
For full JSON Schema validation, call `verify_receipt(..., schemas={"integrity":...,
"status":...})` with saved `/schemas/integrity-v1.json` and
`/schemas/integrity-status-v1.json`. The CLI verifies signatures, contract identifiers,
reference binding and dated observation; it does not perform full schema validation.

Rotation: distribute/pin the new public key first, retain all trusted historical public
keys, change signing secret + keyId + trust anchors atomically, then retire the old key.
Never reuse keyId for different material. A retained public key is sufficient for offline
historical verification; private signing keys need separate protected recovery backups.
Key compromise requires a trust-policy update, not silent re-signing of old receipts.

Crypto reference: https://cryptography.io/en/latest/hazmat/primitives/asymmetric/ed25519/

## Python / FastAPI and httpx

Download `/developers/integrity_client.py`; requires httpx, Python 3.11+.
Create one `IntegrityClient` in a FastAPI lifespan and close it on shutdown. The sample
uses AsyncClient, explicit JSON bytes, HTTPS, no redirects and bounded timeouts.
It never uploads content or retries implicitly.

```python
from integrity_client import IntegrityClient, final_bytes_declaration, reconcile_pending

client = IntegrityClient(settings.CTF_ORIGIN, settings.CTF_API_KEY)
reference = {"companyReference": "company_7f92", "source": "gestionar-deca",
             "documentReference": "doc_9b81", "version": 1}
reservation = await client.reserve(reference)  # Optional pre-finalization step; failure must not block Emitir.
# Embed reservation['verificationUrl'], generate FINAL bytes once, and save PDF+outbox atomically in DECA.
declaration = final_bytes_declaration(reference, final_pdf_bytes, generated_at,
                                     reservation_id=reservation['publicId'])
# Later, in your durable worker:
result = await reconcile_pending(client, declaration)
receipt = await client.evidence(result['publicId'])
# Save result+receipt, mark outbox done in your DB transaction. Never log client/key/evidence/private refs.
```

HTTPX official async and exception guidance:
https://www.python-httpx.org/async/ ; https://www.python-httpx.org/exceptions/

Exceptions:
- `CTFRejected`: inspect HTTP status, machine code and retry_after. 401/403/409/422 need
  correction, not loops or a different identity. 429 schedules retry/backoff as instructed.
- `CTFUnavailable`: service/transport/invalid response. `ambiguous=true` means a write
  could have committed. Lookup then replay EXACT saved declaration; never generate another PDF.
- Connect/pool failure is reported separately. Do not put retry loops inside Emitir.

Own outbox minimum fields: operation ID, immutable declaration, attempt count,
next-attempt time, last safe error code, registration result, completed time. Use
exponential backoff with jitter, honor Retry-After, lease jobs and alert operators
about persistent rejection. Keep quota exhaustion pending until reset/explicit upgrade.

## Webhooks and missed-notification recovery

Existing organization destinations must be explicitly authorized per company. No default
all-company subscription for integrity events. Both enqueue and delivery recheck grants.
Events: `integrity.registered`, `integrity.superseded`, `integrity.revoked`.
Stable `id`, `occurredAt`, `publicId`, `companyReference`, `source`, `documentReference`,
`documentVersion`, `recordStatus`; no files, names, hashes, people or revocation reasons.
HMAC-SHA256 header `CheckThisFile-Signature: t=UNIX_SECONDS,v1=HEX`, signed bytes are
`timestamp + '.' + ORIGINAL_BODY`. See `/developers/integrity_webhook.py`.

Validate signature before parsing. Reject timestamps outside ±300 seconds. Then
transact deduplication by `id` with durable event persistence. Duplicate delivery returns
2xx with no repeated effects. Arrivals can be reordered: never choose current version
from webhook arrival time. Webhook quotas/failed delivery do not roll back registration.

`GET /api/v1/integrity/changes?companyReference=...&after=0&limit=100` returns events,
`nextCursor`, `hasMore`. Cursor is monotonic persisted sequence, NOT offset/time. Commit
events and cursor together in DECA; if it fails, repeat safely and deduplicate IDs.
Use the feed after outages, periodically and on every webhook; it is the durable source
for reconciliation even if webhook quota was exhausted. Feed `version` is the numeric
document version; webhook's protocol `version=1` is distinct from `documentVersion`.

## Cost, quotas and cancellation

Same subscription, independent lower-cost integrity allowance:

| Plan | Monthly subscription, launch price excl. tax | New hash-only versions / UTC month |
|---|---:|---:|
| Gratis | €0 | 1,000 |
| Pro | €29 | 25,000 |
| Escala | €149 | 250,000 |

Internal operator-authorized organizations have no commercial registration quota;
they still have role/company/credential and technical abuse protections. Consumption
is reported even when commercial billing is exempt. No automatic excess purchases or
upgrades. Existing file credit packs DO NOT expand hash-only quotas. Same identity replay,
reservations, reads, exports, public comparisons and changes consume zero registrations.
This REST API and its dedicated MCP do not consume the old monthly HTTP allowance.
Technical per-minute limit is shared with the ordinary API (30/120/600 by plan).
Per-company usage is a contribution to a shared organization allowance, not its own quota.

Warnings at 80/95/100% persist and are emailed to active owner/admin recipients when
Resend is configured; worker required, stable provider idempotency, no automatic charge.
Subscription cancellation does not revoke prior signatures or prevent authorized export;
new registrations fall back to Gratis quota without resetting already consumed versions.
Records, references and states are retained while this service operates, not perpetually
guaranteed. Keep your own exported evidence, schema and pinned public material. Public
check URLs are shareable, not confidential document-download authorization.

## Deployment / acceptance gates

Additive SQLite migration 7, one writer with WAL/FULL and protected persistent volume.
Do NOT run independent SQLite replicas. Hash-only paths do not invoke the file parser.
MCP endpoint `/api/v1/integrity/mcp`: Streamable HTTP, JSON responses, API key, protocol
versions matching ordinary MCP. Tools: integrity_reserve/register/lookup/changes/export/
revoke/verify, filtered by scope and company grants. Writes never declare review.

Before enabling actual customer emission: consistent backup AND encrypted off-host
copy, separately protected signing/trust material, verified restore, queue monitoring,
signed-webhook receive test, provider email delivery test and measured load. No SLA is
claimed. The existing VPS's same-disk backups alone do not cover disk/server loss.

Gestionar DECA and CheckThisFile share their developer/operator. Describe CheckThisFile
as an external verification service with that relationship explicitly disclosed, not an
independent third-party audit. Independent trusted timestamping is NOT implemented;
any future provider evidence/cost must be separately identified.
