The pipeline
A form, an agent, an MCP client, a queued job or your own code: every call to an action takes the same steps, in the same order.
- the door#[Expose], the surface's switch, the agent's toolsetnot found: 404
- the tokenthe effect's ability, such as actions:writenot found: 404
- shouldRegister()exposure, not authorizationnot found: 404
- tenant membershipwhen the call has a tenantnot a member: 404
- authorize()when it takes no inputdenied: 403
- the arguments cutagents and MCP: only the advertised schema
- input preparedfixed input, conversions, prepareForValidation()
- validationschema() plus rules()invalid: 422
- authorize()when it takes ValidatedInputdenied: 403
- the person confirmsan agent's Destructive or External calldeclined: nothing runs
- handle()then the output projection and modelReply()Refusal: 409 by default
- one eventwith no input valuesActionCompletedActionRefusedActionFailed
Dashed: only some callers take this step.
Where a call stops
The door, the token, shouldRegister() and membership answer "not found" when they say no, so an action a caller may not see reads exactly like one that does not exist. A denied authorize() can answer 404 too, when it returns Response::denyAsNotFound(). See The pipeline in Concepts.
What an agent's tool list shows
Before each turn, the agent's tools are built by running every check up to an authorize() without input, for each action in its toolsets. An action leaves the list when one of them says no, or when it would offer a forbidden key.
Before each turn, per action
- the door
- the token
- shouldRegister()
- tenant membership
- authorize() without input
Checks on the caller, such as a role, go in an authorize() without input, which keeps the tool off the list; checks on a particular row go in handle() or in an authorize() that takes ValidatedInput.
public function authorize(ActionContext $context): bool
{
return $context->actor(User::class)->can('edit-posts');
}When authorize() has to take input, put the check on the caller in shouldRegister(), as the strict agent schemas recipe does. See What an agent's tool list shows in Concepts.
Refusals
An action says no from its own code with a Refusal. on() puts the message on a field, which answers 422 like a validation error; otherwise status() sets the HTTP status, 409 by default. Actions::refuse() turns an exception your domain already throws into a refusal on every surface.
throw Refusal::make(__('You already have a post with that title.'))->on('title');Replacements in the message come from your own text or the person's saved rows, never from the caller's input. See Refusals in Concepts for details() and listing().