The contract and how it evolves
What contract version 1 guarantees, how one file keeps two languages in step, and which gaps are known and open.
On this page
Two independent libraries, in two languages, talking to a server that ships on its own schedule. The ordinary outcome is drift: a header the Python side truncates at a different boundary, a cost that renders with one fewer digit, a field one binding never learned about.
This page is how that is avoided, and what it means for you when you upgrade.
The short version: one machine-readable file describes every mechanical surface, a gate binds it to the shipped server, a generator emits both languages’ constants from it, and a shared corpus of executable vectors is run by both bindings on every build.
The version you have
contract_version is 1. Both packages implement it, and the number is not the package
version — the packages are at 1.0.0rc1 and 1.0.0-rc.1, and those will move independently.
Ask the SDK rather than trusting a page:
>>> tt.__version__
'1.0.0rc1'
The contract version answers one question: can this binding and this server understand each other’s bytes? It bumps only on a breaking wire change. Adding a response header does not bump it, for reasons the evolution section below makes concrete.
How one file keeps everything in step
Follow it left to right, then down. The server’s own constants are compared against the contract file by a gate that runs in the server’s test suite, so the contract cannot describe a header name or byte cap the server does not use. From the contract, a generator emits the constant files for both bindings, which is why neither hand-maintains a list of header names. Both bindings then execute the same 70 vectors, and a behaviour that differs between them fails the build.
The corpus splits like this:
| Kind | Vectors | Pins |
|---|---|---|
| emission | 27 | the header map, byte for byte, including caps and sort order |
| receipt | 19 | the parsed receipt, as a canonical nine-key mapping |
| render | 12 | the money strings, character for character |
| outcomes | 7 | the outcome request body |
| feedback | 5 | the feedback request body |
Each vector names the requirements it witnesses. The runner asserts that the number of vectors it executed equals the number found on disk, rather than checking against a hard-coded total, so a vector that fails to load is a failure rather than a silent skip.
$ pytest tests/conformance_test.py -q
71 passed in 0.17s
That is the 70 vectors plus the structural assertion.
What the gate cannot check mechanically, and what covers it instead
The drift gate compares the contract against exported server constants. Four refusal header names exist only as unexported string literals in the server, so they cannot be compared that way. They are covered by a source scan plus a behavioural test that drives a genuine refusal and asserts the names that were stamped.
Two more sit outside both: retry-after, which is an IANA-standard name that cannot drift, and
two vocabularies whose values live as unexported literals. Those are covered behaviourally only.
That distinction is published rather than smoothed over, because “mechanically enforced” and “covered by a test that drives the real path” are different strengths of guarantee, and a reader deciding how much to trust the contract deserves to know which applies where.
Upgrading
Evolution is additive, and the mechanism is worth understanding because it is what makes upgrades boring.
When the server starts stamping a new response header, an SDK built before that header existed
already surfaces its value: unknown x-tt-* headers are captured verbatim into Receipt.extra.
Nothing crashes and nothing is lost. A later SDK release adds a contract row, regenerates the
constants, and adds vectors, which promotes the value from extra to a typed field.
So there are two upgrade shapes, and neither breaks you:
- Stay on your SDK version. New server headers arrive in
extra. You can read them by name if you need them. - Upgrade the SDK. Values that were in
extrabecome typed fields. Code reading them fromextrakeeps working, since the raw capture is not removed for fields it knows.
The same rule covers new dimensions with no SDK change at all. A new business dimension is a
tags={"cost_center": "r7"} argument, because the tag family is open on both sides.
Watch items
Three gaps are known, documented, and open. They are listed here rather than only in the specification, because each one is something you might otherwise spend an afternoon looking for.
RateLimit and RateLimit-Policy are not captured. They appear on some 429 responses. They
are not typed fields, and because they are not x-tt-* names they are not routed into extra
either, so in version 1 they are unreachable through the receipt. Promotion is an additive
contract row if and when the underlying IETF draft settles. Retry-After is typed, as
refusal.retry_after_s.
There is no cost response header, and there cannot honestly be one. Pricing runs after the
last byte of the body, so nothing can stamp a cost on the forward path. Receipt.cost is reserved
for a future server that discloses one, or for a ledger hydrate; the dollar today comes from
the ledger fetch.
Success-side x-ratelimit-* headers are not captured either, for the same reason as the
first item: only x-tt-* names enter extra in version 1. The corpus pins that behaviour today,
so it is a decision rather than an oversight, and broadening the capture rule is an additive
change.
One asymmetry between the bindings' own guards
The rule that no source file embeds a currency literal or a price table is enforced by a source-scanning test in the TypeScript binding only. Python’s side of that rule is covered behaviourally, by tests asserting that unpriced costs never render as zero and that no client-side arithmetic happens.
Both bindings satisfy the rule. Only one of them has a gate that would catch a future edit introducing a price table. That is stated here because “checked by a gate” and “checked by behaviour tests” are different, and the difference is not visible from the outside.