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. |
search | object | — | 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. |
scope | tenant | product | customer | tenant | Plane, the prior spellings shared and superadmin are accepted and folded. 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. |
concurrency | object | — | Optimistic concurrency: strategy (none | version | timestamp), column, required. See Concurrency. |
retention | object | — | Lifecycle policy (archive / anonymise / purge). |
storage | object | — | Storage hints: flat keys apply on every backend; a block under a backend key (postgres, clickhouse, duckdb, sqlite) applies on that backend only. |
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. The coarse form of layering. |
layering | map | — | Per element and per layer, which contributions are allowed: final / additive / replace. |
audit | object | — | 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. |
final | bool | false | Seals the entity against any layer contribution. |
cache | object | — | Read-through entity cache: backend (a declared caches: name), ttl, scope (tenant | user), invalidateon, invalidaterelated, enabled. See Caching. |
remote | map | — | Proxy configuration for materialization: remote. |
hooks | object | — | 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. |
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. Refused at boot on data.svc: no bus is wired. |
subscriptions | list | — | Realtime push: name, source, eventtypes, scope, rls, fieldgroup, transports, filter. Refused at boot on data.svc: no subscription surface is served. |
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. |
actions / queries | list | — | 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. |
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. |
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. |
Blocks data.svc refuses at boot
Section titled “Blocks data.svc refuses at boot”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 listevents: 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 anythingfieldgroups, 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).
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. |
auth-fields, auth-type, identity-field | Stored, no consumer. |
compliance.retentiondays, compliance.retentionaction | Stored, no consumer. Retention is declared under retention:. |
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 | Owns | Meaning |
|---|---|---|
tenant | one tenant | Default. Rows live in the tenant’s own database; normalised to empty internally. |
product | all tenants of one product in one environment | One copy per product and environment, served by its own engine and migrated once per plane. Prior spelling shared. |
customer | customer-level data | The 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.
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, actor. 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: "${secret.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.
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).
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. |
retention.batchsize | 1000 | Rows 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:
| 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.
Three entity keys govern that composition, coarse to granular:
| Key | Effect |
|---|---|
final: true | Seals 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 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 | The columns you would read |
|---|---|---|
data_schema_upgrade | One row per schema upgrade, of any service. The newest applied row for a (service, product, datastore) is the version that datastore is at | upgrade_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_step | One row per step of an upgrade, or per seed file | upgrade_id, step_order, kind, schema_op, entity, identifier, checksum, status, duration_ms, attempt, source_uri |
data_locks | The lock a migration, seed, retention run or scheduled job holds while it runs. Shared by every service in the schema; the rows separate by owner | service, product, tenant, datastore, resource, holder_id, op_kind, hostname, plan_id |
data_archive_handoff | The queue between marking a row for archive and the writer confirming it | plan_id, entity, row_pk, target_name, target_type, status, claimed_by, confirmed_by, checksum, attempt |
data_materialized_refresh_tasks | Pending cross-entity refreshes of a materialised computed field | target_entity, target_field, target_row_id, source_entity, source_op, status, claimed_by, attempts |
data_audit_events | The hash-chained audit stream | seq, prev_hash, hash, plus the principal, operation, entity, outcome and reason |
data_event_outbox | The at-least-once delivery queue for declared entity events | topic, 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.
Entities the loader adds
Section titled “Entities the loader adds”Two kinds of entity exist without being declared, and behave like declared ones on every route:
| Entity | Table | Where it comes from |
|---|---|---|
enum_<name> | enum_<name> | Every top-level enum: its values as rows, keyed by key |
<entity>_archive | <entity>_archive | Every entity whose retention archives to a same-database table |
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.