Tarski/ docsJoin the waitlist

Entities

One declaration expands into the whole record kit — versioned latest-wins state, fail-closed conflicts, soft delete with revival, and generated read queries.

Record-shaped data — the CRUD-like backbone of most apps — normally costs a hand-written kit: observation kinds, a mapper, current-state rules, and read queries. An entity block replaces that kit with one declaration. The expansion is deterministic at load time into ordinary V1 artifacts; nothing downstream has entity-specific semantics.

entity customer {
  key customer_id: text
  field name: text
  field plan: text index
}

What one block generates

  • Observation kindscustomer.created, customer.updated, customer.deleted. Payloads carry the key, an integer version, and the full field set on writes. Appends go through your ordinary write policies — there is no second write path.
  • A generated mapper that owns those kinds exclusively (your app mappers never receive them), emitting namespace-contained source.customer.* atoms.
  • Current-state rules — event union → activity → max-aggregate current version → customer.record (latest version wins), plus customer.tombstoned (soft delete; a higher-version write revives) and fail-closed customer.conflict.
  • Spectator read queriescustomer-get, customer-list, and customer-by-plan (one per index marker), exposed to studio, api. Agent exposure stays hand-authored with decision coverage, deliberately.

The generated relation kit is a supported authoring surface, not an implementation accident:

Relation Meaning
customer.event(key, version, fields...) Union of created and updated record values; the complete write history
customer.activity(key, version) Every write or delete version
customer.current_version(key, version) Maximum activity version for the key
customer.record(key, fields...) Conflict-free, non-tombstoned current value
customer.tombstoned(key) The latest activity is a delete
customer.conflict(key, version) Divergent same-version writes or write/delete collision

App rules may read these relations and may derive additional rows into them. That is the supported extension seam for domain events that participate in the entity’s history. The normal Datalog and provenance rules still apply: every derived row must have explicit support, and a malformed row cannot bypass the current-version/conflict semantics.

Inspect the generated artifacts anytime:

tarski entity expand

Materialized expansions live under generated/entities/<entity>/, written only when changed.

Conflicts fail closed

Entities use optimistic concurrency: writers own version monotonicity. If two divergent writes arrive at the same version, the runtime does not pick a winner — customer.record derives nothing for that key until a higher version resolves the conflict, and customer.conflict makes the situation visible. Clients render the conflict and offer a next-version write. The Team Tasks example is the reference implementation of this pattern, UI included.

Overrides

Generated artifacts can be ceded to hand-written code explicitly:

entity customer {
  key customer_id: text
  field name: text
  omit query customer-list
}

omit accepts exactly query, relation, or rule. Omitting a relation also omits its generated rules; you must declare the replacement relation and rules yourself. There is no omit mapper in V1.

The generated mapper exclusively owns customer.created, customer.updated, and customer.deleted, producing source.customer.created, .updated, and .deleted atoms. A hand-written mapper cannot take over those observation kinds. For a custom envelope or deletion-attribution payload, mint an app-owned observation kind and map it to an app-owned atom, then derive the appropriate supported entity relation in .ta. The generated delete atom carries atom_id, key, and version; its provenance and atom.observed_at remain reachable, but arbitrary observation-envelope fields are not silently projected into it.

Collisions between generated and hand-written names are load errors naming both provenances — nothing is silently shadowed. Typed E_ENTITY_* diagnostics come from a block scanner that runs before the contract parser.

Entity identifiers remain underscore-style Tarski identifiers, but generated query segments are kebab case. For example, entity board_column generates board-column-get, board-column-list, and board-column-by-sort-order. This normalization is fixed in 0.5.9-preview.8. A legacy omit such as omit query board_column-get is temporarily recognized with a deprecation warning and canonicalizes to board-column-get.

V1 boundaries

  • Field types are text, int, float, and bool.
  • Updates carry the full field set; field-level patch is a compatible future extension.
  • *.deleted is domain evidence (soft delete); byte erasure remains the job of privacy-retention tombstones.
  • Modules and plugin packs do not carry entity blocks in V1.