Schema contracts
A schema contract is the reviewed, versioned answer to one question: what may the model see? Tessera infers a draft from your data; a project admin approves it; datasets and serving bundles pin the exact approved version. An attribute you never reviewed is never a model input — deny by default.
A worked example: healthcare operations
A hospital-operations team streams visit entities through their intake
system:
{
"entity": { "type": "visit", "id": "v_2024_88213" },
"event_type": "visit.triaged",
"timestamp": "2026-03-14T08:22:00Z",
"attributes": {
"department": "cardiology",
"acuity_level": 3,
"wait_minutes": 41,
"insurer_ref": "INS-55-20931",
"notes": "patient reports chest tightness since morning"
}
}
Profiling sees four very different kinds of columns here, and the inferred draft contract treats them differently:
| Attribute | Inferred | Why |
|---|---|---|
department | feature / categorical_hash | low-cardinality string — safe, informative |
acuity_level | feature / quantile | numeric — encoded by its position in the observed distribution |
wait_minutes | feature / quantile | numeric |
insurer_ref | identifier — excluded | matches identifier heuristics (*_ref, unique-per-entity); identity is never a feature |
notes | excluded until reviewed | free text touching patient details — a human must decide |
The review is where domain judgment enters. The operations team might:
- keep
department,acuity_level,wait_minutesas features; - mark
notesexcludedoutright (free-text clinical detail — the model may not see it, in any form); - mark a
discharged_against_adviceattributetarget_only— it is the outcome they want to predict, so it may feed label extraction but can never be an input.
Roles: the vocabulary of visibility
Every attribute of every included event type carries exactly one role:
| Role | Model input? | Readable by |
|---|---|---|
feature | yes | tokenizer |
target_only | never | label extraction only |
identifier | never | nothing (also value-scanned at build) |
metadata | never | operational tooling |
excluded | never | nothing |
Transport and integrity metadata defaults to metadata: the
service_transport, service_handler, service_md5 and service_uploaded
family, IP-address names (ip, ip_address, ipv4, ipv6, client_ip,
sender_ip, remote_ip, remote_addr, x_forwarded_for), and hash or
checksum names (md5, sha1, sha256, checksum, crc32), each matched as
the whole key or as its ending after an underscore. Names that carry a network
address are also marked pii: quasi. Bare business fields such as
transport_mode or handler are not matched. The default applies wherever a
contract is inferred: column mappings, batch catalogs, streaming observations
and automatic onboarding. An IP-named field that is unique per entity is
inferred as an identifier and still marked pii: quasi. This is only a
default: a role you set in an approved contract, a field you pick, or a field
of an accepted source mapping always wins.
Two structural rules back this up. First, the tokenizer only ever emits
tokens for role: feature attributes of status: included event types —
target_only and identifier values cannot reach the model through any
code path. Second, there is no allow-by-pattern anywhere: name patterns can
deny (see leakage gates), never allow.
How an attribute is recognised as an identifier
Inference marks an attribute identifier when any of these holds:
- its name matches an identifier pattern:
*_id,*_uuid,email,phone,ssn,card*, or contains a record-key word as a whole word (invoice,order,transactionortxn,receipt,ticket,sku,serial,uuid,guid;invoice_nomatches,reorder_leveldoes not). A name that also carries a measure or descriptive word (order_total,invoice_amount,ticket_price,order_date,receipt_status) is not treated as a key; - it is text whose values are almost one-per-entity (95% or more);
- it is an integer whose values are almost all distinct (98% or more of the
non-null events, over at least 1,000 events) and whose name is not a measure
word (
quantity,price,amount_cents, ...), for example an event sequence number.
Things to know about these rules:
- The record-key words are checked before the leakage name patterns, so a name
that contains one and also looks like a label (
order_label) is inferred asidentifier, nottarget_only. orderis a deliberately conservative match:display_orderandsort_orderare inferred as identifiers too, andPATCHwill not make them features (the guard uses the same name rule). Keep them asmetadataorexcluded, or rename the column.- The integer uniqueness test needs at least 1,000 events in the profile, so it does not apply to the automatic onboarding path, which profiles 256 rows. On streaming and merged sources the counts come from partial observations and the test is more conservative (it marks fewer attributes, never more).
- The
PATCHcheck reads the profile at the time of the request, so the integer uniqueness test reflects the data seen so far, not the draft's inference time.
A number with decimals, or an integer that repeats (a quantity), is never marked an identifier from how often its values differ.
The same rule is enforced when you edit a draft: PATCH /contracts/{version}
refuses role: feature for an attribute whose name matches, or whose integer
values meet the uniqueness test above, with 400 failed_precondition and
details[0].reason == "identifier_as_feature". It applies whatever the
attribute's current role is, so moving an identifier to metadata first does
not get around it. A text attribute that was marked an identifier only because
its values are unique can be re-roled by a reviewer; its values are still
scanned at build.
Versions, approval, and drift
Contracts are immutable versions with a
fingerprint: draft → approved → superseded.
When new attributes appear in your stream (a new client release adds
triage_source), they accumulate as observations; the next inferred draft
includes them — as excluded, until someone approves otherwise. Diff any
two versions with GET /contracts/{version}/diff.
Automatic onboarding approves a new version only when what the contract or
the source's binding says has changed. A re-proposal that changes neither
(more records of the same shapes, or a batch without an optional field)
approves nothing, and the binding keeps the evidence_records and
role_probability of its first approval. Every version it does approve is
in the audit log as contract.approve, by the service actor
automatic-onboarding.
Approving a contract is an act with teeth: dataset builds pin the contract version they were built under, and a serving bundle re-verifies that pin before it ever scores. The model that runs in production can only see what was approved — provably, not by convention.
In the quickstart you approve your first inferred contract in step 3.
You can also declare fields before any data arrives — their types, privacy classes and proposed roles — so a draft holds them and you can approve them before the first event: see Field declarations.
Next: Presence — what the model sees when a value is absent.