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

# Chapter 7: Policy Governance and the Maker-Checker Flow

> Policy Governance is the mechanism that controls how the content of a policy.json file

## What it is

Policy Governance is the mechanism that controls how the content of a `policy.json` file
is allowed to change. A policy change cannot take effect the moment someone writes it. It
must first be proposed by one authenticated human (the maker), then reviewed and
cryptographically approved by a second, genuinely different authenticated human (the
checker), before the live policy content updates and a signed, durable approval record is
created. The system enforces the maker and checker being different people at the API
layer itself, not as a process convention someone could forget to follow.

## Why it was built

Before this feature existed, policy authoring sat entirely outside Parmana's own trust
model. `GOVERNANCE.md`, `SECURITY.md`, and `TRUST_MODEL.md` all named policy authoring as
external to the system's scope. In practice this meant any caller with write access to the
`policies/` directory could change what a policy allows or refuses, with no second party
involved and no durable, signed record of who made the change or why. Given that policy
content decides real outcomes (who gets paid, what gets deployed, what an AI agent is
allowed to do), that gap was treated as unacceptable once the system's scope grew to
include policy authoring at all. Policy Governance is the answer: a policy change now goes
through the same kind of two-person, cryptographically-verified control a bank uses for a
wire transfer.

## How it works, in full

### The lifecycle

A `PendingPolicyChange` (`packages/shared/src/domain/pending-policy-change.ts`) moves
through exactly three states, and only ever forward, never back:

```
PENDING_APPROVAL  ->  APPROVED
PENDING_APPROVAL  ->  REJECTED
```

Its fields capture the full proposal: `pendingPolicyChangeId`, the `policyName` and
`policyVersion` it targets, the full `proposedContent` (the entire `policy.json`, never a
diff or patch), `proposedBy` and `proposedAt`, a required `reason`, and once resolved,
`resolvedBy`, `resolvedAt`, and (for a rejection) `rejectionReason`. The type deliberately
types `proposedContent` as plain JSON rather than `@parmana/policy`'s `Policy` type,
because `@parmana/shared` sits below `@parmana/policy` in the dependency graph and cannot
import it without creating an import cycle. Structural validation against the real policy
schema happens one layer up, in the API route, before a `PendingPolicyChange` is ever
created.

### The four endpoints

All four live in `packages/api/src/routes/pending-policy-changes.ts`, mounted at
`/policies` in `packages/api/src/app.ts`.

| Method | Path                              | Purpose                                                         | Who can call it                                                       |
| ------ | --------------------------------- | --------------------------------------------------------------- | --------------------------------------------------------------------- |
| `POST` | `/:name/:version/pending-changes` | Propose a change (maker)                                        | Any authenticated human caller (`credentialHolderType: USER`)         |
| `GET`  | `/pending-changes?status=...`     | List changes, with a `diff` (`current` vs. `proposed`) for each | Any authenticated human caller                                        |
| `POST` | `/pending-changes/:id/approve`    | Approve (checker)                                               | A human caller who is not the proposer, with a valid step-up envelope |
| `POST` | `/pending-changes/:id/reject`     | Reject (checker)                                                | Same as approve, plus a required `rejectionReason`                    |

Every one of the four calls `requireHumanCaller()` first
(`packages/api/src/routes/pending-policy-changes.ts:107`). That function checks
`isHumanCaller(req.callerCredentialHolderType)`
(`packages/api/src/auth/isHumanCaller.ts:22`), which returns true only when the caller's
credential was provisioned with `credentialHolderType: AuthorityType.USER`. An undefined
value, or `ROLE`/`SERVICE`/`ORGANIZATION`, is denied the same as an unset one; the default
is fail closed, never "assume human." A denial is recorded as a signed
`caller.non_human_denied` audit event before the request is rejected with
`NonHumanCallerDeniedError`. Notably, `isHumanCaller` is called only from these four
handlers. It is not wired into the caller-auth middleware globally, so the core execution
pipeline (an AI agent submitting a transaction, for example) stays fully actor-agnostic;
only policy authorship itself is restricted to verified humans.

### Maker cannot equal checker

Both the approve and reject handlers load the existing `PendingPolicyChange` by id and
compare `existing.proposedBy === req.callerId`
(`packages/api/src/routes/pending-policy-changes.ts:505` and `:605`). A match throws
`SameActorCannotApproveOwnChangeError` immediately, before any step-up verification is
even attempted. There is no override, no admin bypass, and no configuration flag for this
check. It is the literal enforcement of "maker never equals checker."

### Step-up authorization (Layer 4)

Passing `requireHumanCaller` and the maker/checker distinctness check is not enough to
approve or reject. The request must also carry a `PolicyChangeStepUpAuthorization`
envelope, checked by `requireStepUpAuthorization()`
(`packages/api/src/routes/pending-policy-changes.ts:165`), which in turn delegates to
`PolicyChangeStepUpVerifier` (`packages/api/src/auth/PolicyChangeStepUpVerifier.ts`). This
is a second, independent cryptographic proof of intent, on top of (never instead of) the
checker's ordinary bearer token.

The envelope is produced entirely offline, on the checker's own machine, by
`PolicyChangeStepUpAuthorizationSigner`
(`packages/crypto/src/PolicyChangeStepUpAuthorizationCrypto.ts:40`). Signing takes the
`pendingPolicyChangeId` and the intended `action` ("approve" or "reject"), stamps a fresh
`nonce`, `authorizedAt`, and an `expiresAt` (120 seconds later by default), and signs the
whole payload with the checker's Ed25519 step-up private key: a key that is always a
separate keypair from the checker's own bearer-token identity, generated independently
(`ArtifactSigner`/`Ed25519SignatureProvider`, hardcoded to Ed25519 regardless of whatever
signature algorithm the server's own runtime signing key uses, since a step-up key is
operator-held and never depends on server-side config at all).

Verification, server-side, runs through `PolicyChangeStepUpVerifier.verify()`
(`packages/api/src/auth/PolicyChangeStepUpVerifier.ts:154`), which composes two phases:

1. **`verifyChecks()`**: every side-effect-free check: payload version supported,
   signature verifies against `req.callerStepUpPublicKey` (the public key on file for the
   authenticated caller, from `PARMANA_API_KEYS`, never the envelope's own claimed keyId
   alone), not expired, `pendingPolicyChangeId` and `action` match what this specific
   request is for, and the envelope's own TTL (`expiresAt - authorizedAt`) does not exceed
   the server's configured maximum (120 seconds by default,
   `PolicyChangeStepUpVerifierOptions.maxTtlSeconds`).
2. **`consumeNonce()`**: only called if every check above passed, this burns the
   envelope's nonce in a dedicated `NonceStore` (`createPolicyChangeStepUpNonceStore.ts`,
   `SupabasePolicyChangeStepUpNonceStore` in production), so the exact same signed
   envelope can never be replayed for a second approval. This nonce namespace is entirely
   separate from execution-authorization or approval-artifact nonces.

A failing check throws `StepUpAuthorizationInvalidError`. The specific reason (missing
envelope, expired, replayed, wrong id, wrong action, bad signature, no step-up key
provisioned at all) is logged server-side via `console.error` only, and never reaches the
HTTP response body: the caller only ever sees a generic 403 with code
`STEP_UP_AUTHORIZATION_INVALID`. This is deliberate: the approve/reject endpoint is
reachable by any authenticated human other than the proposer, not just the pending
change's intended checker, so a granular failure breakdown would hand an attacker holding
a stolen bearer token useful reconnaissance (for example, whether a given caller even has
a step-up key provisioned) they have no legitimate need for. A real checker debugging
their own signing setup has the detail available locally, from the tool that produced the
envelope in the first place.

### What happens on approve

Once every check passes, the approve handler calls
`PolicyChangeApprovalService.approve(existing, req.callerId)`
(`packages/api/src/governance/PolicyChangeApprovalService.ts:53`) *before* marking the
pending change `APPROVED`. This ordering is load-bearing: if the service throws partway
through, the pending change remains untouched at `PENDING_APPROVAL`, never falsely marked
resolved with no corresponding live effect. Inside `approve()`:

1. Loads the current live content at the write target (`proposedContent.policyVersion`,
   not the pending change's own `policyVersion` field: those can legitimately differ; see
   `PendingPolicyChange`'s own doc comment on why an in-place patch to an existing version
   and a version bump are both allowed proposals).
2. Computes `contentHashAfter` (a hash of the proposed content) and, if a prior version
   existed, `contentHashBefore`.
3. Looks up the most recent `PolicyChangeApprovalRecord` for this same
   `(policyName, writeVersion)`, if any, and computes `previousRecordHash`: this chains
   each new record to the one before it, so a deleted or reordered record in the approval
   history becomes detectable (`verifyPolicyGovernanceIntegrityAtStartup`'s "chain-broken"
   check, below).
4. Signs the resulting `PolicyChangeApprovalRecord` and persists it via
   `policyChangeApprovalRecordRepository.create()`: **before** writing the live file.
5. Only then calls `policyRepository.save(policyName, writeVersion, proposedContent)`.

The record-before-file ordering (step 4 before step 5) means the recoverable failure mode
is a persisted record with no corresponding file write yet, which a later integrity check
can compare against and flag as `"missing"`. The alternative order would risk a live
policy silently changing with no durable evidence anything approved it, a governance gap
with nothing to point an integrity check at.

Rejections never touch `PolicyChangeApprovalService` at all. A rejection is purely a
repository state transition (`resolve()` with `outcome: "rejected"` and the required
`rejectionReason`); the rejection reason itself is the durable evidence a rejection
leaves behind, since there is no live effect to sign evidence about.

### Where the approved content actually lives: two `PolicyRepository` implementations

`PolicyChangeApprovalService.save()` writes through the `PolicyRepository` interface
(`packages/policy/src/PolicyRepository.ts`), which has two real implementations:

* **`FilePolicyRepository`** (`packages/policy/src/FilePolicyRepository.ts`): writes
  `policy.json` to local disk at `<basePath>/<name>/<version>/policy.json`, via a
  temp-file-then-atomic-rename pattern. Used for local development and tests, where the
  filesystem is always writable.
* **`SupabasePolicyRepository`** (`packages/policy/src/SupabasePolicyRepository.ts`) :
  added on **2026-09-16**, the same night this book chapter was written. It stores
  `(policy_name, policy_version) -> content_json` rows in a `policies` table
  (migration `20260916060000_add_policies_table.sql`) via a direct Postgres connection.

The reason a second implementation exists at all is a real production incident: Vercel's
serverless Functions run on a **read-only filesystem**. The very first time
`PolicyChangeApprovalService.approve()` ran against a live, deployed instance (all ten of
this system's original real policies had sat `PENDING_APPROVAL` for a month before that
first real approval), the file write failed immediately with `EROFS`. `application.ts`
now chooses between the two implementations based on `config.storage.provider`: memory or
test conditions get `FilePolicyRepository`, any real database configuration gets
`SupabasePolicyRepository`. See the "What was found and fixed" section below for the full
incident chain, including a second bug this same fix introduced and then closed.

### Deploy-time and periodic integrity checking

Two further mechanisms exist to catch a governance bypass after the fact: a direct edit
to `policies/{name}/{version}/policy.json` that skips the pending-change API entirely:

* **`verifyPolicyGovernanceIntegrityAtStartup()`**
  (`packages/api/src/governance/verifyPolicyGovernanceIntegrityAtStartup.ts`) compares
  every approved `(policyName, policyVersion)`'s live content against its most recent
  approval record's `contentHashAfter`, and separately checks that each record's
  `previousRecordHash` correctly chains to the record before it. It is deliberately
  **fail-open**: it never throws, never blocks startup, and every outcome (clean pass,
  mismatch, or "could not run at all") is logged loudly and distinctly, so "nothing to
  report" and "couldn't check" never look the same in deploy logs.
  `schedulePolicyGovernanceIntegrityCheck()` re-runs the same check every 5 minutes for
  the life of the process, using an `.unref()`'d timer so it can never itself keep the
  process alive past shutdown.
* **`scripts/verify-policy-changes-approved.ts`** is the opposite discipline: **fail
  closed**, meant to gate a CI run or a manual pre-deploy check, not to run inside the
  live server. Given a list of changed policy files (or `--full-scan`), it hashes each
  file's live content and checks it against `policy_change_approval_records` via a
  read-only Supabase `anon` credential, scoped by RLS to that one table. A missing
  approval record, a content-hash mismatch, and any failure to complete the check at all
  (Supabase unreachable, a malformed response) are all treated identically as a failure :
  there is no "couldn't check, let it through" path. `.github/workflows/ci.yml`'s
  `verify-policy-approvals` job runs it on every push and pull request.

## How it enables things, with concrete examples

* **`examples/tutorials/103-policy-governance-maker-checker/run.ts`**: the full manual
  flow against a real, locally-running Express server: a maker proposes, a
  `SERVICE`-credentialed caller is denied from proposing at all, the maker is denied from
  approving its own proposal, a distinct checker with no step-up envelope is denied, and
  finally a distinct checker with a valid envelope succeeds: proving the live
  `policy.json` file gets written and a signed approval record gets persisted only once
  every layer has passed.
* **`examples/tutorials/116-supabase-policy-repository/run.ts`**: proves
  `SupabasePolicyRepository` satisfies the exact same `PolicyRepository` contract as
  `FilePolicyRepository`, hermetically, against a minimal fake `pg.Pool` (no real network
  or database). Also reproduces the real `EROFS` failure directly, against a genuinely
  read-only temp directory, on platforms where the OS actually enforces it.
* **`examples/tutorials/117-maker-checker-one-shot-scripts/run.ts`**: exercises
  `scripts/local-review-action.ts` (sign and submit an approve/reject for an existing
  pending change in one process) and `scripts/refresh-approved-policy-content.ts`
  (propose, sign, and submit in one process, always from the current on-disk file), and
  proves both produce the identical durable effects as the fully manual flow. These
  scripts exist because chaining a hand-signed, 120-second-lived envelope across separate
  `sign` and `submit` shell commands is genuinely fragile in practice: see the incident
  notes below.

## How to validate this yourself

* The four route handlers and their tests: `packages/api/src/routes/pending-policy-changes.ts`,
  and `packages/api/tests/integration/pending-policy-changes-governance.integration.test.ts`
  (23 cases: human-only enforcement on all four endpoints, maker-checker distinctness on
  approve/reject, every step-up failure mode, file-write-plus-signed-record content, and
  path-traversal rejection on a malicious `proposedContent.policyVersion`).
* `packages/api/src/governance/PolicyChangeApprovalService.ts` and its unit test
  `packages/api/tests/unit/PolicyChangeApprovalService.test.ts`, which injects a failure
  at each step independently and confirms the ordering guarantee described above.
* `packages/api/src/auth/isHumanCaller.ts` and `packages/api/tests/unit/isHumanCaller.test.ts`.
* `packages/api/src/auth/PolicyChangeStepUpVerifier.ts` and
  `packages/crypto/src/PolicyChangeStepUpAuthorizationCrypto.ts`, plus
  `packages/api/tests/unit/verifyPolicyGovernanceIntegrityAtStartup.test.ts` (7 cases) for
  the integrity checker specifically.
* To actually run the real flow against a real deployment, the two operational documents
  already written for exactly this purpose are `docs/operations/policy-approval-runbook.md`
  (a copy-pasteable checklist for a specific batch of pending policies) and
  `docs/operations/policy-governance-developer-guide.md` (the full lifecycle, every
  command, and a troubleshooting table for every failure mode actually hit while building
  this feature). This chapter explains the system; those two are the how-to.

## Integration requirements

* **Provisioning a checker credential**, one time, per checker:
  ```
  npx tsx scripts/generate-api-key.ts --caller-id "<checker-name>" --credential-holder-type USER --generate-step-up-key
  ```
  This prints a bearer key, a step-up **private** key (PEM, never written to disk by the
  script itself), and an `entry` JSON block containing only the key **hash** and the
  step-up **public** key: safe to store or transmit, unlike the other two. The operator
  appends `entry` to the `PARMANA_API_KEYS` environment variable (a JSON array); the
  checker keeps the bearer key and the private key file on their own machine.
* **The `PARMANA_API_KEYS` entry shape** for a checker, concretely:
  ```json theme={null}
  {
    "callerId": "checker-name",
    "keyHash": "<64-character lowercase hex sha256 of the bearer key>",
    "credentialHolderType": "USER",
    "stepUpPublicKey": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----\n"
  }
  ```
* **`PARMANA_STORAGE`** determines which `PolicyRepository` implementation is live:
  anything other than `memory` (and `NODE_ENV !== "test"`) selects
  `SupabasePolicyRepository`, which additionally requires `DATABASE_URL`.
* **`packages/governance-ui`** is an optional, small, server-rendered Express app covering
  propose, list, and diff-review only. It deliberately has **no** approve or reject
  routes: its diff page instructs a checker to run `scripts/sign-policy-change-step-up.ts`
  (or one of the one-shot scripts) locally and submit the result themselves, with their
  own bearer token, entirely outside the UI. Collecting a step-up private key in a web UI
  would defeat the one property this whole mechanism exists to guarantee: that the key
  never leaves the checker's own machine.

## What was found and fixed the night of 2026-09-16

This system's ten original real production policies (`access-control`,
`connector-capability`, `customer-refund`, `database-change`, `github-pr-approval`,
`hubspot-deal-update`, `llm-tool-call`, `production-deployment`, `rag-document-access`,
`vendor-payment`) had sat `PENDING_APPROVAL`, proposed on 2026-08-19, untouched for
almost a month, because closing that backfill required a genuinely distinct second human
checker to become available. The night that checker finally acted, closing it surfaced a
real chain of issues, none of them hypothetical:

1. **`EROFS` on the very first live approve.** `PolicyChangeApprovalService.approve()`
   had never actually executed against a real, deployed instance before that moment. It
   failed immediately, because `FilePolicyRepository` needs a writable filesystem and
   Vercel's Functions don't provide one. Fixed by adding `SupabasePolicyRepository`
   (above).
2. **The first version of that fix introduced a second, real bug.** It constructed
   `SupabasePolicyRepository(PostgresPoolFactory.create())` eagerly, at
   `application.ts`'s module scope, rather than lazily. This violated a discipline
   `packages/api/src/repositories.ts` had already established for every other
   Supabase-backed repository in this codebase (a `lazyRepository()` Proxy wrapper,
   closing gap G-15: "importing this module must never itself construct a live Supabase
   client; only an actual repository call should"). The practical consequence: importing
   `application.js` at all now opened a real Postgres connection immediately, using
   whatever `DATABASE_URL` happened to be set at that moment: which broke
   `examples/tutorials/89-readiness-probe`'s "genuinely unreachable database" test
   scenario, since the pool singleton (`PostgresPoolFactory` caches one pool
   process-wide) got created against the *real, working* database before the tutorial
   ever got a chance to point it at a deliberately unreachable one. Fixed by making
   `policyRepository` construction lazy, via the same Proxy pattern `repositories.ts`
   already uses.
3. **`PostgresPoolFactory.create()` had no `connectionTimeoutMillis`.** Found via the same
   tutorial: connecting to a genuinely unreachable address hung indefinitely instead of
   failing fast. Fixed with a 5-second timeout.
4. **The original 2026-08-19 proposals had gone stale.** By 2026-09-16, all ten live
   policy files had gained an `unboundSignalReasons` documentation field the original
   proposals never had (harmless, non-functional), and two of them
   (`connector-capability`, `customer-refund`) had gained a `boundSignals` mapping the
   original proposals were also missing entirely: a real, functional gap, since
   `boundSignals` is what lets the runtime derive a signal like `paymentAmount` directly
   from the request's own parameters rather than requiring independent verification. Approving
   the stale content as-is would have silently persisted that gap into the newly-created
   `policies` table. Instead, all ten (plus four more pre-existing policies that had
   never been proposed at all) were re-proposed with their current, correct file content
   and re-approved, confirmed clean via `scripts/verify-policy-changes-approved.ts --full-scan`.

The full, first-hand account of this incident chain, including exact timestamps and every
resulting `policyChangeApprovalRecordId`, is recorded in `docs/CLAIMS.md` §2.26's
"Legacy-policy backfill" entry.

## What remains genuinely open

`POLICY_EXECUTION_VERIFICATION_ENFORCED` (see Chapter 5's sibling material on execution-time
verification, and `docs/CLAIMS.md` §2.35) remains `false` by design. This flag, when
enabled, makes `RuntimeEngine` refuse execution against any policy with no approval
record, an invalid approval-record signature, or content that no longer matches its
approval record. Now that all fourteen real production policies have a genuine approval
record, the original reason the flag defaulted off (an unconditional gate would have
refused every execution in the system) no longer holds. Turning it on is nonetheless a
**separate, deliberate decision**, explicitly reserved for a dedicated step in
`docs/operations/policy-approval-runbook.md` Part 5, not something that happens as a side
effect of closing the backfill. As of this writing, it has not been turned on.
