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:

KindDeclaresTaught on
schemagroupone group: a DNS-named namespace of entity typesData model
entitytypeone entity type: its properties and edgesData model
propertytypeone custom property type: a refinement of a base typeData model
traitone trait, bound by entity typesData model
entitymappinghow a source record's properties reach its subjectProjection
functionone pure callable in Python or GoFunctions
agentone callable whose body is an LLM loopAgents
bundleone extension: the closure it installs as a unitExtensions
actorone name writes are attributed toThe 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:

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:

Three guardrails worth knowing:

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:

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.