Envelopes & Errors
Success envelope
{
"data": {"id": "CHG-001", "status": "accepted", "...": "..."},
"meta": {
"schemaVersion": "1",
"requestId": "7f3c1f2e-0b2a-4d6b-8c9e-0a1b2c3d4e5f",
"serverTime": "2026-08-01T12:00:00Z",
"stateEpoch": "e7e8e9ea-...-...",
"storeRevision": 7
}
}
Stream variant GET /sync-snapshot:
{"data": {"bookings":[...],"charges":[...]},
"meta":{"schemaVersion":"1","requestId":"...","serverTime":"...","stateEpoch":"...","storeRevision":7,"streamEpoch":"...","streamCursor":"01H..."}}
Error envelope
{
"error": {"code":"charge.invalid_transition","message":"cannot pay an outstanding charge","details":{"currentStatus":"outstanding","event":"pay"}},
"meta": {"schemaVersion":"1","requestId":"...","serverTime":"...","stateEpoch":"...","storeRevision":7}
}
Exceptions (not enveloped): GET /attachments/{id} (bytes), GET /events (text/event-stream), POST /_dev/reset (204 empty).
Status mapping
| HTTP | error.code | When |
|---|---|---|
400 | request.idempotency_key_missing | command without Idempotency-Key |
400 | request.malformed | invalid JSON / multipart parse fail / wrong part content-type |
404 | charge.not_found | unknown charge_id |
404 | booking.not_found | hub/tasks for unknown booking |
404 | inspection.not_found, report.not_found, notification.not_found, attachment.not_found | id not in snapshot |
409 | charge.invalid_transition | total function says no |
409 | charge.version_conflict | expectedVersion != current.version |
409 | store.epoch_mismatch | expectedStateEpoch != snapshot.stateEpoch (client must refetch) |
409 | idempotency.payload_mismatch | same key, different RFC 8785 digest |
413 | attachment.total_too_large | MaxBodySizeMiddleware ceiling |
409 | attachment.too_many | >5 attachments |
413 | attachment.file_too_large | per-file >10 MiB |
422 | validation_failed | Pydantic strict errors → {field, rule} only |
429 | chaos.injected | ChaosController probabilistic 429 |
Unknown charge status filter → 422 with {status:[...], allowed:[...]}.
Idempotency semantics
- Key scope:
(method, normalized route template, resource id, Idempotency-Key). - Digest:
SHA-256(RFC8785(canonicalJSON(body))); contest coversmetadata + [[sha256,sizeBytes,mediaType,sanitizedDisplayName], ...]. - Ledger: only committed 2xx stored inside same
os.replace; same key+digest replays exact response (headers + body) even after restart; same key+different digest ⇒409; 4xx/5xx never cached; missing key on command ⇒400.
Details redaction
app/api/v1/endpoints/commands.py::_redact_errors keeps only {field, rule} from ValidationError.errors() — user prose like contest reason never leaks in an error body.
Examples (goldens)
Each under docs/contracts/examples/ is the canonical oracle for codegen tests.
cat docs/contracts/examples/error_charge_invalid_transition_409.json
cat docs/contracts/examples/error_store_epoch_mismatch_409.json
cat docs/contracts/examples/error_idempotency_payload_mismatch_409.json
Contract tests assert exact code + shape, not just status.
Client guidance
- Always send
expectedStateEpochfrom the lastGET /huborGET /charges/{id}meta.stateEpochyou observed. - On
409 store.epoch_mismatch, drop optimistic state and fetch a fresh snapshot — don't replay. - On
409 idempotency.payload_mismatch, you've reused a key for a different intent; generate a new UUID. - On
413 attachment.*, shrink client-side before upload — server rejects before the multipart parser allocates.