Skip to content
Talk to our solutions team

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.

Traits live under the top-level traits: key. The YAML shape is the same as an entity, but only four keys survive conversion.

KeyTypeEffect
namestringTrait name. This is the map key inherits: resolves against.
descriptionstringCarried onto the trait.
applytoallboolMerge this trait’s fields onto every entity in the schema. Default false.
fieldslistThe 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: true

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
- taggable

In 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.

Four traits are compiled into the loader and registered on every schema under their bare names. Two of them apply themselves to everything.

TraitapplytoallFields
commonyescreatedby, createdon, updatedby, updatedon
softdeleteyesdeletedby, deletedon
idnoid
uidnoid

Field by field:

TraitFieldTypeNullableDefaultModifiersOther
commoncreatedbystringnocontextget("user")finallength 3–255
commoncreatedontimestampnocurrentTimestamp()final
commonupdatedbystringyesmaterialised computed contextget("user"), length 3–255
commonupdatedontimestampyesmaterialised computed currentTimestamp()
softdeletedeletedbystringyeswriteoncelength 3–255
softdeletedeletedontimestampyeswriteonce
ididulidnoulid()finalrequired, unique
uididklidno'U'+ulid()finalrequired, 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).

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.

A trait is stored under its name: verbatim. inherits: resolves in two steps:

  1. Exact match against the trait name.
  2. 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.

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()

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.

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:

  1. Fields the entity declares are placed first, in declaration order.
  2. 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.
  3. applytoall traits, in alphabetical order of trait name, merged onto every entity.
  4. The id backfill, if the primary key is still the default id and no id field exists yet.

Consequences:

SituationOutcome
Entity declares createdon, common also has itEntity’s field wins. No diagnostic.
Two inherits: traits both declare codeThe one listed first wins.
An inherits: trait and an applytoall trait both declare codeThe inherits: trait wins — step 2 runs before step 3.
Two applytoall traits both declare codeThe 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.

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 writes createdby: 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 id backfill is suppressed. A tenant-only entity declared in an overlay must declare its own id field or a primary-key:, otherwise the DDL emits PRIMARY KEY ("id") over a table with no such column.

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.

  • 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