Skip to main content

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:
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. 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:
    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:
  • 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.