Who does what
The proposer and the approver must be two different people. The server refuses an approval from the proposer’s API key, but it cannot tell whether two keys belong to two people: keep each key on its owner’s machine.
Part 1: from a policy file to a policy in effect
1. Write the policy
A policy is a JSON file atpolicies/<name>/<version>/policy.json: policyId, policyVersion, schemaVersion, and ordered rules, where the first matching rule decides. See Write your first policy for rules and operators. Three optional sections tie signals to reality:
boundSignals: a signal must equal a field of the request, such as"refundAmount": "parameters.amount".unboundSignalReasons: why a signal the rules read is not bound. Every fact a rule reads must be bound, declared as an approval signal, or given a reason here, or the policy is refused.approvalSignals: signals that count astrueonly with a person’s signed approval (Part 2).
customer-refund/1.2.0, never an edit of an approved one. Check it before proposing: npx vitest run packages/policy/tests/unit/ReferencePolicies.test.ts validates every policy file under policies/.
2. Set up the people, once
- Proposer and approver API keys, each added as a human. On a self hosted deployment: Manage API keys. Anything else gets
403 NON_HUMAN_CALLER_DENIEDon every governance call. - The approver’s step up key, made on the approver’s own machine. Only the public half goes to the operator:
step-up.public.pem on the approver’s API key and deploys.
3. Propose
The request carries the whole policy and a reason. The name and version in the URL must matchpolicyId and policyVersion in the content, and the content must pass validation.
201 with a pendingPolicyChangeId and status PENDING_APPROVAL. Only one open proposal per policy version is allowed; a second gets 409 CONFLICT. Send the id to the approver.
4. Review
The approver lists what is waiting and reads the proposed content before signing:5. Approve or reject
The approver signs a step up authorization for this one change and this one action, on their own machine, then sends it within its lifetime (120 seconds at most). The SDKs sign too:signPolicyChangeStepUp() in TypeScript, sign_policy_change_step_up() in Python.
--action reject and post to .../reject with a rejectionReason.
On approve, the server does this, in order, and stops at the first failure:
- The caller is a verified human, or
403 NON_HUMAN_CALLER_DENIED. - The change exists, or
404 PENDING_POLICY_CHANGE_NOT_FOUND. - The caller is not the proposer, or
403 SAME_ACTOR_CANNOT_APPROVE_OWN_CHANGE. - The step up authorization is valid for this change and action, signed by the key registered for this caller, not expired, not used before, or
403 STEP_UP_AUTHORIZATION_INVALID. - It writes a signed approval record: who proposed, who approved, when, the content hash before and after, and the hash of the previous record for this policy version, so records form a chain.
- It saves the policy content and marks the change
APPROVED.
6. It takes effect
For an action bound to a policy (such aspaytm:refund to customer-refund), the version in effect is the one with the most recent approval, so the approval is the release: no deploy. From then on:
- Agents must name that version. A request naming any other version, including an older one that was approved before, is refused before any rule runs, with a message naming the version in effect.
- To roll back, approve the older version again. The newest approval wins.
7. What is checked on every request
- The version named is the version in effect for the action.
- The loaded policy has an approval record, the record’s signature verifies, and its content hash equals the live policy’s. Anything else refuses the request, so a policy edited in the database outside this flow cannot authorize anything.
- The gateway checks the approval record again just before release.
- At startup, the server checks every policy against its latest approval record and logs the result.
Part 2: a person signs off on one action
Some actions need a person each time, such as a refund above 10000. The policy declares which signal needs a signed approval, and where the request says what the approval is for:resourceId is "target" or a path into parameters; value is optional and must point at a number. Without value, the approval names exactly one resource, which suits a merge approval ("resourceId": "target", for acme/api#42).
Set up an action approver, once
{ approverId, keyId, revoked: false, publicKeyPem } to TRUSTED_APPROVAL_ISSUERS in packages/api/src/bootstrap/createApprovalIssuerRegistry.ts, with the contents of the .public.pem file as publicKeyPem, and deploys. To revoke, set revoked: true and deploy.
Sign an approval
true and the contents of approval.json in signals.approvalArtifact. Parmana checks the approver is trusted and not revoked, the Ed25519 signature, the expiry, the action, the resource and the amount taken from the request, never from the agent’s signals, and that the approval was not used before. It checks again at the gateway just before release. Without a valid approval the request is refused:
matchedRuleId = 'reject-manager-approval-required' (see Human approval).
Where it is recorded
Keep it honest
- Two people, two machines, two keys. A single person holding both API keys makes the record claim a second person who does not exist.
- Never paste an API key into a chat or a ticket.
Read-Host -AsSecureStringin PowerShell keeps it off the screen. Rotate any key that was shown. - Approver private keys never leave their owner’s machine; only public keys are shared.