Skip to main content

Request-bound Decisions

Preview

The decision API is in Preview: it can change or be removed at any time. Today:

  • A request that needs a person's approval is answered REQUIRE_APPROVAL, with no authorization, and no approval is raised. Nothing is authorized until approvals for decisions exist.
  • Business actions come from the taxonomies Mandaitor has loaded. The procurement taxonomy is not loaded yet, so procurement.* actions are denied with UNKNOWN_ACTION.
  • The SDK's requestDecision, redeemDecision and verifyDecisionAuthorization are in @mandaitor/sdk 5.0.0, which is not published yet.

Beta Limitations lists the rest.

POST /verify answers whether an agent may perform an action on a resource, and the answer holds for any request that matches. The decision API is for an integrator whose own system performs the agent's operation. Its enforcement point asks Mandaitor about the one operation it is about to perform, and gets an authorization for exactly that operation. It redeems the authorization once, immediately before it acts.

The authorization never passes through the agent, so the agent cannot keep it, replay it, or present it elsewhere. If Mandaitor should perform the operation itself, with a credential the agent never holds, use executions instead.

How a decision runs​

  1. Your enforcement point is about to perform an operation for an agent. It reads the record from your own system, never from the agent.
  2. It sends POST /decisions with the tenant's API key. The request names the agent by its DID, the business action, the one resource, and the facts it read.
  3. Mandaitor decides as it decides an execution, against every mandate that names the agent, and records the decision on the audit trail.
  4. An ALLOW comes with an authorization: a signed token for this request, this enforcement point and five minutes at most.
  5. Your enforcement point redeems the authorization with POST /decisions/{id}/redemption, sending the digest of the operation it is about to perform. Mandaitor checks the digest, that the authorization is unspent and in time, and that the agent and the mandate are as they were. It then spends the authorization.
  6. Only if the redemption succeeds does your system perform the operation.

The mandate​

A decision counts the same mandates an execution does: every ACTIVE mandate in the tenant whose delegate is the agent. A mandate backs a decision only when all of these hold:

  • A person created it, or approved it.
  • It names the business action exactly.
  • Its resources cover the resource.
  • Its constraints use only the keys an execution reads.

A mandate written for executions lists those keys and what each does. A DENY mandate that covers the request wins, whoever made it.

A mandate that limits amounts needs an amount fact. Its max_amount, cumulative limits and amount_above are read from that fact.

Asking for a decision​

import { MandaitorClient } from "@mandaitor/sdk";

const client = new MandaitorClient({ apiKey, tenantId });

const request = {
agent: "did:web:agents.mandaitor.io:agt_01JZ8Y3S6N0F4K7Q2W9E5R1T8V",
action: "procurement.requisition.release",
resource: "procurement:plant:1000/requisition:10004711",
// Read from your own system of record, never from the agent.
facts: { plant: "1000", amount: { currency: "EUR", value: "1250.00" } },
audience: "https://erp.example.com", // your enforcement point
};

const decision = await client.requestDecision(request);
if (decision.decision !== "ALLOW") {
throw new Error(`${decision.code}: ${decision.reason}`);
}

Without the SDK, send the request as JSON with "contract": "mandaitor.decision-request/v1":

FieldMeaning
agentThe agent you act for, by its DID.
actionA business action a taxonomy defines, such as procurement.requisition.release.
resourceThe one record the operation acts on, such as procurement:plant:1000/requisition:10004711. No * and no {placeholder}: a request never names more than one record.
factsWhat you read from your system, by name. Up to 32 of them. A value is a string, an integer, a boolean, or money, written as { "currency": "EUR", "value": "1250.00" }. amount, when you send it, is money.
audienceYour enforcement point, as it names itself. The authorization is for it alone.
mandate_idOptional. The mandate you believe covers the operation. A hint only.
purposeOptional. Recorded with the decision, never used to decide.

A key Mandaitor does not know is refused with 400 INVALID_REQUEST.

The API key needs the verify scope, as for POST /verify. A person's session with the verify:execute permission may ask too.

The answer​

Every decision is answered 200, an ALLOW or not:

{
"contract": "mandaitor.decision/v1",
"decision": "ALLOW",
"code": "ALLOWED",
"reason": "Mandate mdt_01J9PROCUREMENT covers this call, and its constraints hold on the record",
"request_digest": "sha-256:TVGNlLrTD9Tmch5o2UF3jt52VI36BeKuytYMlfuLHOs",
"event_id": "evt_01JZ8Y3S6N0F4K7Q2W9E5R1T8X",
"decided_at": "2026-10-13T12:00:30.000Z",
"mandate_id": "mdt_01J9PROCUREMENT",
"decision_id": "dec_01JZ8Y3S6N0F4K7Q2W9E5R1T8W",
"authorization": "eyJhbGciOiJQUzI1NiIsInR5cCI6Im1hbmRhaXRvci1hdXRob3JpemF0aW9uK2p3dCIs…",
"expires_at": "2026-10-13T12:05:30.000Z"
}
  • ALLOW carries authorization, its decision_id and its expires_at: five minutes, never past the mandate's own expiry.
  • DENY carries no authorization. code says why: NO_COVERING_MANDATE, DENIED_BY_MANDATE, AMOUNT_ABOVE_LIMIT, CUMULATIVE_LIMIT_EXCEEDED, FACT_UNAVAILABLE (with fact), UNSUPPORTED_CONSTRAINT (with constraints), UNKNOWN_ACTION, AGENT_UNKNOWN, AGENT_NOT_ACTIVE, and the others the API reference lists. An agent of another tenant is answered as an agent that does not exist.
  • REQUIRE_APPROVAL carries no authorization either. approval_rules says why a person would have to approve: AMOUNT_ABOVE_THRESHOLD, REQUIRE_MFA or ACTION_REQUIRES_APPROVAL. In the Preview, no approval is raised, so treat it as a refusal.

When the decision cannot be recorded on the audit trail, none is given: 503 AUDIT_UNAVAILABLE. Retry after Retry-After.

Redeeming the authorization​

Redeem the authorization immediately before you act, with the digest of the operation you are about to perform:

// The SDK computes the digest from `request`: what you redeem is what you are about to do.
await client.redeemDecision(decision.decision_id!, request);
await releaseRequisition("10004711"); // act once

Without the SDK, compute the digest yourself and send it. Never copy it from the answer:

POST /decisions/dec_01JZ8Y3S6N0F4K7Q2W9E5R1T8W/redemption
Content-Type: application/json

{ "request_digest": "sha-256:TVGNlLrTD9Tmch5o2UF3jt52VI36BeKuytYMlfuLHOs" }

A 200 means act now: the authorization is spent. Anything else means do not act:

StatuserrorMeaning
409ALREADY_REDEEMEDIt was redeemed before. It succeeds once.
409DIGEST_MISMATCHIt was given for another request: another amount, another record, another audience.
409AUTHORIZATION_EXPIREDIts five minutes have passed.
409AGENT_NOT_ACTIVEThe agent was suspended or revoked after the decision, even if it is active again.
409MANDATE_NOT_ACTIVEThe mandate it rests on was revoked or suspended after the decision, even if it is active again.
404DECISION_NOT_FOUNDNo authorization of your tenant has this id.
503AUDIT_UNAVAILABLEThe redemption could not be recorded, so it was taken back. Retry.

If the answer to a redemption is lost, a retry is answered ALREADY_REDEEMED when the first attempt went through. Do not act on it: ask for a new decision.

A redemption does not decide again. A DENY mandate created after the decision does not stop it; suspending the agent or revoking the mandate does.

The authorization​

The authorization is a compact JWS, typ mandaitor-authorization+jwt, signed PS256. The key is the one Mandaitor's DID document lists as #authorization-key-1, used for nothing else. Its claims:

ClaimValue
issMandaitor's DID, such as did:web:api.mandaitor.io:v1
audYour audience
subThe agent's DID
iat, expWhen it was given, and when it expires
jtiThe decision_id
contractmandaitor.decision-authorization/v1
tenant_id, request_digest, action, resourceWhat it authorizes
mandate_id, event_idThe mandate it rests on, and the decision's event on the audit trail

When the component that acts is not the one that asked, verify the authorization before you redeem it:

import { verifyAuthorizationForRequest } from "@mandaitor/sdk";

const result = await verifyAuthorizationForRequest(decision, tenantId, request, {
allowedIssuers: ["did:web:api.mandaitor.io:v1"],
});
if (!result.valid) throw new Error(result.errors.join("; "));

It checks the signature against the issuer's DID document, the audience, the expiry, and the digest of request. A Proof-of-Mandate or an execution receipt is never accepted as an authorization, nor an authorization as either of them. Verifying is not redeeming: act only after the redemption succeeds.

The request digest​

The digest is sha-256: and the unpadded base64url SHA-256 of the RFC 8785 form of this object:

{
"contract": "mandaitor.decision-request/v1",
"tenant_id": "<your tenant id>",
"agent": "…",
"action": "…",
"resource": "…",
"facts": { "…": "…" },
"audience": "…"
}

mandate_id and purpose are not part of it. RFC 8785 sorts the keys at every level and writes no whitespace. For tenant tnt_test and the request above, the text hashed is this, and its digest is the request_digest in the answer above:

{"action":"procurement.requisition.release","agent":"did:web:agents.mandaitor.io:agt_01JZ8Y3S6N0F4K7Q2W9E5R1T8V","audience":"https://erp.example.com","contract":"mandaitor.decision-request/v1","facts":{"amount":{"currency":"EUR","value":"1250.00"},"plant":"1000"},"resource":"procurement:plant:1000/requisition:10004711","tenant_id":"tnt_test"}

The SDK computes it as decisionRequestDigest(tenantId, request).

Limits and usage​

  • Cumulative limits are charged when the authorization is given. An ALLOW counts against the mandate's max_total and max_count, on the same counters as its executions. It keeps counting whether or not it is redeemed: Mandaitor cannot see whether you acted. Two decisions, or a decision and an execution, never both take the last of a limit.
  • Usage. Each decision counts 2 units against the monthly allowance, an ALLOW or not, as a POST /verify with a Proof-of-Mandate does. Redemptions are not counted. See Rate Limiting.
  • Rate limits. Your tenant's plan and the platform limit apply. There is no separate limit for the decision routes.

The audit trail​

Every answer and every redemption is recorded before it is given, on the chain of the mandate concerned:

EventWhen
DECISION_ALLOWED, DECISION_DENIED, DECISION_APPROVAL_REQUIREDA decision, with the request's facts and its digest
DECISION_REDEEMEDA redemption that succeeded
DECISION_REDEMPTION_REFUSEDA redemption that was refused, with its error

The actor is your API key's service identity, and the key's id is in the details.