Change the approval policy
The approval policy decides, for every posting and control action, whether the actor may do it
directly, needs a second person, or is refused. It is a decision table that each organization can
replace; every change is a new version, read and written with an ETag and If-Match so that two
administrators never overwrite each other. A change that relaxes any decision waits for a second
administrator. This page reads the policy, replaces it with a shorter table that sends large
manual entries to a second person, has the change approved, shows the effect and resets the policy.
Before you start
- Reading needs
read; changing needs a human key withadmin. The page uses the demo organization's first key ($ERP_KEY) for that,$ALICE_KEY, a person with the admin scope, to approve a change that relaxes the policy, and$BOB_KEY, the key of a second person, to approve an entry (see getting started). - A change that relaxes any decision (
denytorequire_approvalorallow, orrequire_approvaltoallow, for any actor, action, amount or source) does not apply at once: it waits for a second person with the admin scope, never the one who asked and never an agent. 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. - The table has the four inputs (
actor_type,action,amount,source), optionally a fifth,actor_name(a system process's name, such asclosefor the scheduled close job; else the actor id; declared withrequired: false, and tested only with a literal orin), and adecisionoutput (allow,require_approval,deny). The first matching rule decides; no match denies. Whatever the table says, agents stay denied every action butpost: the server refuses a table that lets them do more.
1. Read the policy and its ETag
GET returns the table in force and its version in the ETag header ("policy-0" until the
organization first changes it). The page stores it as $ETAG.
curl "$ERP_URL/v1/approval-policy" -H "Authorization: Bearer $ERP_KEY"
{"source": "default", "version": 0, "comment": null, "table": {"id": "erp.policy.default"}}
2. Replace it
The new table is short: agents only post and are reviewed, the system's processes are allowed,
and a person's manual entry above 10,000 needs approval. If-Match names the version this change
is based on.
curl -X PUT "$ERP_URL/v1/approval-policy" \
-H "Authorization: Bearer $ERP_KEY" \
-H 'If-Match: $ETAG' \
-H "Content-Type: application/json" \
-d '{"comment": "Four eyes on large manual entries", "table": {
"id": "acme.policy.v1", "name": "Acme approval policy",
"description": "The default rules, with four eyes on large manual entries.", "domain": "erp.policy",
"hit_policy": "first",
"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", "release_own_payments", "finalize_own_vat_return", "approve_purchase_order", "approve_match_variance", "record_receipt", "send_document", "approve_ic_relationship", "approve_ic_agreement", "accept_own_ic_document", "approve_group_structure", "approve_consolidation_adjustment", "finalize_consolidation", "finalize_own_consolidation", "activate_revenue_contract", "satisfy_obligation", "activate_recurring_invoice", "release_dunning", "close_fiscal_year", "change_required_account_role", "change_company_bank_details", "reverse_entry", "approve_plan", "approve_own_plan", "manage_close", "set_ssp", "approve_subscription", "change_asset_value", "change_posting_limit", "close_support_case", "send_support_reply", "record_quote_answer", "erase_personal_data"]},
{"name": "amount", "type": "number"},
{"name": "source", "type": "string"}
],
"outputs": [{"name": "decision"}, {"name": "reason"}],
"rules": [
{"id": "agents_human_only", "when": {"actor_type": "agent", "action": {"in": ["approve", "close_period", "reopen_period", "manage_accounts", "manage_keys", "release_payments", "finalize_vat_return", "activate_party", "change_bank_details", "change_party_details", "release_own_payments", "finalize_own_vat_return", "approve_purchase_order", "approve_match_variance", "record_receipt", "send_document", "approve_ic_relationship", "approve_ic_agreement", "accept_own_ic_document", "approve_group_structure", "approve_consolidation_adjustment", "finalize_consolidation", "finalize_own_consolidation", "activate_revenue_contract", "satisfy_obligation", "activate_recurring_invoice", "release_dunning", "close_fiscal_year", "change_required_account_role", "change_company_bank_details", "reverse_entry", "approve_plan", "approve_own_plan", "manage_close", "set_ssp", "approve_subscription", "change_asset_value", "change_posting_limit", "close_support_case", "send_support_reply", "record_quote_answer", "erase_personal_data"]}},
"then": {"decision": "deny", "reason": "Reserved for humans."}},
{"id": "agents_review", "when": {"actor_type": "agent", "action": "post"},
"then": {"decision": "require_approval", "reason": "Agent postings are reviewed."}},
{"id": "system_allowed", "when": {"actor_type": "system"},
"then": {"decision": "allow", "reason": "System processes."}},
{"id": "payment_creator_does_not_release", "when": {"actor_type": "human", "action": "release_own_payments"},
"then": {"decision": "deny", "reason": "A second person releases the payment file."}},
{"id": "vat_preparer_does_not_finalise", "when": {"actor_type": "human", "action": "finalize_own_vat_return"},
"then": {"decision": "deny", "reason": "A second person finalises the VAT return."}},
{"id": "purchase_orders_need_a_second_person", "when": {"actor_type": "human", "action": "approve_purchase_order"},
"then": {"decision": "require_approval", "reason": "A purchase order is approved by a second person."}},
{"id": "human_large_manual", "when": {"actor_type": "human", "action": "post", "source": "manual", "amount": {"gt": 10000}},
"then": {"decision": "require_approval", "reason": "Manual entries above 10,000."}},
{"id": "humans_allowed", "when": {"actor_type": "human"},
"then": {"decision": "allow", "reason": "Human with the required scope."}}
],
"sources": [{"authority": "Acme AG", "url": "https://example.com/policy", "reference": "Approval policy v1"}]
}}'
{
"source": "default",
"version": 0,
"table": {"id": "erp.policy.default"},
"pending_change": {
"approval_id": "<uuid>",
"source": "organization",
"comment": "Four eyes on large manual entries",
"relaxed": ["<any>", "..."],
"relaxed_count": "<any>"
}
}
The table leaves out the default's rules that send a new vendor, a change of the company's bank
details, raising the posting approval limit and more to a second person, so it relaxes those
decisions. The default stays in force; pending_change lists what the table relaxes (for example
human change_company_bank_details: require_approval -> allow), and the request waits under
Approvals (action change_approval_policy), audited as approval_policy.request. While it
waits, another change that relaxes the policy answers 409 approval_policy_change_waiting.
Alice, a second person with the admin scope, approves it. The first key, which asked, could not.
curl -X POST "$ERP_URL/v1/approvals/$POLICY_CHANGE/approve" \
-H "Authorization: Bearer $ALICE_KEY"
{"status": "approved", "object_type": "approval_policy", "action": "change_approval_policy"}
The table is in force now as version 1, made by the key that asked; the change is audited as
approval_policy.update with the request, and the previous table stays in the version history.
The page stores the new ETag ("policy-1") as $ETAG2.
curl "$ERP_URL/v1/approval-policy" -H "Authorization: Bearer $ERP_KEY"
{"source": "organization", "version": 1, "comment": "Four eyes on large manual entries", "table": {"id": "acme.policy.v1"}, "pending_change": null}
3. A stale ETag is refused
Another administrator read version 0 and tries to save a change of their own. The version moved on,
so the request is refused with 412 and names the current version; nothing is overwritten. The
client reads the policy again, merges and retries with the new ETag.
curl -X PUT "$ERP_URL/v1/approval-policy" \
-H "Authorization: Bearer $ERP_KEY" \
-H 'If-Match: $ETAG' \
-H "Content-Type: application/json" \
-d '{"comment": "A competing change", "table": {
"id": "acme.policy.v2", "name": "Acme approval policy",
"description": "The default rules, with four eyes on large manual entries.", "domain": "erp.policy",
"hit_policy": "first",
"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", "release_own_payments", "finalize_own_vat_return", "approve_purchase_order", "approve_match_variance", "record_receipt", "send_document", "approve_ic_relationship", "approve_ic_agreement", "accept_own_ic_document", "approve_group_structure", "approve_consolidation_adjustment", "finalize_consolidation", "finalize_own_consolidation", "activate_revenue_contract", "satisfy_obligation", "activate_recurring_invoice", "release_dunning", "close_fiscal_year", "change_required_account_role", "change_company_bank_details", "reverse_entry", "approve_plan", "approve_own_plan", "manage_close", "set_ssp", "approve_subscription", "change_asset_value", "change_posting_limit", "close_support_case", "send_support_reply", "record_quote_answer", "erase_personal_data"]},
{"name": "amount", "type": "number"},
{"name": "source", "type": "string"}
],
"outputs": [{"name": "decision"}, {"name": "reason"}],
"rules": [
{"id": "agents_human_only", "when": {"actor_type": "agent", "action": {"in": ["approve", "close_period", "reopen_period", "manage_accounts", "manage_keys", "release_payments", "finalize_vat_return", "activate_party", "change_bank_details", "change_party_details", "release_own_payments", "finalize_own_vat_return", "approve_purchase_order", "approve_match_variance", "record_receipt", "send_document", "approve_ic_relationship", "approve_ic_agreement", "accept_own_ic_document", "approve_group_structure", "approve_consolidation_adjustment", "finalize_consolidation", "finalize_own_consolidation", "activate_revenue_contract", "satisfy_obligation", "activate_recurring_invoice", "release_dunning", "close_fiscal_year", "change_required_account_role", "change_company_bank_details", "reverse_entry", "approve_plan", "approve_own_plan", "manage_close", "set_ssp", "approve_subscription", "change_asset_value", "change_posting_limit", "close_support_case", "send_support_reply", "record_quote_answer", "erase_personal_data"]}},
"then": {"decision": "deny", "reason": "Reserved for humans."}},
{"id": "humans_allowed", "when": {"actor_type": "human"},
"then": {"decision": "allow", "reason": "Human with the required scope."}}
],
"sources": [{"authority": "Acme AG", "url": "https://example.com/policy", "reference": "Approval policy v2"}]
}}'
{"error": {"code": "precondition_failed", "current_version": 1}}
4. The new rule at work
The admin key posts a manual entry above 10,000. The policy does not allow it directly any more;
the entry waits for a second person and result.policy names the rule.
curl -X POST "$ERP_URL/v1/journal-entries" \
-H "Authorization: Bearer $ERP_KEY" \
-H "Idempotency-Key: pol-large-1" \
-H "Content-Type: application/json" \
-d '{
"entity": "CH01",
"entry_date": "2026-09-15",
"description": "Prepayment of annual rent",
"lines": [
{"account": "6000", "debit": "12000.00"},
{"account": "1000", "credit": "12000.00"}
]
}'
{
"result": {
"status": "pending_approval",
"policy": {"decision": "require_approval", "rule_id": "human_large_manual"},
"approval_id": "<uuid>"
},
"entry": {"status": "pending_approval"}
}
A second person with the approve scope decides: Bob ($BOB_KEY, a key of a person). The
organization's first key ($ERP_KEY) belongs to nobody, so it cannot decide.
curl -X POST "$ERP_URL/v1/approvals/$APPROVAL_ID/approve" \
-H "Authorization: Bearer $ERP_KEY"
{"error": {"code": "forbidden"}}
curl -X POST "$ERP_URL/v1/approvals/$APPROVAL_ID/approve" \
-H "Authorization: Bearer $BOB_KEY"
{"status": "approved"}
5. What a policy cannot do
A table that does not list every action (GET /v1/approval-policy shows them in actions; each release that adds one extends the list), does not have the four inputs (with at most actor_name besides), or that would let an agent do more than post, is
refused with 422 and the list of problems. The same refusal applies to any change by an
agent key (no admin scope).
curl -X PUT "$ERP_URL/v1/approval-policy" \
-H "Authorization: Bearer $ERP_KEY" \
-H "Content-Type: application/json" \
-d '{"table": {"id": "too-small", "name": "Too small", "description": "Only the actor.", "domain": "erp.policy", "hit_policy": "first", "inputs": [{"name": "actor_type", "type": "enum", "values": ["human", "agent", "system"]}], "outputs": [{"name": "decision"}], "rules": [{"id": "all", "when": {"actor_type": "human"}, "then": {"decision": "allow"}}], "sources": [{"authority": "Acme AG", "url": "https://example.com/policy", "reference": "none"}]}}'
{"error": {"code": "validation_failed", "problems": ["<any>"]}}
6. History and reset
Every version is kept, newest first. DELETE returns to the default table as a new version; it
takes If-Match as well. The default lets a person post a large manual entry alone and an agent
post routine documents without review, which this table does not, so the return relaxes the
policy and waits for a second administrator too.
curl -X DELETE "$ERP_URL/v1/approval-policy" \
-H "Authorization: Bearer $ERP_KEY" \
-H 'If-Match: $ETAG2'
{"source": "organization", "version": 1, "pending_change": {"source": "default", "relaxed": ["agent post: require_approval -> allow", "human post: require_approval -> allow"]}}
curl -X POST "$ERP_URL/v1/approvals/$RESET/approve" \
-H "Authorization: Bearer $ALICE_KEY"
{"status": "approved"}
curl "$ERP_URL/v1/approval-policy/versions" -H "Authorization: Bearer $ERP_KEY"
[
{"version": 2, "source": "default", "table": null},
{"version": 1, "source": "organization", "comment": "Four eyes on large manual entries"}
]