Skip to content
Talk to our solutions team

Entities

An entity is one named record type in a schema. It maps to one table in the datastore and to one REST/GraphQL resource on the HTTP surface. You declare entities under the top-level entities: list; the whole directory of YAML is loaded as one document, so file layout is yours to choose.

entities:
- name: currency
description: "Currency master data"
fields:
- name: code
type: string
modifiers: [required, unique]
- name: name
type: string
modifiers: [required]
- name: symbol
type: string
modifiers: [required]
- name: fraction_units
type: integer
defaultvalue: 100

That entity gets an id ULID primary key, four audit columns and two soft-delete columns without asking — see Primary key and Inherited fields.

KeyTypeDefaultEffect
namestringEntity identifier and default table name. Empty is a load error.
descriptionstringCarried onto the entity and into schema exports.
tablestringentity namePhysical table-name override.
inheritsstring | []stringTrait names to merge in.
applytoallboolfalseOn a trait, merges it onto every entity. Inert on a regular entity.
primary-keystring | []stringidPrimary key; a list makes it composite.
default-orderstringPK ascendingORDER BY applied when a query supplies none.
fieldslistField declarations. See Fields.
indexeslistSecondary indexes.
scopetenant | shared | superadmintenantIsolation plane. Any other value fails the load.
materializationtable | view | remote | contract | virtualtablePhysical shape. Unrecognised values fall back to table.
historyobjectCanonical row-versioning block.
scdobjectAlias for history:; identical shape.
cdcstringnoneAlias for history.strategy.
temporalboolfalseShorthand for history.strategy: type2.
retentionobjectLifecycle policy (archive / anonymise / purge).
storageobjectPer-backend storage hints.
accessobjectServices / actions / fields / RLS rules. See Access rules.
rowlevelsecurityobjectRLS; hoisted into access.rls on load.
access-lock[]stringAccess tiers a product layer may not override.
finalboolfalseSeals the entity against any layer contribution.
cacheobjectenabled, backend, strategy, ttl, scope, invalidateon, invalidaterelated. Inert — see Keys that parse and do nothing.
remotemapProxy configuration for materialization: remote.
hooksobjectv1 lifecycle hooks (beforeCreate, afterCreate, beforeRead, afterRead, beforeUpdate, afterUpdate, beforeDelete, afterDelete, beforePurge, afterPurge), converted to triggers. Exact camelCase.
triggerslistTyped triggers: name, event, priority, script ref.
pointcutslistMethod interceptions. An unknown method: fails the load.
eventslistOutbound event publication: name, operations, condition, topic, format (cloudevents | json | avro), include, exclude. Inert — no bus is wired.
subscriptionslistRealtime push: name, source, eventtypes, scope, rls, fieldgroup, transports, filter. Inert — no runtime reads them.
stateflowslistInline state machines. See Stateflows.
constraintslistMulti-entity business rules: name, language, rule, severity, message, code, entities.
entityvalidationslistCross-field validations: name, language (default expr), rule, message, code.
fieldgroupsmapNamed projections; each value is a field-name list or the literal "*". Inert — nothing projects on them.
actions / querieslistEntity-scoped named SQL. Both keys parse to the same list; action names are globally unique per schema.
queryhasheslistLegacy named queries; folded into actions.
compliance.retentiondaysintLegacy v1 retention window, independent of retention:. Inert — use retention:.
compliance.retentionactionstringLegacy v1 retention action. Inert.
mask-strategystringEntity-level mask strategy. Inert — masking is per-field.
featurestringFeature-flag name. Not enforced — see Feature gating.
datastorestringLogical datastore name. Stored, not used for routing.
deprecatedboolfalseMarks the entity deprecated.
deprecated-messagestringDeprecation note.

These bind to the entity struct and are read by no code in the shipping engine. They are listed so you can recognise them in an inherited schema, not so you can use them.

KeyStatus
status, tags, annotationsStored, no consumer.
update-strategyStored, no consumer.
delete-strategyStored, no consumer. Soft delete is driven by column presence instead.
fulltextsearchStored; no dialect reads it.
auth-fields, auth-type, identity-fieldStored, no consumer.
fieldgroupsStored; no read path projects on a named group.
mask-strategy, compliance.retentiondays, compliance.retentionactionStored, no consumer. Masking is declared per field; retention is declared under retention:.
cacheStored; the read-through and invalidation hooks exist but no cache backend is registered in the deployable service, so they never run.
eventsStored; the publishing hook is registered with no bus, so it returns immediately and nothing is emitted.
subscriptionsStored; no runtime code reads them.
datastoreStored; one engine serves every entity in the schema, and the datastore is chosen once per (tenant, datastore name) at boot.
featureStored; gating runs only on a code path the deployable service does not use.

The entity name is the API resource name and, unless table: overrides it, the physical table name. Table names are emitted schema-qualified: the namespace comes from the deployment (one per tenant/product/plane), becomes the PostgreSQL schema, and is created with CREATE SCHEMA IF NOT EXISTS as the first migration step.

inherits: names one or more traits. It accepts a single string, a comma-separated string, or a list; namespaced names resolve after the namespace segment is stripped, so inherits: kisai.id and inherits: id are the same trait. Unknown trait names are skipped silently. Entity-declared fields always win over trait fields; trait fields are appended after the entity’s own and renumbered above them. Cycles are broken by a visited set.

Four traits are compiled into the engine and need no declaration:

TraitAppliedFields
commonevery entitycreatedby, createdon, updatedby, updatedon
softdeleteevery entitydeletedby, deletedon
idopt-in via inheritsid — ULID, default ulid(), required, unique, final
uidopt-in via inheritsidklid, default 'U'+ulid(), required, unique, final

common and softdelete are applytoall, so every entity carries six extra columns and soft-delete semantics whether or not it asks. Declaring your own trait with a built-in’s bare name replaces the built-in entirely. See Traits for the declaration shape.

primary-key: takes a string or a list. A list sets the composite key from the whole list and the scalar primary key from its first element; PostgreSQL emits PRIMARY KEY (…) from the composite when present.

entities:
- name: currency
primary-key: code
fields:
- name: code
type: string
required: true
unique: true

When primary-key: is omitted it defaults to id. If no id field exists after traits are merged, the loader injects the built-in id ULID column so the primary key names a real column. Declaring a natural key, as above, suppresses that injection.

KeyTypeEffect
namestringIndex name. Omitted on PostgreSQL it becomes idx_<entity>_<fields joined by _>.
fields[]stringIndexed columns in order. More than one makes it composite.
uniqueboolEmits UNIQUE.
typestringIndex method — btree, hash, gin, gist, brin, minmax, bloom_filter. Emitted as USING <type> when set and not btree. Not validated at load.
wherestringPartial-index predicate, emitted verbatim after WHERE.
include[]stringCovering columns, emitted as PostgreSQL INCLUDE (…).
granularityintClickHouse data-skipping granularity. Parsed on every backend; only ClickHouse emits it.
entities:
- name: sales_order
indexes:
- name: sales_order_open_by_customer
fields: [customer_id, createdon]
include: [total_amount]
where: status <> 'cancelled'
type: btree

PostgreSQL emits CREATE [UNIQUE] INDEX IF NOT EXISTS, then USING, then INCLUDE, then WHERE.

default-order: sets the ordering applied when a query supplies none. It is a comma-separated list of field names, each optionally suffixed with asc or desc — only desc (case-insensitive) flips direction. With no default-order:, results come back ordered by the primary key ascending. An explicit order in the query always overrides it.

entities:
- name: outlet
inherits: id
default-order: name asc, createdon desc

scope: decides which engine and which database the entity’s rows live in. It is orthogonal to datastore:.

ValueMeaning
tenantDefault. Rows live in the tenant’s own database; normalised to empty internally.
sharedOne deployment-wide store, served by its own engine and migrated per deployment.
superadminControl-plane store, served by its own engine.
entities:
- name: orders
fields: [{ name: id, type: ulid }] # scope omitted → tenant plane
- name: catalog
scope: shared
fields: [{ name: id, type: ulid }]
- name: registry
scope: superadmin
fields: [{ name: id, type: ulid }]

Any other value is a hard load error: entity "x": scope "global" is not one of tenant|shared|superadmin. A composed schema is split per plane before the engines are built, and a relation is kept only when both endpoints are in the same plane — cross-plane relations are dropped by design.

Four keys feed one history configuration. history: is canonical; scd: has the identical shape; cdc: carries the strategy string alone; temporal: true is shorthand for type2. Options are taken in the order history > scd > cdc > temporal, but every spelling present on the entity must name the same strategy or the load fails with row-history conflict: … declare one canonical form (history:) or matching aliases.

StrategyShape
noneDefault. No versioning.
type1Overwrite in place; no history retained.
type2Validity-interval rows in the main table.
type3<field>_prev columns for each name in tracked.
type4Current row in the main table, pre-images snapshotted into a side table.
type6type2 + type3 combined.
typeaudit (alias audit)Per-field change rows in an audit side table.
typenoversion (alias noversion)History capture without version numbering.
KeyDefaultEffect
history.strategynoneStorage shape. An unknown token is a load error.
history.valid_from_fieldvalid_fromVersion-start column (type2/type6).
history.valid_to_fieldvalid_toVersion-end column; NULL marks the current row.
history.trackedField names retaining a <field>_prev column (type3/type6).
history.history_table<entity>_historySide-table name for type4.
history.archive.afterAge at which version rows move to the archive table.
history.archive.table<entity>_archiveDestination for aged version rows.
history.archive.dropfalseDelete expired version rows instead of moving them.
entities:
- name: orders
history:
strategy: type2
valid_from_field: from_ts
valid_to_field: to_ts
fields:
- { name: id, type: ulid }

type2 and type6 synthesise three readonly fields — the valid-from column, the valid-to column and _version_num — plus three indexes:

IndexDefinition
<entity>_current_uniqueUNIQUE on the primary key WHERE <valid_to> IS NULL
<entity>_versioning_idx(<pk>, <valid_to>)
<entity>_validfrom_idx(<pk>, <valid_from>)

On PostgreSQL the primary key is extended with the valid-from column so one logical id can hold many rows. typeaudit creates <entity>_audit with a fixed column set — id, entity_id, field_name, old_value, new_value, operation, changed_at, changed_by. type4 creates <entity>_history. Both side tables are created by the migration.

Soft delete is driven by column presence, not by a flag. If the entity has a deletedon field, DELETE is rewritten to an UPDATE that stamps it, and every read injects WHERE deletedon IS NULL. deletedby is stamped alongside. Because the built-in softdelete trait is applytoall, this is on for every entity by default; to turn it off, declare your own softdelete trait without those columns.

These column names change engine behaviour by name alone:

ColumnEffect
deletedon / deletedbySwitches the entity into soft-delete mode.
versionSet to 1 on create and incremented on every update.
createdon / updatedonStamped with the write timestamp.
createdby / updatedbyStamped with the calling user id.

Naming a business column any of these silently changes how the engine treats the entity.

ValueBehaviour
tableDefault. A real table.
viewExcluded from the migration plan.
contractExcluded from the migration plan.
virtualExcluded from the migration plan.
remoteReads and writes are proxied to an external service through the remote: block.

Anything unrecognised — including the British spelling materialisation:, which binds to nothing — silently becomes table.

materialization: remote plus a remote: block proxies the entity to an external service: name, protocol (rest default, or graphql), base_url (alias baseurl), path, timeout (default 10s), auth (type: bearer | apikey | basic | none, plus token, header, user, password), and per-operation create / get / getlist / update / delete blocks, each with pre.template (url, method, headers, body) and post.expression. Operations with neither pre nor post are dropped, and the router fails closed for any operation left unwired.

entities:
- name: guest
materialization: remote
remote:
name: bookings
base_url: https://api.example.com
path: /v1
protocol: rest
timeout: 5s
auth:
type: bearer
token: "${env.BOOKINGS_TOKEN}"
create:
pre:
template:
url: /guests
method: POST
body: |
{ "data": { "name": "{{payload.data.name}}" } }
post:
expression: .data

Template bindings, response extraction, the reduced execution path and what query capability survives are covered in Remote entities.

Schema-level views: parse cleanly and every dialect implements view creation, but nothing in the migration path calls it. Declaring a view has no DDL effect today.

KeyTypeBackend
storage.enginestringBackend storage engine, e.g. a ClickHouse MergeTree family name.
storage.order-bystringPhysical ordering key.
storage.partition-bystringPartitioning expression.
storage.ttlstringBackend-level TTL expression.
storage.settingsmapClickHouse MergeTree SETTINGS.
storage.tablespacestringPostgreSQL tablespace.
storage.fill-factorintPostgreSQL fillfactor.
storage.unloggedboolEmits CREATE UNLOGGED TABLE on PostgreSQL.

All eight parse on every backend. On PostgreSQL only unlogged reaches the DDL today; the rest are carried on the entity for backends that consume them. partition-by on a backend with no partitioning support (SQLite, DuckDB) is rejected at boot rather than silently ignored.

A retention: block drives the lifecycle runner.

KeyDefaultEffect
retention.activeHow long a row stays in the primary store before becoming archive- or purge-eligible.
retention.archiveHow long it stays in the archive store before purge. Ignored without archiveto.
retention.purgeTotal age from creation before hard delete.
retention.ttlShorthand for purge with no archive phase; when non-zero it wins over active/archive/purge.
retention.archivetoName of a top-level archive target. Naming an undeclared target is a load error.
retention.actionnonePII pass — anonymize, delete or none.
retention.anonymizeafteractiveOverrides active for the anonymise pass only.
retention.onfkconstraintfailBehaviour when a purge would violate a foreign key — fail, cascade or anonymize_refs.
retention.agefieldcreatedonColumn used as the lifecycle clock.
retention.noautoindexfalseDeclared opt-out from auto-emitted retention indexes. Inert — the loader emits none, so index the age field yourself.

Durations use the retention grammar — 90d, 7y, 30m, 1.5y. A malformed value fails the load, naming the offending key.

Archive targets are declared once at the top level and referenced by name:

KeyDefaultEffect
archives[].nameTarget name referenced by retention.archiveto.
archives[].typesame_db_tablesame_db_table copies in-process; datastore, s3, gcs and azblob enqueue an ETL handoff instead. Not validated at load — an unknown value fails in the runner.
archives[].datastoreTarget datastore name when type: datastore.
archives[].suffix_archiveCompanion-table suffix when type: same_db_table.
archives[].settingsFree-form string map.
archives:
- name: cold
type: same_db_table
suffix: _archive
entities:
- name: audit_event
retention:
active: 90d
archive: 2y
archiveto: cold
action: anonymize
agefield: createdon

No companion entity is synthesised for the non-same_db_table types. Those rows are marked and pushed onto the handoff queue for an external ETL to drain; the destination table is the target’s to define, not this schema’s.

Definitions compose in three layers: the service base embedded in the binary, then the product layer, then the tenant layer. Structural contributions — fields, indexes, relations — are additive, and a name collision keeps the lower layer’s definition. Access is the one tier that can be overridden, and only the product layer may do it, per tier, wholesale.

Two entity keys seal that composition:

KeyEffect
access-lock: [services|actions|rls|fields]Named access tiers a product layer may not override. Values are lower-cased on load; unrecognised names are inert.
final: trueSeals the entity against any layer contribution — no added fields, no access override. Stronger than locking every tier.
entities:
- name: users
final: true
access-lock: [RLS, Actions]
fields:
- { name: id, type: ulid }

Plane rules ride along with the seals: no layer may introduce a superadmin entity, the tenant layer may introduce only tenant-scoped entities and may not add to a shared or superadmin base entity, and a scope change on an existing entity is ignored.

The augments: key parses into the schema and is then never applied to any entity on the shipping path. To add fields from a higher layer, re-declare the entity in that layer and let the additive fold merge it.

feature: names a flag that is meant to remove the entity for tenants without it. The loader stores it; the gate runs only on a code path the deployable service does not use. On the shipping path a feature-gated entity is always present.

The migration subsystem declares its own bookkeeping entities. They carry the data_ prefix so the diff layer excludes them, and they are exempt from deny-by-default access gating. No YAML key sets this flag, so a declared entity cannot masquerade as one.

TableContents
data_db_versionCoarse per-datastore version row.
data_plansOne row per migration plan.
data_applied_changesOne row per applied step, keyed to a plan.
data_archive_handoffArchive/ETL handoff queue.
data_locksMigration locks.
data_materialized_refresh_tasksPending materialised-compute refreshes.

Every *.yaml and *.yml under the loaded root is walked recursively in lexical order and merged into one document; *.sql files are collected as raw assets keyed by path relative to the root.

CollectionMerge rule
entities, traitsKeep first. A later duplicate is dropped wholesale and logged at error level.
enums, references, embeddables, customtypes, stateflows, functions, views, syncs, seeds, caches, unions, encryptionkeys, subscriptions, queryhashes, actions, queries, datastores, endpointsLast write wins, keyed by name (endpoints by path).
augments, migrationsAccumulate.

Because non-entity collections are last-write-wins over a lexical walk, renaming a file can change which definition survives.

Foreign keys are declared once at the top level under references:, not on the entity. See Relations for the full key set, direction rules and the referential actions that ship.