GraphQL
POST /graphql, served by GraphqlController. /graphiql is mounted in development.
app/graphql/
The schema
Section titled “The schema”class ThingiesSchema < GraphQL::Schema query(Types::QueryType) mutation(Types::MutationType) subscription(Types::SubscriptionType)
use GraphQL::Subscriptions::ActionCableSubscriptions use GraphQL::Dataloader
max_depth(15) max_query_string_tokens(5000) validate_max_errors(100)endQueries
Section titled “Queries”| Field | Grant | |
|---|---|---|
tenant |
things:read |
who answered |
thing(id:) |
things:read |
one thing |
things(kind:, resourceId:, after:, limit:) |
things:read |
a page of the catalog |
search(query:, kind:, limit:) |
things:read |
the index, clamped to 1–200 |
kinds |
things:read |
counts grouped by kind |
resources |
resources:read |
active resources, ordered by type and key |
runs(kind:, status:, after:, limit:) |
things:read |
a page of runs |
mergeProposals(status:, after:, limit:) |
things:read |
open proposals by default |
auditEvents(action:, status:, subject:, after:, limit:) |
things:read |
a page of the audit log |
Paged fields go through Page.of(scope, after:, limit:) — keyset pagination on the primary key,
newest first, limit clamped to 1–200 with a default of 50. One extra row is fetched to answer
“is there more”; nothing counts.
Mutations
Section titled “Mutations”| Field | Grant |
|---|---|
analyzeThing mergeThings splitReference proposeMerges settleMergeProposal |
things:write |
exportThings cancelRun |
things:write |
syncResource checkResource setDefaultStorage setSyncInterval |
resources:command |
exportThings and cancelRun take things:write here and resources:command over
MCP. Merging and dedupe are reachable here and nowhere else.
Subscriptions
Section titled “Subscriptions”thingAnalyzed, triggered by Thing#announce_analyzed! both globally and per thing id.
Subscriptions::BaseSubscription declares subscription_scope :tenant_id.
Authorization is the same Grant
Section titled “Authorization is the same Grant”Types::BaseField takes a grants: option and checks it in authorized?:
field :resources, [ Types::ResourceType ], null: false, grants: "resources:read"A field whose grant the token does not carry raises a GraphQL::ExecutionError naming the missing
scope. A field with no grants: is open to any authenticated caller.
Both interfaces reach Grant through the Granted concern. The browser’s token lives in the
encrypted session and is verified on the way in exactly as a presented bearer would be — see
Drive it from Claude.
The Ruby schema is the source of truth
Section titled “The Ruby schema is the source of truth”The TypeScript is generated from it and is not committed:
bin/rails graphql:dump_schema # writes schema.graphqlnpm run codegen # regenerates the TypeScriptbin/dev runs both watchers (Procfile.dev, the schema and codegen processes). CI runs the
same two commands before the typecheck, because schema.graphql and the generated types are build
products — a clean checkout has neither and the Vite build fails without them.
The client
Section titled “The client”urql, with @urql/exchange-graphcache and Action Cable subscriptions.