Every action needs a signed approval
Two checks enforce this in code, for every policy and every action:- When a policy loads. Every rule that approves must require one of the policy’s
approvalSignalswithis_true, either as its whole condition or directly inside its top levelall. A policy with any other approve rule fails to load, so every request under it is refused with400andapproves without a signed human approval. A proposed policy change of that shape is refused the same way, so it cannot get through policy governance either. - When a request is decided. A signal in
approvalSignalscounts as true only with a valid signed approval in the request: from a trusted, unrevoked approver, for this action and this resource (and amount, where the policy names one), not expired, used once. A runtime with no approval verifier configured refuses every approval instead of trusting the signal.
true without a signed approval is refused, whatever
else it declares. Tutorials 119 to 122 in examples/tutorials/ show it step by step, for a
refund, the rule itself, a read, a Slack post and a HubSpot update.
target:
vendor-payment 2.1.0, access-control 1.1.0, agent-vendor-payment 1.1.0,
api-key-issuance 1.1.0, connector-capability 1.1.0, database-change 3.1.0,
expense-reimbursement 1.1.0, production-deployment 1.1.0, rag-document-access 1.1.0 and
llm-tool-call 1.1.0. The payment and expense policies also cover the amount. Older versions
that approved without a person stay in the repository as history and are refused when they load.
GET /policies/in-effect?capability=<capability>, and send that version.Approve policies
In production, a policy authorizes nothing until one person proposes it and a second person approves it with a signed step up authorization. The same person cannot do both. The whole process, with commands, is in Policy lifecycle and approvals.Approve a refund
Thecustomer-refund 1.2.0 policy authorizes a refund only with a signed approval from a
manager:
refundEligible and fraudCheckPassed itself, and
nothing checks them. In 1.1.0 a refund up to 10000 was authorized on those two claims alone, so
an agent manipulated by prompt injection could approve its own refunds (G-75). In 1.2.0 they can
only refuse a refund.
customer-refund 1.2.0 has been in effect in production since 2026-09-28. A
refund of any amount without a signed manager approval is refused with 403 POLICY_DENIED. There is no amount below which a refund runs on its own.ORD-1042:
The agent's request is refused
managerApproved: false. Parmana refuses it with 403 POLICY_DENIED: the refund needs a signed manager approval. Nothing is
executed, and a signed Refusal Record is stored.A manager reviews it
The manager signs an approval, on their own machine
--ttl-seconds or ttlSeconds changes it, up to a day), and can be used once. The SDKs
need no clone of the repository; the private key never leaves the process.The agent sends a new request with the approval
businessTransactionId, the same order and amount, managerApproved: true, and the
contents of approval.json in signals.approvalArtifact:Parmana verifies the approval and executes
paytm:refund, for this order (the Intent’s
parameters.orderId), covers this amount (the Intent’s parameters.amount), and has not
been used before. The gateway runs the same checks again just before release. Then the
refund runs. The policy says which order and amount to check, in its approvalSignals
(see Add an approval to any action).managerApproved: true without a valid approval is refused, at any amount:
Set up an approver
An approver is a person with an Ed25519 key pair. The private key stays on their machine; the server trusts the public key. Add one through maker checker, with no deploy:The approver creates a key pair on their own machine
manager-priya__manager-priya-key-1.public.pem, to a proposer.One person proposes trusting it
POST /approval-issuers/changes with action: "add", the ids, the public
key and a reason.A different person approves it
POST /approval-issuers/changes/{id}/approve with a step up authorization.
The key is trusted from the next request on.TRUSTED_APPROVAL_ISSUERS in
packages/api/src/bootstrap/codeApprovalIssuers.ts. Those change only with a pull request and a
deploy. The code list is checked first.
manager-charak1987-key-1,
revoked on 2026-09-30; its current approver key was added through maker
checker. Approver keys are always Ed25519, whatever signing algorithm the
server itself uses.Add an approval to any action
A policy declares which of its signals need a signed approval inapprovalSignals, with a path
to the resource in the Intent and, optionally, a path to a number the approval must cover. For
a merge that needs a release manager:
{ "fact": "releaseManagerApproved", "operator": "is_true" }. The approver signs without --max-amount, since there is no amount;
the approval then names exactly this pull request, acme/api#42:
resourceIdis"target"(the Intent’s target, such asacme/api#42for a pull request) or a path into the Intent’sparameters, such asparameters.orderId. A number is compared as its decimal string.valueis a path intoparametersto a number.- The approval’s
scope.fieldmust bevaluewhen the declaration gives avalue, andresourceIdwhen it does not. The SDKs’signApprovalandscripts/sign-approval.tswrite these names. An approval scoped to any other field is refused, even when its bound would compare true. - The key must be read by a rule, and must not also be in
boundSignals. - With more than one approval signal, give each its own
artifact, the signal that carries its approval. The default isapprovalArtifact.
Approve a HubSpot update or read
hubspot-deal-update 1.1.0 authorizes every deal update only with dealUpdateApproved: true
and a signed approval for that deal in signals.approvalArtifact, whatever the amount. Sign it
with --capability hubspot:deal-update and the deal id as --resource-id, without
--max-amount. The dealstage transition is still checked against the real deal, and a
transition that is not allowed is refused even with an approval.
Reading a deal (hubspot:deal-fetch) has its own policy, hubspot-deal-read 1.0.0. It needs
readApproved: true with an approval for that deal, signed with --capability hubspot:deal-fetch.
Under 1.0.0 an update within the amount threshold needed no person, and only a larger amount
change needed preAuthorizedForAmountChange with an approval. 1.0.0 now fails to load.
Review refused requests
Every request that a policy refuses is stored with a signed Refusal Record. To list recent refunds that were refused:and decision_json ->> 'matchedRuleId' = 'reject-manager-approval-required'.
Requests refused before policy runs, such as a failed authentication or a capability the caller
is not allowed to use, are in caller_audit_events:
Get notified
The full guide, with receiver code in Node.js and Python, is Approval notifications. SetAPPROVAL_WEBHOOK_URL and APPROVAL_WEBHOOK_SECRET, and Parmana POSTs an event to that URL
each time a request is refused only for want of an approval: the policy declares
approvalSignals, and the same request with those signals true would have been authorized. A
refund refused for a failed fraud check, or above the maximum, sends nothing.
approvals is what to sign: --capability is action, --resource-id is resourceId, and
--max-amount is value when there is one. The event carries nothing else from the request.
Each request has two headers. parmana-webhook-timestamp is Unix seconds.
parmana-webhook-signature is v1= and the lowercase hex HMAC SHA256, keyed with the secret,
of the timestamp, a dot, and the exact body. Check it before acting, and refuse an old
timestamp:
approval_needed_notification_failed and changes nothing about the refusal. The refused
requests query above stays the complete list.
Not available
A refused request cannot be approved afterwards. A policy decision is approve or refuse. Nothing waits for a person. After a manager signs an approval, the agent sends a new request. Eligibility and fraud signals are still the caller’s word.refundEligible and
fraudCheckPassed are not checked against another system (G-51 in
docs/VERIFICATION-GAPS.md). They can only refuse; the signed approval authorizes.
An approval covers the action and the resource, not every parameter. An approval for a
Slack channel does not fix the message text, and an approval for a HubSpot deal does not fix the
new amount or stage. The approver trusts the agent with those details for that one use. An
approval for a payment, an expense or a refund does cover the amount.
There is no automatic path. No action is authorized without a person, by design. Adding one
would need a server side check that reads the facts itself, and a change to this rule
(G-80 in docs/VERIFICATION-GAPS.md).
Why each action is bound to one policy
Each request names the policy that should judge it. If Parmana accepted any approved policy, a caller could send a refund and name a policy written for Slack messages, which has no refund limit. So each live action is bound to one policy name:sandbox:receipt -> sandbox-receipt).
The version is not fixed: in production it is the version most recently
approved through policy governance. Approving customer-refund 1.2.0 makes 1.2.0 the version
refunds must use, with no deploy, and approving 1.1.0 again rolls back. A request that names a
different policy, or any other version, is refused before any policy runs, with a message that
names the version in effect. Agents must then send that version.