Schema as entities
Schema is data. Every schema element is a manifest, a document in the same envelope as everything else, and every declaration is an ordinary entity, readable through the same collections as data. "What does the schema say" is a query, not a file read.
The declarable resources
Nine kinds declare everything:
| Kind | Declares | Taught on |
|---|---|---|
schemagroup | one group: a DNS-named namespace of entity types | Data model |
entitytype | one entity type: its properties and edges | Data model |
propertytype | one custom property type: a refinement of a base type | Data model |
trait | one trait, bound by entity types | Data model |
entitymapping | how a source record's properties reach its subject | Projection |
function | one pure callable in Python or Go | Functions |
agent | one callable whose body is an LLM loop | Agents |
bundle | one extension: the closure it installs as a unit | Extensions |
actor | one name writes are attributed to | The API |
Each kind is the manifest's type: value, and its collection is the plural
(entitytypes, functions). Only schemagroup keeps a prefix: group is
already an envelope key on every document, so the kind cannot be the bare
word.
All nine live in core.teild.dev, whatever group the document declares into,
and a schema entity's id is its full declared name (task.tasks.teild.dev),
the one place dots are legal in an id. The smallest manifest declares a group
in five lines:
group: core.teild.dev
type: schemagroup
metadata:
id: tasks.teild.dev
data:
version: v1alpha1
How schema reaches the substrate
There is no runtime schema editing form; schema arrives one of three ways, each auditable in the changelog:
- Shipped vocabulary loads at boot from the substrate's own tree and is read-only through the API: it changes by shipping a new substrate. The catalog is Built-in entity types.
- Extension install applies an extension's whole closure as one batch (Extensions), the group and every resource it ships active on commit. Uninstalling tears it down.
- A batch apply:
POST …/core.teild.dev/schema/applywith a list of documents, which is wheressctl applyroutes any schema documents it is given. A generic PUT, PATCH, or DELETE of a schema entity is a batch of one.
Admission
However it arrives, admission is the same: the loader is the validator. Not a separate validation package, not a webhook: the rules live where the manifest is parsed. A batch is one transaction, every document admitted or none (a refusal carries the full problem list), and a committed batch is active immediately, no restart anywhere. A candidate registry is built and compiled whole, closure resolution and CEL guards and templates included, before the write transaction opens, so a broken closure fails the batch rather than half-loading.
The loader's rules are hard errors, never warnings. The load-bearing ones:
- Casing is one rule. Every declared name and system key is camelCase with initialisms uppercase (
displayTemplate,oneOf,ifVersion,onEnter,endsAt). Snake spellings are errors, not aliases. Type singulars and plurals stay[a-z][a-z0-9]*; groups and actors stay dotted lowercase; enum and state values stay lowercase words. - Reserved property names.
title,body,at,endsAt,dueAtare the five properties every entity already carries, each with its own storage column; redeclaring one is a load error naming the built-in. The temporal three arrive through thetemporaltrait. - Unknown keys anywhere in
dataare refused, so a typo cannot be silently ignored. - Mapping constraints: at most one
entitymappingperfromtype; itsedgemust be declared on the from-type,required, single, and itstomatches the mapping'sto; a mapping'stotype may not itself be any mapping'sfrom(bipartite, one level). Everymappath type-checks against both declared types at load, so a disagreement fails on the manifest that caused it, never on the first sync that hits it. - States:
type: staterequiresstatesandtransitions;initialis a single declared state; everyfrom/tois a declared state; stamps auto-declare their datetime properties.
Three guardrails worth knowing:
- Deleting an entity type with live entities is refused, with the count, inside the same transaction. Cascade is never a default, and identities are never reused: history orphans by design and stays readable.
- Narrowing a type that has live entities is refused, with the count. The next section is the contract.
- The shipped vocabulary refuses writes; only installed groups change through the API.
Schema evolution and the dialect contract
Schema changes over the life of a tenant, and v1 fixes how, so a binary upgrade or a bundle upgrade can never silently corrupt data already written against the old shape. The contract has two halves.
A per-tenant schema dialect. Each tenant carries a monotonic
schema-dialect integer, stamped by the binary when the tenant is opened.
Dialect promotions are keyed, recorded, ordered steps from N to N+1, run at
open and recorded per tenant, so the history of how a tenant's schema
advanced is itself readable. A binary whose maximum supported dialect is
below a tenant's stored dialect refuses to open that tenant with a named
error ("the store speaks a newer schema dialect than this binary"), rather
than opening it and misreading rows written by a newer shape; the API
surfaces the refusal as 503 tenant temporarily unavailable, never as an
invalid token. A tenant's stored dialect is internal to its own store and
never appears on the wire; what
API discovery reports is the binary's
maximum, which is the number a client actually needs.
Admission refuses narrowing. A definition change that would strand
existing data is refused at admission, as a guard error naming every
narrowed property with the count of live entities affected, all problems at
once. The narrowing diffs that refuse:
- dropping a property an entity still carries
- renaming a property (
renamedFrom:, below) - changing a property's kind, a
repeated:flip included - removing an enum value an entity still holds
- removing a state an entity still occupies
- adding
required:to a property an entity lacks
Widening diffs (a new type, a new optional property, a new enum value, a new
state or transition, removing required:) always admit. The guard counts, it
never blanket-refuses the class: removing an enum value no entity holds
admits, and dropping a property admits once every entity has nulled it. As a
minimal example, re-applying the task type with dropped removed refuses
while one task still sits in it:
properties:
status:
type: state
states:
- proposed
- open
- done
Nothing converts or discards your entities behind your back; they are yours to migrate, and the refusal tells you how many stand in the way.
Renaming: renamedFrom: is reserved. A property may declare the name it
replaces:
properties:
dimensions:
type: string
renamedFrom: size
The key is admitted, validated (it may not name the property itself, a name the type still declares, or a built-in) and stored, but not yet acted on: nothing rewrites entities today, so a rename whose old name live entities still carry refuses like any other narrowing change. It is reserved so that when the rewrite arrives, the declaration is already in the manifest dialect and nothing changes shape on the wire.
Quarantine
A binary that tightens a schema or trait contract can make an
already-installed extension's stored closure fail admission at the next
tenant open. When that happens the substrate does not brick the tenant: it
installs the maximal admissible subset of the installed groups and
quarantines the rest. Each quarantined group is logged with its admission
reason, left out of the live registry (its types refuse writes and its
callables do not run), and marked quarantined: true with a
quarantineReason on its schemagroup row. The console surfaces such a
group as "needs re-install". Re-applying a valid closure for the extension
clears the marker, and so does a later open under a binary that relaxed the
contract again.
Next: projection, how many source records describe one subject.