Skip to main content
A policy authorizes nothing until one person proposes it and a different person approves it with a signed step up authorization. Once approved, it is the version in effect for its action, with no deploy. Separately, a policy can say that some actions also need a named person’s signed approval each time they run. This page covers both, in the order you do them.

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 at policies/<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 as true only with a person’s signed approval (Part 2).
A new version of a live policy is a new folder, such as 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_DENIED on every governance call.
  • The approver’s step up key, made on the approver’s own machine. Only the public half goes to the operator:
The operator registers 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 match policyId and policyVersion in the content, and the content must pass validation.
The response is 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. Send the file’s content unchanged. The approval stores a hash of the approved policy content (hashPolicyContent), and CI’s verify-policy-approvals job (scripts/verify-policy-changes-approved.ts) compares it with the hash of the file in git; the server makes the same comparison at startup (verifyPolicyGovernanceIntegrityAtStartup.ts). If any value differs, even one character inside a description, the check fails. A version that is already approved can be proposed and approved again; the most recent approval is the one that counts, both for CI and for the server.

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.
To reject, sign with --action reject and post to .../reject with a rejectionReason. On approve, the server does this, in order, and stops at the first failure:
  1. The caller is a verified human, or 403 NON_HUMAN_CALLER_DENIED.
  2. The change exists, or 404 PENDING_POLICY_CHANGE_NOT_FOUND.
  3. The caller is not the proposer, or 403 SAME_ACTOR_CANNOT_APPROVE_OWN_CHANGE.
  4. 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.
  5. 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.
  6. It saves the policy content and marks the change APPROVED.

6. It takes effect

For an action bound to a policy (such as paytm: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.
Binding a built in action to a different policy name still needs a code change and a deploy. A new action for a system Parmana has no connector for needs neither: register it as an external connector, with its policy, through maker checker. See Connect any external system.

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.
These checks are on whenever NODE_ENV is anything other than test or development, and nothing switches them off in production. In test or development they are off unless POLICY_EXECUTION_VERIFICATION_ENFORCED=true. So approve every policy a deployment executes against before promoting it; a policy still PENDING_APPROVAL, or rejected with no approved replacement, is refused on the first request.

8. Check what is approved

content_hash_after is a hash of the canonical JSON (keys sorted), not of the file bytes, so a plain file hash will not match. Use the script that compares every policy on disk with its latest approval record:
The CI job verify-policy-approvals runs the same full scan on every push and pull request, and is a required check on main, so a pull request that adds a policy version merges only after that version is approved in production. A rejection writes no approval record: the change’s rejectionReason is the record it leaves.

Sign and submit in one step

Signing and sending by hand leaves a gap in which the 120 second step up lifetime can run out. scripts/local-review-action.ts signs and submits in one process, on the approver’s own machine:
Always pass --private-key-file, --key-id and --api-base: the defaults name a local file, a caller id and a host from this project’s own setup. The script prints the HTTP status and response only, never a key. scripts/refresh-approved-policy-content.ts proposes the file on disk and approves it in one run, so it needs both the proposer’s and the approver’s keys in one process. That is one person acting as both; use it only in a test environment, never where the approval record must stand for a second person.

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

The private key stays on the manager’s machine. The public key is trusted through maker checker, with no deploy: one person proposes adding it and a different person approves it (Manage approvers). Revoking it is the same flow.

Sign an approval

The agent sends a new request with the approval signal set to 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:
To find refusals waiting for a manager, query the Refusal Records for matchedRuleId = 'reject-manager-approval-required' (see Human approval). To see the whole flow run, without a server or AWS, run tutorial 119: npx tsx examples/tutorials/119-human-approval-for-one-action/run.ts. It uses the real customer-refund 1.2.0 policy and shows a small refund refused without a manager, a refusal, a refused unsigned claim, a signed approval accepted and checked again before release, and an approval refused when reused, stretched to a larger amount or moved to another order.

Where it is recorded

Troubleshooting

Good to know

  • A decision is final. A change moves from PENDING_APPROVAL to APPROVED or REJECTED once and never back; to change your mind, propose again.
  • The step up private key is never sent. Only the signed authorization it produces is.
  • With a database configured, approved content is written to the policies table through SupabasePolicyRepository, and requests read it from there. With PARMANA_STORAGE=memory it is written to the local file.
  • packages/governance-ui can propose, list and show a diff, but cannot approve or reject: a web page that collected a step up private key would break the rule that the key never leaves its owner’s machine.
  • The code for this flow is small: packages/api/src/routes/pending-policy-changes.ts, packages/api/src/governance/PolicyChangeApprovalService.ts and packages/api/src/auth/PolicyChangeStepUpVerifier.ts.

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 -AsSecureString in 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.