The decision contract
This page is the precise data contract between the client, the cache, and the PDP. Everything here is what the
code actually emits and reads — use it when integrating, debugging, or writing a custom transport.
DecisionRequest
The normalized query the client builds from ($user, $ability, $context).
final readonly class DecisionRequest {
public function __construct(
public string $permission,
public string $subjectId,
public string $subjectType = 'user',
public ?string $organization = null,
public ?string $application = null,
public ?string $resource = null,
public array $context = [], // ABAC facts
public string $currentAal = 'aal1',
public bool $explain = false,
) {}
}
toArray() — the wire body
{
"subject": { "type": "user", "id": "42" },
"permission": "billing:invoices.update",
"organization": "org_acme",
"application": "billing",
"resource": "inv_1001",
"context": { "amount": 300 },
"current_aal": "aal1",
"explain": false
}
This same array is used two ways:
local— passed toAuthorizationEngine::check($array)in-process.http— JSON body ofPOST {base}/decisions/check.
cacheKey()
A SHA-256 over subjectType, subjectId, permission, organization, application, resource, context, currentAal
— every input that can change the verdict. (Note explain is not in the key, because explained requests
bypass the cache entirely.)
IamDecision
The normalized outcome.
final readonly class IamDecision {
public function __construct(
public bool $allowed,
public string $decisionId = '',
public int $policyVersion = 0,
public bool $requiresStepUp = false,
public ?string $requiredAal = null,
public array $explanation = [], // list<string>
) {}
public static function deny(string $reason): self;
public static function fromArray(array $data): self;
public function granted(): bool; // allowed && !requiresStepUp
public function toArray(): array;
}
fromArray() — reading the PDP response
fromArray() is defensive: every field is type-checked and falls back to a safe default. The expected
(snake_case) keys:
| Response key | Maps to | Fallback |
|---|---|---|
allowed |
allowed (=== true) |
false |
decision_id |
decisionId |
'' |
policy_version |
policyVersion |
0 |
requires_step_up |
requiresStepUp (=== true) |
false |
required_aal |
requiredAal |
null |
explanation |
explanation (strings only) |
[] |
Because allowed defaults to false, a malformed or partial response can never accidentally grant — it
decays to a deny. This is the fail-closed property at the parsing layer.
toArray() — what the cache stores
{
"allowed": true,
"granted": false,
"decision_id": "dec_abc",
"policy_version": 7,
"requires_step_up": true,
"required_aal": "aal2",
"explanation": ["role billing:operator grants invoices.update", "step-up required for delete"]
}
The CachingDecider stores this array and rehydrates it with fromArray() on a
hit. (granted is included for readability; on rehydrate it’s recomputed from allowed and
requires_step_up.)
The { "data": ... } envelope
The server’s Admin API wraps every response in an envelope:
{ "data": { "allowed": true, "requires_step_up": false, "decision_id": "dec_abc", "...": "..." } }
HttpDecider unwraps it transparently: if the decoded body has an array data key, it reads from there;
otherwise it reads the body as-is (so a flat body — e.g. a local PDP behind a proxy — still works). Without
this unwrap, fromArray() would read the wrong level and every decision would silently become a deny.
Endpoint
The decision endpoint is POST {base}/decisions/check (slash). base is your versioned API root, e.g.
https://iam.example.com/api/iam/v1, so the full URL is …/api/iam/v1/decisions/check. The companion
list endpoint is …/decisions/list-resources.