Skip to content

GraphQL

POST /graphql, served by GraphqlController. /graphiql is mounted in development.

app/graphql/

class UrisSchema < 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)
end
Field Grant
tenant uris:read who answered
item(id:) uris:read one item
items(kind:, resourceId:, after:, limit:) uris:read a page of the catalog
search(query:, kind:, limit:) uris:read the index, clamped to 1–200
kinds uris:read counts grouped by kind
resources resources:read active resources, ordered by type and key
runs(kind:, status:, after:, limit:) uris:read a page of runs
mergeProposals(status:, after:, limit:) uris:read open proposals by default
auditEvents(action:, status:, subject:, after:, limit:) uris: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.

Field Grant
analyzeItem mergeItems splitReference proposeMerges settleMergeProposal uris:write
exportItems cancelRun uris:write
syncResource checkResource setDefaultStorage setSyncInterval resources:command

exportItems and cancelRun take uris:write here and resources:command over MCP. Merging and dedupe are reachable here and nowhere else.

itemAnalyzed, triggered by Item#announce_analyzed! both globally and per item id. Subscriptions::BaseSubscription declares subscription_scope :tenant_id.

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 TypeScript is generated from it and is not committed:

Terminal window
bin/rails graphql:dump_schema # writes web/schema.graphql
npm run codegen # regenerates the TypeScript

bin/dev runs both watchers (Procfile.dev, the schema and codegen processes). CI runs the same two commands before the typecheck, because web/schema.graphql and the generated types are build products — a clean checkout has neither and the Vite build fails without them.

urql, with @urql/exchange-graphcache and Action Cable subscriptions.