Skip to content
Talk to our solutions team

Where definitions live

Definitions are resolved per (tenant, datastore) pair. A schema source returns raw YAML bytes for that pair, the loader parses them into one schema, and the layers that apply to the tenant fold onto it. Swapping the source changes nothing downstream — the parse, the fold and the engine build are identical whether the bytes came from the metadata service, a directory on disk, or the binary itself.

A product is one repository, one folder per block — see Product repository layout. Entity definitions live under data/. The one file the Data API reads outside that folder is content/contentstores/contentstores.yaml, the content-store catalogue — see File fields and the content store.

data/
access.yaml # product-level authorization
datastores/
datastores.yaml # the manifest: defaultdatastore + datastores[]
main/ # one folder per datastore (matches datastores[].path)
datastore.yaml # the datastores: declaration — see the note below
types.yaml
entities/
billing/invoice.yaml
billing/invoice_line.yaml
admin/audit.yaml # arbitrary nesting — the walk is recursive
charts/
charts.yaml
code/js/ # scripted-endpoint sources
code/go/
plugins/<name>/plugin.yaml
PathWhat reads it
data/datastores/datastores.yamlThe manifest — datastore names and folders
data/datastores/<path>/Definition YAMLs for one datastore, walked recursively
data/access.yamlProduct-level access rules
data/code/js, data/code/goScripted-endpoint sources
data/plugins/<name>/Data plugins

data/datastores/datastores.yaml is required. Without it no datastore resolves and the service gets an empty datastore map.

defaultdatastore: main
injectdatastore: main
datastores:
- name: main
path: main
inject: true
injectservices:
- iam
- name: charts
path: charts
KeyTypeRequiredNotes
defaultdatastorestringyesThe datastore a blank ?datastore= resolves to
injectdatastorestringnoDatastore injected into other services; defaults to defaultdatastore
datastores[].namestringyesLogical datastore name. Empty fails with datastore name is empty
datastores[].pathstringyesFolder under data/datastores/. Empty fails with <name> datastore path is empty
datastores[].injectboolnoMarks the datastore injectable into other services. Default false
datastores[].injectserviceslistnoService names this datastore is injected into

The manifest keys are spelled without hyphens (defaultdatastore), and they are a different surface from the default-datastore key inside a definition YAML. Both exist, and both matter.

Every *.yaml and *.yml under the folder is a definition document; nesting is free and files are read in lexical order by full path. One of them must declare the datastore itself, or the engine build fails with schema has no datastore — see Datastores and database pools.

default-datastore: main
datastores:
- name: main
SourceSelected byReads
Metadata serviceDefault; no configurationThe tenant’s product tree through meta.svc, per tenant, per datastore
Local directoryschemasource: {type: localdir, path: …} in boot configEvery YAML under the directory, recursively
Embedded filesystemGo-level assignment in a service binaryA //go:embed tree compiled into the binary
Arbitrary filesystemGo-level assignmentAny fs.FS
ComposedGo-level assignmentSeveral sources concatenated

localdir is the only source type selectable from configuration. There is no type: embed and no type: compose — the other three are assignments a service binary makes in its own boot code, not operator-facing options.

The boot-config block replaces the metadata-service call entirely. It is the local-development and test-harness path: no metadata service needs to be running.

schemasource:
type: localdir
path: ./erp-product
KeyRequiredNotes
typeyeslocaldir is the only implemented value
pathyesRelative paths resolve against the process working directory; absolute paths are used verbatim

The directory is walked recursively and every YAML under it is loaded, sorted lexically by full path. The requested datastore name is used as a subdirectory selector: a datastore named main loads <path>/main/ when that directory exists, and otherwise falls back to walking the whole root.

Pointing meta.svc at a folder on disk — meta serve <customer>:<product>:<path>, covered in Meta operations — is a different mechanism. It also serves definitions from a local directory, but the call still goes over HTTP through the metadata client, so the tenant still needs meta.url and the metadata service still has to be running. schemasource: localdir removes the call.

Compose concatenates the byte output of several sources and hands the whole list to one loader.

Message fragmentCause
response missing data.datastoreThe product has no data/datastores/datastores.yaml, or the metadata deployment resolved no product repository
dsName empty and no default datastore in responseA blank datastore was requested, the manifest declares no defaultdatastore, and the response holds anything other than exactly one datastore
datastore "<name>" not present in responseThe requested datastore is not one the product publishes
datastore "<name>" has no files.dataThe datastore folder exists but contains no definition YAMLs
product publishes no datastore files for this serviceNormal for a block with embedded base entities — the base serves alone
parse <file>: …A definition YAML failed to decode

Three layers exist in the model. Which of them run depends on the block.

LayerWhere it comes fromMay addMay override access
Service baseEntities embedded in the block’s binaryentities, fields, indexes, relationsno
ProductYAML published for the productentities, fields, indexes, relationsyes, per tier
TenantRows in the tenant’s own tenant_extensions tabletenant-scoped entities and fields onlyno

data.svc ships no base entities of its own, so the product’s data/datastores/ YAML is the base and only one fold runs: the tenant layer onto it. The service-base row applies to blocks that embed their own definitions; for those, the product layer arrives from that block’s own folder in the product repository, and the fold is base ⊕ product, then ⊕ tenant.

A consequence worth stating plainly: in data.svc, access-lock: parses and is carried, but nothing supplies a product layer for it to refuse. final: true still bites, because it blocks the tenant layer too.

CollectionBehaviour
Entities present in both layersFolded field by field
Entities only in the layerAdded, subject to the plane rules
FieldsAdditive; a name collision keeps the lower layer’s field
Indexes, entity relationsAppended
Schema-level references:Added; a name collision keeps the base’s
customtypes:, enums:Added; a layer entry that shadows the base is dropped
access:The one override — product layer only, per tier, wholesale
datastores:, default-datastoreTaken from the base only; a layer cannot introduce a datastore
Traits, embeddables, stateflows, functions, views, syncs, seeds, caches, unions, symbols, encryption keysBase’s carried; the layer’s ignored
actions:, queries:, endpoints:, migrations:, archives:Dropped from both layers

No fold is ever fatal. Every conflict is dropped and recorded; the schema still loads and the engine still builds.

Isolation planes ride along with the fold: 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 dropped — the plane is fixed by the layer that declared the entity. Seals are covered on Entities and Access rules.

Duplicate handling is not uniform across collections, and the difference is easy to trip over when definitions are split across many files.

CollectionOn a duplicate name
entities:, traits:Keep the first, ignore the later one, log at error level
enums:, references:, embeddables:, customtypes:, stateflows:, functions:, views:, syncs:, seeds:, caches:, unions:, encryptionkeys:, subscriptions:, datastores:, actions:, queries:, queryhashes:Last-write-wins by name
endpoints:Last-write-wins by path
augments:, migrations:Appended, never deduplicated

An entry whose name is the empty string is never a merge key — it is always appended. Unnamed references: therefore accumulate across files rather than collapsing into one.

Two duplicate cases are hard failures rather than a keep-first: an action name that collides across actions:, queries: and queryhashes:, and an entity-scoped action colliding with a schema-level one. Action names are globally unique per schema.

Every fold decision is recorded. Only the ignored ones are logged, at error level, with the layer, entity, field and tier.

DiagnosticMeaning
override-appliedA product tier override was applied (informational; not logged)
ignored-duplicateA field or relation already declared by a lower layer was dropped
ignored-tenant-accessA tenant layer’s access: block was dropped
ignored-locked-tierAn override of an access-locked tier was dropped
ignored-final-entityA contribution to a final: true entity was dropped
ignored-scope-changeA plane change or plane escalation was dropped
ignored-shadowA layer type or enum shadowing the base was dropped

These are the only signal that a layer’s YAML was silently not applied. Grep the logs for them after changing any layered definition.

A tenant’s own definitions are rows in that tenant’s database, in the tenant_extensions table — not in the product repository and not in the metadata service.

ColumnPurpose
pathVirtual path, rooted at /. Unique
content_typeMIME type. Only YAML types are treated as definitions
content_textThe YAML body
content_blobBinary payloads (non-definition rows)
updatedonAudit column
deletedonSoft delete — rows with a value set are skipped

The tree is walked from /, and a row counts as a definition when content_type is one of application/x-yaml, application/yaml, text/yaml or text/x-yaml — case-insensitive, with any ; charset=… suffix trimmed. Non-YAML rows are read and discarded, so keep unrelated assets out of the same table if it is large.

The deployable service does not create this table. Blocks that ship base entities declare it in their own definitions; a product that wants tenant extensions declares it in data/datastores/. When the table does not exist, the tenant layer is skipped and the base schema serves — a missing-table error is recognised on PostgreSQL, SQLite, MySQL and DuckDB and swallowed deliberately, so deploying a newer binary never bricks a tenant that predates the table.

An overlay row holds an ordinary definition fragment:

entities:
- name: invoice # declared by the product; this fragment adds one field
fields:
- name: cost_centre
type: string
nullable: true
- name: site_visit # tenant-only entity — must declare its own key
fields:
- name: id
type: ulid
defaultvalue: ulid()
- name: note
type: text
nullable: true

Three things happen to an overlay that do not happen to product YAML:

  • The surrogate id backfill is suppressed. A standalone entity with no primary-key: and no inherits: id normally gets an id ULID column injected. A fragment does not, because it usually re-declares an entity the product owns. A tenant-only entity must therefore declare its own id field or a primary-key:, or the emitted DDL names a primary key over a column that does not exist.
  • Audit columns are stripped. createdby, createdon, updatedby, updatedon, deletedby and deletedon are removed from every entity in the overlay before the fold, with no diagnostic. Those columns belong to the layer that owns the entity — see Traits.
  • A top-level endpoints: block is removed from the raw YAML before parsing. Scripted endpoints are product-only. There is no error, no warning and no diagnostic.

A tenant’s composed schema is built on the first request for a (tenant, datastore) pair and cached until the tenant’s whole scope is drained. Nothing watches the bytes themselves — no source is polled for content changes.

TriggerEffect
Metadata service reports a configuration updateDrains the tenant’s scope
Tenant configuration changeDrains the tenant’s scope
Tenant removalDrains the scope and drops the metadata client
DELETE /data/cacheDrains the calling tenant’s scope and drops its metadata client
DELETE /data/superadmin/tenant/:tenant/cacheSame, for another tenant; superadmin only
Process shutdownDrains every tenant

The first trigger is the only automatic one. Each tenant with a metadata client runs a config-change poll on a fixed 15-second interval; when the metadata service answers that its service or product revision moved, the tenant’s scope is drained and the next request rebuilds.

The cache-bust endpoints run the same path as tenant removal, so they also stop that poll. It restarts when the next request rebuilds the tenant’s metadata client, which is the same request that rebuilds the schema — but a tenant that is busted and then left idle is not being polled.

A drain is all-or-nothing for the tenant: engines, cached schemas, materialized-refresh runners, script runtimes and connection pools go for every datastore that tenant holds, not just the one whose definitions changed. The rebuild cost is paid lazily, on the next request that touches each datastore.

GET /data/schema returns the composed schema for the requesting tenant — the result of every layer that applied, after every drop. It is the check that a product or tenant contribution is actually live, and it pairs with the error-level diagnostics for the ones that are not. The entity list, per-entity detail and generated SDL are covered in Schema export and discovery; the endpoint-level reference is at /api/data/.