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
namestring—Entity identifier and default table name. Empty is a load error.
descriptionstring—Carried onto the entity and into schema exports.
tablestringentity namePhysical table-name override.
inheritsstring | []string—Trait 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.
fieldslist—Field declarations. See Fields.
indexeslist—Secondary indexes.
searchobject—Full-text search: language (a PostgreSQL text search configuration, per entity, default simple) and document (ordered list of {field, weight}, weights A–D). Compiles to a stored, generated tsvector column and a GIN index. See Search and vectors.
scopetenant | product | customertenantPlane, the prior spellings shared and superadmin are accepted and folded. Any other value fails the load.
materializationtable | view | remote | contract | virtualtablePhysical shape. Unrecognised values fall back to table.
historyobject—Canonical row-versioning block.
scdobject—Alias for history:; identical shape.
cdcstringnoneAlias for history.strategy.
temporalboolfalseShorthand for history.strategy: type2.
concurrencyobject—Optimistic concurrency: strategy (none | version | timestamp), column, required. See Concurrency.
retentionobject—Lifecycle policy (archive / anonymise / purge).
storageobject—Storage hints: flat keys apply on every backend; a block under a backend key (postgres, clickhouse, duckdb, sqlite) applies on that backend only.
accessobject—Services / actions / fields / RLS rules. See Access rules.
rowlevelsecurityobject—RLS; hoisted into access.rls on load.
access-lock[]string—Access tiers a product layer may not override. The coarse form of layering.
layeringmap—Per element and per layer, which contributions are allowed: final / additive / replace.
auditobject—Opts the entity in to the tenant’s audit stream: mutations and reads (both false unless set), fields (names | none). No block records nothing for the entity; denials are recorded regardless. Product layer only; a tenant overlay’s block is dropped. See Observability.
finalboolfalseSeals the entity against any layer contribution.
cacheobject—Read-through entity cache: backend (a declared caches: name), ttl, scope (tenant | user), invalidateon, invalidaterelated, enabled. See Caching.
remotemap—Proxy configuration for materialization: remote.
hooksobject—v1 lifecycle hooks (beforeCreate, afterCreate, beforeUpdate, afterUpdate, beforeDelete, afterDelete), converted to triggers. Exact camelCase. The before* side can refuse; the after* side runs once the row is written, with rows bound to the written rows. The read and purge slots (beforeRead, afterRead, beforePurge, afterPurge) are refused at load: nothing would fire them.
triggerslist—Typed triggers: name, event, priority, script ref.
pointcutslist—Method interceptions. An unknown method: fails the load.
eventslist—Outbound event publication: name, operations, condition, topic, format (cloudevents | json | avro), include, exclude. Refused at boot on data.svc: no bus is wired.
subscriptionslist—Realtime push: name, source, eventtypes, scope, rls, fieldgroup, transports, filter. Refused at boot on data.svc: no subscription surface is served.
constraintslist—Multi-entity business rules: name, language, rule, severity, message, code, entities.
entityvalidationslist—Cross-field validations: name, language (default expr), rule, message, code.
actions / querieslist—Entity-scoped named SQL. Both keys parse to the same list; action names are globally unique per schema. An action may add transaction: true and lock: <template>: see Declared actions.
queryhasheslist—Legacy named queries; folded into actions.
compliance.retentiondaysint—Legacy v1 retention window, independent of retention:. Inert: use retention:.
compliance.retentionactionstring—Legacy v1 retention action. Inert.
featurestring—Feature-flag name. Not enforced: see Feature gating.
datastorestring—Logical datastore name. Stored, not used for routing.
deprecatedboolfalseMarks the entity deprecated.
deprecated-messagestring—Deprecation note.

No HTTP caller carries a service identity, so an entity declaring access.services: would refuse every request. The service refuses the schema when it builds the engine instead, naming the entity:

boot: the schema declares what this deployment does not serve: entity "order" declares access.services: but callers on this deployment carry no service identity, so every request would be refused; remove the list

events: and subscriptions: are served, see Change streams, but they are checked against each other, because half a declaration is a stream that never carries anything:

boot: the schema's events and subscriptions do not hold together: entity "order" subscription "watch": source: "entity.nothing" is a topic no events: block publishes to, so the stream would never carry anything

fieldgroups, mask-strategy, compliances[].category and an endpoint’s tx-mode are no longer keys at all: the loader refuses them as unknown. history.archive, fulltextsearch, delete-strategy, update-strategy and stateflows are refused with their own messages, each naming what does the work instead: search:, the deletedon column (with retention: for a scheduled purge), history: / concurrency:, and — for a state machine — a definition under the schema’s top-level stateflows: with a stateflow: <name> binding on the field it governs (see Stateflows).

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.
auth-fields, auth-type, identity-fieldStored, no consumer.
compliance.retentiondays, compliance.retentionactionStored, no consumer. Retention is declared under retention:.
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 inheritsid: 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: 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:.

ValueOwnsMeaning
tenantone tenantDefault. Rows live in the tenant’s own database; normalised to empty internally.
productall tenants of one product in one environmentOne copy per product and environment, served by its own engine and migrated once per plane. Prior spelling shared.
customercustomer-level dataThe control plane, served by its own engine. Declarable by the service base only. Prior spelling superadmin.

A scope names the level of the tenant key that owns the data, and every spelling is folded to one value at load: tenant to empty, shared to product, superadmin to customer. There is no environment-scoped plane; the environment is a hard floor for application data.

entities:
- name: orders
fields: [{ name: id, type: ulid }] # scope omitted → tenant plane
- name: catalog
scope: product
fields: [{ name: id, type: ulid }]
- name: registry
scope: customer
fields: [{ name: id, type: ulid }]

Any other value is a hard load error: entity "x": scope "global" is not one of tenant|product|customer. 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.tracked—Field names retaining a <field>_prev column (type3/type6).
history.history_table<entity>_historySide-table name for type4.
history.archive.after—Age 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, actor. 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: "${secret.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 hint is read by the backend it is meant for. Keys at the top of storage: apply everywhere; the same keys under a backend name apply on that backend alone, laid over the flat ones:

storage:
unlogged: true # every backend that understands it
clickhouse:
engine: "MergeTree()"
partition-by: "toYYYYMM(posting_date)"
order-by: "item_id, posting_date"
ttl: "posting_date + INTERVAL 7 YEAR"
postgres:
partition-by: "RANGE (posting_date)"

A ClickHouse partition expression declared this way never reaches SQLite or DuckDB, so an entity can carry it and still boot on an embedded backend. A flat partition-by is still refused on a backend without partitioning, as is one declared under that backend’s own key: scoping is how you say which backend a hint is for, not a way past the gate, the older v1 spelling, postgresql: with a structured partition: {type, column, interval}, is read the same way and renders as RANGE (column).

A retention: block drives the lifecycle runner.

KeyDefaultEffect
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.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.
retention.batchsize1000Rows per statement for this entity’s anonymise, archive and purge passes. Overrides the runner’s default.

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[].name—Target 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[].datastore—Target datastore name when type: datastore.
archives[].suffix_archiveCompanion-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: 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.

Three entity keys govern that composition, coarse to granular:

KeyEffect
final: trueSeals the entity against any layer contribution. No added fields, no access override.
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.
layering:Per element and per layer: final, additive (the layer may narrow, never widen) or replace.

The first two are the coarse forms of the third and resolve through the same rules. access-lock: beside layering: is refused — one decision, one vocabulary. See Access rules for the modes, the shape and what each element accepts.

entities:
- name: users
layering:
default: final
access:
actions:
read: { product: replace } # the product decides who may read
unmask: final # nobody may grant it
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.

TableContentsThe columns you would read
data_schema_upgradeOne row per schema upgrade, of any service. The newest applied row for a (service, product, datastore) is the version that datastore is atupgrade_id, service, product, tenant, datastore, version_from, version_to, source, status, trigger, triggered_by, max_risk, total_steps, applied_count, failed_count, meta
data_schema_upgrade_stepOne row per step of an upgrade, or per seed fileupgrade_id, step_order, kind, schema_op, entity, identifier, checksum, status, duration_ms, attempt, source_uri
data_locksThe lock a migration, seed, retention run or scheduled job holds while it runs. Shared by every service in the schema; the rows separate by ownerservice, product, tenant, datastore, resource, holder_id, op_kind, hostname, plan_id
data_archive_handoffThe queue between marking a row for archive and the writer confirming itplan_id, entity, row_pk, target_name, target_type, status, claimed_by, confirmed_by, checksum, attempt
data_materialized_refresh_tasksPending cross-entity refreshes of a materialised computed fieldtarget_entity, target_field, target_row_id, source_entity, source_op, status, claimed_by, attempts
data_audit_eventsThe hash-chained audit streamseq, prev_hash, hash, plus the principal, operation, entity, outcome and reason
data_event_outboxThe at-least-once delivery queue for declared entity eventstopic, target, entity, operation, subject, status, claimed_by, attempts

Every one of them carries id, createdby, createdon, updatedby and updatedon, so the columns are the same shape as any entity’s.

status on the three queues reads the same way: pending, claimed, done, failed, with claimed_by naming the worker holding it. A row stuck in claimed with no progress is a worker that went away; the admin routes for events, retention and materialization are how to move them without a database session.

They are engine-managed, so the access rules that guard your entities do not gate them, and they are excluded from drift reports: discovery recognises them by the data_ prefix and never offers one as a table to create or drop.

With a datastore prefix every one of them carries it, em1_data_audit_events and so on, so each datastore has its own — with three deliberate exceptions. data_schema_upgrade, data_schema_upgrade_step and data_locks keep their names under every prefix, in every tenant and every installation: they are shared by every kis.ai service that migrates this database, and their rows separate by the service, product and datastore columns rather than by table name. The rule is what a table holds — rows derived from this datastore’s data take the prefix, shared coordination does not.

Two kinds of entity exist without being declared, and behave like declared ones on every route:

EntityTableWhere it comes from
enum_<name>enum_<name>Every top-level enum: its values as rows, keyed by key
<entity>_archive<entity>_archiveEvery entity whose retention archives to a same-database table

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.