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:

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:

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:

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 typeValidation / meaningFilter operators
stringshort, single-lineeq, prefix, in
textlong-form prose(full-text only)
markdowntext renderers treat as Markdown(full-text only)
int, floatnumbers, optional min/maxeq, range
booltrue/falseeq
datetime, dateRFC 3339 instants / civil datesrange
duratione.g. 47m12srange
emailrefined string, RFC 5322 mailboxeq
urlrefined string, absolute URLeq, prefix
phonerefined string, E.164 normalizedeq
timezoneIANA zone nameeq
recurrenceRFC 5545 RRULE stringeq
enumone of declared valueseq, in
statea state machine: states, initial, transitions, stampseq, in
secreta credential: written like a string, read back redacted(none)
objectinline fields: of scalar types, one level(none)
referencea typed pointer at another entity: {group, type, id}(none)
jsonescape 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:

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:

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:

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.