graphql grant: a fixed document with caller-supplied variables, which no existing body construct can express #306

Closed
opened 2026-08-19 08:23:37 +00:00 by coilyco-ops · 1 comment
Member

Filed by Angie (engineer, claude seat) on Kai's call, as the umbra half of mcp-beaver#70. That epic is filed on mcp-beaver, but the grammar it asks for is this repo's: applyInlineGrantChild at http/opcore/inline.go:210 owns every grant-body node, and its set today is path, query, body, method, raw-response, describe, set, fail-when. Kai's call is to teach umbra graphql rather than route around it.

The gap

A GraphQL upstream needs one request carrying an authored document the caller cannot influence plus caller-supplied variables. No node here can build that body. Findings carried from mcp-beaver#65, verified against the live APIs:

  • body key=value sets a fully fixed body with no caller input, so a search tool could not take a title.
  • Inline body { field ... } mounts caller fields with no way to give one a fixed literal value.
  • body { map "a" to="b" } remaps caller input and cannot introduce a value the caller never sent.
  • Fields and map cannot be combined, refused at inline.go:231, and validateBodyMappingMode (body_mapping.go:106) refuses a fixed body alongside mappings.
  • set is body-only and equally all-or-nothing. pin reaches query parameters, not body fields.

The pin workaround fits an API accepting GET /?query=...&variables=.... AniList is POST-only and 404s on GET, so it does not apply.

Shape

can post search {
    path "/"
    graphql {
        document "query ($search: String!, $page: Int) { Page(page: $page) { media(search: $search) { id } } }"
        variable "page" describe="1-based page number" minimum=1 maximum=100
    }
}

Descriptor gains GraphQL *GraphQL carrying Document, Operation, and derived Variables []Field.

The document is the source of truth for the variable set. opcore parses the operation signature and derives each variable's name, type, and requiredness from it, so a variable node only decorates one that already exists. Naming an undeclared variable is a build error. This removes the whole class of guardfile where the block and the document disagree.

Type mapping is the GraphQL scalars only: String/ID to string, Int to integer, Float to number, Boolean to boolean, [X] to array, trailing ! to required. A non-scalar named type is a build error unless a variable node states its type, since an enum and an input object are not distinguishable from the document alone, and guessing is the failure this repo exists to avoid.

Variables project into InputSchema at LocationBody and assembleBody nests them under variables. That is deliberate: it means a consumer pinned to today's splitArgs picks them up with no lockstep change.

Acceptance

  • A guardfile expresses a fixed document plus caller-supplied variables in one request.
  • The document is not caller-influenceable and never appears in the tool schema.
  • Variables surface as individually typed inputs rather than one opaque object.
  • Parsing fails closed on a half-specified block: no document, empty document, an unknown child, a variable naming something the document does not declare, a non-scalar type left unstated, a graphql block combined with body/set/map, or a method that is not POST.
  • coilyco-bridge/deploy#467 AniList becomes an ordinary guardfile once this and a consumer bump land.

Scope call

Validation is document-internal only. The parse checks the operation signature and cross-checks the declared variables against it, with no network and no GraphQL dependency. Fetching a remote schema to type-check the selection set is a separate question and explicitly not this issue, matching the open question the mcp-beaver#70 director comment left to the children.

Refs mcp-beaver#70, mcp-beaver#65, coilyco-bridge/deploy#467, coilyco-bridge/deploy#557.

**Filed by Angie (engineer, `claude` seat) on Kai's call**, as the umbra half of `mcp-beaver#70`. That epic is filed on mcp-beaver, but the grammar it asks for is this repo's: `applyInlineGrantChild` at `http/opcore/inline.go:210` owns every grant-body node, and its set today is `path`, `query`, `body`, `method`, `raw-response`, `describe`, `set`, `fail-when`. Kai's call is to teach umbra graphql rather than route around it. ## The gap A GraphQL upstream needs one request carrying **an authored document the caller cannot influence** plus **caller-supplied variables**. No node here can build that body. Findings carried from `mcp-beaver#65`, verified against the live APIs: * `body key=value` sets a fully fixed body with no caller input, so a search tool could not take a title. * Inline `body { field ... }` mounts caller fields with no way to give one a fixed literal value. * `body { map "a" to="b" }` remaps caller input and cannot introduce a value the caller never sent. * Fields and `map` cannot be combined, refused at `inline.go:231`, and `validateBodyMappingMode` (`body_mapping.go:106`) refuses a fixed body alongside mappings. * `set` is body-only and equally all-or-nothing. `pin` reaches query parameters, not body fields. The `pin` workaround fits an API accepting `GET /?query=...&variables=...`. AniList is POST-only and 404s on GET, so it does not apply. ## Shape ```kdl can post search { path "/" graphql { document "query ($search: String!, $page: Int) { Page(page: $page) { media(search: $search) { id } } }" variable "page" describe="1-based page number" minimum=1 maximum=100 } } ``` `Descriptor` gains `GraphQL *GraphQL` carrying `Document`, `Operation`, and derived `Variables []Field`. **The document is the source of truth for the variable set.** opcore parses the operation signature and derives each variable's name, type, and requiredness from it, so a `variable` node only decorates one that already exists. Naming an undeclared variable is a build error. This removes the whole class of guardfile where the block and the document disagree. Type mapping is the GraphQL scalars only: `String`/`ID` to string, `Int` to integer, `Float` to number, `Boolean` to boolean, `[X]` to array, trailing `!` to required. **A non-scalar named type is a build error unless a `variable` node states its type**, since an enum and an input object are not distinguishable from the document alone, and guessing is the failure this repo exists to avoid. Variables project into `InputSchema` at `LocationBody` and `assembleBody` nests them under `variables`. That is deliberate: it means a consumer pinned to today's `splitArgs` picks them up with no lockstep change. ## Acceptance * A guardfile expresses a fixed document plus caller-supplied variables in one request. * The document is not caller-influenceable and never appears in the tool schema. * Variables surface as individually typed inputs rather than one opaque object. * Parsing fails closed on a half-specified block: no `document`, empty `document`, an unknown child, a `variable` naming something the document does not declare, a non-scalar type left unstated, a `graphql` block combined with `body`/`set`/`map`, or a method that is not POST. * `coilyco-bridge/deploy#467` AniList becomes an ordinary guardfile once this and a consumer bump land. ## Scope call Validation is **document-internal only**. The parse checks the operation signature and cross-checks the declared variables against it, with no network and no GraphQL dependency. Fetching a remote schema to type-check the selection set is a separate question and explicitly not this issue, matching the open question the `mcp-beaver#70` director comment left to the children. Refs `mcp-beaver#70`, `mcp-beaver#65`, `coilyco-bridge/deploy#467`, `coilyco-bridge/deploy#557`.
Author
Member

Landed on main as 47eed47. Built by Angie (engineer, claude seat).

Acceptance

  • A fixed document plus caller variables in one request. graphql { document "..." } sends the document verbatim as query with input nested under variables.
  • The document is not caller-influenceable and never appears in the schema. Asserted directly: a body key named query is dropped rather than forwarded, and only declared variables ride.
  • Variables surface as individually typed inputs. Derived from the operation signature, so $search: String! becomes a required string and $ids: [ID!] an array of strings.
  • Fails closed on a half-specified block. Missing or blank document, unknown child, a variable the document does not declare, an unstated non-scalar, two operations in one document, a duplicate graphql, combination with body/map/set, and a non-POST method.

Verified live

Against graphql.anilist.co, the API coilyco-bridge/deploy#467 is blocked on:

leaf=post group=search method=POST
input schema: search (string, required), page (integer, minimum 1, maximum 100, "1-based page")
response: {"data":{"Page":{"media":[{"id":1,"title":{"romaji":"Cowboy Bebop"}}, ...]}}}

Two design calls worth recording

The document owns the variable set. I had planned for variable nodes to state name and type, then changed it: the document already carries that in its signature, and two sources of truth for one fact is a guardfile that can disagree with itself. A variable node now only decorates something that already exists, and naming a variable the document does not take is a build error. A non-null variable carrying a default is optional, since the server fills it.

Variables ride LocationBody. They are not really body fields, but a consumer that already splits arguments by Location picks them up with no lockstep change, and assembleBody is the single place that nests them under variables. mcp-beaver needs only a version bump.

Scope held

Validation is document-internal, as filed: a signature reader rather than a GraphQL parser, no dependency and no schema fetch. The selection set is sent verbatim and never inspected. Whether lint should type-check it against a fetched schema is still open and still not this issue.

make test, make lint (0 issues), and pre-commit run --all-files all clean. docs/opcore-inline.md gained a GraphQL grants section, which needed the existing prose condensed to stay inside the band's line cap.

One incidental finding

make lint was failing on this repo before I touched it, reporting a G101 in /var/folders/.../T/umbra-headless-issues/pkg/credseed/credseed.go. That path does not exist: it is a deleted temporary clone still in golangci-lint's cache. golangci-lint cache clean clears it. Not a code issue, but it reproduces on a clean main and would waste the next agent's time.

**Landed on `main` as `47eed47`.** Built by Angie (engineer, `claude` seat). ## Acceptance * **A fixed document plus caller variables in one request.** `graphql { document "..." }` sends the document verbatim as `query` with input nested under `variables`. * **The document is not caller-influenceable and never appears in the schema.** Asserted directly: a body key named `query` is dropped rather than forwarded, and only declared variables ride. * **Variables surface as individually typed inputs.** Derived from the operation signature, so `$search: String!` becomes a required string and `$ids: [ID!]` an array of strings. * **Fails closed on a half-specified block.** Missing or blank `document`, unknown child, a `variable` the document does not declare, an unstated non-scalar, two operations in one document, a duplicate `graphql`, combination with `body`/`map`/`set`, and a non-POST method. ## Verified live Against `graphql.anilist.co`, the API `coilyco-bridge/deploy#467` is blocked on: ``` leaf=post group=search method=POST input schema: search (string, required), page (integer, minimum 1, maximum 100, "1-based page") response: {"data":{"Page":{"media":[{"id":1,"title":{"romaji":"Cowboy Bebop"}}, ...]}}} ``` ## Two design calls worth recording **The document owns the variable set.** I had planned for `variable` nodes to state name and type, then changed it: the document already carries that in its signature, and two sources of truth for one fact is a guardfile that can disagree with itself. A `variable` node now only decorates something that already exists, and naming a variable the document does not take is a build error. A non-null variable carrying a default is optional, since the server fills it. **Variables ride `LocationBody`.** They are not really body fields, but a consumer that already splits arguments by `Location` picks them up with no lockstep change, and `assembleBody` is the single place that nests them under `variables`. mcp-beaver needs only a version bump. ## Scope held Validation is document-internal, as filed: a signature reader rather than a GraphQL parser, no dependency and no schema fetch. The selection set is sent verbatim and never inspected. Whether lint should type-check it against a fetched schema is still open and still not this issue. `make test`, `make lint` (0 issues), and `pre-commit run --all-files` all clean. `docs/opcore-inline.md` gained a **GraphQL grants** section, which needed the existing prose condensed to stay inside the band's line cap. ## One incidental finding `make lint` was failing on this repo before I touched it, reporting a G101 in `/var/folders/.../T/umbra-headless-issues/pkg/credseed/credseed.go`. That path does not exist: it is a deleted temporary clone still in golangci-lint's cache. `golangci-lint cache clean` clears it. Not a code issue, but it reproduces on a clean `main` and would waste the next agent's time.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-flight-deck/umbra#306
No description provided.