# spec.sr.ht — the read schema.
#
# There are no mutations here and that is the design's decision, not an
# omission: the write plane's concurrency story is `If-Match: <base-rev>`, an
# HTTP idiom with well-defined 409 semantics that agents get right by default,
# and modelling base-rev as a mutation argument is a contract worth designing
# once, after the proposal state machine has stopped moving. Federated types are
# consumed contracts, so churning the proposal types here would be expensive.
#
# Everything this schema serves goes through service/ — the same functions the
# REST, MCP and web surfaces call — so the three agent-facing surfaces cannot
# disagree about what a document is or which revision is approved.
"""
An RFC-3339 timestamp.
"""
scalar Time
"""
The lifecycle of a proposal. Collapsed from the usual five states because there
is exactly one reviewer: "approve" is "merge now", and a proposal you dislike is
rejected rather than sent back for changes.
"""
enum ProposalState {
OPEN
MERGED
REJECTED
}
"""
How a merge was authorized. Auto-merged is not human-approved, and readers must
be able to tell: a bot asking for the approved text of a spec should be able to
require human approval and get a different answer than for a firehose note.
"""
enum Approval {
HUMAN
POLICY
}
type Query {
"""
Every space on the instance, ordered by owner then name. There is one human
here and no visibility levels, so there is nothing to filter: the list is the
whole corpus.
"""
spaces: [Space!]!
"""
One space by reference. `owner` carries no leading '~' — that is routing
decoration, never part of the stored name. Null when there is no such space.
"""
space(owner: String!, name: String!): Space
"""
One document of a space.
Exactly one of `id` and `path` must be given. `id` is the document's address
under the design's rule: its frontmatter `id:` when that is well-formed and
unique in the space, and otherwise its path without the ".md" extension. An id
claimed by two documents resolves to neither — refusing to guess is the only
safe answer, since silently picking one would make links point at a document
the author did not mean.
`rev` pins the read to an immutable revision and must be a full 40-character
object name; omit it to read the space's approved head, which is the default
because serving drafts by default would poison every downstream agent context
with unreviewed text. Ref names are refused, so there is no `rev` that makes
this field serve an unreviewed proposal branch.
Null when the space, the revision or the document is absent.
"""
document(space: String!, id: String, path: String, rev: String): Document
"""
Every document of a space at a revision, in tree order. `rev` follows the same
rule as on `document`.
"""
documents(space: String!, rev: String): [Document!]!
"""
Keyword search over the one global index.
`spaces` is the project filter: a project is a saved filter over this index,
not a container, so searching a project means passing its member spaces here.
Omitting the argument searches every space — that is the meta-project, a
filter that excludes nothing. Passing a list searches exactly those spaces,
and passing an *empty* list therefore searches nothing at all, which is what
an empty project's membership means. The two are different arguments here on
purpose: null and [] are distinct in GraphQL, so a client that resolved a
project to zero spaces cannot be handed the whole corpus by accident.
An empty `query` returns no hits rather than every document: "search for
nothing" is a caller that has not collected its input yet.
"""
search(query: String!, spaces: [String!], limit: Int, offset: Int): SearchResults!
"""
Every stored project, ordered by owner then name. The meta-project is not in
this list because it is not a row; it is an address that resolves to a filter,
and `project(owner: ..., name: "everything")` returns it.
"""
projects: [Project!]!
"""
One project by reference. `name` carries no leading '+'. Null when there is no
such project.
"""
project(owner: String!, name: String!): Project
"""
A space's proposals in one state, newest first.
`state` is required. The proposal store lists by state, and an optional
argument would need an "every state" meaning for its absent case — the same
empty-means-everything polarity this schema refuses on `search(spaces:)`. Ask
for the states you want, with aliases if you want more than one.
This is a read: opening, merging and rejecting proposals are write-plane
operations and live on REST and MCP.
"""
proposals(space: String!, state: ProposalState!): [Proposal!]!
}
"""
A space: one bare git repository, addressed `~owner/name`. It is the unit of
ownership, ACL and review policy.
"""
type Space {
"""The owning user, with no leading '~'."""
owner: String!
name: String!
"""The canonical reference, "~owner/name"."""
ref: String!
created: Time!
"""
The current tip of the approved branch, as a full object name. This is the
value to pin a later read to, and the value the write plane takes as
`If-Match`. Resolving it opens the space's repository, so it is only paid for
when it is asked for.
"""
approvedRev: String!
}
"""
A markdown document with YAML frontmatter, at one revision. A document reachable
from the approved ref is approved: approval is a property of the branch, never
of the frontmatter, which is why there is no "approved" field here and no
"approved" value in `status`.
"""
type Document {
"""The space this document belongs to, "~owner/name"."""
space: String!
"""
How this document is addressed: its frontmatter id when that is well-formed
and unique in the space, and its path without the extension otherwise.
"""
id: String!
"""
The frontmatter `id:`, when the document has a well-formed one. Null when it
is absent or malformed.
It is not always the same as `id`: an id two documents claim resolves to
neither of them, so `id` falls back to the path there as well. `id != docId`
is precisely the set of documents that cannot be addressed by their id, which
is what an agent about to propose an edit needs to know.
"""
docId: String
"""The document's path in the tree, extension included."""
path: String!
"""The commit this content was read at. Immutable: pass it back as `rev`."""
rev: String!
"""The sha of this document's content. Changes only when the content does."""
blob: String!
"""
Whether the caller named the revision. False means this is the approved head
as of this call — the reviewed, canonical text.
"""
pinned: Boolean!
"""
The document's title: its frontmatter `title:`, then its first `# heading`,
then its file name. Never empty — the chain is what keeps an untitled document
nameable rather than reported as blank.
"""
title: String!
"""
The authored lifecycle marker (draft, review, superseded). It is not approval
state. Empty when the frontmatter does not carry the key.
"""
status: String!
"""The top-level directory the document lives under ("specs", "notes")."""
section: String!
"""Authored metadata. Empty when the frontmatter does not carry the key."""
summary: String!
type: String!
supersedes: String!
tags: [String!]!
owners: [String!]!
"""The whole document, frontmatter included, exactly as stored."""
markdown: String!
}
"""
A project: a named set of spaces sharing one search scope, one MCP view and one
wikilink namespace.
A project is pure metadata — a saved filter, not a container. It owns no index
and no storage, which is why it has no revision and no documents of its own.
"""
type Project {
"""The owning user, with no leading '~'."""
owner: String!
"""The project name, with no leading '+'."""
name: String!
"""The canonical reference, "~owner/+name"."""
ref: String!
"""
Whether this is the meta-project: the degenerate filter that excludes nothing.
Its `spaces` is therefore every space as of this call rather than a stored
membership — a snapshot, which is why a query restricted to a project should
be built from the filter and not from a frozen list of names.
"""
meta: Boolean!
"""
The member spaces, ordered by owner then name. Empty for a project that
selects nothing, which is what a project with no members means.
"""
spaces: [Space!]!
}
"""One page of ranked hits."""
type SearchResults {
"""How many documents matched, not how many were returned."""
total: Int!
"""How long the query took, as a Go duration string."""
took: String!
hits: [SearchHit!]!
}
"""
One ranked document. `space`, `path`, `rev` and `anchor` together are a pinned,
immutable address for the result.
"""
type SearchHit {
space: String!
"""The document's address within its space, as `Document.id` reports it."""
id: String!
rev: String!
path: String!
"""The heading anchor within `path`, set for one entry of an activity log."""
anchor: String!
title: String!
section: String!
"""The language the document was indexed as ("en", "ru")."""
lang: String!
score: Float!
"""
A fragment of the matching text with the matched terms wrapped in `<mark>`.
Everything around them is HTML-escaped by the index, so this is HTML: render
it as HTML or strip the tags, but do not escape it again.
"""
snippet: String!
}
"""
A bundle of document edits awaiting review: a branch `proposals/<id>` plus a
row. Proposals are read-only here; they are opened over REST and MCP.
"""
type Proposal {
id: Int!
"""The space the proposal is against, "~owner/name"."""
space: String!
title: String!
rationale: String!
"""
The approved-head sha the agent had read when it opened the proposal. It does
not move as the proposal accumulates edits.
"""
baseRev: String!
"""The branch carrying the edits, "proposals/<id>"."""
branch: String!
state: ProposalState!
"""How the merge was authorized. Null until the proposal has merged."""
approval: Approval
"""The merge commit. Null until the proposal has merged."""
mergedRev: String
"""The agent identity that opened it, e.g. "claude-code/spec-writer"."""
agent: String!
"""The agent's session id."""
agentSession: String!
created: Time!
"""When the proposal left the open state. Null while it is still open."""
resolved: Time
}