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: 100That entity gets an id ULID primary key, four audit columns and two soft-delete columns
without asking — see Primary key and Inherited fields.
Entity keys
Section titled “Entity keys”| Key | Type | Default | Effect |
|---|---|---|---|
name | string | — | Entity identifier and default table name. Empty is a load error. |
description | string | — | Carried onto the entity and into schema exports. |
table | string | entity name | Physical table-name override. |
inherits | string | []string | — | Trait names to merge in. |
applytoall | bool | false | On a trait, merges it onto every entity. Inert on a regular entity. |
primary-key | string | []string | id | Primary key; a list makes it composite. |
default-order | string | PK ascending | ORDER BY applied when a query supplies none. |
fields | list | — | Field declarations. See Fields. |
indexes | list | — | Secondary indexes. |
scope | tenant | shared | superadmin | tenant | Isolation plane. Any other value fails the load. |
materialization | table | view | remote | contract | virtual | table | Physical shape. Unrecognised values fall back to table. |
history | object | — | Canonical row-versioning block. |
scd | object | — | Alias for history:; identical shape. |
cdc | string | none | Alias for history.strategy. |
temporal | bool | false | Shorthand for history.strategy: type2. |
retention | object | — | Lifecycle policy (archive / anonymise / purge). |
storage | object | — | Per-backend storage hints. |
access | object | — | Services / actions / fields / RLS rules. See Access rules. |
rowlevelsecurity | object | — | RLS; hoisted into access.rls on load. |
access-lock | []string | — | Access tiers a product layer may not override. |
final | bool | false | Seals the entity against any layer contribution. |
cache | object | — | enabled, backend, strategy, ttl, scope, invalidateon, invalidaterelated. Inert — see Keys that parse and do nothing. |
remote | map | — | Proxy configuration for materialization: remote. |
hooks | object | — | v1 lifecycle hooks (beforeCreate, afterCreate, beforeRead, afterRead, beforeUpdate, afterUpdate, beforeDelete, afterDelete, beforePurge, afterPurge), converted to triggers. Exact camelCase. |
triggers | list | — | Typed triggers: name, event, priority, script ref. |
pointcuts | list | — | Method interceptions. An unknown method: fails the load. |
events | list | — | Outbound event publication: name, operations, condition, topic, format (cloudevents | json | avro), include, exclude. Inert — no bus is wired. |
subscriptions | list | — | Realtime push: name, source, eventtypes, scope, rls, fieldgroup, transports, filter. Inert — no runtime reads them. |
stateflows | list | — | Inline state machines. See Stateflows. |
constraints | list | — | Multi-entity business rules: name, language, rule, severity, message, code, entities. |
entityvalidations | list | — | Cross-field validations: name, language (default expr), rule, message, code. |
fieldgroups | map | — | Named projections; each value is a field-name list or the literal "*". Inert — nothing projects on them. |
actions / queries | list | — | Entity-scoped named SQL. Both keys parse to the same list; action names are globally unique per schema. |
queryhashes | list | — | Legacy named queries; folded into actions. |
compliance.retentiondays | int | — | Legacy v1 retention window, independent of retention:. Inert — use retention:. |
compliance.retentionaction | string | — | Legacy v1 retention action. Inert. |
mask-strategy | string | — | Entity-level mask strategy. Inert — masking is per-field. |
feature | string | — | Feature-flag name. Not enforced — see Feature gating. |
datastore | string | — | Logical datastore name. Stored, not used for routing. |
deprecated | bool | false | Marks the entity deprecated. |
deprecated-message | string | — | Deprecation note. |
Keys that parse and do nothing
Section titled “Keys that parse and do nothing”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.
| Key | Status |
|---|---|
status, tags, annotations | Stored, no consumer. |
update-strategy | Stored, no consumer. |
delete-strategy | Stored, no consumer. Soft delete is driven by column presence instead. |
fulltextsearch | Stored; no dialect reads it. |
auth-fields, auth-type, identity-field | Stored, no consumer. |
fieldgroups | Stored; no read path projects on a named group. |
mask-strategy, compliance.retentiondays, compliance.retentionaction | Stored, no consumer. Masking is declared per field; retention is declared under retention:. |
cache | Stored; the read-through and invalidation hooks exist but no cache backend is registered in the deployable service, so they never run. |
events | Stored; the publishing hook is registered with no bus, so it returns immediately and nothing is emitted. |
subscriptions | Stored; no runtime code reads them. |
datastore | Stored; one engine serves every entity in the schema, and the datastore is chosen once per (tenant, datastore name) at boot. |
feature | Stored; gating runs only on a code path the deployable service does not use. |
Naming and namespace
Section titled “Naming and namespace”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.
Inherited fields
Section titled “Inherited fields”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:
| Trait | Applied | Fields |
|---|---|---|
common | every entity | createdby, createdon, updatedby, updatedon |
softdelete | every entity | deletedby, deletedon |
id | opt-in via inherits | id — ULID, default ulid(), required, unique, final |
uid | opt-in via inherits | id — klid, 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
Section titled “Primary key”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: trueWhen 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.
Indexes
Section titled “Indexes”| Key | Type | Effect |
|---|---|---|
name | string | Index name. Omitted on PostgreSQL it becomes idx_<entity>_<fields joined by _>. |
fields | []string | Indexed columns in order. More than one makes it composite. |
unique | bool | Emits UNIQUE. |
type | string | Index method — btree, hash, gin, gist, brin, minmax, bloom_filter. Emitted as USING <type> when set and not btree. Not validated at load. |
where | string | Partial-index predicate, emitted verbatim after WHERE. |
include | []string | Covering columns, emitted as PostgreSQL INCLUDE (…). |
granularity | int | ClickHouse 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: btreePostgreSQL emits CREATE [UNIQUE] INDEX IF NOT EXISTS, then USING, then INCLUDE, then
WHERE.
Default order
Section titled “Default order”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 descIsolation planes
Section titled “Isolation planes”scope: decides which engine and which database the entity’s rows live in. It is
orthogonal to datastore:.
| Value | Meaning |
|---|---|
tenant | Default. Rows live in the tenant’s own database; normalised to empty internally. |
shared | One deployment-wide store, served by its own engine and migrated per deployment. |
superadmin | Control-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.
Row history
Section titled “Row history”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.
| Strategy | Shape |
|---|---|
none | Default. No versioning. |
type1 | Overwrite in place; no history retained. |
type2 | Validity-interval rows in the main table. |
type3 | <field>_prev columns for each name in tracked. |
type4 | Current row in the main table, pre-images snapshotted into a side table. |
type6 | type2 + type3 combined. |
typeaudit (alias audit) | Per-field change rows in an audit side table. |
typenoversion (alias noversion) | History capture without version numbering. |
| Key | Default | Effect |
|---|---|---|
history.strategy | none | Storage shape. An unknown token is a load error. |
history.valid_from_field | valid_from | Version-start column (type2/type6). |
history.valid_to_field | valid_to | Version-end column; NULL marks the current row. |
history.tracked | — | Field names retaining a <field>_prev column (type3/type6). |
history.history_table | <entity>_history | Side-table name for type4. |
history.archive.after | — | Age at which version rows move to the archive table. |
history.archive.table | <entity>_archive | Destination for aged version rows. |
history.archive.drop | false | Delete 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 }What versioning adds
Section titled “What versioning adds”type2 and type6 synthesise three readonly fields — the valid-from column, the valid-to
column and _version_num — plus three indexes:
| Index | Definition |
|---|---|
<entity>_current_unique | UNIQUE 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
Section titled “Soft delete”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:
| Column | Effect |
|---|---|
deletedon / deletedby | Switches the entity into soft-delete mode. |
version | Set to 1 on create and incremented on every update. |
createdon / updatedon | Stamped with the write timestamp. |
createdby / updatedby | Stamped with the calling user id. |
Naming a business column any of these silently changes how the engine treats the entity.
Materialization
Section titled “Materialization”| Value | Behaviour |
|---|---|
table | Default. A real table. |
view | Excluded from the migration plan. |
contract | Excluded from the migration plan. |
virtual | Excluded from the migration plan. |
remote | Reads 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: .dataTemplate 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.
Storage hints
Section titled “Storage hints”| Key | Type | Backend |
|---|---|---|
storage.engine | string | Backend storage engine, e.g. a ClickHouse MergeTree family name. |
storage.order-by | string | Physical ordering key. |
storage.partition-by | string | Partitioning expression. |
storage.ttl | string | Backend-level TTL expression. |
storage.settings | map | ClickHouse MergeTree SETTINGS. |
storage.tablespace | string | PostgreSQL tablespace. |
storage.fill-factor | int | PostgreSQL fillfactor. |
storage.unlogged | bool | Emits 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.
Retention and archives
Section titled “Retention and archives”A retention: block drives the lifecycle runner.
| Key | Default | Effect |
|---|---|---|
retention.active | — | How long a row stays in the primary store before becoming archive- or purge-eligible. |
retention.archive | — | How long it stays in the archive store before purge. Ignored without archiveto. |
retention.purge | — | Total age from creation before hard delete. |
retention.ttl | — | Shorthand for purge with no archive phase; when non-zero it wins over active/archive/purge. |
retention.archiveto | — | Name of a top-level archive target. Naming an undeclared target is a load error. |
retention.action | none | PII pass — anonymize, delete or none. |
retention.anonymizeafter | active | Overrides active for the anonymise pass only. |
retention.onfkconstraint | fail | Behaviour when a purge would violate a foreign key — fail, cascade or anonymize_refs. |
retention.agefield | createdon | Column used as the lifecycle clock. |
retention.noautoindex | false | Declared 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:
| Key | Default | Effect |
|---|---|---|
archives[].name | — | Target name referenced by retention.archiveto. |
archives[].type | same_db_table | same_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[].datastore | — | Target datastore name when type: datastore. |
archives[].suffix | _archive | Companion-table suffix when type: same_db_table. |
archives[].settings | — | Free-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: createdonNo 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.
Layering seals
Section titled “Layering seals”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:
| Key | Effect |
|---|---|
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: true | Seals 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 gating
Section titled “Feature gating”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.
System entities
Section titled “System entities”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.
| Table | Contents |
|---|---|
data_db_version | Coarse per-datastore version row. |
data_plans | One row per migration plan. |
data_applied_changes | One row per applied step, keyed to a plan. |
data_archive_handoff | Archive/ETL handoff queue. |
data_locks | Migration locks. |
data_materialized_refresh_tasks | Pending materialised-compute refreshes. |
Splitting a schema across files
Section titled “Splitting a schema across files”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.
| Collection | Merge rule |
|---|---|
entities, traits | Keep 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, endpoints | Last write wins, keyed by name (endpoints by path). |
augments, migrations | Accumulate. |
Because non-entity collections are last-write-wins over a lexical walk, renaming a file can change which definition survives.
Relations
Section titled “Relations”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.