The data model
Everything in the substrate is an entity, and every entity, whether a task, a person, or a type declaration, reads and writes as one shape. This page is that shape and the rules around it: identity, the envelope, groups and entity types, what a property can hold, traits, and the validation every write passes.
Entities and identity
An entity is the object itself: one typed thing in the graph. Its
identity is the full triple (group, type, id). An id is unique per type,
never per tenant, so a bare id names nothing: two types may hold the same id
without collision, and a person and a task may both be 9f2k and stay
unrelated. Always address an entity by its full identity, on every
surface, never by bare id.
Three more words, used precisely on every page:
- A document is one serialized YAML item,
----separated when there are several. - The envelope is the five-key document shape every entity serializes to:
group,type,metadata,data,status. - A manifest is an envelope-shaped document written to be applied declaratively: a type declaration in git, an extension's install closure, anything
apply -ftakes.
The envelope is the YAML form. It is what
ssctl get -o yaml emits and what apply and the batch schema
apply consume. REST and GraphQL reads
return the entity flat, as one JSON object, not wrapped in an envelope.
The envelope
Here is a task from the to-do list:
group: tasks.teild.dev
type: task
metadata:
id: t9 # omit on create; the server assigns one
labels: # short, queryable metadata
owner/starred: true
data:
properties:
title: Buy milk
description: the oat kind
status: open # a state property, see Validation below
dueAt: 2026-08-08T00:00:00Z
edges:
- rel: project
to:
group: tasks.teild.dev
type: project
id: infra7
status: # server-set, refused on input
version: 4
createdAt: "2026-08-04T09:00:00Z"
updatedAt: "2026-08-04T09:12:00Z"
Three rules make the envelope predictable:
datacarries two keys and no others,propertiesandedges. Everything authored,titleand the temporal fields included, lives indata.properties, so nothing needs a reserved list to name a property.statusis server-set and refused on input, so a document you read, edit, and re-apply means exactly what it looks like.versionis a version check. A write may assert the version it read, and a mismatch is rejected so the caller re-reads and retries.
A property is one key under data.properties. An edge is one entry under
data.edges, naming its relationship and its target entity. Edges are
written {group, type, id}, the envelope's own words. A bare {id} is
accepted as shorthand only where the declaration already fixes the target
type: the reference resolves within that one type and nowhere else, because
ids are unique per type. A polymorphic edge (to: any) always requires the
full form. Declarations may name their target by bare type name too
(to: project): a bare name resolves in the declaring group first, then
uniquely across all groups, and a name that stays ambiguous refuses to load.
The envelope is the one canonical representation. The flat JSON that the
API returns is a lossless view of it: the
same properties and edges under data, the same server-set fields under
status. Edges are a list in the canonical envelope, one entry per target;
the flat read form groups them by relationship into a map for convenience, and
the two convert without loss. Because the mapping round-trips, a document you
read applies back unchanged, which is what makes ssctl get -o yaml | ssctl
apply a no-op on an entity that has not changed.
Labels and annotations
Opinions about an entity never get welded into it. They layer on under
metadata, in two forms:
- Labels: short scalar values under a namespaced key (
owner/starred: true). Indexed, filterable like a property. - Annotations: arbitrary JSON under the same key convention. Fetched with the entity, never filtered on.
The rule of thumb is mechanical: filter on it, label; blob, annotation. Writers may only touch their own key namespace.
Groups and entity types
A group is a DNS-named namespace of entity types owned by one authority.
The shipped vocabulary is split by subsystem, Kubernetes-style:
people.teild.dev, messaging.teild.dev, calendar.teild.dev,
tasks.teild.dev, media.teild.dev, and core.teild.dev for the
substrate's own machinery. Groups namespace names; they never partition the
graph: an edge crosses groups as easily as it stays inside one. The smallest
declaration brings a group into being in five lines:
group: core.teild.dev
type: schemagroup
metadata:
id: tasks.teild.dev
data:
version: v1alpha1
An entity type is the definition of one thing the graph can hold: its
properties and its edges. It belongs to exactly one group, and its full name
is <type>.<group>.
The to-do list needs two entity types. person ships built in, one
entity per human, the target of every "a person" edge in the system:
group: people.teild.dev
type: person
metadata:
id: 9f2k # server-assigned: nothing external names a human
data:
properties:
name: Ada Lovelace
emails:
- ada@example.com
The task type we declare ourselves. Entity types are manifests: YAML
documents, versioned and reviewed in git (or installed by an
extension); there is no runtime schema editing. A type
declaration is itself an entity, wearing the same envelope, and it lives in
core.teild.dev whatever group it declares into
(schema as entities):
group: core.teild.dev # type declarations live in core
type: entitytype
metadata:
id: task.tasks.teild.dev # <singular>.<group>
data:
group: tasks.teild.dev # the group being declared into
names:
singular: task
plural: tasks
properties:
description:
type: markdown
url:
type: url
dueAt:
type: datetime
status:
type: state # a state machine, declared in place
states:
- proposed
- open
- done
- dropped
initial: open
transitions:
- from: proposed
to: open
- from: proposed
to: dropped
- from: open
to: done
stamps:
completedAt: now
- from: done
to: open
edges:
project:
to: project
source:
to: any # the message, mail, or issue the task came from
Every property here names a declared property type (markdown, url,
datetime, state), covered next. Because a declaration is an entity,
"what does the schema say" is a query, not a file read: the loaded types read
back through the same API as data.
(The shipped task type declares dueAt in one line through a shared
trait instead of the plain datetime shown here.)
Property types
Every property declares a type. The type decides two things: what a write
must look like to be accepted, and which filter operators the query grammar
offers for it. Nothing else about a property is special; title and
description are declared and written identically.
| Property type | Validation / meaning | Filter operators |
|---|---|---|
string | short, single-line | eq, prefix, in |
text | long-form prose | (full-text only) |
markdown | text renderers treat as Markdown | (full-text only) |
int, float | numbers, optional min/max | eq, range |
bool | true/false | eq |
datetime, date | RFC 3339 instants / civil dates | range |
duration | e.g. 47m12s | range |
email | refined string, RFC 5322 mailbox | eq |
url | refined string, absolute URL | eq, prefix |
phone | refined string, E.164 normalized | eq |
timezone | IANA zone name | eq |
recurrence | RFC 5545 RRULE string | eq |
enum | one of declared values | eq, in |
state | a state machine: states, initial, transitions, stamps | eq, in |
secret | a credential: written like a string, read back redacted | (none) |
object | inline fields: of scalar types, one level | (none) |
reference | a typed pointer at another entity: {group, type, id} | (none) |
json | escape hatch: schemaless blob, never filtered | (none) |
Our to-do list already uses four: the task's description is markdown,
its url is a url, dueAt is a datetime, and status is a state.
Enums. An enum accepts one of a declared list of values. Each value
is either a bare string or a {value, label} pair, where the optional label
is what a client renders in a picker while the value is what is stored and
filtered:
kind:
type: enum
values:
- value: direct
label: Direct message
- value: group
label: Group chat
- value: channel
label: Channel
Validation is on the value alone, so a bare-string list stays valid, and an empty label leaves the client to humanize the value. Declaration order is render order.
Secrets. A secret property stores a credential. Writes take a string
like any other property, but every read, whatever the surface, returns the
sentinel <redacted> in its place, and applying a document carrying the
sentinel back leaves the stored value alone, so a read-edit-apply round trip
never wipes a credential. A secret offers no filter operators and cannot be
ordered by (comparing against a redacted value would reconstruct it one probe
at a time), never indexes into search, and
an entity mapping may never read one: a secret never leaves
its record. The built-in token type stores its hash this way
(authentication and actors).
Lists. A property declared repeated: true holds a list of its type and
filters with contains. The built-in person uses it for addresses:
emails:
type: email
repeated: true
Objects. An object property declares its fields inline, each a scalar
type, one level deep, no object inside an object. This is how an
integration's types mirror what their provider actually sends. In the GitHub
integration, issues carry milestones in
GitHub's own shape:
milestone:
type: object
fields:
name: string
number: int
state: string
dueOn: datetime
repeated: true over an object holds a list of objects. Object properties
validate recursively on write and stay out of the filter grammar until a
consumer needs them.
References. A reference is a typed pointer stored as a property value:
the same {group, type, id} triple an edge target wears, but data, not a
graph edge. Reach for it where a manifest field needs to NAME another
type and entity, like a trigger's callable:
callable:
type: reference
to: any
An optional to: pins the referent type, exactly like an edge's to:; to:
any (and an absent to:) leaves it unconstrained, and then the value must
carry an explicit type. A value is {group, type, id}; {type, id} is
accepted and its group is normalized from the type, and a bare id is accepted
only when to: pins a concrete type. Validation checks the shape and that the
referent TYPE exists; the referent ENTITY need not exist at write time,
because a reference is a pointer, not an edge. repeated: true holds a list
of references. The console renders a reference as a link to the
referent's detail page.
A reference is not an edge. An edge is a traversable relationship with its own incoming views and its part in entity mapping subject resolution; a reference is an inert value you read and rewrite like any other property. Point at an entity as a relationship with an edge; name one as data with a reference.
Your own property types. A custom property type is a refinement of a
base type plus validations, declared as a propertytype manifest and
group-local. The media group defines one for ISBNs:
group: core.teild.dev
type: propertytype
metadata:
id: isbn.media.teild.dev
data:
group: media.teild.dev
description: "ISBN-10 or ISBN-13, normalized and hyphen-free"
base: string
pattern: "^(97[89])?[0-9]{9}[0-9X]$"
Because the declaration is an entity, "what does isbn validate" is a
query, not a file read.
Search coverage. Full-text search covers the title and every string-family
property by default; a property may additionally declare embed: true to opt
into the semantic pipeline. Both are covered in
GraphQL and search.
Traits
Some properties mean the same thing on every entity type that carries them:
"when does this sit on the timeline" is one question whether the entity is a
calendar event, an email, or a task. A trait declares such a set of
properties once, as a trait manifest, and any entity type binds it with
one line. Binding gives the type the trait's properties, their indexes, and a
shared GraphQL interface.
The one worked example is temporal, shipped in core:
group: core.teild.dev
type: trait
metadata:
id: temporal.core.teild.dev
data:
group: core.teild.dev
oneOf:
point:
at: datetime
range:
at: datetime
endsAt: datetime
oneOf declares two variants: a point in time, or a range with an end.
A calendar event spans time, so its type binds the range variant under
traits::
traits:
- temporal(range)
and with that one line the type carries at and endsAt, indexed, and joins
the Temporal GraphQL interface, so "everything on the timeline this week,
whatever its type" is one query.
A binding may also rename where the trait's property lands. A task's moment on the timeline is its due date, so the shipped task type binds:
traits:
- "temporal(point: dueAt)"
which is the point variant with its at property carried under the name
dueAt. This is how the dueAt shown earlier as a plain datetime is
really declared: one line instead of a property block, and the task still
answers every Temporal query. (Temporal properties are the substrate's one
"hot" trait: they map onto dedicated storage columns, which is why the trait
lives in core.)
Extension traits. A few traits are more than shared properties: the host recognizes them by identity and builds behavior on top. These are how an extension declares the pieces the substrate's OAuth facility and lifecycle machinery need to see. Three ship in core:
bundleconfigis a marker: the one type an extension names as itsconfigTypebinds it, and that type is the extension's single configuration entity.accountconfigis a Connection type. Binding it gives the typetokenRef(a secret),tokenStatus, and a repeatedgrantedScopes, the fields the OAuth facility owns and writes as a provider account connects and syncs.oauth2carries the OAuth client credentials,clientIdand a secret-typedclientSecret, for an extension that speaks OAuth.
Binding one is the same single line as temporal, without a variant. A
provider account type declares:
traits:
- accountconfig
Because implementing a trait is queryable, the console can page every entity
of a trait, which is what its Connections view over accountconfig accounts
is. Extensions puts these three interfaces to work.
Validation and state machines
The substrate has no verb endpoints: no "complete", no "accept", no "close". Every behavior is a declaration checked on write.
Properties are validated on every write against the entity type's declaration, and unknown ones are rejected: a write naming a property the type does not declare fails whole, with the error naming the property. Whatever a property's type, it is filterable exactly as declared (filterable, indexed, and declared are the same set), so you cannot write a slow query, only extend the schema. (Schema documents pass through admission of their own; schema as entities covers that side.)
A patch removes a property by naming it with a null value, which is why a
stored property never holds null: a null always means delete. A property's
value replaces whole, and the API page pins
the rest of the patch rules (merge depth, status codes, strict decoding).
A state property declares a state machine in place: its states, its
initial state, and its transitions, as the task's status above shows.
The state property is the entire behavioral seam. Completing a task is a
patch setting status: done; the declaration checks the transition is
legal and stamps completedAt for you. Illegal moves are rejected, so a
state can never be corrupted by an eager writer. The rules, each one
mechanical:
- Creations are born in the declared
initialstate. A creating write may name any declared state instead (an integration mirroring a provider's already-done item starts it there); an undeclared state name is refused. - Transitions travel only as
patch. Aputthat would move a state is refused ("patch does transitions"), so re-applying a document you read can never accidentally complete a task. - A patch naming the current state is a no-op, never an illegal transition. This is what lets a function re-assert
doneon every delivery without churning: the re-assertion writes no changelog row. - Stamps record the moment of a transition.
completedAt: nowwrites the transition time into a datetime property that the stamp itself declares; a stamp name may not collide with a declared property. - Transitions carry no guards: any actor may perform any declared transition. What a transition may additionally do is declared too: an
onEntereffect runs in the same transaction, which is how accepting a merge request performs the merge. - States are never recomputed. A state moves through its declared transitions or not at all, so no amount of syncing can quietly complete a task.
- A state cannot be removed out from under its entities. Re-declaring the machine without a state some entity still occupies is refused with the count, like every narrowing schema change (schema evolution).
The server-owned status
The envelope's status block is server-set and refused on input:
version, createdAt, updatedAt, and per-property provenance
(managed properties). Two consequences:
- A document you
get, edit, andapplyback means exactly what it looks like; thestatusyou carried along is ignored. - Optimistic concurrency is one field: a write may assert
metadata.ifVersionwith the version it read, and a mismatch is a conflict, so the caller re-reads and retries instead of overwriting a write it never saw.
Refused writes come back as one error shape with one code each: validation
for a rejected property, conflict for a version mismatch, guard for an
illegal transition. The API has the full table.
Next: schema as entities, how these declarations reach the substrate and evolve without breaking the data underneath them.