Signed action receipts
An audit log is ours. It answers the admin’s question — what happened on this
platform? — and it answers it well: hash-chained, tamper-evident, complete.
It does not answer the user’s question, which is what did my agents do, and can I
show that to someone who does not trust your database?
A receipt does. It is a compact JWS, signed with the same ES256 key as the access
tokens and verifiable against the same public JWKS, stating:
agent
agent:01J8…, acting foruser:42under grantdgr_01J9…, asserts it
performedorders.createonorder:9001at2026-08-26T00:41:12Z, with
outcomeok, under PDP decisiondec_01J9….
What the signature attests — and what it does not
The issuer vouches for the identity binding: that the actor really was that
agent, really was acting for that user, really under that grant, and that the grant
was live at that moment.
It does not vouch for the truth of the action. That part is the actor’s
assertion.
This distinction is not pedantry. Without it someone will read a receipt as proof
the order shipped. What a receipt proves is that that agent said so, in a document
it cannot repudiate — which makes it evidence against an agent that lies, not a
way to frame one.
Who can mint one
Only the holder of a valid delegated token for that grant.
POST /iam/agent/receipts
Authorization: Bearer <delegated access token>
{ "action": "orders.create", "resource": "order:9001", "outcome": "ok",
"decision_id": "dec_01J9…", "idempotency_key": "req-7731" }
sub, act and pds_dgr are copied from the verified token, never from the
body. The body says only what was done. So an agent cannot sign for another
agent, cannot sign for a user who never delegated to it, and cannot invent the
grant it acted under.
Five refusals, each a test in the shipped suite:
| No token, or a token this issuer did not sign | refused |
A plain user token (no act) |
refused — it would make a user sign as if they were an agent |
| The grant is revoked or expired | refused — otherwise an agent just cut off could backdate its own history, exactly when it most wants to |
| The agent is suspended or retired | refused |
| Delegation is frozen | refused — signing is an action |
The client always gets the same generic receipt_not_issued. Telling an agent
why it could not sign teaches it how to try better; the detailed reason goes to
the delegation audit stream, where it is useful.
The user’s timeline
GET /iam/me/delegations/receipts
Every row carries its own JWS, so the user can export it and have it verified by
anyone with the public JWKS — without asking us for anything. That is the whole
difference between an audit (ours, for admins) and a receipt (theirs).
Two halves, on purpose
A receipt is stored as the JWS and a digest of its canonical claims.
- The JWS is the portable half. It travels, it verifies anywhere.
- The digest is the durable half. It is sealed into the tamper-evident audit
chain, so the receipt remains probative after the signing key leaves the JWKS
through rotation — which, on a horizon of years, it will.
exp is set ten years out and is a formality: evidence does not expire, but the
TokenSigner contract requires an expiry. What actually bounds external
verification is key rotation. If you need decade-scale third-party verification,
archive your historical JWKS.
Not an access token
A receipt is a JWT signed by the same issuer, with sub = the user — which is
exactly the shape a careless resource server might accept as authority. So every
receipt carries a dedicated audience that no resource registers:
aud: urn:padosoft:iam:delegation-receipt
pds_att: actor
Any resource server that validates aud — the posture already required for
delegated tokens — rejects it. verify() checks both, so a delegated access token
handed to it as “a receipt” is refused rather than parsed.
Receipts carry no action parameters. A receipt ends up in a user timeline, an
export, possibly a dispute; call arguments are where PII hides. action and
resource say what happened, and the detail stays in the audit, where GDPR
crypto-shredding already exists.
Configuration
'receipts' => [
'enabled' => env('IAM_AGENTS_RECEIPTS_ENABLED', true),
'rate_limit' => env('IAM_AGENTS_RECEIPTS_RATE_LIMIT', '120,1'),
'ttl_seconds' => env('IAM_AGENTS_RECEIPTS_TTL', 315360000),
],
On by default: minting still requires a valid delegated token and a live grant, so
the surface does not exist until a delegation does.
Verifying one yourself
use Padosoft\Iam\Agents\Receipts\DelegationReceiptService;
$receipt = app(DelegationReceiptService::class)->verify($jws);
$receipt->subject; // user:42
$receipt->agent; // agent:01J8…
$receipt->action; // orders.create
$receipt->outcome; // ok
Outside the platform, verify the JWS against /.well-known/jwks.json with any JOSE
library, then check aud and pds_att exactly as above.