The wire contract
Every Laravel IAM SDK — Rust, Node, React Native — and the canonical PHP client speak the same HTTP
contract to the server. This SDK mirrors the PHP Padosoft\Iam\Client\Deciders\HttpDecider byte-for-byte,
so a request from Rust is indistinguishable from one sent by PHP. This page is the exact specification.
Motivation
A shared, frozen wire format means the server has a single contract to honour and any client can be
swapped for another without server changes. It also means the request body shape is not a place for
creative interpretation: the SDK serializes the documented shape exactly, including fields that are
null.
Endpoints
| Operation | Method + path | SDK method |
|---|---|---|
| Decision check | POST {base_url}/decisions/check |
check() |
| Resource listing | POST {base_url}/decisions/list-resources |
list_resources() |
| JWKS | GET {base_url}/.well-known/jwks.json |
(internal, for verify_token()) |
base_url is the versioned API root, e.g. https://iam.example.com/api/iam/v1. A trailing slash is
trimmed.
Slash, not colon. The real server route is decisions/check (slash form), defined in the server’s
routes/admin.php / resources/openapi.yaml. SDK v1.0.0 shipped the colon form decisions:check, which
never matched the route and always 404’d → denied. v1.0.1 fixed this to the slash form. If you are
on 1.0.0, upgrade.
Request headers
| Header | Value |
|---|---|
Accept |
application/json |
Authorization |
Bearer <service token> — only when a token is configured |
Request body — decisions/check
Serialized verbatim from DecisionQuery, matching PHP DecisionRequest::toArray():
{
"subject": { "type": "user", "id": "usr_123" },
"permission": "stock.adjust",
"organization": null,
"application": "warehouse",
"resource": "wh_milan",
"context": { "amount": 300 },
"current_aal": "aal1",
"explain": false
}
Notes that the SDK enforces and that its tests assert exactly:
subjectis{ "type", "id" }— the field istypeon the wire (the Rust field iskind, renamed via
serde).resourceis a plain string, not an object.organizationis present even whennull(serde does not skip it).current_aaldefaults to"aal1";explaindefaults tofalse.
Response body — decisions/check
{
"allowed": true,
"decision_id": "dec_1",
"policy_version": 7,
"requires_step_up": false,
"required_aal": null,
"explanation": ["role grants stock.adjust"]
}
Parsed into Decision with the same defensive rules as PHP IamDecision::fromArray:
| Rule | Effect |
|---|---|
| body is not a JSON object | IamError::Malformed → deny |
allowed missing or not boolean true |
allowed = false (deny) |
policy_version missing/wrong type |
0 |
decision_id missing |
"" |
explanation missing/not an array of strings |
[] |
| any other field wrong-typed | its safe default |
Request / response — decisions/list-resources
Request:
{ "subject": { "type": "user", "id": "usr_123" }, "relation": "viewer" }
Response — either envelope is accepted:
{ "resources": [ { "type": "warehouse", "id": "wh_milan" } ] }
[ { "type": "warehouse", "id": "wh_milan" } ]
Each item parses into a Resource ({ kind, id }, kind ↔ type).
HTTP status mapping
Applied before any body parsing, identical for both POST endpoints:
| Status | Result |
|---|---|
200–299 |
parse the body |
401, 403 |
IamError::Unauthorized(status) |
| any other non-2xx | IamError::Http(status) |
This mirrors the PHP client, which denies on every non-2xx.
The { "data": ... } envelope
The server wraps some responses in { "data": {...} }. The decision parser reads the decision fields
defensively from the object it is given; align your server/proxy so the decision object is what reaches
the client (as the PHP client and the other SDKs expect). All SDKs are kept consistent on this point.
Why mirror PHP exactly
ADR-0003 — Byte-compatible with the PHP client
Problem. Multiple SDKs must interoperate with one server.
Decision. Treat the PHP HttpDecider request/response shapes as the canonical contract and mirror
them exactly in Rust — same field names, same nulls, same defensive parsing, same status mapping.
Consequences.
- ✅ Any SDK is a drop-in for any other; the server has one contract.
- ✅ Cross-SDK tests can share fixtures.
- ⚠️ The Rust types carry some PHP-isms (e.g.
resourceas a string,kindserialized astype) — a
small price for interoperability, documented here and in Types.
See also: Types, Checking decisions,
Error taxonomy.