Traits
A trait is a named group of fields that entities pull in by name. Entities apply a trait
explicitly with inherits:, or a trait applies itself to every entity in the schema with
applytoall: true. Four traits are compiled into the loader, so every entity in every
schema already carries audit and soft-delete columns before you declare a single field.
Declaring a trait
Section titled “Declaring a trait”Traits live under the top-level traits: key. The YAML shape is the same as an entity, but
only four keys survive conversion.
| Key | Type | Effect |
|---|---|---|
name | string | Trait name. This is the map key inherits: resolves against. |
description | string | Carried onto the trait. |
applytoall | bool | Merge this trait’s fields onto every entity in the schema. Default false. |
fields | list | The field list. Same field vocabulary as an entity — see Field types. |
traits: - name: docstatus description: Document lifecycle columns shared by every posting document fields: - name: docstatus type: string defaultvalue: draft modifiers: [required] - name: posted_on type: timestamp nullable: trueApplying a trait
Section titled “Applying a trait”An entity names traits in inherits:. Three surface forms are accepted:
entities: - name: sales_order inherits: docstatus # single string
- name: purchase_order inherits: docstatus, taggable # comma-separated string
- name: journal_entry inherits: # list - docstatus - taggableIn the comma-separated form each name is trimmed; empty entries are dropped in every form. A
trait with applytoall: true needs no inherits: anywhere — it merges onto every entity in the
schema, including entities that also name it explicitly (the second merge is a no-op).
applytoall is only meaningful on a traits: entry. Written on a regular entity it does
nothing.
Built-in traits
Section titled “Built-in traits”Four traits are compiled into the loader and registered on every schema under their bare names. Two of them apply themselves to everything.
| Trait | applytoall | Fields |
|---|---|---|
common | yes | createdby, createdon, updatedby, updatedon |
softdelete | yes | deletedby, deletedon |
id | no | id |
uid | no | id |
Field by field:
| Trait | Field | Type | Nullable | Default | Modifiers | Other |
|---|---|---|---|---|---|---|
common | createdby | string | no | contextget("user") | final | length 3–255 |
common | createdon | timestamp | no | currentTimestamp() | final | — |
common | updatedby | string | yes | — | — | materialised computed contextget("user"), length 3–255 |
common | updatedon | timestamp | yes | — | — | materialised computed currentTimestamp() |
softdelete | deletedby | string | yes | — | writeonce | length 3–255 |
softdelete | deletedon | timestamp | yes | — | writeonce | — |
id | id | ulid | no | ulid() | final | required, unique |
uid | id | klid | no | 'U'+ulid() | final | required, unique |
The uid variant types the column klid — a one-letter prefix plus a 26-character ULID, so
the Postgres column is sized CHAR(27) instead of CHAR(26).
What the built-ins imply
Section titled “What the built-ins imply”Six columns you did not declare. common and softdelete are applytoall, so every
entity in the schema carries createdby, createdon, updatedby, updatedon,
deletedby, deletedon.
Soft delete is on by default. The query compiler enables soft delete because a field
named deletedon or deletedby exists — not because of a flag. DELETE becomes
UPDATE … SET deletedon = NOW(), deletedby = :user, and every read that does not explicitly
ask for deleted rows injects WHERE deletedon IS NULL. deletedon is the state column;
an entity carrying only deletedby falls back to filtering on the principal column instead.
Remove both and delete becomes a hard delete. The delete-strategy: key is parsed and read
by nothing.
You rarely need inherits: id. When an entity declares no primary-key:, the primary
key defaults to id; if no id field exists after all trait merging, the loader injects the
id trait’s column so the table is DDL-valid. An explicit primary-key: code suppresses
that injection. inherits: uid must be written out — the backfill only ever uses id.
Name resolution and namespacing
Section titled “Name resolution and namespacing”A trait is stored under its name: verbatim. inherits: resolves in two steps:
- Exact match against the trait name.
- On a miss, everything up to and including the last
.is stripped and the remainder is looked up.
So kisai.common, erp.common, and plain common all resolve to the same trait. The
namespaced spellings are what most schemas in the platform use:
entities: - name: user_token inherits: kisai.common fields: - name: token type: string modifiers: [required, unique]Stripping happens on the reference, never on the declaration. A trait declared as
name: erp.docstatus is reachable only as inherits: erp.docstatus; inherits: docstatus
misses.
Overriding a built-in
Section titled “Overriding a built-in”A schema-declared trait whose name matches a built-in replaces it entirely — the built-in is only registered when no trait of that name is already present. The match is on the stored name, which for the built-ins is the bare form.
traits: - name: common # replaces the built-in `common` applytoall: true fields: - name: createdby type: string defaultvalue: contextget("user") - name: createdon type: timestamp defaultvalue: currentTimestamp()Turning a built-in off
Section titled “Turning a built-in off”Declare the trait with an empty field list. It replaces the built-in and contributes nothing:
traits: - name: common fields: [] - name: softdelete fields: []Dropping softdelete this way removes deletedon, which turns soft delete off for every
entity in the schema — deletes become physical. Dropping common removes the columns the
audit hook populates, so nothing is auto-populated on write.
Conflicts and ordering
Section titled “Conflicts and ordering”Merging never overwrites. A field name that is already on the entity is skipped, whatever its source. The order in which candidates are considered decides who wins:
- Fields the entity declares are placed first, in declaration order.
inherits:traits, in the order written. Each trait is merged once; a trait repeated in the list, or one whose name equals the entity’s own name, is skipped.applytoalltraits, in alphabetical order of trait name, merged onto every entity.- The
idbackfill, if the primary key is still the defaultidand noidfield exists yet.
Consequences:
| Situation | Outcome |
|---|---|
Entity declares createdon, common also has it | Entity’s field wins. No diagnostic. |
Two inherits: traits both declare code | The one listed first wins. |
An inherits: trait and an applytoall trait both declare code | The inherits: trait wins — step 2 runs before step 3. |
Two applytoall traits both declare code | The alphabetically first trait name wins. |
Trait fields are appended after entity-declared fields, then all positions are renumbered so the field order is deterministic across processes. Within one trait, fields merge in their declared order. Each merged field is deep-copied — its validations, modifiers, transforms, compliance tags, foreign key and storage hints come along, and mutating one entity’s inherited field does not affect the trait or any other entity.
There is no way to remove or rename a single field a trait contributes. The unit of override is the whole trait.
Traits across schema layers
Section titled “Traits across schema layers”Trait resolution happens per layer, at load time. The service loads the product schema, then folds each tenant’s overlay onto it; both sides are parsed and trait-expanded on their own before they meet. A trait declared in a tenant overlay therefore cannot inject fields into entities the product declared — by the time the layers compose, both are already flat field lists, and the composed schema keeps only the base layer’s trait map. The overlay’s own traits are discarded at composition.
Two further rules apply to the overlay side:
- The six audit and soft-delete field names (
createdby,createdon,updatedby,updatedon,deletedby,deletedon) are stripped from every entity in a parsed tenant overlay before composition. A tenant that deliberately writescreatedby:in its YAML has it dropped silently — the strip happens before composition could notice a collision. These columns belong to the layer that owns the entity. - The
idbackfill is suppressed. A tenant-only entity declared in an overlay must declare its ownidfield or aprimary-key:, otherwise the DDL emitsPRIMARY KEY ("id")over a table with no such column.
Merging traits across files
Section titled “Merging traits across files”A schema directory is walked recursively and every *.yaml / *.yml file merges into one
document. Traits merge keep-first: a trait name declared in a second file is ignored and
the duplicate is logged at error level. It is never fatal, and the merge is never deep — two
files declaring the same trait do not combine their field lists.
Splitting traits across files by topic is safe. Re-declaring a trait to add a field to it is not.
Related
Section titled “Related”- Entities — the entity keys traits sit alongside
- Field types — the field vocabulary a trait’s
fields:uses - Embeddables — the other reusable field-group construct
- Access rules — what a trait cannot carry