The API

One surface for everything. Four reads (entity, entities, search, changelog) plus a watch stream, and seven mutations. A new entity type never adds an endpoint: the REST path pattern is the same routes for every group, and the GraphQL schema is generated from the loaded types. This page is the REST surface, the filter grammar, pagination, the mutations, errors, versioning, and how you authenticate. Everything here holds identically over REST and GraphQL.

REST resources

Every group serves the same routes; the resource segment is the type's declared plural:

GET    /api/v1/{group}/{plural}          # list, filter, watch
POST   /api/v1/{group}/{plural}          # create / upsert
GET    /api/v1/{group}/{plural}/{id}
PATCH  /api/v1/{group}/{plural}/{id}
PUT    /api/v1/{group}/{plural}/{id}     # put addressed at id
DELETE /api/v1/{group}/{plural}/{id}     # soft delete
GET    /api/v1/{group}/{plural}/{id}/incoming   # paged reverse edges

The path carries an entity's full identity: {group}/{plural} names the type, {id} the id within it. Ids are unique per type, not per tenant, so the same id may exist in two collections as two unrelated entities, and a resource read is always scoped to its own collection. There is no cross-type read by bare id anywhere on the surface.

Reverse edges are a derived view of their own, paged separately so a popular entity's fan-in never inflates its document.

The flat entity

Requests and responses carry the flat entity: one JSON object with properties, edges, and the server-set fields at the top level. The five-key envelope is the YAML document form; REST never wraps. title, body, and the temporal properties appear inside properties and nowhere else, so PutInput and PatchInput accept them only there. PutInput carries an optional top-level id and an optional ifVersion; the CLI is what maps metadata.id and metadata.ifVersion onto them. The id is how a POST to a collection names the entity it creates; on a PUT the path already names it, so the path is what the write addresses.

A worked sequence over the to-do list. Add a task (the group and type come from the path):

POST /api/v1/tasks.teild.dev/tasks
{"properties": {"title": "Buy milk", "dueAt": "2026-08-11T09:00:00Z"}}

→ {"id": "kq3v9x2m41pf", "type": "task.tasks.teild.dev",
   "properties": {"title": "Buy milk", "status": "open",
                  "dueAt": "2026-08-11T09:00:00Z"},
   "version": 1, "createdAt": "2026-08-04T10:00:00Z"}

List what is open, soonest first (the filter is URL-encoded JSON, the grammar is below):

GET /api/v1/tasks.teild.dev/tasks
      ?filter={"properties":{"status":{"eq":"open"}}}&orderBy=dueAt

→ {"entities": [...], "cursor": "eyJv…", "head": 4207}

Complete one. A state change is just a patch, and the declaration stamps completedAt:

PATCH /api/v1/tasks.teild.dev/tasks/kq3v9x2m41pf
{"properties": {"status": "done"}}

Read the person GitHub linked up. Single-entity reads also carry propertyMeta (per property: who wrote it, and the alternatives other sources assert, managed properties explains the mechanism), and, if you asked by an id that was merged away, canonicalId tells you where it went (merges):

GET /api/v1/people.teild.dev/people/9f2k

→ {"id": "9f2k", "type": "person.people.teild.dev",
   "properties": {"name": "Ada Lovelace", "emails": ["ada@example.com"]},
   "propertyMeta": {"name": {"manager": "owner", "tier": "owner",
     "alternatives": [{"actor": "function.sync.github.bundles.teild.dev",
                       "value": "ada"}]}}}

The seven mutations

The complete write surface, for every actor, forever. Each one addresses its target by full identity: the type beside the id (on REST the path's {group}/{plural} names the type; on GraphQL the type travels in the mutation's arguments, as type on patch, delete and merge, as srcType/dstType on link and unlink, and inside input on put), because an id is unique per type, never per tenant:

MutationWhat it does
putCreate or upsert. Merges and never prunes: what the document omits is left alone. Accepts inline edges, so an entity and its edges commit as one unit.
patchEdit in place: properties, labels, annotations. A null value deletes a key. State transitions travel only this way.
deleteSoft delete: tombstones the entity; hard deletion waits for finalizers to release.
linkAdd one edge between two entities.
unlinkRemove one edge. Both refuse a mapping's subject edge, which only create-time resolution, merge, and split may move.
mergeJoin two entities of one type; the loser's id resolves to the winner forever (merges).
splitReverse one merge, restoring the loser from the merge record.

A put onto a tombstone restores that record: same id, same row, one changelog row saying so. It is undelete, not id reuse.

put and patch take an optional ifVersion: the write applies only if the addressed entity's stored version equals it (a non-existent entity is version 0), else the whole write fails a conflict. It is the safe read-then-conditional-write primitive.

Edges over REST. link and unlink are first-class REST verbs, not GraphQL-only, so an edge change is a request against the resource whose edge it is:

POST   /api/v1/{group}/{plural}/{id}/edges/{rel}
DELETE /api/v1/{group}/{plural}/{id}/edges/{rel}

The body is an edge reference: {group, type, id}, or a bare {id} where the edge declaration already pins one target type. A link body may also carry the edge's own properties. Both return the refreshed source entity. A put could always add an edge inline; DELETE is how a REST client removes one, which it could not do before. ssctl has matching link and unlink commands.

This follows one rule, written into the contract: a resource's operational verbs live at the resource, its own {group}/{plural}/{id} path. That is also why the trigger verbs live under automation.teild.dev/triggers/… (triggers are automation.teild.dev entities), not under core. The old core.teild.dev/triggers/… verb paths keep answering for one release, each with a Warning header naming the new location; the collection itself has already moved, so core.teild.dev/triggers is a not_found.

The filter grammar

A filter is one JSON document, the same shape URL-encoded in REST's ?filter= and passed whole to GraphQL's filter argument:

{"types": ["task.tasks.teild.dev"],
 "properties": {"status": {"eq": "open"},
                "dueAt": {"lt": "2026-08-11T00:00:00Z"}},
 "labels": {"owner/starred": {"eq": true}}}

A list also takes two shaping parameters beside the grammar: withEdges=1 adds each row's edges map, and withAnnotations=1 adds its annotations. Both are off by default so a list of a fanned-out entity stays small.

Ordering is orderBy with camelCase columns (dueAt, at:desc,createdAt). Only declared properties filter and order: filterable, indexed, and declared are the same set, so a query that would be slow is one the grammar cannot express.

A list parameter a given mode does not honor is a bad_request that names it, never a silent success. On a collection list the path names the type, so an explicit filter.types conflicts and is refused (drop it, or list a different collection). A watch=1 stream ignores the list-query grammar, so filter/orderBy/first/after/withEdges/withAnnotations alongside it are refused. A reverse-edge (incoming) read honors only first/after, so filter/orderBy are refused. A misspelled ordering column is refused naming the camelCase replacement, and a malformed filter document is refused naming the field that would not decode.

Pagination

Lists page forward with a keyset cursor carried behind one opaque token. You pass first for the page size and, on the next request, the cursor a page returned as after:

GET /api/v1/tasks.teild.dev/tasks?first=50
→ {"entities": [...], "cursor": "eyJv…", "head": 4211}

GET /api/v1/tasks.teild.dev/tasks?first=50&after=eyJv…
→ {"entities": [...], "cursor": "eyJv…", "head": 4230}

The cursor is opaque, so treat it as a token and never parse it. Its payload is a keyset position (the last row's sort-key values plus the id tiebreak), not an offset, so a deep page costs the same as a shallow one, and the walk is stable under concurrent writes. The stability guarantee is exact: a cursor walk sees every row that existed for the whole walk exactly once. A row inserted or deleted mid-walk may or may not appear; a row that lived throughout is never skipped and never repeated. The token is bound to the orderBy it was minted for, so replaying it against a different order is rejected rather than silently mis-seeking. An exhausted list carries no cursor at all over REST, and an empty one over GraphQL; both mean the same thing, there is no next page.

The continuation rule is one sentence: transparent sequence numbers are from and before; opaque cursors are after. The changelog's history and watch use the transparent seq (it is a real, meaningful ordinal, so its history response returns a cursor seq to pass as the next before); entity lists and reverse-edge (incoming) lists use the opaque after cursor.

Every list response also carries the changelog head seq captured at the snapshot it was served from. Page a collection, then resume a watch from head: every listed row's change is at or before head, and the watch replays exactly the changes after it, so the handoff has no gap and no double-see.

Versioning and discovery

The API is served at /api/v1. The v1alpha1 prefix from before the freeze stays served as an alias for a sunset window, then goes; new work targets /api/v1. Every response on the alias carries a Warning header (RFC 7234 warn-code 299) naming /api/v1 as the replacement, so a client on the old prefix is told to move without breaking.

GET /api is discovery. It is unversioned and unauthenticated, and touches no tenant, so a client can call it before it holds a token. It reports the served API versions (v1, and v1alpha1 marked deprecated with its replacement), the server version, the binary's maximum schema dialect (a tenant's own stored dialect is internal to its store and never on the wire; a binary too old for it refuses the open, which surfaces as unavailable), the changelog horizon (0 today, meaning full history), and a feature list. The feature list is what replaces probing for 501s: each entry names a capability and its stability, and the agent surface reports alpha:

{"versions": [{"name": "v1", "status": "served"},
              {"name": "v1alpha1", "status": "deprecated", "replacedBy": "v1"}],
 "server": {"version": "…"},
 "schema": {"maxDialect": 6, "note": "…"},
 "changelog": {"horizon": 0},
 "features": [{"name": "triggers", "stability": "stable"},
              {"name": "agents", "stability": "alpha"}]}

Within v1 the surface is additive only: fields and types are added, never removed or narrowed under the same version. A deprecation is signalled, not a silent break: a Warning HTTP header on the REST response and @deprecated on the GraphQL schema element, each with a minimum sunset window before removal. There is no Kubernetes-style multi-version conversion machinery; one version is served, plus the alias during its sunset.

Authentication and actors

Access is by bearer token. The first token is minted with a one-time TOTP code (handed over when the tenant is created), so bootstrap is resource creation like everything else:

POST /api/v1/core.teild.dev/tokens
{"tenant": "ada", "otp": "123456", "name": "cli", "actors": ["owner"]}

→ 201 {"token": {...}, "secret": "teild_tok_ada_…"}

otp is the tenant's current 6-digit TOTP code, never the seed. Every request after that carries Authorization: Bearer teild_tok_ada_…. The secret is shown exactly once; the server stores only its hash, so a lost token is revoked and re-minted, never recovered. With a live token, minting further tokens is the same request without the otp, naming the new token and the actors it may write as. Tokens list and revoke as metadata only, never the secret.

Writes are attributed to an actor: a token may carry several (owner, an integration's own name), and a request picks one with X-Substrate-Actor; the named actor must be in the token's set, and the default is the token's first actor. Attribution is load-bearing three ways:

The manager tier an actor's writes hold at is explicit data on the actor, never derived from its name: a declared actor document may carry tier: owner|extension|machine (machine is the default for group-declared actors), function and agent dispatch stamps the extension tier on its own writes, and an actor no declaration knows, a stranger's own client, holds at the owner tier. The tier is read from the live declarations on every write, not frozen when a token was minted, so re-declaring it takes effect at once.

Expiry and scopes

A token carries two optional, least-privilege fields set at mint. Both default so an existing or unspecified token is unaffected: no expiry, and full-tenant access.

expiresAt is an RFC 3339 UTC instant. It is server-enforced at authentication: a token past its expiresAt fails with an auth error, no revoke step needed. An absent expiresAt never expires.

scopes narrows a token to less than the whole dataset. It is a list of grants, each naming a group (or a single type within it, by full identity) and the verbs it allows, read (GET and list) or write (create, put, patch, delete, link, unlink). Omitting group covers every group, and omitting type covers every type in the group; there is no wildcard string, an absent field is the wildcard. Grants are OR'd: a request is allowed as soon as one grant covers its group and type and carries its verb, and a scoped token refused outside its scopes gets a forbidden error. An empty scope list is full-tenant, every group and type, read and write. Every grant must carry at least one verb, else the mint is a validation error.

POST /api/v1/core.teild.dev/tokens
{"name": "reader",
 "expiresAt": "2027-01-01T00:00:00Z",
 "scopes": [{"group": "tasks.teild.dev", "verbs": ["read"]}]}

The gate is not REST-only. GraphQL is scoped the same way: entity, history, and edge traversal check the read verb before answering, entities, search, and changelog filter row by row, and all seven mutations check the write verb against the type they address. The changelog is gated too: a scoped consumer sees only the rows it may read, in history and in the watch stream alike, and the stream's cursor still advances past the rows it filtered out, so a scoped resume stays monotonic and never replays.

A mint may only narrow. Scopes and expiry are set on the authenticated mint (the request carrying a bearer, above): a token may mint a child that holds a subset of its own scopes and actors, never a superset. A scoped token cannot mint a full-tenant one, a grant no single held grant covers is refused, and a token that expires cannot mint a child that outlives it or one with no expiry at all. Each refusal is a forbidden error naming what exceeded what. The unauthenticated bootstrap exchange has no caller to narrow from, so it always mints a full-tenant, non-expiring token, which you then narrow.

Both ssctl (ssctl login) and the web console drive the bootstrap exchange: tenant name plus the current 6-digit code, a token minted and stored on their side.

The canonical envelope

The document envelope (group, type, metadata, data, status) is the one canonical representation of an entity. The flat JSON that REST and GraphQL carry is a lossless view of it: properties and edges land under data, metadata holds the id and the authored key spaces, and the server-set fields (version, timestamps, provenance) land under status. The mapping round-trips exactly, so ssctl get -o yaml output applies back with no edit, and a generic client can read, modify, and write the same object.

Edges are one shape in the canonical envelope, a list of {rel, to, properties} in both directions. The flat read entity groups the same edges into a relationship-keyed map for convenience; that map converts to and from the list without loss (the list is the map flattened, one entry per target, in a stable order), and the list is what a write carries. So a read-modify-write of an entity with edges is a fixed point.

Every versioned write body and every filter document is decoded strictly. An unknown key, a miscased key (a lowercase ifversion is not ifVersion), or a duplicate key is a bad_request naming it, never a silently dropped precondition or a broadened filter. Openness stays only inside the map-valued fields that are meant to be open: properties, labels, annotations, an object-typed or json-typed property, and the filter's per-property operators.

PATCH semantics are pinned:

Status codes follow the write: a create is 201, an update or replace is 200, consistently across POST-to-collection and PUT-at-id.

Errors

Errors are one shape everywhere, with problems carrying the full list when a batch or a multi-part validation refuses:

{"error": {"code": "validation", "message": "…", "problems": ["…"]}}

The code set is closed. The client-error codes:

CodeHTTPWhen
bad_request400A malformed request, an unknown field, or an unsupported list parameter.
validation422An undeclared property, a malformed value, a type mismatch.
conflict409A version check failed (ifVersion); re-read and retry.
guard403A refused state transition, or a protected operation (a subject edge, a type with live entities).
forbidden403The actor or token may not do this at all.
auth401Missing or invalid token, or a refused login exchange.
not_found404No such entity; a former id is not this, it resolves (merges).
rate_limited429Slow down; the response carries Retry-After.

The server-error family is split so a client can tell "try again" from "never going to work": internal (500, an unexpected fault), unsupported (501, a capability this deployment does not offer, the thing GET /api feature detection replaces), and unavailable (503, a temporary outage, always with a Retry-After). The same problem object appears in a GraphQL error's extensions and in the changelog watch stream's terminal error frame, so an error means the same thing wherever it surfaces.

One more code lives on the changelog surface: compacted (410) answers a from= below the retention horizon, telling a consumer that has fallen too far behind to re-list rather than silently miss rows. That is the whole closed set; nothing else appears in error.code.

Next: GraphQL and search, the same graph at one endpoint.