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

# Credentials for maker, checker, approver and agent

> Every key a Parmana deployment needs, who creates each one, every command to create, deploy, test, rotate and use them, and how the production keys were rebuilt on 2026-09-27 and rotated on 2026-09-30.

A running Parmana deployment needs four kinds of credential. This page gives every command, in Windows PowerShell, from creating the keys to approving a policy and signing a refund approval. Every command here was checked against the scripts and routes it names.

## The four credentials

| Role | What it does | Credential | Created by | Where the server keeps it |
| - | - | - | - | - |
| **Maker** | Proposes policy changes | API key (a human caller) | the maker, `scripts/generate-api-key.ts` | a SHA256 hash in `PARMANA_API_KEYS` |
| **Checker** | Reviews and approves policy changes | API key and a step up key pair | the checker, `scripts/generate-api-key.ts --generate-step-up-key` | the hash and the step up **public** key in `PARMANA_API_KEYS` |
| **Approver** (for example a refund manager) | Signs approval for one action on one resource, such as one refund | Ed25519 key pair, no API key | the approver, `scripts/generate-approver-key.ts` | the **public** key, added through maker checker (`/approval-issuers`) or listed in code |
| **Agent** | Sends actions to `POST /execute` | API key scoped to its capabilities | the operator, `scripts/generate-api-key.ts --allowed-capabilities` | a hash in `PARMANA_API_KEYS` |

What the server never holds: a raw API key, a step up private key, or an approver private key. A key shown once by a script cannot be recovered; if it is lost, you make a new one.

<Warning>
  **Each person creates their own credential on their own machine.** If one
  person creates the checker or approver key for someone else, that person holds
  both halves of maker and checker. The server checks that the approving caller
  differs from the proposing caller (`SameActorCannotApproveOwnChangeError`). It
  cannot tell whether two callers are two people. If one person holds both, say
  so: it is one person with two credentials, not two person control.
</Warning>

## Before you start (every machine, once)

You need Node.js 24 or later and Git.

```powershell theme={null}
git clone https://github.com/pavancharak/parmana.git
cd parmana
npm install
npx tsc -b
```

`npx tsc -b` builds the packages the scripts import. Without it every script fails with `ERR_MODULE_NOT_FOUND ... @parmana\shared\dist\index.js`.

Each key script prints its secret **once**. The blocks below save everything to files automatically, so only the printed `Key :` value needs to go into a password manager. Keep the files outside the repository folder: the repository is public.

```powershell theme={null}
foreach ($d in "parmana-entries", "parmana-checker", "parmana-approver") {
  New-Item -ItemType Directory -Force "$env:USERPROFILE\$d" | Out-Null
}
```

`parmana-entries` holds one JSON line per caller: the caller id, the key hash and, for a checker, the step up public key. None of it is secret. It is what you deploy.

## 1. Maker key (the maker runs this)

Replace `maker-NAME` with the maker's caller id.

```powershell theme={null}
$id = "maker-NAME"
$out = npx tsx scripts/generate-api-key.ts --caller-id $id --credential-holder-type USER
$out | Where-Object { $_ -match '^(Caller ID|Key)\s+:' }
$out | Where-Object { $_.StartsWith('{"callerId"') } | Set-Content "$env:USERPROFILE\parmana-entries\$id.json" -Encoding ascii
Remove-Variable out
```

Save the printed `Key :` value in a password manager now. `--credential-holder-type USER` is required: policy governance endpoints accept only callers provisioned as human (`packages/api/src/auth/isHumanCaller.ts`), and a key without it is refused with `NON_HUMAN_CALLER_DENIED`.

## 2. Checker key (the checker runs this, on their own machine)

```powershell theme={null}
$id = "reviewer-NAME"
$out = npx tsx scripts/generate-api-key.ts --caller-id $id --credential-holder-type USER --generate-step-up-key
$out | Where-Object { $_ -match '^(Caller ID|Key)\s+:' }
$start = [array]::IndexOf($out, '-----BEGIN PRIVATE KEY-----')
$end = [array]::IndexOf($out, '-----END PRIVATE KEY-----')
$out[$start..$end] | Set-Content "$env:USERPROFILE\parmana-checker\step-up.private.pem" -Encoding ascii
$out | Where-Object { $_.StartsWith('{"callerId"') } | Set-Content "$env:USERPROFILE\parmana-checker\$id.json" -Encoding ascii
Remove-Variable out
```

| Output | Secret | What to do |
| - | - | - |
| `Key :` value | yes | the checker's password manager |
| `step-up.private.pem` | yes | stays on the checker's machine; it signs every approval |
| `reviewer-NAME.json` | no | send it to the operator, who puts it in their `parmana-entries` folder |

## 3. Approver key pair (the approver runs this, on their own machine)

```powershell theme={null}
$id = "manager-NAME"
npx tsx scripts/generate-approver-key.ts --approver-id $id --key-id "$id-key-1" --out-dir "$env:USERPROFILE\parmana-approver"
```

It writes two files, and refuses to overwrite existing ones:

| File | Secret | What to do |
| - | - | - |
| `manager-NAME__manager-NAME-key-1.private.pem` | yes | stays on the approver's machine |
| `manager-NAME__manager-NAME-key-1.public.pem` | no | send it to the operator (step 8) |

## 4. Agent key (the operator runs this)

Grant only the capabilities the agent needs. For a refund agent:

```powershell theme={null}
$id = "paytm-refund-agent"
$out = npx tsx scripts/generate-api-key.ts --caller-id $id --allowed-capabilities paytm:refund
$out | Where-Object { $_ -match '^(Caller ID|Key)\s+:' }
$out | Where-Object { $_.StartsWith('{"callerId"') } | Set-Content "$env:USERPROFILE\parmana-entries\$id.json" -Encoding ascii
Remove-Variable out
```

The key goes into the agent's own configuration. With no `--allowed-principal-ids`, the agent may only act as itself: the `principalId` in its requests must equal its caller id.

## 5. Deploy the API keys (the operator runs this)

`PARMANA_API_KEYS` is a JSON array of the entries. This block builds it from every file in `parmana-entries`, replaces the variable on Vercel and redeploys, because an environment change only takes effect in a new deployment. The first time, it links a folder to the Vercel project (run `vercel login` before).

```powershell theme={null}
$link = "$env:USERPROFILE\parmana-vercel-link"
if (-not (Test-Path "$link\.vercel")) {
  New-Item -ItemType Directory -Force $link | Out-Null
  vercel link --yes --project parmana-api-real --scope YOUR-VERCEL-SCOPE --cwd $link
  Remove-Item "$link\.env.local" -ErrorAction SilentlyContinue
}
$entries = @(Get-ChildItem "$env:USERPROFILE\parmana-entries\*.json" | ForEach-Object { Get-Content $_.FullName -Raw | ConvertFrom-Json })
$entries | ForEach-Object { "{0}  human={1}  stepUp={2}  capabilities={3}" -f $_.callerId, ($_.credentialHolderType -eq "USER"), ($null -ne $_.stepUpPublicKey), ($_.allowedCapabilities -join ",") }
Read-Host "Only these callers will work in production. Enter to continue, Ctrl+C to stop"
$value = ConvertTo-Json -InputObject $entries -Depth 5 -Compress
vercel env rm PARMANA_API_KEYS production --yes --cwd $link
$value | vercel env add PARMANA_API_KEYS production --sensitive --cwd $link
$latest = vercel ls parmana-api-real --prod --cwd $link 2>$null | Select-Object -First 1
vercel redeploy $latest --target production --cwd $link
```

Every caller not in the folder loses access at that redeploy. That is how a key is revoked. Keep the `parmana-entries` folder: a sensitive Vercel variable cannot be read back, so this folder is the only copy of the list.

Do not use `scripts/rotate-caller-key.mjs` for this. It rewrites an entry as only a caller id and a hash, dropping `credentialHolderType` and `stepUpPublicKey`, so a human caller would lose access to policy governance.

## 6. Test a key

`GET /callers/me` returns the caller id of a key, and never the key itself.

```powershell theme={null}
$url = "https://parmana-api-real.vercel.app"
$sec = Read-Host -AsSecureString "API key"
$key = [System.Net.NetworkCredential]::new("", $sec).Password
$h = @{ Authorization = "Bearer $key" }
try { (Invoke-RestMethod -Uri "$url/callers/me" -Headers $h).callerId } catch { $_.ErrorDetails.Message }
Remove-Variable key, sec, h
```

| Response | Meaning |
| - | - |
| the caller id | the key works |
| `authentication required` | the key is not in the deployed `PARMANA_API_KEYS` (wrong key, lost key, or not redeployed) |
| `NON_HUMAN_CALLER_DENIED` on a governance endpoint | the key works but its entry lacks `credentialHolderType: USER` |

## 7. Maker and checker: change a policy

### Propose (maker)

The body needs `reason` and `proposedContent`, the whole `policy.json`, whose `policyId` must match the name in the path. Propose a **new** version folder (an approved version is never edited); `customer-refund` 1.2.0 is in effect, so the next would be, for example, 1.3.0.

```powershell theme={null}
$url = "https://parmana-api-real.vercel.app"
$name = "customer-refund"; $version = "1.3.0"   # the new version you wrote
$sec = Read-Host -AsSecureString "Maker API key"
$key = [System.Net.NetworkCredential]::new("", $sec).Password
# Read as UTF-8: Windows PowerShell 5.1 Get-Content uses the local codepage and changes characters such as an em dash.
$policy = [System.IO.File]::ReadAllText("$PWD\policies\$name\$version\policy.json", [System.Text.Encoding]::UTF8)
$body = [System.Text.Encoding]::UTF8.GetBytes("{`"reason`":`"Why this change is needed`",`"proposedContent`":$policy}")
Invoke-RestMethod -Method Post -Uri "$url/policies/$name/$version/pending-changes" -Headers @{ Authorization = "Bearer $key" } -ContentType "application/json" -Body $body
Remove-Variable key, sec
```

The response holds `pendingPolicyChangeId`. Send it to the checker.

### Review (checker)

```powershell theme={null}
$url = "https://parmana-api-real.vercel.app"
$changeId = "THE-PENDING-POLICY-CHANGE-ID"
$sec = Read-Host -AsSecureString "Checker API key"
$key = [System.Net.NetworkCredential]::new("", $sec).Password
$r = Invoke-RestMethod -Uri "$url/policies/pending-changes" -Headers @{ Authorization = "Bearer $key" }
$c = $r.changes | Where-Object { $_.pendingPolicyChangeId -eq $changeId }
$c | Select-Object policyName, policyVersion, status, proposedBy, reason | Format-List
foreach ($rule in $c.diff.proposed.rules) { "{0,-38} {1,-8} {2}" -f $rule.id, $rule.outcome.action, ($rule.condition | ConvertTo-Json -Depth 10 -Compress) }
"Coverage warnings: " + ($c.coverageWarnings | ConvertTo-Json -Depth 5 -Compress)
"Rule conflicts:    " + ($c.ruleConflicts | ConvertTo-Json -Depth 5 -Compress)
Remove-Variable key, sec
```

Read every rule, and every warning and conflict. Rules are evaluated first match wins, so an overlap between two rules with the same outcome is harmless, while an overlap between an approve rule and a reject rule decides behavior by their order. If anything is wrong, do not approve.

### Approve (checker)

The step up signature is valid for 120 seconds: run the whole block at once.

```powershell theme={null}
$url = "https://parmana-api-real.vercel.app"
$changeId = "THE-PENDING-POLICY-CHANGE-ID"
$id = "reviewer-NAME"
$pem = "$env:USERPROFILE\parmana-checker\step-up.private.pem"
$sec = Read-Host -AsSecureString "Checker API key"
$key = [System.Net.NetworkCredential]::new("", $sec).Password
$signed = npx tsx scripts/sign-policy-change-step-up.ts --private-key-file $pem --key-id $id --pending-policy-change-id $changeId --action approve
$envelope = $signed | Where-Object { $_.StartsWith("{") } | Select-Object -First 1
$body = [System.Text.Encoding]::UTF8.GetBytes("{`"stepUpAuthorization`":$envelope}")
try {
  Invoke-RestMethod -Method Post -Uri "$url/policies/pending-changes/$changeId/approve" -Headers @{ Authorization = "Bearer $key" } -ContentType "application/json" -Body $body
} catch { $_.ErrorDetails.Message }
Remove-Variable key, sec
```

The response shows `status : APPROVED`. The server verifies the envelope against the step up public key in the approving caller's own entry, not against `--key-id`. From then on, the approved version is the one in effect for its action, with no deploy (`docs/CLAIMS.md` 2.43).

## 8. Register an approver

There are two ways. Use the first; it needs no code change and no deploy.

**Through maker checker (recommended).** The maker proposes the public key, the checker approves it with a step up signature, and it applies to the next request. Every command, in curl, TypeScript and Python, is in [Manage approvers](/guides/manage-approvers):

```powershell theme={null}
$url = "https://parmana-api-real.vercel.app"
$approverId = "manager-NAME"; $keyId = "manager-NAME-key-1"
$pub = "$env:USERPROFILE\parmana-approver\${approverId}__${keyId}.public.pem"
$key = [System.Net.NetworkCredential]::new("", (Read-Host "Maker API key" -AsSecureString)).Password.Trim()
# ReadAllText, not Get-Content -Raw: in Windows PowerShell 5.1, ConvertTo-Json sends Get-Content's
# result as an object with file properties, and the server refuses it as "publicKeyPem must be an Ed25519 public key".
$body = @{ action = "add"; approverId = $approverId; keyId = $keyId; publicKeyPem = [System.IO.File]::ReadAllText($pub); reason = "New refund manager key" } | ConvertTo-Json -Compress
$change = Invoke-RestMethod -Method Post -Uri "$url/approval-issuers/changes" -Headers @{ Authorization = "Bearer $key" } -ContentType "application/json" -Body ([System.Text.Encoding]::UTF8.GetBytes($body))
$change.changeId
Remove-Variable key
```

The checker then approves `changeId` exactly as in step 7's approve block, with the URL `$url/approval-issuers/changes/$changeId/approve` (the step up signature is made the same way, with `--pending-policy-change-id $changeId`). `GET /approval-issuers` then lists the key with `source: governed` and `revoked: false`.

**In code, with a pull request.** Add the entry with the public key file's contents to `TRUSTED_APPROVAL_ISSUERS` in `packages/api/src/bootstrap/codeApprovalIssuers.ts`:

```ts theme={null}
const TRUSTED_APPROVAL_ISSUERS: readonly ConfiguredApprovalIssuer[] = [
  {
    approverId: "manager-NAME",
    keyId: "manager-NAME-key-1",
    revoked: false,
    publicKeyPem: "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----\n",
  },
];
```

Open a pull request; merging deploys it. Until an approver is listed, every signed approval is refused. To revoke an approver, set `revoked: true` and deploy.

## 9. Approver: sign one approval

For one action on one resource, up to an amount, valid 15 minutes by default, usable once:

```powershell theme={null}
$id = "manager-NAME"
npx tsx scripts/sign-approval.ts --private-key-file "$env:USERPROFILE\parmana-approver\${id}__${id}-key-1.private.pem" --approver-id $id --key-id "$id-key-1" --capability paytm:refund --resource-id ORD-1042 --max-amount 75000 --out approval.json
```

The agent sends a new request with `managerApproved: true` and the contents of `approval.json` in `signals.approvalArtifact`. See [Human approval](/concepts/human-approval).

## 10. Rotate or revoke a key

| To | Do |
| - | - |
| rotate an API key | run its step 1, 2 or 4 again (it overwrites the entry file), then step 5 |
| revoke a caller | delete its file from `parmana-entries`, then step 5 |
| rotate a checker's step up key | step 2 again on the checker's machine, then step 5 |
| rotate an approver key | add a new key id (step 8), then revoke the old one |
| revoke an approver added through maker checker | propose `{"action":"revoke",...}` to `/approval-issuers/changes` and approve it ([Manage approvers](/guides/manage-approvers)) |
| revoke an approver in code | `revoked: true` in `TRUSTED_APPROVAL_ISSUERS` (`codeApprovalIssuers.ts`), then deploy |

Rotate at once if a key was pasted into a chat, a ticket or a log.

### Rotating the refund agent service

The refund agent service (`parmana-paytm-agent`) holds three values tied to Parmana. Change them together, or refunds fail in between:

| Variable | On which project | Must match |
| - | - | - |
| `PARMANA_API_KEY` | the agent service | a `paytm:refund` entry in Parmana's `PARMANA_API_KEYS` (step 4) |
| `PAYTM_CONNECTOR_SHARED_SECRET` | the agent service **and** Parmana | the same value on both |
| `AGENT_API_KEY` | the agent service | what every caller of the agent's `/agent/refunds` sends |

Order: set all of them on both Vercel projects, then redeploy Parmana and the agent one right after the other. A new value takes effect only in a new deployment. Afterwards: `GET /callers/me` with the new agent key returns its caller id, and a request to `/agent/refunds` without a key returns `401`. Only a real refund proves the shared secret end to end.

## What was done on 2026-09-27

The production deployment was rebuilt with these steps, in this order:

1. The operator had no working human key, and two keys had been shown in a chat transcript. Every production API key was replaced from scratch with step 5; the old `policy-maker`, `policy-reviewer-1` and `paytm-refund-agent` keys stopped working at the redeploy.
2. The operator decided to hold the maker, checker and approver roles for now. New callers: `charak1987` (maker), `reviewer-charak1987` (checker, with a step up key) and `paytm-refund-agent` (`paytm:refund` only). Governance in production is therefore one person with two credentials until a second person replaces the checker.
3. `reviewer-charak1987` reviewed and approved `customer-refund` 1.1.0 (change `008f504d-0efd-4bec-b33a-2991bb84099f`, proposed by `policy-maker`) at 18:53:40 UTC. Refunds then ran under 1.1.0: automatic up to 10000, a verified manager approval above 10000 and up to 100000, refused above 100000. Since 2026-09-28 `customer-refund` 1.2.0 is in effect: no refund runs without a signed manager approval.
4. The refund agent key was deployed after the approval, so no refund ran under 1.0.0 with the new key, and the refund agent's Vercel project was given it.
5. A refund manager key pair, `manager-charak1987`, was created and, on 2026-09-28, added to `TRUSTED_APPROVAL_ISSUERS` (step 8). Before that, every production refund above 10000 was refused.

The record is `docs/REMAINING-WORK.md` section G.

## What was done on 2026-09-30

Every credential was made again from scratch, in this order:

1. New keys for `charak1987` (maker), `reviewer-charak1987` (checker, with a new step up key pair) and `paytm-refund-agent`, a new `AGENT_API_KEY` and a new `PAYTM_CONNECTOR_SHARED_SECRET`. Both Vercel projects were changed and redeployed together. Each new key returned its own caller id from `GET /callers/me`; every old key returned `401`.
2. A new refund manager key, `manager-charak1987-key-2`, was added through maker checker (step 8), proposed by `charak1987` and approved by `reviewer-charak1987`. `manager-charak1987-key-1`, listed in code, was revoked by PR #88 (`revoked: true`), merged and deployed the same day; its private key was deleted.
3. The old key files were deleted.
4. The 13 policy versions of the G-80 change (every action needs a signed approval, reads included) were proposed by `charak1987` and approved by `reviewer-charak1987`, then merged (PR #87) and deployed. `connector-capability` 1.1.0 was approved a second time: the first proposal was read with Windows PowerShell 5.1 `Get-Content -Raw`, which changed an em dash in its description, so its hash did not match the file and CI refused the merge. The commands on this page read files as UTF-8 for that reason.

The same person still holds the maker, checker and approver credentials.

## Troubleshooting

| Symptom | Cause | Fix |
| - | - | - |
| `ERR_MODULE_NOT_FOUND ... @parmana\shared\dist` | the clone is not built | `npx tsc -b` |
| `The '<' operator is reserved for future use` | a placeholder such as `<name>` was typed literally | replace it with the real value, without angle brackets |
| `authentication required` | the key is not in the deployed list | check step 6; redeploy after changing the list |
| `NON_HUMAN_CALLER_DENIED` | the entry lacks `credentialHolderType: USER` | make the key again with `--credential-holder-type USER` |
| approve refused as the proposer | maker and checker are the same caller | approve with a different caller |
| step up refused | signed more than 120 seconds before sending, or the wrong private key | sign and send in one block |
| a Vercel command stops with `NativeCommandError` in a script | Windows PowerShell 5.1 treats the CLI's banner on stderr as an error when `$ErrorActionPreference` is `Stop` | use `Continue` around Vercel commands and check the result |
| `ConvertFrom-Json : Invalid JSON primitive` | the clipboard held a command, not the value | read the value with `Read-Host` or from a file instead |
| `$*` or a missing backslash in a pasted command | the command was copied from a rendered page that mangled it | copy from the source file |
| a key pasted at a hidden prompt is refused with `401` | in the classic PowerShell window Ctrl+V often does not reach `Read-Host -AsSecureString`, so an empty key was sent | paste with a right click and check that asterisks appear |
| CI `verify-policy-approvals`: "the live content does not match the most recent approval" | the approved content differs from the file, often because PowerShell read it in the local codepage | propose the same version again from the file read as UTF-8, and approve it |
| `publicKeyPem must be an Ed25519 public key` with a valid key | the key was read with `Get-Content -Raw` and sent through `ConvertTo-Json`, which sends an object | read it with `[System.IO.File]::ReadAllText` |
| `gh pr merge`: "the base branch policy prohibits the merge" right after checks pass | GitHub has not yet updated the pull request's merge state | wait until `gh pr view N --json mergeStateStatus` shows `CLEAN`, then merge |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.