The contract

The changelog.

The agent contract is additive-only within a major. New fields and verbs may appear. Existing ones do not change shape or disappear without a major bump. This page renders the same file our CI enforces: a contract change without a changelog entry fails the build. Agents and humans read one record.

Agent contract changelog

The turntodata agent contract (contract_version, published by GET /api/capabilities) is additive-only within a major (design/product-app.md §6.1): new fields, verbs, and capability facts may appear; existing ones do not change shape or disappear without a major bump. An agent pins nothing beyond the major and adapts to get_capabilities at runtime.

This file is keyed by contract_version. A CI guard fails the build if contract_version changes without a matching entry here.

1.0.0-alpha

The initial contract. Additive changes within 1.0.0-alpha are listed newest-first under it.

Released 2026-07-15 — API keys become revocable (additive)

  • DELETE /api/admin/keys/{key_id} appears (admin entitlement): the rotation loop's missing verb — mint-new → swap → revoke-old — and the leaked-key kill switch now complete over HTTP with no box access. Org-scoped like every admin surface: a cross-org or unknown id is a 404 (key existence never leaks across tenants; the operator reaches a client org's keys via the org switch). Idempotent: re-revoking answers the same 200 {ok, key_id, revoked, already_revoked} with already_revoked: true and never moves the original revocation time — safe to repeat until the old key 401s. Each actual kill writes one key.revoke audit row.
  • GET /api/admin/keys appears (admin entitlement): the caller org's keys — metadata + lifecycle only, never a hash or plaintext (that existed exactly once, in the mint response). Revoked keys stay listed with revoked_at. This is the discovery surface revocation needs: a key minted months ago is found here by name, then killed by id.
  • POST /api/admin/keys response gains key_id (additive field): record it with the plaintext so a later rotation names the old key without hunting the list. Existing fields (key, note) are unchanged.

Released 2026-07-14 — uscc becomes a returnable identifier type (additive)

  • A new identifier type appears in client outputs: uscc (China Unified Social Credit Code, the public government entity id) now serializes in search results, match evidence, deliveries, and eval truth — wherever returnable_keys surfaces. Policy decision 2026-07-14: public CN COMPANY data is releasable (PIPL governs citizen data, not this); the sourcing chain is fully open (CC0 GLEIF RA000092, checksum-verified · Wikidata P6795 · listed-company disclosure · company-self-published footers). Purely additive — no existing field changes shape; agents that enumerate key types simply see one more.

Released 2026-07-14 — the v8.10 serving surface (additive)

  • GET /api/capabilities gains channels ({exact_name, domain, fuzzy, spacefold}): the candidate channels this deployment actually serves, so an integration reasons about recall behavior from facts. channels.spacefold went true 2026-07-14 (the merged-space recall channel, corroborated-or-review by construction).
  • calibration gains per_row (cal-v1-v810): delivered confidences are per-row calibrated from 667k labeled holdout rows (bucketed empirical precision, isotonic-smoothed, one-sided over-claim gate 0.0000). The flat per-tier table stays for compat.
  • Proxy failure envelope: an unreachable or mid-restart api now answers 503 {code: "api_unreachable", retryable: true} with retry-after instead of an opaque non-JSON 500 (the error-envelope guarantee now holds through pack swaps and restarts).

Released 2026-07-12 — serving-speed retune + warm-cached named evals + crm_v1 (additive)

  • GET /api/eval/public gains ?set= (additive; default unchanged gleif_v1): crm_v1 serves the field-representative benchmark anonymously through the same warm cache + throttle. An unknown set is a mechanical 400 unknown_set with the available list. GET /api/capabilities/public eval gains public_benchmarks (["gleif_v1","crm_v1"]); the original public_benchmark field stays.
  • POST /api/eval/run with a named set may now answer a retryable 503 benchmark_warming (the code GET /api/eval/public already speaks): named sets serve from a per-(set, pack) warm cache — the first call after a pack swap arms one background compute and asks for a retry; every later call serves the identical cached receipt instantly. Rows are metered on the SERVE only (a 503 never charges). BYO rows stay synchronous under the sync cap with the existing 413 eval_row_cap.
  • GET /api/capabilities sync block gains observed_ms_per_row (measured per-row match latency from the benchmark warm against the CURRENT pack; the field is absent until a warm has run — it is measured, never estimated) and guidance (when to use sync vs the async job path). Agents sizing a request should read these instead of assuming.
  • Defaults retuned, self-reported as always: sync.row_cap default 500 → 25 and sync.timeout_s 60 → 25, so a sync request always answers inside the web proxy's window with a structured error instead of an opaque proxy 500. Over the cap keeps the published 413 use_job_api. Read the live values from get_capabilities (they are deploy-tunable).
  • eval.sets gains crm_v1 (additive): the field-representative 200-row benchmark (real-CRM noise model, domains at the measured 83% fill, truth = CC0 LEIs, planted no-match + honest review rows). gleif_v1 (the adversarial merged-space floor) and fixture_v1 are unchanged, byte-identical. Methodology: docs/EVAL.md §"Public benchmark sets".

Released 2026-07-12 — the anonymous public benchmark (WS4 trust lane, additive)

  • GET /api/eval/public (no auth, live on prod) — run the CC0 gleif_v1 benchmark through the same engine + posture match_rows serves and get the eval_run response back (the scored rates, full per_row, and a deterministic receipt.sha256) with no key and no sandbox requirement. This is the anonymous "check us yourself" surface: an agent (or a landing-page button) verifies our accuracy claim itself.
  • Cached per pack identity. The result is deterministic per reference pack, so it is computed once per pack_mtime and every later call serves the identical receipt — re-running is the verification, not repeated compute. A new pack drops the old cache entry. A per-IP courtesy throttle (20 requests / rolling minute) guards hammering the cached path (new mechanical code 429 rate_limited); a cold pack is a retryable 503 pack_unavailable.
  • GET /api/capabilities/public gains eval: {public_benchmark: "gleif_v1", endpoint: "/api/eval/public"} so an agent discovers the anonymous benchmark before it holds a key. The authed GET /api/capabilities eval block (named sets + byo_row_cap) is unchanged, and no authed surface leaks onto the public subset.

Unreleased — correct-and-rerun (§4's fourth review verb, additive, 2026-07-11)

  • review_decide gains decision: "correct" — when the reviewer sees the row's INPUT was wrong (typo'd name, missing country, a value in the wrong column), pass corrected: {field: value, …} (mapping-field vocabulary only: name/domain/country/ticker/phone/city/postal/street; ""/null clears a field; the merged row must keep a non-empty name → else 400 name_required; unknown fields → 400 unknown_fields; empty map → 400 no_corrections). The SINGLE row re-grades synchronously through the same engine path as match_rows (the bounded sync pool, the serving pack); the response carries the re-graded row plus reopened, corrections_remaining, open_remaining, job_state.
  • The served result moves; the grade-time record does not. result_v2.jsonl stays the engine's immutable grade-time record — the replacement v2 object lands in the append-only overlay result_v2_corrections.jsonl (last line per row wins), stamped corrected: {original, applied, by, ts}. Every reader (result JSON + gated CSV, the delivery artifact, scorecards, the refresh diff's old side) reads THROUGH the overlay, so downstream reflects the NEW grade while the original stays on disk forever. (A later refresh still re-runs the uploaded input verbatim — corrections never rewrite the client's file.)
  • Queue semantics: legal only while the job is in_review (else 409 invalid_state). The item resolves as decision=correct (append-only log records the corrected values — the evidence-echo equivalent; the provenance bundle's review_decision gains corrected). If the re-grade lands in review AGAIN the item re-enters the queue as its re-graded self (reopened: true, the job stays in_review) — bounded: at most review.correct_max_per_row corrections per row (TTD_CORRECT_MAX, default 3), then 409 correct_limit. No infinite auto-loop.
  • Metered like everything else: a correction re-match charges 1 row against the acting key's meter (rows are the unit regardless of call shape, §3.4); client-agent deciders stay velocity-capped. Attribution: the audit trail gains review.correct events carrying the corrected fields (length-capped) + decider class; reviewed_by in deliveries attributes the corrector; state transitions (queue drain → resolved) fire webhooks as usual.
  • Review payloads gain input — the client row's mapped field values (their own data echoed back, all eight fields, null where unmapped): what a UI/agent pre-fills a correction from. Present on job review rows seeded from now on; corrected re-seeded items carry the merged values. GET /api/capabilities · review.decisions gains "correct" and review gains correct_max_per_row.

Unreleased — the verifiable eval (ER-8 / WS4, additive, 2026-07-11)

  • POST /api/eval/run — run a LABELED benchmark through the same engine + posture match_rows serves and get scored, re-runnable proof back (the WS4 trust lane: agent-re-runnable accuracy claims, which no data vendor offers). Body: {"set": "gleif_v1"} (named sets ship in-repo: gleif_v1, ~200 messy rows derived from CC0 GLEIF LEI data with planted no-match rows and registry-real ambiguous rows, truth = the LEI the matched entity must carry on its returnable key surface; fixture_v1, the sandbox fixture pack's documented 14-row map) — or bring-your-own {"rows": […], "truth": […]} (truth per row: {"outcome": "match"|"no_match"|"review", "keys": [{"type","value"},…]}, returnable key types only; capped at sync_row_cap, 413 eval_row_cap). Unknown set → 400 unknown_set with the available list.
  • Response: {set, set_version, contract_version, pack identity, n, results: {matched_rate, wrong_entity_rate, review_rate, unmatched_rate, no_match_correctly_refused_rate, counts}, per_row: [{row, expected, disposition, tier, tnid, correct, keys_seen}], methodology_url, receipt: {sha256}, took_ms}. Scoring is false-match-first: wrong_entity_rate counts ONLY confidently-wrong autos; correct is null for review/unmatched rows (routing a close call to review is never wrong); numbers report as-is on any pack — a sandbox honestly scores gleif_v1 near 0%, never special-cased. The receipt is sha256 over (set_version + pack identity + per-row outcomes): same set + same pack ⇒ same receipt (§3.2 guarantee 3) — two parties can verify each other's runs.
  • Auth + metering: submit-gated; rows charge the SAME per-key daily meter as match_rows (benchmark rows are engine rows — no free-compute arbitrage). A sandbox deployment also serves it anonymously (the pre-key trust probe) under the per-IP daily velocity posture (429 sandbox_velocity); on prod, anonymous is a 401 as everywhere.
  • GET /api/capabilities gains eval: {sets, byo_row_cap}; MCP gains the matching run_eval tool (17 tools). Benchmark provenance rides the committed artifact (turntodata_api/data/eval_bench_v1.json, generated by scripts/build_eval_bench.py; GLEIF LEI data is CC0 1.0 — courtesy attribution included).

Unreleased — the sandbox (GTM-4 / P10, additive, 2026-07-11)

  • Sandbox deployments (TTD_SANDBOX=1 — a dedicated deployment, never prod): the ENTIRE contract — upload, mapping, match, review, resolve, deliver, scorecards, events, webhooks — served against a synthetic fixture pack (~200 fictional entities covering every disposition, tier, and gate case; scripts/build_fixture_pack.py). Synthetic data only — not real companies; a startup guard refuses any non-fixture pack, so sandbox mode can never serve production data.
  • Self-serve keys — POST /api/sandbox/keys (no auth): mints a client_agent key bound to the dedicated sandbox org with entitlements submit+review+deliver, 500 rows/day, and a per-IP daily mint cap (429 sandbox_velocity). The response carries the key once plus the loud banner {"sandbox": true, "data": "synthetic fixture — not real companies"}. No guarantee of persistence — sandbox jobs, deliveries, and keys may be reset at any time.
  • The banner rides everything in sandbox mode: sandbox: true on get_capabilities and GET /api/capabilities/public (which also gains sandbox_url), sandbox: true in delivery manifests, and an X-TTD-Sandbox: 1 response header on every response. On a production deployment the flag is off: the endpoint is a 404 and none of these fields exist — the prod contract surface is unchanged.
  • GET /api/capabilities · limits.daily_rows now reports the calling key's actual daily row cap (previously always null for keys) — agents adapt to the cap before hitting the 429.

Unreleased — LLM-assisted column mapping (PR-6, additive, 2026-07-11)

  • Mapping proposals (proposed_mapping on upload responses and job reads) may now include LLM-proposed fields for headers the deterministic heuristics could not claim (opaque Col_A/field_17, non-English labels): same {field: {column, confidence, via}} shape with via: "llm" and confidence capped at 0.75 — an LLM guess never clears a high-confidence bar on its own; the confirm/edit stage stays the safety net. Heuristic claims always win (the LLM fills gaps only, never re-claims a column) and only columns actually in the file can be proposed.
  • Deployment-gated and best-effort: off by default (TTD_MAPPING_LLM=1 + an OpenAI-compatible backend via TTD_MAPPING_LLM_URL/_MODEL/_KEY turn it on); one bounded call (~10s) at upload; any backend failure silently degrades to the heuristics-only proposal — uploads never fail or hang because of it. GET /api/capabilities gains mapping: {llm: bool} so an agent knows whether via: "llm" can appear before it ever uploads.

Unreleased — review-queue candidate sets (additive, 2026-07-11)

  • Review items (review_next payloads, schema-v2 rows with disposition=review) gain review.candidates: the top-K candidate set the engine weighed at grade time (K published as get_capabilities.review.candidates_per_item, currently 5). Each candidate carries returnable fields onlytnid, display_name, country, city/state/lat/lon, sources, output-gated returnable_keys + key_types, its own location_match leg, and typed per- candidate evidence signals (name_cosine, channels, owns_client_domain, ticker_match, phone_match, country_conflict, dissolved). No internal id ever appears; kind-gated (person) entities are never offered.
  • review_decide accepts now bind to the stored set: on items that carry review.candidates, chosen_tnid must be one of the stored candidates (or the item's own entity) — an off-queue tnid is a 409 with the new mechanical code unknown_candidate. Items seeded before candidate sets keep the prior semantics. reject / mark_unmatchable are unchanged.
  • Deliveries enrich the ACCEPTED candidate like an auto match: the accepted candidate's card fields + gated returnable keys serialize with match__tier=review_accepted (still no fabricated confidence), location-specific fields gated by the candidate's own grade-time location_match. Previously an accept of a tnid other than the row's entity delivered the id with no enrichment.

Unreleased — the audit trail + scorecard lift (additive, 2026-07-11)

  • GET /api/match/jobs/{id}/events — the per-job audit-event timeline (design §5 "queryable per job" / §3.2 guarantee 6): upload, mapping confirm, grade, every review decision with decider attribution, resolve, delivery (artifact hashes), refresh — newest-first, offset/limit paginated (limit ≤ 200). Event shape: {id, ts, action, actor, decider_class, detail}; actor is the acting principal (null for engine/system events); IP addresses never serialize. Org-scoped like every job read (cross-org → 404). MCP gains the matching get_job_events tool (16 tools); GET /api/capabilities advertises audit.job_events.
  • Job scorecards gain lift — the §7 A/B headline ("you arrived 31% keyed, you leave 74% keyed"): incoming_keyed_rows/_rate (rows arriving with a mapped domain/ticker/phone identifier value; null when unknowable — never a fabricated 0) → outgoing_keyed_rows/_rate (rows leaving keyed to a stable tnid, auto + review-accepted), with each leg's basis string published beside the numbers — and enrichment (rows_with_keys, rate, keys_by_type: per-type row coverage of returnable open-identifier keys, counted through the same overlay the delivered file serializes).
  • Account scorecards gain series — the per-job trend (graded jobs oldest-first: {job_id, file, created_at, state, rows, match_rate}) behind the account scorecards page.

Unreleased — job-event webhooks (PR-7) + Excel upload (PR-8) (additive)

  • Webhooks — job.state_changed: per-org webhook config via PUT /api/org/webhook (owner/admin; {"url": "https://…"} sets, {"url": null} disables). Setting a URL mints a fresh HMAC secret returned in that response once and never again (re-PUT to rotate); GET /api/org/webhook returns the URL + contract facts, never the secret. On every job state transition (including the runner's advances and the review-queue drain's auto-resolve) the URL receives POST {"event": "job.state_changed", "job_id", "org_id", "from", "to", "ts", "contract_version"} signed X-TTD-Signature: sha256=<hex> — HMAC-SHA256 over the raw body with the org's secret.
  • Delivery is best-effort in v1 — explicitly NO delivery guarantee: fire-and-forget from a bounded worker (~5s timeout, ONE retry ~30s later, terminal failures audit-logged, no re-drive, no ordering promise). A consumer that must not miss a state keeps polling GET /api/match/jobs/{id} — the job object remains the source of truth; webhooks are a nudge, not a ledger.
  • Excel input: POST /api/match/upload now accepts .xlsx (detected by magic bytes or extension). The FIRST sheet is parsed to the same all-string frame the CSV path produces, then mapping/caps/metering are identical. Multi-sheet workbooks are not rejected: sheet 1 is used and the fact is noted on the job record (excel: {sheet_used, sheets_total, note} on GET /api/match/jobs/{id} and in the upload response). Legacy .xls gets a structured 400 xls_unsupported. The uploaded workbook is kept verbatim beside the job's parsed input.
  • GET /api/capabilities gains webhooks: true and upload_formats: ["csv", "xlsx"].

Unreleased — real file capacity (PR-1, additive)

  • The default per-job row cap rises 10,000 → 250,000 (TTD_MATCH_ROW_CAP; still published as limits.match_row_cap). POST /api/match/upload now enforces it at upload — BEFORE the row meter and BEFORE job creation: an oversized file is a structured 413 {code: "row_cap_exceeded", cap, rows} and charges nothing (it used to burn meter rows on a guaranteed-failed job).
  • Metered rows and rows_total are the parsed row count (one full parse at upload) — a quoted newline is cell data, not a row; the raw-newline heuristic is gone.
  • Job runs execute on a bounded worker pool (TTD_JOB_WORKERS, default 2) with per-org fairness (TTD_JOB_PER_ORG, default 1; the excess queues in order — job states are unchanged). GET /api/healthz gains jobs_queued / jobs_running.
  • The upload byte cap is env-tunable (TTD_UPLOAD_MAX_MB, default 30 — unchanged); limits.upload_bytes follows it.

Unreleased — result CSV is now the gated projection (behavioral fix)

  • GET /api/match/jobs/{id}/result?format=csv now serves the schema-v2 gated projection (the delivery CSV dialect: fixed columns + field__source/field__confidence triplets, no review overlay) instead of the raw engine frame. The raw frame carried internal entity_ids and raw evidence JSON — contrary to the "tnid is the only public id" guarantee; it remains on disk as an internal grade-time record only.
  • Pre-v2 legacy jobs return a structured 409 result_upgrade_required (re-run/refresh to get a downloadable CSV) rather than falling back to the raw frame — fail-closed by design.
  • format=json unchanged.

Unreleased — anonymous discovery subset (additive)

  • New GET /api/capabilities/public (no auth — the GTM-1 discovery pack's API face): the curated facts an agent needs before it holds a key — contract_version, pack identity, public reference counts (served from the /api/stats cache), accepted formats (upload: csv · results: csv|json · delivery: csv|provenance_jsonl), row caps, docs_url, the MCP pointer, and auth: "api-key (private beta)".
  • The authed GET /api/capabilities is unchanged. Per-key limits, calibration provenance, live channels, and review/org/principal context remain authed-only.

Geo delivery — state/province + coordinates (additive, 2026-07-11)

  • Result schema v2 rows gain matched_state / matched_lat / matched_lon (nullable; populated once the serving pack carries geo_lookup — v8.10). The delivery CSV gains state / lat / lon value columns with the standard __source/__confidence siblings.
  • All three are location-gated exactly like city (withheld unless location_match.status == "match"). Coordinates are city centroids (the geo method is provenance'd internally); street-level geocoding is a later refinement. Geo data derives from GeoNames (CC-BY 4.0 — attribution rides the delivery docs).

P8 — the client portal (additive)

  • GET /api/capabilities · principal gains role (a session's role; null for an API key) and org_id; a new org object {id, name, is_operator}; review.client_agent_review (whether the org may mint client-agent review keys).
  • Delivery manifests carry prepared_for (the client org name) — theming metadata; no shape change to the CSV or provenance artifacts.

P7 — real accounts (additive)

  • Multi-user, multi-org accounts behind the login. No REST/MCP contract shape changed — every existing require_entitlement(...) gate is unchanged; entitlements are now derived from a session's role (viewer/reviewer/operator/owner), with owner reproducing the prior full-entitlement session exactly. API keys are unchanged (entitlement-based, org-bound).
  • New session/admin surfaces (human UI, not agent verbs): invites, org/user admin, the operator org switch. GET /api/auth/me expanded (org_id, role, is_operator, active_org_id).
  • Security: the internal /api/labels/* eval surface is now operator-only.

P1–P6 — the initial agent contract

  • P1 the unified principal (session or API key), entitlements (submit/review/deliver/ admin), per-key daily row meter, idempotency (Idempotency-Key), org scoping.
  • P2 the explicit job lifecycle (created → mapping_ready → running → graded → in_review → resolved → delivered; failed/canceled), the mapping stage, schema-v2 result rows.
  • P3 the review queue (review_next / review_decide; attributed + evidence-echoed; the client-agent velocity cap).
  • P4 the immutable delivery artifact (inline-metadata CSV + JSONL provenance bundle, pack-stamped).
  • P5 the synchronous small-batch path (match_rows) on a dedicated pool, metered identically.
  • P6 refresh + the diff report; job/account scorecards.
  • Reference surface: search_entities / get_entity (returnable, output-gated keys only).
  • Honesty: get_capabilities publishes posture, live channels, calibration provenance, and limits.

Read it live

The contract answers anonymously.

The public capabilities endpoint publishes the contract version and what this deployment serves right now. No key needed.

curl -s https://turntodata.ai/api/capabilities/public