A GraphQL upstream is not expressible: no grant can send a fixed query with caller-supplied variables #65

Closed
opened 2026-08-15 07:21:20 +00:00 by coilyco-ops · 3 comments
Member

Filed by Olaf (ops, claude seat), blocked building coilyco-bridge/deploy#467, the AniList anime server. Recording the exact grammar gap rather than shipping the shape that issue forbids.

What a curated GraphQL tool needs

One POST whose JSON body is:

{ "query": "<fixed document, authored in the guardfile>",
  "variables": { "search": "<caller input>" } }

The document must be fixed or the tool is a raw passthrough. deploy#467 rules that out explicitly, and correctly: it would be the widest unbounded surface in the roster and it contradicts the deny-by-absence property every other guardfile depends on.

So the requirement is one body carrying both an authored literal and a caller-supplied value.

Why neither grammar can do it

  • body key=value sets FixedBody, an entirely fixed JSON document with no caller input. search_anime could not take a title.

  • Inline body { field ... } mounts caller flags with no way to give one a fixed literal value.

  • body { map "a" to="b" } remaps caller input; it cannot introduce a value the caller did not send.

  • Combining them is refused outright at http/opcore/inline.go:231:

    body fields and body `map` declarations cannot be combined (fail-closed)
    

set is body-only and equally all-or-nothing, and pin reaches query parameters rather than body fields.

The pin workaround does not apply here

pin "<tool>" { query "<name>" literal "<value>" } would be a clean fit for a GraphQL server that accepts GET /?query=…&variables=…: pin the document, expose the variables, done. AniList is POST-only and says so:

GET https://graphql.anilist.co -> 404
{"errors":[{"message":"Not Found.","hint":"Use POST request to access graphql subdomain."}]}

So the workaround is unavailable for this upstream, though it may work for a GraphQL API that does accept GET.

Smallest construct that would unblock it

A literal body field, so an authored value and caller flags can coexist:

can search anime {
    path "/"
    body {
        literal "query" "query($search:String){ Media(search:$search,type:ANIME){ ... } }"
        field "variables" type="object"
    }
}

Alternatively a body-scoped pin, mirroring the query-parameter one that already exists, which would keep the pinned field out of the tool schema the way query pins already do. That second shape is probably better: it reuses a concept rather than adding one, and "absent from the schema" is the right property for a document the caller must not influence.

Scope beyond AniList

Every GraphQL upstream hits this, so it decides whether this runtime can wrap that class of API at all. deploy#495 also lists Wikidata, whose SPARQL passthrough raises the same bounded-versus-raw question from the other direction.

What I did instead

Nothing. deploy#467 stays open and unbuilt, with a comment pointing here. The two REST servers in the same epic, TVmaze and Gutendex, shipped normally.

Acceptance

  • A guardfile can express a fixed document plus caller-supplied variables in one body.
  • The fixed part is not caller-influenceable, and ideally not in the tool schema.
  • ward-mcp lint fails closed on a half-specified body rather than silently sending one.
**Filed by Olaf (ops, claude seat)**, blocked building `coilyco-bridge/deploy#467`, the AniList anime server. Recording the exact grammar gap rather than shipping the shape that issue forbids. ## What a curated GraphQL tool needs One POST whose JSON body is: ```json { "query": "<fixed document, authored in the guardfile>", "variables": { "search": "<caller input>" } } ``` The document must be **fixed** or the tool is a raw passthrough. `deploy#467` rules that out explicitly, and correctly: it would be the widest unbounded surface in the roster and it contradicts the deny-by-absence property every other guardfile depends on. So the requirement is one body carrying **both** an authored literal and a caller-supplied value. ## Why neither grammar can do it * `body key=value` sets `FixedBody`, an entirely fixed JSON document with no caller input. `search_anime` could not take a title. * Inline `body { field ... }` mounts caller flags with no way to give one a fixed literal value. * `body { map "a" to="b" }` remaps caller input; it cannot introduce a value the caller did not send. * Combining them is refused outright at `http/opcore/inline.go:231`: ``` body fields and body `map` declarations cannot be combined (fail-closed) ``` `set` is body-only and equally all-or-nothing, and `pin` reaches query parameters rather than body fields. ## The pin workaround does not apply here `pin "<tool>" { query "<name>" literal "<value>" }` would be a clean fit for a GraphQL server that accepts `GET /?query=…&variables=…`: pin the document, expose the variables, done. AniList is POST-only and says so: ``` GET https://graphql.anilist.co -> 404 {"errors":[{"message":"Not Found.","hint":"Use POST request to access graphql subdomain."}]} ``` So the workaround is unavailable for this upstream, though it may work for a GraphQL API that does accept GET. ## Smallest construct that would unblock it A literal body field, so an authored value and caller flags can coexist: ``` can search anime { path "/" body { literal "query" "query($search:String){ Media(search:$search,type:ANIME){ ... } }" field "variables" type="object" } } ``` Alternatively a body-scoped `pin`, mirroring the query-parameter one that already exists, which would keep the pinned field out of the tool schema the way query pins already do. That second shape is probably better: it reuses a concept rather than adding one, and "absent from the schema" is the right property for a document the caller must not influence. ## Scope beyond AniList Every GraphQL upstream hits this, so it decides whether this runtime can wrap that class of API at all. `deploy#495` also lists Wikidata, whose SPARQL passthrough raises the same bounded-versus-raw question from the other direction. ## What I did instead Nothing. `deploy#467` stays open and unbuilt, with a comment pointing here. The two REST servers in the same epic, TVmaze and Gutendex, shipped normally. ## Acceptance * A guardfile can express a fixed document plus caller-supplied variables in one body. * The fixed part is not caller-influenceable, and ideally not in the tool schema. * `ward-mcp lint` fails closed on a half-specified body rather than silently sending one.
Author
Member

Consumer context, since this issue has none and it is the sole blocker on a shipped epic's child. Olaf (DevOps, claude seat).

coilyco-bridge/deploy #467 AniList is blocked on this and nothing else. It is one of two remaining children of the #465 five-keyless-media epic, where the other three — TVmaze, Open Library, Gutendex — are shipped to both Sirens lanes.

Why the obvious workarounds do not apply

Worth recording so nobody re-derives them:

  • pin does not help. A pinned argument leaves the tool schema and is fixed server-side, which is the right shape for a constant, but a curated GraphQL query needs a fixed document plus caller-supplied variables in the same body. Pinning the document does not let the variables through.
  • The body mapper does not help either. body { map "x" to="y" } renames a caller field onto a wire field, which solved the reserved-name problem for Exa web search today. It cannot introduce a value the caller did not send, and validateBodyMappingMode refuses a fixed body alongside mappings outright, so set plus map is not expressible.

So the missing capability is specifically a fixed body fragment combined with caller-supplied fields in one request. That is the same shape #495 wants for other query-language upstreams, and #557 in deploy lists "curated queries rather than passthrough" as an option that is currently unavailable because of this issue.

Two consumers, not one

That makes this a blocker on more than AniList:

  • #467 AniList — POST-only GraphQL, curated query.
  • deploy#557 — if Kai picks curated queries over a raw SPARQL passthrough for Wikidata, that choice depends on this landing.

Not claiming, and not asking for a priority change. Recording it because the issue read as a single stuck server and it is the general construct.

**Consumer context, since this issue has none and it is the sole blocker on a shipped epic's child.** Olaf (DevOps, claude seat). `coilyco-bridge/deploy` #467 AniList is blocked on this and nothing else. It is one of two remaining children of the #465 five-keyless-media epic, where the other three — TVmaze, Open Library, Gutendex — are shipped to both Sirens lanes. ## Why the obvious workarounds do not apply Worth recording so nobody re-derives them: - **`pin` does not help.** A pinned argument leaves the tool schema and is fixed server-side, which is the right shape for a constant, but a curated GraphQL query needs a fixed *document* plus caller-supplied *variables* in the same body. Pinning the document does not let the variables through. - **The body mapper does not help either.** `body { map "x" to="y" }` renames a caller field onto a wire field, which solved the reserved-name problem for Exa web search today. It cannot introduce a value the caller did not send, and `validateBodyMappingMode` refuses a fixed body alongside mappings outright, so `set` plus `map` is not expressible. So the missing capability is specifically **a fixed body fragment combined with caller-supplied fields in one request**. That is the same shape #495 wants for other query-language upstreams, and #557 in deploy lists "curated queries rather than passthrough" as an option that is currently unavailable *because of this issue*. ## Two consumers, not one That makes this a blocker on more than AniList: - **#467 AniList** — POST-only GraphQL, curated query. - **deploy#557** — if Kai picks curated queries over a raw SPARQL passthrough for Wikidata, that choice depends on this landing. Not claiming, and not asking for a priority change. Recording it because the issue read as a single stuck server and it is the general construct.
Owner

So we add true graphql support

So we add true graphql support
Author
Member

Superseded by #70. Closing. Darren (director seat), 2026-08-16, from a full triage pass of this repo.

Kai's call in a consult round is a first-class graphql grant type rather than the narrow body-literal construct proposed here. #70 carries it and is now labelled autonomy/epic.

Nothing in this issue is lost. Every finding above is quoted into #70 with attribution - the four grammar dead ends, the inline.go:231 and validateBodyMappingMode refusals, and the AniList POST-only evidence - and the acceptance criteria are folded into its own.

The consumer block stands and moves with it. coilyco-bridge/deploy#467 AniList is blocked on #70 rather than on this issue, and deploy#557 Wikidata's curated-queries option depends on the same thing. Both should be repointed.

Rejected alternative, recorded so nobody re-derives it: landing this construct first as a tracer bullet while #70 stayed open as the epic. It would have unblocked AniList sooner. Kai declined it in favour of one design, and the stated cost is that AniList waits.

**Superseded by #70. Closing. Darren (director seat), 2026-08-16, from a full triage pass of this repo.** Kai's call in a consult round is a **first-class `graphql` grant type** rather than the narrow body-literal construct proposed here. #70 carries it and is now labelled `autonomy/epic`. Nothing in this issue is lost. Every finding above is quoted into #70 with attribution - the four grammar dead ends, the `inline.go:231` and `validateBodyMappingMode` refusals, and the AniList POST-only evidence - and the acceptance criteria are folded into its own. The consumer block stands and moves with it. `coilyco-bridge/deploy#467` AniList is blocked on **#70** rather than on this issue, and `deploy#557` Wikidata's curated-queries option depends on the same thing. Both should be repointed. Rejected alternative, recorded so nobody re-derives it: landing this construct first as a tracer bullet while #70 stayed open as the epic. It would have unblocked AniList sooner. Kai declined it in favour of one design, and the stated cost is that AniList waits.
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
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/mcp-beaver#65
No description provided.