Skip to content

Grant or deny a pending resource request

POST
/admin/requests/{id}/decide

Atomic CAS — only pending requests transition. Re-deciding an already-decided request returns 409.

There are two kinds of request behind this one endpoint, and requested_capability — not the caller’s rank — decides which gate and which consequence apply.

Access requests (anything except content.restore.request)

Requires share.grant OR system.adminor ownership of the requested asset, for requests naming content.access.request only. The owner disjunct is narrower on purpose: requested_capability is requester-controlled, so an owner who could decide any request could be induced to grant system.admin from a panel about their own artwork.

On grant, a user_capability_grants row is inserted in the same tx as the resource_request CAS. expires_at on the grant is the same value the operator supplied here; the CapabilitySweeper reaps both together when it fires.

A grant does not unlock the asset’s bytes. There is no per-object capability scope, so approval records agreement, not access — see #912 and ADR 0064.

Restoration appeals (content.restore.request, #931)

Decidable ONLY by a caller who passes auth.CanRestoreDeleted against the TARGET’s deleter: the person who deleted it, or system.admin. share.grant alone does not qualify — authority over sharing is not authority over moderation — and neither does ownership, so a requester can never decide their own appeal.

On grant the server performs the restore and writes no user_capability_grants row. The marker confers nothing and there is nothing to confer: what was asked for is a state change on the item, not a standing right. This is also what keeps the #881 escalation surface closed — the grant path that copies requested_capability verbatim into a capability grant is not reached on this branch at all.

expires_at on a restore grant is 400. A performed restore cannot expire, and silently ignoring the field would let an operator believe they had set a deadline.

If someone else already restored the target, the grant still succeeds: the requested end-state holds, and the decision reason records that it was already live.

Failure shape

A caller who is neither an approver nor the owner gets 403 for every failure, including a request id that does not exist: a 404 here would let a non-approver probe for request ids and read off which capability each names.

id
required
string format: uuid
object
decision
required
string
Allowed values: granted denied
reason

Operator-supplied decision reason.

string
<= 1000 characters
expires_at

On grant: optional auto-expiry. Past values rejected. Omit / null for a permanent grant. Ignored on deny.

string format: date-time
nullable

Decided.

A resource_request row. The state machine is pending → granted / denied (admin decision); granted → expired (CapabilitySweeper cascade). Terminal states admit no further transitions.

object
id
required
string format: uuid
requester_user_ref
required
integer format: int64
target_kind
required

Which table target_id refers to. Access requests (#881) are always asset; restoration appeals (#931) may name any of the three soft-deletable kinds.

string
Allowed values: asset post collection
target_id
required

The requested object, in the table named by target_kind. Called target_asset_id before #931, when the workflow could only ever name an asset.

string format: uuid
requested_capability
required

The capability code this request names. Two of them are inert markers that confer nothing and exist only to TYPE the row for its decide gate:

  • content.access.request — “may I see this asset” (#881). Decidable by the asset’s owner, share.grant, or system.admin.
  • content.restore.request — “please undo this delete” (#931). Decidable ONLY by whoever deleted the target, or by system.admin. share.grant does not qualify: authority over sharing is not authority over moderation. Granting one performs the restore; it writes no capability grant.
string
decidable_by_caller

True when THIS caller may decide THIS row right now, computed by the same gate the decide endpoint applies. Populated on the queue listings (/admin/requests, /account/requests/incoming) and absent elsewhere.

It exists because the two queues have different gates and the admin queue is readable by people who cannot act on every row it contains: share.grant sees restoration appeals but may not decide them. Rendering a decide control from row presence alone would offer a button that 403s.

boolean
reason
string
state
required
string
Allowed values: pending granted denied expired
decided_at
string format: date-time
nullable
decided_by_user_ref
integer format: int64
nullable
decision_reason
string
expires_at

On granted requests, the linked grant’s auto-expiry. Null means permanent.

string format: date-time
nullable
requested_at
required
string format: date-time

Malformed request

object
error
required

Human-readable error summary

string
Example
the request could not be completed

Authentication required, missing, or invalid

object
error
required

Human-readable error summary

string
Example
the request could not be completed
Example
{
"error": "authentication required: sign in and retry with a valid session or API token"
}

Authenticated but missing required capabilities

object
error
required

Human-readable error summary

string
Example
the request could not be completed

Resource not found

object
error
required

Human-readable error summary

string
Example
the request could not be completed

Already decided.

object
error
required

Human-readable error summary

string
Example
the request could not be completed