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 same200 {ok, key_id, revoked, already_revoked}withalready_revoked: trueand never moves the original revocation time — safe to repeat until the old key 401s. Each actual kill writes onekey.revokeaudit row.GET /api/admin/keysappears (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 withrevoked_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/keysresponse gainskey_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 — whereverreturnable_keyssurfaces. 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/capabilitiesgainschannels({exact_name, domain, fuzzy, spacefold}): the candidate channels this deployment actually serves, so an integration reasons about recall behavior from facts.channels.spacefoldwent true 2026-07-14 (the merged-space recall channel, corroborated-or-review by construction).calibrationgainsper_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}withretry-afterinstead 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/publicgains?set=(additive; default unchangedgleif_v1):crm_v1serves the field-representative benchmark anonymously through the same warm cache + throttle. An unknown set is a mechanical 400unknown_setwith the available list.GET /api/capabilities/publicevalgainspublic_benchmarks(["gleif_v1","crm_v1"]); the originalpublic_benchmarkfield stays.POST /api/eval/runwith a named set may now answer a retryable 503benchmark_warming(the codeGET /api/eval/publicalready 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 413eval_row_cap.GET /api/capabilitiessyncblock gainsobserved_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) andguidance(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_capdefault 500 → 25 andsync.timeout_s60 → 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 413use_job_api. Read the live values fromget_capabilities(they are deploy-tunable). eval.setsgainscrm_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) andfixture_v1are 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 CC0gleif_v1benchmark through the same engine + posturematch_rowsserves and get theeval_runresponse back (the scored rates, fullper_row, and a deterministicreceipt.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_mtimeand 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 429rate_limited); a cold pack is a retryable 503pack_unavailable. GET /api/capabilities/publicgainseval: {public_benchmark: "gleif_v1", endpoint: "/api/eval/public"}so an agent discovers the anonymous benchmark before it holds a key. The authedGET /api/capabilitiesevalblock (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_decidegainsdecision: "correct"— when the reviewer sees the row's INPUT was wrong (typo'd name, missing country, a value in the wrong column), passcorrected: {field: value, …}(mapping-field vocabulary only: name/domain/country/ticker/phone/city/postal/street;""/nullclears a field; the merged row must keep a non-empty name → else400 name_required; unknown fields →400 unknown_fields; empty map →400 no_corrections). The SINGLE row re-grades synchronously through the same engine path asmatch_rows(the bounded sync pool, the serving pack); the response carries the re-gradedrowplusreopened,corrections_remaining,open_remaining,job_state.- The served result moves; the grade-time record does not.
result_v2.jsonlstays the engine's immutable grade-time record — the replacement v2 object lands in the append-only overlayresult_v2_corrections.jsonl(last line per row wins), stampedcorrected: {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 laterrefreshstill re-runs the uploaded input verbatim — corrections never rewrite the client's file.) - Queue semantics: legal only while the job is
in_review(else409 invalid_state). The item resolves asdecision=correct(append-only log records the corrected values — the evidence-echo equivalent; the provenance bundle'sreview_decisiongainscorrected). If the re-grade lands in review AGAIN the item re-enters the queue as its re-graded self (reopened: true, the job staysin_review) — bounded: at mostreview.correct_max_per_rowcorrections per row (TTD_CORRECT_MAX, default 3), then409 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.correctevents carrying the corrected fields (length-capped) + decider class;reviewed_byin 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.decisionsgains"correct"andreviewgainscorrect_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 + posturematch_rowsserves 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 atsync_row_cap, 413eval_row_cap). Unknown set → 400unknown_setwith 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_ratecounts ONLY confidently-wrong autos;correctisnullfor review/unmatched rows (routing a close call to review is never wrong); numbers report as-is on any pack — a sandbox honestly scoresgleif_v1near 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 asmatch_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 (429sandbox_velocity); on prod, anonymous is a 401 as everywhere. GET /api/capabilitiesgainseval: {sets, byo_row_cap}; MCP gains the matchingrun_evaltool (17 tools). Benchmark provenance rides the committed artifact (turntodata_api/data/eval_bench_v1.json, generated byscripts/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 aclient_agentkey bound to the dedicated sandbox org with entitlements submit+review+deliver, 500 rows/day, and a per-IP daily mint cap (429sandbox_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: trueonget_capabilitiesandGET /api/capabilities/public(which also gainssandbox_url),sandbox: truein delivery manifests, and anX-TTD-Sandbox: 1response 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_rowsnow reports the calling key's actual daily row cap (previously alwaysnullfor keys) — agents adapt to the cap before hitting the 429.
Unreleased — LLM-assisted column mapping (PR-6, additive, 2026-07-11)
- Mapping proposals (
proposed_mappingon upload responses and job reads) may now include LLM-proposed fields for headers the deterministic heuristics could not claim (opaqueCol_A/field_17, non-English labels): same{field: {column, confidence, via}}shape withvia: "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 viaTTD_MAPPING_LLM_URL/_MODEL/_KEYturn 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/capabilitiesgainsmapping: {llm: bool}so an agent knows whethervia: "llm"can appear before it ever uploads.
Unreleased — review-queue candidate sets (additive, 2026-07-11)
- Review items (
review_nextpayloads, schema-v2 rows withdisposition=review) gainreview.candidates: the top-K candidate set the engine weighed at grade time (K published asget_capabilities.review.candidates_per_item, currently 5). Each candidate carries returnable fields only —tnid,display_name,country,city/state/lat/lon,sources, output-gatedreturnable_keys+key_types, its ownlocation_matchleg, and typed per- candidateevidencesignals (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_decideaccepts now bind to the stored set: on items that carryreview.candidates,chosen_tnidmust be one of the stored candidates (or the item's own entity) — an off-queue tnid is a 409 with the new mechanical codeunknown_candidate. Items seeded before candidate sets keep the prior semantics.reject/mark_unmatchableare 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-timelocation_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/limitpaginated (limit ≤ 200). Event shape:{id, ts, action, actor, decider_class, detail};actoris the acting principal (null for engine/system events); IP addresses never serialize. Org-scoped like every job read (cross-org → 404). MCP gains the matchingget_job_eventstool (16 tools);GET /api/capabilitiesadvertisesaudit.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;nullwhen unknowable — never a fabricated 0) →outgoing_keyed_rows/_rate(rows leaving keyed to a stable tnid, auto + review-accepted), with each leg'sbasisstring published beside the numbers — andenrichment(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 viaPUT /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/webhookreturns 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 receivesPOST {"event": "job.state_changed", "job_id", "org_id", "from", "to", "ts", "contract_version"}signedX-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/uploadnow 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}onGET /api/match/jobs/{id}and in the upload response). Legacy.xlsgets a structured400 xls_unsupported. The uploaded workbook is kept verbatim beside the job's parsed input. GET /api/capabilitiesgainswebhooks: trueandupload_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 aslimits.match_row_cap).POST /api/match/uploadnow 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_totalare 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/healthzgainsjobs_queued/jobs_running. - The upload byte cap is env-tunable (
TTD_UPLOAD_MAX_MB, default 30 — unchanged);limits.upload_bytesfollows it.
Unreleased — result CSV is now the gated projection (behavioral fix)
GET /api/match/jobs/{id}/result?format=csvnow serves the schema-v2 gated projection (the delivery CSV dialect: fixed columns +field__source/field__confidencetriplets, no review overlay) instead of the raw engine frame. The raw frame carried internalentity_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=jsonunchanged.
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/statscache), accepted formats (upload: csv·results: csv|json·delivery: csv|provenance_jsonl), row caps,docs_url, the MCP pointer, andauth: "api-key (private beta)". - The authed
GET /api/capabilitiesis 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 carriesgeo_lookup— v8.10). The delivery CSV gainsstate/lat/lonvalue columns with the standard__source/__confidencesiblings. - All three are location-gated exactly like
city(withheld unlesslocation_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·principalgainsrole(a session's role;nullfor an API key) andorg_id; a neworgobject{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), withownerreproducing 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/meexpanded (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_capabilitiespublishes 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