Source on GitHub

truewire conform

A conformance run calls every recorded request of one project against the live API, once, through the project's own generated Python client and hand-written core, and writes a report of what changed. It is phase one of ADR 0012: HTTP rpc endpoints, Python. Streams, write scenarios and the other languages come later.

truewire conform --project examples/kraken --state ~/conformance --new public=true

What one call is checked for

The response to each recorded <id>.request.json is judged three ways:

  1. The raw wire body against the response schema, with the same validator truewire check uses on recordings. A violation is a drift finding: the API no longer sends what the spec says.
  2. The same response as the generated client handled it, with validation on. When the body passed step 1 and the client still raised, the finding is client:python: the API is as specced and our code is wrong. When the body failed step 1, the client's rejection is the API's doing and is noted on the example, not counted again.
  3. The body's shape against the recorded <id>.response.json. Values are never compared; a balance or a timestamp differs every night.
check kind what it means
key_added drift a key the recording did not have and the schema does not declare
key_removed drift a key the recording had that is gone, where the schema requires it or does not declare it
type_changed drift the wire type at a pointer changed (integer and number are one type)
nullability drift null where it was not, or no longer null, and the schema does not declare both
enum_value drift a value outside the schema's enum; the value is quoted, it is the finding
array_element drift a tuple (prefixItems) changed length
status drift an HTTP status other than the recorded one
not_json drift a 2xx body that is not JSON; actual is the media type (text/html), without parameters
schema:<keyword> drift any other schema violation (minimum, pattern, oneOf, ...)
client client:python the client rejected a body the schema accepts; actual is pydantic's error type

A difference the schema already allows on both sides is not a finding: an optional key present last time and absent tonight, a nullable field that was null in the recording.

A value no alternative of an anyOf or oneOf accepts is named by what the alternatives say. A new value of a nullable enum (anyOf: [{enum}, {type: null}]) is enum_value, with every declared value as expected. A value of a type no alternative takes is type_changed, with every declared type. Anything else is the finding for the failing alternative jsonschema ranks most relevant, at its own pointer. schema:oneOf remains for a value that more than one alternative accepts.

Pointers are JSON pointers into the wire body. An array index and a map key (an object whose keys the schema says are data, via additionalProperties or patternProperties) are written *, so one change on every element is one finding with a count, and a newly listed market does not make every finding new. A tuple position keeps its index. A client finding's pointer comes from pydantic's error location, read against the response schema: a key the schema declares keeps its name and everything else is *, so a map key, or a key under an object the schema does not describe, is never written.

What is not called

Every endpoint in the spec gets an entry. The ones not called say why:

reason
no_recording no <id>.request.json; the run never invents a request
missing_credentials the core raised AuthError before sending, or the API answered 401/403 while a [secrets].required variable is unset
not_a_read not a GET. Phase one sends no writes. A read the API sends as a POST (Kraken's private half) is named with --allow
stream: phase 2, ws: phase 2 WebSocket endpoints
stale_recording called, and not judged: see below

Stale recordings

A recorded request can age out while the API stays the same: a time window past the API's lookback, an option that has expired, a trade id past retention. The API refuses it with a 4xx, and calling that drift would page someone every night for a recording, not an API. So a 4xx where the recording has a 2xx is skipped, stale_recording, when the recorded request pins something that ages:

  • a time before the run's date: an integer in epoch seconds to nanoseconds (2000 to 2100) under a parameter the request schema declares a time (format epoch-seconds, epoch-millis, epoch-micros, epoch-nanos, date-time, date) or whose name says so (startTime, end_timestamp, since, dateFrom, end_ts, createdAt), or a string that starts with an ISO date. An id, a hash or a row offset under any other name (subUid, announcement_id, cursor, a bare start with no time format) is not a time, even in that range;
  • an identifier with a date code from 2000 on and before the run's date: a YYMMDD or YYYYMMDD group bounded by -, _ or the value's edge (BTC-260810-65000-C);
  • a cursor into history: a parameter named fromId, from_id, startId, sinceId, afterId and the like, whatever its value.

The detail names each pinned parameter and its date, and says to re-record it. A 4xx on a request that pins nothing is still drift, and so is any status change on one that does while its time is still ahead. The rule is a heuristic, and it can hide one real change: an API that starts refusing a parameter it accepted, on a request that also pins a time. The endpoint's other examples still run, and the report lists every stale example.

A 429, a 408 or any 5xx is an error, not drift: it says "not now" rather than "not like this". So is a 401 or 403 the recording does not have, with every secret set: an expired key, or GitHub's exhausted rate limit, which is a 403. So is a 2xx the core reads as an error in the body (Kraken's {"error": [...]}).

Files

Under <state>/<project>/:

  • <YYYY-MM-DD>.json and .md: one entry per endpoint, ok, drift, client, error or skipped, with each example's HTTP status, what the client made of it, and its findings with pointer and both sides.
  • ledger.json: every finding seen, keyed by a fingerprint of endpoint, example, kind, check and pointer (plus the value, for enum_value). It carries first_seen, last_seen and resolved, so a report says "since 2026-10-02" rather than "today". A finding resolves on the first run that called its example, checked the part of the response the finding is about (status, body, or the client's verdict), and did not see it. A run that skipped the example, errored on it, or left it out with --only resolves nothing. A non-2xx or non-JSON answer resolves no body finding, and a body that fails the schema resolves no client:python finding. A body finding under an array or map (a * in its pointer) resolves only when tonight's body has an element there, one under a key only when the key's parent is an object, one on a tuple position only when the position is there, and a tuple-length (array_element) finding only when the tuple has elements: an empty order book says nothing about its rows, an empty row nothing about its length, and a null fees nothing about fees/maker. A resolved finding that comes back starts a new episode and remembers previously_resolved. A finding the run can never judge again is withdrawn: resolved on the run's date, with withdrawn saying why. That happens when its example is stale_recording, when its example is gone from an endpoint the run covered, when the run does not call its endpoint at all (not_a_read, a stream), when a run with no --only finds its endpoint gone from the spec (deleted or renamed), or when the core refused the call before sending it, no [secrets].required variable is set, and the endpoint's meta does not mark it public (public: true, private: false, or signed: false with security absent, NONE or System: Binance's unsigned MARKET_DATA reads need a key). A refusal of an endpoint the spec still marks public is the core's fault and withdraws nothing. A spec that marks only its private endpoints (Kraken's and Bybit's say signed: true and leave the key off the rest) cannot tell the two apart, and withdraws. A run with --only withdraws nothing for a missing endpoint: it cannot tell gone from left out. Keep --allow the same from night to night, or a read it stops naming is withdrawn as not_a_read. The last case assumes one ledger per surface: a public run withdraws what an authenticated run found on a private endpoint, so give each its own --state.

A report quotes method, path, status, exception class and shapes. It never quotes a header, a request body or a response body, beyond an unknown enum value. An exception's message is dropped for the same reason (pydantic's quotes its input), except a transport failure's (ConnectError, a timeout). --state may not point inside the spec tree; the run writes nothing under spec/.

Pacing and exit code

Calls are sequential, one per recorded request, at least --interval seconds apart (1 by default). The core's own rate limiter, where it has one, applies on top. --timeout bounds one call (60 s).

The exit code is 1 when any finding exists, 2 when there is none but a call errored, and 0 otherwise.

Flags

flag
--state <dir> where reports and the ledger go (required)
--only <glob> function (spot.market_data.*) or directory under spec/endpoints; repeatable
--allow <glob> a non-GET endpoint that only reads; repeatable. Never a write
--new key=value passed to the root client's .new(...), as for capture
--interval, --timeout pacing, in seconds
--date YYYY-MM-DD the run's date (UTC today by default); names the report and dates the ledger