The mandate
Why the ordinary token exchange is not enough
Interactive delegation (RFC 8693) presents the user’s token as subject_token, and the issuer
checks that their session is still alive. That is the property that makes delegation safe while
the user is at the screen: if they logged out, the agent stops.
A routine runs at 3am. That session does not exist. The constraint cannot simply be dropped — it is
what stops a delegation from outliving the user — so it has to be replaced, and the replacement
has to be stated:
| Interactive delegation | A routine’s delegation |
|---|---|
| A live session proves the user is present | A standing mandate, consented once with step-up and bound to the payload digest |
| The user sees every action as it happens | The mandate limits in advance: action classes, spend ceiling, window |
| Out of scope ⇒ ask on screen | Out of scope ⇒ the fire stops and the question leaves on a channel |
| Revoked ⇒ no further exchange | Revoked ⇒ no further exchange, checked at every fire |
The row that is not in that table is the important one: there is no case in which the routine
proceeds without cover. If the mandate does not cover, it stops. If the delegation is gone, it
suspends.
Granting one
app(RoutineManager::class)->grantMandate(
$routine,
actionClasses: ['order.create'],
budgetCeiling: 500.0,
notAfter: new DateTimeImmutable('+90 days'),
delegationGrantId: $grant->id,
confirmationId: $stepUp->id, // the step-up evidence
aal: 'aal2',
);
The digest of the approved payload is computed here rather than accepted, and that is the
point: if someone edits the payload later, the digest no longer matches and mandateCovers()
returns false. The consent was for that configuration, not for the routine in general — the
same principle as PSD2 dynamic linking, where changing the amount after confirmation invalidates
the confirmation.
An empty actionClasses authorises nothing, not everything. Fail-closed.
Getting the token
RoutineDelegationBroker::tokenFor() is asked before any work. Asking afterwards would mean
discovering you were not authorized to do something you have already done.
It throws rather than returning null, because a null reads as “run as the application” — which
is full authority. When it throws DelegationUnavailable the routine is suspended, not
retried: a backoff does not bring back a consent that was withdrawn.
Running as the application is a choice (no delegation_grant_id, isDelegated() is false).
Declaring a delegation that nobody can issue is a broken configuration, and it suspends. The
default broker throws with a message naming the package to install.
Stopping and asking
public function fire(RoutineExecution $execution): TargetResult
{
if ($amount > ($execution->budgetRemaining ?? INF)) {
return TargetResult::paused(
sprintf('It wants to place an order for %s. The mandate covers up to %s.', …),
pendingApprovalId: $approval->id,
resumeToken: 'step-3',
metadata: ['action_class' => 'order.create', 'amount' => $amount],
);
}
…
}
Throwing MandateExceeded works too and the core converts it — that is the most natural mistake to
make when writing a target, and treating it as an ordinary failure would be the worst outcome: the
routine would give up silently instead of asking.
The question is already written text. It is the evidence of what a person approved, so it is
not recomposed downstream: a text rebuilt by the panel or by a channel could diverge from the one
approved, and at that point the approval proves nothing.
Escalation
RoutineEscalator carries the question out of the application —
laravel-rebel-channels (Telegram, WhatsApp, SMS, voice) is the reference implementation.
It informs; it does not approve. A “yes” written in a chat is not a confirmation, because
anyone holding that phone could have written it. The channel carries the question and the link; the
approval happens where identity is verified.
If delivery fails everywhere, the fire records it (escalation_error) and the question still shows
in the panel. A stopped routine waiting on a question nobody ever received is the worst failure in
the system, and it must be visible on the fire — not only in a log.
Answering
app(RoutineManager::class)->resolve($run, approved: true, resolvedBy: 'user:42', note: 'ok');
app(RoutineManager::class)->resolve($run, approved: false, resolvedBy: 'user:42', note: 'Wrong supplier');
- Approving opens a new attempt with the same idempotency key: for the target it is the
same work, so it resumes instead of repeating what it had already done before stopping. - Rejecting closes it as
skippedwith the reason — notfailed. Nothing broke: someone
decided no, andfailedwould retry it. The reason is mandatory, because someone will read it. - Answering twice does nothing the second time.