feat(grants): answer a refused grant with 403, not the 400 a typo gets #880

Merged
coilysiren merged 1 commit from aos/claude/hs68-grant-denial-403 into main 2026-08-17 03:09:16 +00:00
Member

IsGrantDenial is wired, and answering #825's three questions changed what the fix is.

First, a correction to the headline

a permanent grant refusal is retried like a transient failure

Nothing in this repository retries it. Submit already handled a denial correctly before this branch: it writes the job record, transitions it to failed with the outcome not permitted, emits job.denied, and returns. There is no retry loop, no backoff, and no second attempt. enqueue is never reached.

The bare return err at jobrunner.go:169 that the issue points at is inside permits, whose only caller is that same Submit, six lines above the code that records the denial.

The retry the issue is right about is the caller's, and a status code invites it. That is the real defect and it is the one fixed here.

The three questions

1. What the retry costs today. Zero model calls and zero tool rounds - the denial is refused before the job is queued, so nothing executes. What it costs is a caller re-sending a request that can never succeed, and on HTTP the service told it to.

2. Whether the caller sees anything different. No, and this is the #449 family exactly as the issue anticipated. Every grant denial fell to the default arm of writeJobError:

400  sirens_echo.jobs.rejected  "job could not be accepted"

The same answer an unknown job kind gets, and the same answer a malformed body gets. 400 asserts the request was wrong. For a grant the deployment does not hold and will not hold, that is a true-sounding sentence pointing at the wrong thing: the caller reads "fix your request", fixes nothing, and tries again.

The Discord path is the same shape - every Submit error becomes job could not be accepted, so a refused member and a member who hit a full queue read the identical notice.

3. Whether wiring it is the whole fix. No, and the issue was right that acting on the classification is the part worth designing. The classification is one line. What it enables:

  • HTTP answers 403 with a new code, sirens_echo.jobs.not_permitted, and the phrase job kind is not permitted.
  • Discord says the caller is not permitted to start that job kind, rather than a notice that reads as an invitation to retry.
  • 503 for a full queue is untouched and remains the one job refusal that is this service's fault rather than the caller's.

Why 403 leaks nothing

Worth stating, because this repo collapses not-found and not-owner into one 404 on purpose so an id cannot be probed for.

That collapse protects other principals' records. This one would protect nothing: a principal learns only about its own grant, and GrantedKinds exists specifically to answer that question without making them discover it by being refused. The denial's reason string stays out of the response body, so 403 carries the fact and not the table.

Changes

  • IsGrantDenial called from writeJobError and the Discord command path.
  • exceptionJobNotPermitted added, faultCaller, stage jobs. Catalog bound 34 to 35.
  • TestARefusedGrantIsForbiddenRatherThanABadRequest asserts the 403 and that a malformed request no longer answers the same way, which is the property that was missing rather than the code itself.
  • IsGrantDenial drops out of unwiredCapabilities, leaving CommandFromPrompt as the last row.
  • docs/sirens-echo-grant-refusals.md is new. docs/sirens-echo-grants.md points at it and stays at its line cap.

One thing I am flagging rather than deciding. The Discord wording is a member-facing string and Content Creator owns those. I matched the existing notice's plainness rather than choosing a voice, and a different phrasing is a one-line change.

just gate passes.

closes #825

`IsGrantDenial` is wired, and answering #825's three questions changed what the fix is. ## First, a correction to the headline > a permanent grant refusal is retried like a transient failure **Nothing in this repository retries it.** `Submit` already handled a denial correctly before this branch: it writes the job record, transitions it to `failed` with the outcome `not permitted`, emits `job.denied`, and returns. There is no retry loop, no backoff, and no second attempt. `enqueue` is never reached. The bare `return err` at `jobrunner.go:169` that the issue points at is inside `permits`, whose only caller is that same `Submit`, six lines above the code that records the denial. **The retry the issue is right about is the caller's, and a status code invites it.** That is the real defect and it is the one fixed here. ## The three questions **1. What the retry costs today.** Zero model calls and zero tool rounds - the denial is refused before the job is queued, so nothing executes. What it costs is a caller re-sending a request that can never succeed, and on HTTP the service told it to. **2. Whether the caller sees anything different.** No, and this is the #449 family exactly as the issue anticipated. Every grant denial fell to the `default` arm of `writeJobError`: ``` 400 sirens_echo.jobs.rejected "job could not be accepted" ``` The same answer an unknown job kind gets, and the same answer a malformed body gets. **400 asserts the request was wrong.** For a grant the deployment does not hold and will not hold, that is a true-sounding sentence pointing at the wrong thing: the caller reads "fix your request", fixes nothing, and tries again. The Discord path is the same shape - every `Submit` error becomes `job could not be accepted`, so a refused member and a member who hit a full queue read the identical notice. **3. Whether wiring it is the whole fix.** No, and the issue was right that acting on the classification is the part worth designing. The classification is one line. What it enables: - **HTTP answers `403`** with a new code, `sirens_echo.jobs.not_permitted`, and the phrase `job kind is not permitted`. - **Discord says the caller is not permitted** to start that job kind, rather than a notice that reads as an invitation to retry. - `503` for a full queue is untouched and remains the one job refusal that is this service's fault rather than the caller's. ## Why 403 leaks nothing Worth stating, because this repo collapses not-found and not-owner into one `404` on purpose so an id cannot be probed for. That collapse protects **other** principals' records. This one would protect nothing: a principal learns only about its own grant, and `GrantedKinds` exists specifically to answer that question without making them discover it by being refused. The denial's reason string stays out of the response body, so `403` carries the fact and not the table. ## Changes * `IsGrantDenial` called from `writeJobError` and the Discord command path. * `exceptionJobNotPermitted` added, `faultCaller`, stage `jobs`. Catalog bound 34 to 35. * `TestARefusedGrantIsForbiddenRatherThanABadRequest` asserts the 403 **and** that a malformed request no longer answers the same way, which is the property that was missing rather than the code itself. * `IsGrantDenial` drops out of `unwiredCapabilities`, leaving `CommandFromPrompt` as the last row. * `docs/sirens-echo-grant-refusals.md` is new. `docs/sirens-echo-grants.md` points at it and stays at its line cap. **One thing I am flagging rather than deciding.** The Discord wording is a member-facing string and Content Creator owns those. I matched the existing notice's plainness rather than choosing a voice, and a different phrasing is a one-line change. `just gate` passes. closes #825
feat(grants): answer a refused grant with 403, not the 400 a typo gets
All checks were successful
ci / test (pull_request) Successful in 50s
ci / publish-echo-image (pull_request) Has been skipped
ci / publish-observed (pull_request) Has been skipped
ci / image-build (pull_request) Successful in 46s
671c816b8f
IsGrantDenial existed to tell a permanent refusal from a transient failure and
had no caller, so both submit surfaces reported one as whatever their default
arm said.

On HTTP that default is 400 with sirens_echo.jobs.rejected and "job could not
be accepted", which is also what an unknown kind and a malformed body get. A
caller could not tell a request it should fix from an authority it does not
have, and 400 asserts the first. It now answers 403 with its own code,
sirens_echo.jobs.not_permitted. On Discord the notice says the caller is not
permitted rather than reading as an invitation to try again.

sirens-echo#825 framed this as a permanent refusal being retried. Nothing in
this repository retried it: Submit already writes the job, moves it to failed
with the outcome "not permitted", and emits job.denied. The retry it names is
the caller's, invited by a status code that said the request was wrong.

403 leaks nothing a principal cannot already ask for. GrantedKinds exists to
answer it without a refusal, and the reason string stays out of the body. The
404 that not-found and not-owner share is a different case: that collapse stops
an id being probed for, and this one would protect nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Sign in to join this conversation.
No reviewers
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-gaming/sirens-echo!880
No description provided.