Entities and events
Tessera's entire input model is one sentence: entities emit typed events with typed attributes at timestamps. There is no fixed taxonomy — no "users", no "transactions", no built-in business categories. You declare what an entity is in your world, and every event is a typed, timestamped fact about one of them.
A worked example: gaming sessions
Say you run a mobile game. Your entities are players, and your world emits
facts like these:
{
"entity": { "type": "player", "id": "p_9331" },
"event_type": "session.start",
"timestamp": "2026-04-02T18:03:11Z",
"attributes": {
"device_class": "tablet",
"client_version": "3.14.2",
"connection": "wifi"
}
}
{
"entity": { "type": "player", "id": "p_9331" },
"event_type": "match.completed",
"timestamp": "2026-04-02T18:19:47Z",
"attributes": {
"mode": "ranked",
"result": "win",
"duration_seconds": 412,
"party_size": 3
}
}
Everything above is your vocabulary: player, session.start,
match.completed, party_size. Tessera imposes structure only where
learning requires it:
entity— who this fact is about. Theidis your identifier, opaque to the platform, and never a model input. It must not contain#(see entity ids).event_type— lowercase, dot-namespaced (session.start,shop.purchase_completed), up to four segments.timestamp— when the event happened in your world (RFC 3339 UTC), not when it was sent. Tessera orders each entity's history by it.attributes— a flat map of values (numbers, strings, booleans, nulls, small arrays). Nested objects are rejected: flatten upstream.
How identifiers become entity ids
An entity id is text. When a source sends an identifier as a number (a JSON
12, a warehouse NUMERIC column, a Parquet DOUBLE), every connector,
webhook and label table turns it into the same text,
so one customer is one entity however the data arrives:
- Text is kept as sent, in one canonical form:
"007"stays007,"12.0"stays12.0, and spaces and letter case are never changed. Ids are stored in Unicode NFC with LF line endings, the form every stored event takes, so canonically equivalent spellings are one id: anésent as one character or asefollowed by a combining accent, or the Kelvin signKand the letterK. A CR or CRLF line break in an id becomes LF. Event ids follow the same text rule. - A whole number becomes its digits:
12,12.0and aNUMERIC12.000000000are all the entity12, for events and labels alike. - Anything else is refused, with the source's usual error (a dead-lettered
message, a rejected row or label, or a quarantined raw record), never
guessed: booleans, fractional numbers (
12.5),NaNor infinity, and whole numbers larger than 2^53 − 1 sent as floating-point values (a floating-point value cannot hold them exactly).
Large ids stay exact where the source keeps them exact: a Postgres numeric
or MySQL DECIMAL column mapped as an entity or event id is read as an exact
decimal (up to 38 digits before and after the point; a wider value, NaN or
an infinity is refused for its row only), and BigQuery, Snowflake, Databricks
and Redshift deliver decimals exactly. An id held in a floating-point value (a
DOUBLE column, a JSON number) above 2^53 − 1 cannot be read exactly: send it
as text, or cast the column to text in a view and map that.
A refusal names the field and one of these reasons (never the value):
| Reason | The value was | Fix |
|---|---|---|
boolean | true or false | map the column or pointer that holds the id |
fractional | a number with a fraction (12.5) | map the id itself; an id has no fraction |
non_finite | NaN or infinity | send the id; Tessera never invents one |
imprecise | a whole number above 2^53 − 1 in a floating-point value | send it as text, or cast the column to text in a view |
too_long | longer than 256 characters in Unicode NFC (or a number of more than 4,300 digits) | send a shorter id |
unsupported_type | a date, bytes, a list or an object | map the column or pointer that holds the id |
A secondary entity (an event's other participant) whose id is refused, or
longer than the 256-character limit, never costs you the event: the event
lands with its primary entity, without that participant, and the drop is
counted. On a batch sync the count is a
{type: "secondary_ids_dropped", column, reason, count} entry of the sync's
details (reason too_long for an over-length id). A row rejected because
its entity or event id was refused (as missing_entity_id or
invalid_event_id) is counted in a
{type: "entity_ids_refused", column, reason, count} entry, and a refused
label row in one that also names its label_id, so a refused id is never
mistaken for an empty cell.
Event ids read from an event_id_column (batch mappings and event-label
tables) follow the same rule, so a NUMERIC order id 12.0 labels the event
whose id is 12. A batch source that stored its event ids as 12.0 before
this rule keeps that spelling, and a label naming 12 still attaches to its
event 12.0. Event ids of Pub/Sub messages and of
automatically onboarded sources keep their earlier rule: a JSON 12.0 used as
an event id stays 12.0 there. An event-label table whose event-id column is
floating-point labels 12, which matches batch events but not those
messages: give event labels an integer or text event-id column.
The HTTPS ingestion endpoint accepts
text ids only. CSV files are the exception today: the reader types a column of
digits as numbers before the mapping runs, so 007 lands as 7; keep ids with
leading zeros in a Parquet string column.
Ids stored before this rule
Ids stored before this rule (for example 12.0) keep their history and are
not merged with the new form: new events land under 12, so one customer can
hold two histories. The earlier forms are kept as they were stored:
- Re-reads. A batch source whose event ids came from a floating-point or
NUMERICcolumn stored them as12.0, and keeps spelling them that way: a source's event ids never change spelling, so reading its rows again lands no event twice and no sync is refused for it; see numeric ids stored before the canonical rule. A label table synced before the rule lands its rows again under the canonical id on its first sync, and label stats count both forms for a while. - Crosswalks. A crosswalk entry written for the earlier spelling (
12.0) keeps applying to the id now read as12on batch column mappings, counted ascrosswalk_legacy_matchin the sync'sdetails; see crosswalks. - Deletion. Entity deletion matches ids exactly: to erase a customer whose events arrived under both forms, request the deletion of each form; see data deletion. A deletion made before this rule covers the form it named only.
Your operator can count the stored ids in an earlier form, per source, label and crosswalk (counts only, never ids).
One ledger per entity
Every accepted event lands in its entity's chronological ledger. The
behavioral model reads that history in order — session.start,
match.completed, match.abandoned, shop.purchase_completed, silence for
three days, session.start again — and produces a single vector, the
entity embedding, summarizing how this player behaves. Scores and
segments derive from that embedding.
Two consequences worth internalizing:
- Sequence is the signal. You never build aggregate features ("matches played last 7 days"); the model owns temporal context. Pre-aggregated columns can be ingested as attributes, but the platform never computes cross-event aggregates itself — that is how time-travel leakage starts.
- Secondary entities get their own history. A
match.completedevent can reference other participants (a party, a guild, a device) viasecondary_entities; each maintains its own ledger if its type is registered.
Where events come from
Batch and streaming sources meet in the same canonical event:
- Batch: file uploads, warehouse connectors (BigQuery, Postgres), bucket connectors (GCS, S3) — tables become events through a column mapping.
- Streaming: the HTTPS ingestion endpoint.
- Planned: the Python and TypeScript SDKs, the Kafka and Kinesis bridges, and the Segment and RudderStack destinations.
However an event arrives, idempotency holds: retries with the same
event_id (or the platform-derived one) are no-ops, so the same fact never
lands twice.
Entity types are registered on first sight on both paths. Streaming events register their primary and secondary types before the ledger append; a batch sync registers every type produced by its mapping before the event-store commit. If registration is unavailable or the project has reached its type limit, the write fails closed rather than leaving events that labels cannot reference.
Next: Schema contracts — deciding what the model may see.