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 kinds —
customer.created,customer.updated,customer.deleted. Payloads carry the key, an integerversion, 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), pluscustomer.tombstoned(soft delete; a higher-version write revives) and fail-closedcustomer.conflict. - Spectator read queries —
customer-get,customer-list, andcustomer-by-plan(one perindexmarker), exposed tostudio, 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, andbool. - Updates carry the full field set; field-level patch is a compatible future extension.
*.deletedis domain evidence (soft delete); byte erasure remains the job of privacy-retention tombstones.- Modules and plugin packs do not carry entity blocks in V1.