> ## Documentation Index
> Fetch the complete documentation index at: https://docs.parmanasystems.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Human approval

> Where people approve what agents do: policies, and any action a policy says needs a signed approval, such as large refunds. How to add an approval to an action, and how to review refused requests.

People approve policies before those policies can authorize anything, sign approvals for any
action a policy says needs one, and review every refused request. Which actions need an
approval is written in the policy, not in code, so adding one to a new action is a policy
change. Parmana does not hold a request while it waits for a person: a refused request stays
refused, and the agent sends a new one with the approval attached.

| What a person does                                               | Status                                       |
| ---------------------------------------------------------------- | -------------------------------------------- |
| Approve a policy before it can authorize anything                | **\[AVAILABLE]**                             |
| Review refused requests                                          | **\[AVAILABLE]**                             |
| Sign an approval for any action whose policy declares one        | **\[AVAILABLE]**, no approver configured yet |
| Sign an approval for a refund above 10000                        | **\[AVAILABLE]**, no approver configured yet |
| Sign an approval for a HubSpot amount change above the threshold | **\[AVAILABLE]**, no approver configured yet |
| Approve a refused request so that same request runs              | **\[NOT AVAILABLE]**                         |

## 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](/guides/policy-lifecycle-and-approvals).

## Approve a large refund

The `customer-refund` 1.1.0 policy authorizes an eligible refund that passed the fraud check:

| Refund amount             | What it needs                     |
| ------------------------- | --------------------------------- |
| Up to 10000               | Nothing more. Authorized at once. |
| Above 10000, up to 100000 | A signed approval from a manager  |
| Above 100000              | Refused, even with an approval    |

The flow for a refund of 75000 on order `ORD-1042`:

<Steps>
  <Step title="The agent's request is refused">
    The agent sends the refund with `managerApproved: false`. Parmana refuses it with `403
            POLICY_DENIED`: "amounts above 10000 require a signed manager approval". Nothing is
    executed, and a signed [Refusal Record](/concepts/refusal-records) is stored.
  </Step>

  <Step title="A manager reviews it">
    The manager finds the refusal, for example with the query in
    [Review refused requests](#review-refused-requests).
  </Step>

  <Step title="The manager signs an approval, on their own machine">
    ```bash theme={null}
    npx tsx scripts/sign-approval.ts \
      --private-key-file ~/.parmana/manager-priya__manager-priya-key-1.private.pem \
      --approver-id manager-priya --key-id manager-priya-key-1 \
      --capability paytm:refund --resource-id ORD-1042 --max-amount 75000 \
      --out approval.json
    ```

    The approval is for this order only, for refunds up to 75000, valid for 15 minutes
    (`--ttl-seconds` changes it, up to a day), and can be used once.
  </Step>

  <Step title="The agent sends a new request with the approval">
    A new `businessTransactionId`, the same order and amount, `managerApproved: true`, and the
    contents of `approval.json` in `signals.approvalArtifact`:

    ```json theme={null}
    "signals": {
      "refundEligible": true,
      "fraudCheckPassed": true,
      "refundAmount": 75000,
      "managerApproved": true,
      "approvalArtifact": { "payload": { "...": "..." }, "signature": { "...": "..." } }
    }
    ```
  </Step>

  <Step title="Parmana verifies the approval and executes">
    Before authorizing, Parmana checks that the approval is signed by a trusted approver, has
    not expired or been revoked, is for `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](#add-an-approval-to-any-action)).
  </Step>
</Steps>

`managerApproved: true` without a valid approval is refused, at any amount:

```text theme={null}
Execution rejected: Rejected: declared signal(s) do not match independently verified state (managerApproved=true != verified managerApproved=false).
```

The same happens for an approval for another order, a smaller amount, another action, a
changed payload, an unknown or revoked approver, an expired approval, or one already used.

## Set up an approver

<Steps>
  <Step title="The manager creates a key pair on their own machine">
    ```bash theme={null}
    npx tsx scripts/generate-approver-key.ts \
      --approver-id manager-priya --key-id manager-priya-key-1 --out-dir ~/.parmana
    ```

    The private key stays on that machine. The manager sends the operator the public key file,
    `manager-priya__manager-priya-key-1.public.pem`.
  </Step>

  <Step title="The operator trusts it">
    Add `{ approverId: "manager-priya", keyId: "manager-priya-key-1", revoked: false,
            publicKeyPem: "<the contents of the .public.pem file>" }` to `TRUSTED_APPROVAL_ISSUERS` in
    `packages/api/src/bootstrap/createApprovalIssuerRegistry.ts`, and deploy. Public keys are not
    secret, so the key can live in the code, which is how it works on Vercel. On a deployment
    with a key directory you can leave out `publicKeyPem` and copy the file to
    `$PARMANA_KEY_DIR/approval-issuers/` instead. To revoke the key later, set `revoked: true` and
    deploy.
  </Step>
</Steps>

<Info>
  No approver is configured in the current deployment, so every approval is
  refused until an operator adds one. Approver keys are always Ed25519, whatever
  signing algorithm the server itself uses.
</Info>

## Add an approval to any action

A policy declares which of its signals need a signed approval in `approvalSignals`, 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:

```json theme={null}
"approvalSignals": {
  "releaseManagerApproved": { "resourceId": "target" }
}
```

A rule then reads the signal like any other, for example `{ "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`:

```bash theme={null}
npx tsx scripts/sign-approval.ts   --private-key-file ~/.parmana/lead-sam__lead-sam-key-1.private.pem   --approver-id lead-sam --key-id lead-sam-key-1   --capability github:pr-merge --resource-id "acme/api#42" --out approval.json
```

The change goes live like any policy change: a new version, proposed and approved through
policy governance. It takes effect when it is approved, with no code change and no deploy. Two
things still need a deploy: a new approver, and binding an action to a different policy name.

Rules for a declaration, checked when the policy loads:

* `resourceId` is `"target"` (the Intent's target, such as `acme/api#42` for a pull request) or a
  path into the Intent's `parameters`, such as `parameters.orderId`. A number is compared as its
  decimal string. `value` is a path into `parameters` to a number.
* 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 is `approvalArtifact`.

## Approve a HubSpot amount change

For `hubspot:deal-update`, an amount change above the configured threshold runs only with
`preAuthorizedForAmountChange: true` and a signed approval in `signals.approvalArtifact`. Sign
it the same way, with `--capability hubspot:deal-update`, the deal id as `--resource-id`, and
the largest amount change allowed as `--max-amount`. HubSpot keeps its own check, because its
limit applies to an amount change worked out from a live read of the deal, not to a value in the
request.

## Review refused requests

Every request that a policy refuses is stored with a signed
[Refusal Record](/concepts/refusal-records). To list recent refunds that were refused:

```sql theme={null}
select
  refusal_record_id,
  business_transaction_id,
  submitted_by,
  created_at,
  decision_json ->> 'reason'                   as reason,
  evaluated_intent_json -> 'parameters'        as parameters
from refusal_records
where decision_json -> 'policy' ->> 'name' = 'customer-refund'
order by created_at desc
limit 50;
```

To list only the refunds waiting for a manager, add
`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`:

```sql theme={null}
select occurred_at, caller_id, capability, type, reason
from caller_audit_events
where type in (
  'caller.rejected',
  'caller.capability_denied',
  'caller.principal_denied',
  'caller.non_human_denied',
  'caller.structural_rejected'
)
order by occurred_at desc
limit 50;
```

<Warning>
  A Refusal Record is written after the request is refused, and a failed write
  does not change the refusal. A query can therefore miss a refused request.
  Each miss is logged as `refusal_record_write_failed`.
</Warning>

## Not available

**A refused request cannot be approved afterwards.** A policy decision is approve or refuse.
Nothing waits for a person, and nothing notifies one. 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`).

## 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:

```text theme={null}
paytm:refund         ->  customer-refund
hubspot:deal-update  ->  hubspot-deal-update
```

The name is fixed in code. The version is not: 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.
