Approval policy
The decision table that says who may post, approve or release what.
| Operation | Summary | Scope | Idempotency-Key |
|---|---|---|---|
GET /v1/approval-policy |
Get the approval policy | read |
|
PUT /v1/approval-policy |
Replace the approval policy | admin |
|
DELETE /v1/approval-policy |
Reset the approval policy | admin |
|
GET /v1/approval-policy/versions |
List policy versions | read |
GET /v1/approval-policy
Get the approval policy
The approval policy in force: a decision table over actor type, action, amount and
source whose first matching rule decides allow, require_approval or deny.
source says whether it is the default table or the organization's own; version is 0
until the first change. The response carries an ETag header (e.g. "policy-3"): send it
as If-Match with PUT or DELETE to change the policy only if nobody else did in the
meantime. actions lists every action the services ask about, agents_always_denied
those no table may allow agents.
| Required scope | read (admin implies every scope) |
| Response header | ETag names the current version |
| Operation id | get_policy_v1_approval_policy_get |
Example request
curl "$ERP_URL/v1/approval-policy" \
-H "Authorization: Bearer $ERP_KEY"
Responses
| Status | Meaning | Body |
|---|---|---|
| 200 | OK | ApprovalPolicyOut, ETag header |
| 401 | Missing or invalid API key (unauthorized). |
ErrorEnvelope |
| 403 | The caller lacks the required scope (missing_right, with scope), the key belongs to no person where a person is needed (forbidden), or the approval policy denies the action (policy_denied, with action and rule_id). |
ErrorEnvelope |
| 422 | Validation failed: the error envelope (validation_failed). For a malformed body or parameter its message is in sentences ("Line 2: the quantity must be above zero") and fields lists each error with pydantic's loc, type and msg beside its sentence (message) and its parts, to word it in another language: items (name and number from 1: line 2), field (its name as sent) and the rule it breaks with its limit, count and choices (none for a validator's own sentence); FastAPI's own detail list stays beside the envelope. |
HTTPValidationError |
Example response (shape)
{
"source": "default",
"version": 1,
"updated_at": "2026-09-21T09:30:00Z",
"updated_by": {},
"comment": "string",
"actions": [
"string"
],
"agents_always_denied": [
"string"
],
"table": {
"published_at": "2026-09-21",
"updated_at": "2026-09-21",
"sources": [
{
"authority": null,
"url": null,
"reference": null,
"verified_at": null,
"kind": null
}
],
"id": "string",
"name": "string",
"description": "string",
"domain": "string",
"hit_policy": "first",
"inputs": [
{
"name": null,
"type": null,
"description": null,
"values": null,
"required": null,
"default": null
}
],
"outputs": [
{
"name": null,
"description": null
}
],
"rules": [
{
"id": null,
"when": null,
"then": null,
"reference": null,
"note": null
}
],
"limitations": [
"string"
]
},
"pending_change": {
"approval_id": "string",
"requested_by": {},
"requested_at": "2026-09-21T09:30:00Z",
"comment": "string",
"source": "default",
"relaxed": [
"string"
],
"relaxed_count": 1
}
}
PUT /v1/approval-policy
Replace the approval policy
Replace the organization's policy with a decision table. Only a human with the admin scope may do this (403 otherwise).
The table is validated (422 with the list of problems): it must declare the four inputs
actor_type, action, amount and source (optionally actor_name, a system process's
name or else the actor id, never required and tested only with a literal or in) and a
decision output, list every action,
and deny agents every action but post, whatever the rules say. Each change is a new
version and an audit event. Send the ETag you read in If-Match: when the policy has
moved on, the answer is 412 and nothing changes. The response carries the new ETag.
A change that relaxes any decision (deny to require_approval or allow, or
require_approval to allow, for any actor type, action, amount or source) does not apply
yet: it becomes an approval request (change_approval_policy) that a second person with
the admin scope approves under Approvals, never the one who asked and never an agent. The
response keeps the policy in force and shows the request in pending_change, with what it
relaxes. One such change waits at a time (409 approval_policy_change_waiting), until it
is decided or the person who asked withdraws it (POST /v1/approvals/{approval_id}/withdraw;
an administrator whose colleagues are not administrators has nobody to approve it). A change
that only tightens applies at once, and so does any change in an organization with fewer
than two active people, unless another user was disabled or lost the admin scope in the
last 30 days (audited with what it relaxes).
| Required scope | admin (admin implies every scope) |
| Idempotency-Key | not used |
| ETag / If-Match | If-Match optional: a changed version answers 412 |
| Response header | ETag names the current version |
| Operation id | put_policy_v1_approval_policy_put |
Parameters
| In | Name | Type | Required | Description |
|---|---|---|---|---|
| header | If-Match |
string or null | The ETag of the version you read ("policy-3", from GET /v1/approval-policy). If the policy has changed since, the call answers 412 and changes nothing. * always matches; omitted, the change is made without the check. (max length 200) |
Request body
application/json (required): PolicyInput
| Field | Type | Required | Description |
|---|---|---|---|
table |
PolicyTable |
yes | A decision table with inputs actor_type, action, amount and source and a decision output (allow, require_approval or deny); first hit wins. Start from GET /v1/approval-policy. |
comment |
string or null | Why the policy changes, up to 1000 characters; kept with the version and in the audit event. (max length 1000) |
Example request
curl -X PUT "$ERP_URL/v1/approval-policy" \
-H "Authorization: Bearer $ERP_KEY" \
-H 'If-Match: "1"' \
-H "Content-Type: application/json" \
-d '{
"comment": "Human postings above 250000 need approval",
"table": {
"description": "Agents may only post; their manual and large postings are reviewed. Humans may post directly, but a posting above 250000 needs a second person.",
"domain": "erp.policy",
"hit_policy": "first",
"id": "org.approval-policy",
"inputs": [
{
"name": "actor_type",
"type": "enum",
"values": [
"human",
"agent",
"system"
]
},
{
"name": "action",
"type": "enum",
"values": [
"post",
"approve",
"close_period",
"reopen_period",
"manage_accounts",
"manage_keys",
"release_payments",
"finalize_vat_return",
"activate_party",
"change_bank_details",
"change_party_details"
]
},
{
"description": "Total debits of the entry in functional currency.",
"name": "amount",
"type": "number"
},
{
"description": "manual, ar, ap, fa, bank, ...",
"name": "source",
"type": "string"
}
],
"name": "Demo AG approval policy",
"outputs": [
{
"description": "allow | require_approval | deny",
"name": "decision"
},
{
"name": "reason"
}
],
"rules": [
{
"id": "agents_only_post",
"then": {
"decision": "deny",
"reason": "Agents may only post."
},
"when": {
"action": {
"not_in": [
"post"
]
},
"actor_type": "agent"
}
},
{
"id": "agent_manual_journals",
"then": {
"decision": "require_approval",
"reason": "Agent journals are reviewed."
},
"when": {
"actor_type": "agent",
"source": "manual"
}
},
{
"id": "agent_large_postings",
"then": {
"decision": "require_approval",
"reason": "Agent postings above 5000."
},
"when": {
"actor_type": "agent",
"amount": {
"gt": 5000
}
}
},
{
"id": "agent_routine_postings",
"then": {
"decision": "allow",
"reason": "Routine subledger posting."
},
"when": {
"actor_type": "agent"
}
},
{
"id": "system_allowed",
"then": {
"decision": "allow",
"reason": "System processes run under their own controls."
},
"when": {
"actor_type": "system"
}
},
{
"id": "new_vendors_need_a_second_person",
"then": {
"decision": "require_approval",
"reason": "A new vendor needs a second person."
},
"when": {
"action": "activate_party",
"actor_type": "human",
"source": "vendor"
}
},
{
"id": "human_large_postings",
"then": {
"decision": "require_approval",
"reason": "Postings above 250000."
},
"when": {
"action": "post",
"actor_type": "human",
"amount": {
"gt": 250000
}
}
},
{
"id": "humans_allowed",
"then": {
"decision": "allow",
"reason": "Human with the required scope."
},
"when": {
"actor_type": "human"
}
}
],
"sources": [
{
"authority": "Demo AG",
"url": "https://example.com/policies/approval"
}
]
}
}'
Responses
| Status | Meaning | Body |
|---|---|---|
| 200 | OK | ApprovalPolicyOut, ETag header |
| 401 | Missing or invalid API key (unauthorized). |
ErrorEnvelope |
| 403 | The caller lacks the required scope (missing_right, with scope), the key belongs to no person where a person is needed (forbidden), or the approval policy denies the action (policy_denied, with action and rule_id). |
ErrorEnvelope |
| 409 | The request conflicts with the data: an Idempotency-Key reused with a different request, a closed period, or an integrity rule of the database (conflict, integrity_violation). |
ErrorEnvelope |
| 412 | If-Match names a version that is no longer current (precondition_failed). |
ErrorEnvelope |
| 422 | Validation failed: the error envelope (validation_failed). For a malformed body or parameter its message is in sentences ("Line 2: the quantity must be above zero") and fields lists each error with pydantic's loc, type and msg beside its sentence (message) and its parts, to word it in another language: items (name and number from 1: line 2), field (its name as sent) and the rule it breaks with its limit, count and choices (none for a validator's own sentence); FastAPI's own detail list stays beside the envelope. |
HTTPValidationError |
Example response (shape)
{
"source": "default",
"version": 1,
"updated_at": "2026-09-21T09:30:00Z",
"updated_by": {},
"comment": "string",
"actions": [
"string"
],
"agents_always_denied": [
"string"
],
"table": {
"published_at": "2026-09-21",
"updated_at": "2026-09-21",
"sources": [
{
"authority": null,
"url": null,
"reference": null,
"verified_at": null,
"kind": null
}
],
"id": "string",
"name": "string",
"description": "string",
"domain": "string",
"hit_policy": "first",
"inputs": [
{
"name": null,
"type": null,
"description": null,
"values": null,
"required": null,
"default": null
}
],
"outputs": [
{
"name": null,
"description": null
}
],
"rules": [
{
"id": null,
"when": null,
"then": null,
"reference": null,
"note": null
}
],
"limitations": [
"string"
]
},
"pending_change": {
"approval_id": "string",
"requested_by": {},
"requested_at": "2026-09-21T09:30:00Z",
"comment": "string",
"source": "default",
"relaxed": [
"string"
],
"relaxed_count": 1
}
}
DELETE /v1/approval-policy
Reset the approval policy
Return to the default policy. Only a human with the admin scope may do this (403
otherwise). It is recorded as a new version, so the history keeps the table that was
replaced. Send the ETag you read in If-Match; a changed policy answers 412. Returns the
default policy with its new ETag. If the default is looser than the organization's table
anywhere, the return waits for a second administrator like any change that relaxes the
policy (pending_change; see PUT).
| Required scope | admin (admin implies every scope) |
| Idempotency-Key | not used |
| ETag / If-Match | If-Match optional: a changed version answers 412 |
| Response header | ETag names the current version |
| Operation id | reset_policy_v1_approval_policy_delete |
Parameters
| In | Name | Type | Required | Description |
|---|---|---|---|---|
| header | If-Match |
string or null | The ETag of the version you read ("policy-3", from GET /v1/approval-policy). If the policy has changed since, the call answers 412 and changes nothing. * always matches; omitted, the change is made without the check. (max length 200) |
Example request
curl -X DELETE "$ERP_URL/v1/approval-policy" \
-H "Authorization: Bearer $ERP_KEY" \
-H 'If-Match: "1"'
Responses
| Status | Meaning | Body |
|---|---|---|
| 200 | OK | ApprovalPolicyOut, ETag header |
| 401 | Missing or invalid API key (unauthorized). |
ErrorEnvelope |
| 403 | The caller lacks the required scope (missing_right, with scope), the key belongs to no person where a person is needed (forbidden), or the approval policy denies the action (policy_denied, with action and rule_id). |
ErrorEnvelope |
| 409 | The request conflicts with the data: an Idempotency-Key reused with a different request, a closed period, or an integrity rule of the database (conflict, integrity_violation). |
ErrorEnvelope |
| 412 | If-Match names a version that is no longer current (precondition_failed). |
ErrorEnvelope |
| 422 | Validation failed: the error envelope (validation_failed). For a malformed body or parameter its message is in sentences ("Line 2: the quantity must be above zero") and fields lists each error with pydantic's loc, type and msg beside its sentence (message) and its parts, to word it in another language: items (name and number from 1: line 2), field (its name as sent) and the rule it breaks with its limit, count and choices (none for a validator's own sentence); FastAPI's own detail list stays beside the envelope. |
HTTPValidationError |
Example response (shape)
{
"source": "default",
"version": 1,
"updated_at": "2026-09-21T09:30:00Z",
"updated_by": {},
"comment": "string",
"actions": [
"string"
],
"agents_always_denied": [
"string"
],
"table": {
"published_at": "2026-09-21",
"updated_at": "2026-09-21",
"sources": [
{
"authority": null,
"url": null,
"reference": null,
"verified_at": null,
"kind": null
}
],
"id": "string",
"name": "string",
"description": "string",
"domain": "string",
"hit_policy": "first",
"inputs": [
{
"name": null,
"type": null,
"description": null,
"values": null,
"required": null,
"default": null
}
],
"outputs": [
{
"name": null,
"description": null
}
],
"rules": [
{
"id": null,
"when": null,
"then": null,
"reference": null,
"note": null
}
],
"limitations": [
"string"
]
},
"pending_change": {
"approval_id": "string",
"requested_by": {},
"requested_at": "2026-09-21T09:30:00Z",
"comment": "string",
"source": "default",
"relaxed": [
"string"
],
"relaxed_count": 1
}
}
GET /v1/approval-policy/versions
List policy versions
Every change to the organization's policy, newest first, with the table of each.
| Required scope | read (admin implies every scope) |
| Operation id | policy_versions_v1_approval_policy_versions_get |
Example request
curl "$ERP_URL/v1/approval-policy/versions" \
-H "Authorization: Bearer $ERP_KEY"
Responses
| Status | Meaning | Body |
|---|---|---|
| 200 | OK | array of PolicyVersionOut |
| 401 | Missing or invalid API key (unauthorized). |
ErrorEnvelope |
| 403 | The caller lacks the required scope (missing_right, with scope), the key belongs to no person where a person is needed (forbidden), or the approval policy denies the action (policy_denied, with action and rule_id). |
ErrorEnvelope |
| 422 | Validation failed: the error envelope (validation_failed). For a malformed body or parameter its message is in sentences ("Line 2: the quantity must be above zero") and fields lists each error with pydantic's loc, type and msg beside its sentence (message) and its parts, to word it in another language: items (name and number from 1: line 2), field (its name as sent) and the rule it breaks with its limit, count and choices (none for a validator's own sentence); FastAPI's own detail list stays beside the envelope. |
HTTPValidationError |
Example response (shape)
[
{
"version": 1,
"source": "default",
"comment": "string",
"created_by": {},
"created_at": "2026-09-21T09:30:00Z",
"table": {
"published_at": null,
"updated_at": null,
"sources": [
null
],
"id": "string",
"name": "string",
"description": "string",
"domain": "string",
"hit_policy": "first",
"inputs": [
null
],
"outputs": [
null
],
"rules": [
null
],
"limitations": [
null
]
}
}
]
Schemas
ApprovalPolicyOut
| Field | Type | Required | Description |
|---|---|---|---|
source |
"default" | "organization" |
yes | default, or the organization's own table. |
version |
integer | yes | 0 until the organization first changes its policy. |
updated_at |
string (date-time) or null | yes | When the current version was made (ISO 8601, UTC); null at version 0. |
updated_by |
object or null | yes | The actor that made it, as an actor object; null at version 0. |
comment |
string or null | yes | The comment given with the current version; null if none. |
actions |
array of string | yes | Every action the services ask the policy about. |
agents_always_denied |
array of string | yes | Actions no organization policy can allow agents. |
table |
PolicyTable |
yes | The decision table in force. |
pending_change |
PendingPolicyChange or null |
A change that relaxes the policy and waits for a second administrator; the table above stays in force until it is approved. Null if none. |
PendingPolicyChange
| Field | Type | Required | Description |
|---|---|---|---|
approval_id |
string | yes | The approval request a second administrator decides, or the person who asked withdraws (POST /v1/approvals/{approval_id}/withdraw). |
requested_by |
object | yes | Who asked for it, as an actor object. |
requested_at |
string (date-time) | yes | When it was asked for (ISO 8601, UTC). |
comment |
string or null | yes | The comment given with the change; null if none. |
source |
"default" | "organization" |
yes | default (a return to the default table) or organization. |
relaxed |
array of string | yes | What it relaxes, at most 20, e.g. human change_posting_limit: require_approval -> allow. |
relaxed_count |
integer | yes | How many decisions it relaxes in all. |
PolicyInput
| Field | Type | Required | Description |
|---|---|---|---|
table |
PolicyTable |
yes | A decision table with inputs actor_type, action, amount and source and a decision output (allow, require_approval or deny); first hit wins. Start from GET /v1/approval-policy. |
comment |
string or null | Why the policy changes, up to 1000 characters; kept with the version and in the audit event. (max length 1000) |
PolicyTable
| Field | Type | Required | Description |
|---|---|---|---|
published_at |
string (date) or null | When this record entered the dataset. | |
updated_at |
string (date) or null | When its content last changed after publication. | |
sources |
array of Source |
yes | Where the rules come from (at least one), e.g. the company's own policy document. (min items 1) |
id |
string | yes | Identifier of the table, e.g. org.approval-policy. |
name |
string | yes | Human-readable name. |
description |
string | yes | What the table decides, in a sentence or two. |
domain |
string | yes | e.g. 'tax.indirect', 'standards.us_gaap'. |
hit_policy |
"collect" | "first" |
first: the first rule whose conditions all match decides (use this for the approval policy). collect: every matching rule is returned. (default "first") |
|
inputs |
array of PolicyTableInput |
yes | The inputs rules can test. An approval policy declares exactly actor_type, action, amount and source, as in GET /v1/approval-policy. |
outputs |
array of PolicyTableOutput |
yes | What a rule yields. An approval policy declares decision and reason. |
rules |
array of PolicyTableRule |
yes | The rules, tried in order. A rule's when maps an input to a literal or an operator map (in, not_in, ne, gt, gte, lt, lte); an input left out matches anything. (min items 1) |
limitations |
array of string | Known gaps of the table, as sentences; may be empty. |
PolicyTableInput
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | yes | Input name; for an approval policy one of actor_type, action, amount, source. |
type |
"boolean" | "enum" | "number" | "string" |
yes | string, boolean, number or enum; an approval policy needs enum for actor_type and action, number for amount, string for source. |
description |
string or null | What the input means; optional. | |
values |
array of string or null | Allowed values for enum inputs. | |
required |
boolean | Whether callers must supply the input; default true. (default true) |
|
default |
decimal string or null | Value used when an optional input is not supplied. |
PolicyTableOutput
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | yes | Output name; an approval policy needs decision (allow, require_approval or deny) and usually reason. |
description |
string or null | What the output means; optional. |
PolicyTableRule
| Field | Type | Required | Description |
|---|---|---|---|
id |
string | yes | Unique id of the rule within the table; returned with each decision as rule_id. |
when |
object | Conditions on the inputs, e.g. {"actor_type": "human", "amount": {"gt": 250000}}. Every condition must hold; an empty object always matches. |
|
then |
object | yes | The outputs when the rule matches, e.g. {"decision": "allow", "reason": "..."}; decision must be allow, require_approval or deny. |
reference |
string or null | Where the rule comes from, e.g. a policy clause; optional. | |
note |
string or null | A remark on the rule; optional. |
PolicyVersionOut
| Field | Type | Required | Description |
|---|---|---|---|
version |
integer | yes | Version number, from 1. |
source |
"default" | "organization" |
yes | organization, or default for a reset to the default policy. |
comment |
string or null | yes | The comment given with this version; null if none. |
created_by |
object | yes | Who made the version, as an actor object. |
created_at |
string (date-time) | yes | When it was made (ISO 8601, UTC). |
table |
PolicyTable or null |
yes | null: the default policy. |
Source
| Field | Type | Required | Description |
|---|---|---|---|
authority |
string | yes | Publisher, e.g. 'European Commission', 'IRS'. |
url |
string (uri) | yes | Address of the source, an http or https URL. (min length 1) |
reference |
string or null | Legal or paragraph reference, e.g. 'UStG §12', 'IAS 16.31'. | |
verified_at |
string (date) or null | Date a maintainer last checked the value against the source. | |
kind |
"official" | "secondary" |
'official' = publisher of the law or data; 'secondary' = professional summary used where the official page could not be checked. (default "official") |