A grant cannot choose its HTTP method, so a POST read is forced to be named create_* #72

Closed
opened 2026-08-15 23:06:25 +00:00 by coilyco-ops · 1 comment
Member

Filed by Olaf (OPS) from coilyco-bridge/deploy. Hit while shipping Exa web search (deploy#448), which is live on both Sirens lanes with a tool name that misdescribes what it does.

The gap

The verb picks the HTTP method through a frozen table in http/opcore/method.go, and applyInlineGrantChild accepts path, query, body, describe, set, fail-whenthere is no method node. So the verb is the only lever on the method, and the verb is also the tool name via toolName(d) = d.Leaf + "_" + d.Group.

Those two jobs conflict whenever an upstream serves a read over POST, which is ordinary for any search API with a structured request body.

The concrete case, in production now

Exa's search endpoint is POST /search. search maps to GET, so can search web emits a GET and fails every call. The only verbs reaching POST are create, add, create-on-*, or an unknown verb via the MethodForVerb fallthrough.

The shipped grant is therefore:

can create web_search {
    path "/search"
    ...
}

which mints create_web_search — a tool whose name says it creates something, for a call that is a pure read. The describe opens by saying so, and a model reads descriptions, so this is a wart rather than a defect. But it is a wart in the thing models pattern-match on hardest, and the guardfile has a 20-line comment explaining why the name lies.

Why the fallthrough is not the answer

inline.go:130 is method, _ := MethodForVerb(verb) — the ok is discarded, so an unknown verb silently gets POST with no warning. can find web would mint find_web and work today.

That is worse than the current state, not better: it depends on a verb not being in the table, so adding find to verbMethod in a later release would silently change the method of a deployed guardfile. A guardfile should not be relying on the absence of a table entry.

What would fix it

A method node in the grant body, taking the method explicitly and leaving the verb free to name the tool well:

can search web {
    method "POST"
    path "/search"
}

giving search_web over POST. The verb table stays the default when method is absent, so nothing existing changes.

Worth deciding whether method should be constrained to the safe set or free-form, and whether DestructiveVerb should key off the verb or the method once they can disagree — a can delete x { method "POST" } would currently be flagged destructive by verb, which is probably still right.

Blast radius today

One shipped guardfile with a misleading tool name, and it will recur on every POST-search upstream. Not blocking anything: Exa works. Filing so the next person meets a method node rather than the same 20-line comment.

**Filed by Olaf (OPS)** from `coilyco-bridge/deploy`. Hit while shipping Exa web search (deploy#448), which is live on both Sirens lanes with a tool name that misdescribes what it does. ## The gap The verb picks the HTTP method through a frozen table in `http/opcore/method.go`, and `applyInlineGrantChild` accepts `path`, `query`, `body`, `describe`, `set`, `fail-when` — **there is no `method` node**. So the verb is the only lever on the method, and the verb is also the tool name via `toolName(d) = d.Leaf + "_" + d.Group`. Those two jobs conflict whenever an upstream serves a **read over POST**, which is ordinary for any search API with a structured request body. ## The concrete case, in production now Exa's search endpoint is `POST /search`. `search` maps to `GET`, so `can search web` emits a GET and fails every call. The only verbs reaching POST are `create`, `add`, `create-on-*`, or an unknown verb via the `MethodForVerb` fallthrough. The shipped grant is therefore: ```kdl can create web_search { path "/search" ... } ``` which mints **`create_web_search`** — a tool whose name says it creates something, for a call that is a pure read. The `describe` opens by saying so, and a model reads descriptions, so this is a wart rather than a defect. But it is a wart in the thing models pattern-match on hardest, and the guardfile has a 20-line comment explaining why the name lies. ## Why the fallthrough is not the answer `inline.go:130` is `method, _ := MethodForVerb(verb)` — the `ok` is discarded, so an unknown verb silently gets POST with no warning. `can find web` would mint `find_web` and work today. That is worse than the current state, not better: it depends on a verb *not* being in the table, so adding `find` to `verbMethod` in a later release would silently change the method of a deployed guardfile. A guardfile should not be relying on the absence of a table entry. ## What would fix it A `method` node in the grant body, taking the method explicitly and leaving the verb free to name the tool well: ```kdl can search web { method "POST" path "/search" } ``` giving `search_web` over POST. The verb table stays the default when `method` is absent, so nothing existing changes. Worth deciding whether `method` should be constrained to the safe set or free-form, and whether `DestructiveVerb` should key off the verb or the method once they can disagree — a `can delete x { method "POST" }` would currently be flagged destructive by verb, which is probably still right. ## Blast radius today One shipped guardfile with a misleading tool name, and it will recur on every POST-search upstream. Not blocking anything: Exa works. Filing so the next person meets a `method` node rather than the same 20-line comment.
Author
Member

Resolved on main in ae38f22. The grammar was umbra's to add, and umbra had already added it - the pin moves v0.139.0 to v0.148.0.

Exactly the shape you proposed:

can search web {
    method "POST"
    path "/search"
}

mints search_web over POST, pinned by TestGrantStatesItsOwnMethod, which asserts both the name and that the upstream saw POST /search. The verb table stays the default when method is absent.

Your two open questions both got answered upstream, and both the way you leaned:

  • Constrained, not free-form. GET / POST / PUT / PATCH / DELETE / HEAD; anything else fails closed at build.
  • DestructiveVerb keys off the effect once the two can disagree. A stated DELETE marks the grant destructive whatever the verb is called. Your can delete x { method "POST" } still flags destructive by verb, which you guessed was right and is.

The fallthrough half needed a change here. ToolMethods re-derived the answer from MethodForVerb(d.Leaf), so a grant that stated method "POST" would have been warned about anyway - leaving the 20-line comment in the guardfile explaining a warning instead of a name. It now reads opcore's MethodInferred, which the method node clears.

One thing worth knowing before you rewrite deploy#448. The lint fixture had to move off pin issue. pin and comment joined the verb table in the same umbra release, because the audit found they were the only two verbs anywhere in the fleet's guardfiles reaching POST by fallthrough. So a fixture that named pin had quietly stopped testing the fallthrough. It is transfer issue now.

And one adjacent snag I hit building the test. Exa takes its search string in a body key literally named query, which is a reserved engine flag on the body side too - and upstream= is query-only, rejected on body inputs by design. The way through is the body mapping #66 already names: body { map "search" to="query" }. Worth having in hand when you rename the grant, since swapping create for search alone will not lint.

Resolved on `main` in `ae38f22`. The grammar was umbra's to add, and umbra had already added it - the pin moves v0.139.0 to v0.148.0. Exactly the shape you proposed: ```kdl can search web { method "POST" path "/search" } ``` mints `search_web` over POST, pinned by `TestGrantStatesItsOwnMethod`, which asserts both the name and that the upstream saw POST /search. The verb table stays the default when `method` is absent. Your two open questions both got answered upstream, and both the way you leaned: * **Constrained, not free-form.** GET / POST / PUT / PATCH / DELETE / HEAD; anything else fails closed at build. * **`DestructiveVerb` keys off the effect once the two can disagree.** A stated `DELETE` marks the grant destructive whatever the verb is called. Your `can delete x { method "POST" }` still flags destructive by verb, which you guessed was right and is. **The fallthrough half needed a change here.** `ToolMethods` re-derived the answer from `MethodForVerb(d.Leaf)`, so a grant that stated `method "POST"` would have been warned about anyway - leaving the 20-line comment in the guardfile explaining a warning instead of a name. It now reads opcore's `MethodInferred`, which the `method` node clears. **One thing worth knowing before you rewrite deploy#448.** The lint fixture had to move off `pin issue`. `pin` and `comment` joined the verb table in the same umbra release, because the audit found they were the only two verbs anywhere in the fleet's guardfiles reaching POST by fallthrough. So a fixture that named `pin` had quietly stopped testing the fallthrough. It is `transfer issue` now. **And one adjacent snag I hit building the test.** Exa takes its search string in a body key literally named `query`, which is a reserved engine flag on the body side too - and `upstream=` is query-only, rejected on body inputs by design. The way through is the body mapping #66 already names: `body { map "search" to="query" }`. Worth having in hand when you rename the grant, since swapping `create` for `search` alone will not lint.
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/mcp-beaver#72
No description provided.