GraphQL and search

The whole read/write surface also serves at one endpoint, POST /api/v1/graphql. Filters take the same JSON grammar as REST:

query ($f: JSON) {
  entities(filter: $f, first: 20) {
    nodes { id type title ... on Task { status } }
    cursor
    head
  }
}
# variables:
# {"f": {"types": ["task.tasks.teild.dev"],
#        "properties": {"status": {"eq": "open"}}}}

cursor is the same opaque keyset token REST returns (pass it back as after), and head is the changelog head seq at the snapshot, for a gapless handoff to watch. The changelog(from, filter, first) query resumes forward from a transparent seq (the arg is from, not an opaque cursor) and echoes the last seq back as from.

Every declared entity type gets a generated GraphQL type with its properties as fields; interfaces span groups (Temporal, HasStatus: everything with that trait or that state, so "everything with a status, anywhere" is one query). Single-entity lookups are ref-addressed: identity is the (type, id) pair, so entity takes both:

query { entity(type: "task.tasks.teild.dev", id: "kq3v9x2m41pf") { id title } }

Mutations are the seven, each addressing its target the same way:

mutation ($t: String!, $id: ID!, $in: JSON!) {
  patch(type: $t, id: $id, input: $in) { id version }
}
# {"t": "task.tasks.teild.dev", "id": "kq3v9x2m41pf",
#  "in": {"properties": {"status": "done"}}}

Generated names and scalars

A GraphQL type name is a pure function of the entity type's identity, never of which other types happen to be installed, so the schema is deterministic and installing one bundle can never rename another type. Bare names are reserved for the shipped vocabulary (Task, Person, Calendarevent: the singular with its first letter uppercased, nothing more). An installed type is always group-prefixed: the name is the group's leading label, TitleCased, an underscore, then the singular. Two extensions may declare the same singular and stay distinct, because the full identity separates them: page.notion.bundles.teild.dev is Notion_Page and page.web.bundles.teild.dev is Web_Page, and neither can collide with the other or with a shipped bare name. Interfaces follow the same rule: one per trait that carries properties (a pure marker trait like bundleconfig adds none), and one per distinct state-property name (HasStatus, HasDecision).

A type whose computed name would land on a structural name (Entity, Change, Edge, Reference, a scalar, an interface) is refused at schema build with a named error, never silently renamed.

version and seq (and the changelog resume seqs, head and from, and the ifVersion precondition) are the Long scalar, a 64-bit signed integer serialized as a JSON number. GraphQL's built-in Int is 32-bit, so a tenant's version or seq counter would overflow past 2^31 (about 2.1 billion); Long carries the full int64 range on the wire. A JavaScript client should read these through a 64-bit-safe path if a counter can exceed 2^53, since a JSON number past that loses precision in the browser's Number.

Property types render as their proper shapes. A repeated property is a GraphQL list of its element type for every kind ([Int], [Float], [Boolean], [String]), not a bare scalar. An object property (inline structured fields) renders as the JSON scalar, lossless, rather than flattening to String. A reference property is the Reference object ({group, type, id}).

Search is one query, search(q, mode, types, k), served over GraphQL. It has two arms:

mode picks lexical, semantic, or hybrid (the default): hybrid runs both arms, normalizes each against its own best hit, and merges. Hits return with per-arm raw scores so a caller can threshold. On a deployment with no embedder configured, hybrid degrades to lexical and semantic reports an error rather than pretending.

Two honest boundaries. There is no REST search endpoint: filtering is REST's job (?filter=), searching is the GraphQL query's. And the substrate does retrieval only: it returns typed entities with scores, and anything generative built on top (a RAG loop, an assistant) is a client reading this API like every other. Functions run on the shared runner and reach the same search through a host call, under their declared read allowlist.

One ranking rule is built in: the built-in person carries a two-state prominence machine (utility at birth, known once something promotes it, an address-book sync or the owner), and search ranks utility people below every known match, so the recruiter who emailed once never outranks a friend.

Next: changelog and watch, the one log every write lands in.