What it is
Policy Governance is the mechanism that controls how the content of apolicy.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
APendingPolicyChange (packages/shared/src/domain/pending-policy-change.ts) moves
through exactly three states, and only ever forward, never back:
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 inpackages/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 existingPendingPolicyChange 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)
PassingrequireHumanCaller 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:
verifyChecks(): every side-effect-free check: payload version supported, signature verifies againstreq.callerStepUpPublicKey(the public key on file for the authenticated caller, fromPARMANA_API_KEYS, never the envelope’s own claimed keyId alone), not expired,pendingPolicyChangeIdandactionmatch 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).consumeNonce(): only called if every check above passed, this burns the envelope’s nonce in a dedicatedNonceStore(createPolicyChangeStepUpNonceStore.ts,SupabasePolicyChangeStepUpNonceStorein 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.
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 callsPolicyChangeApprovalService.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():
- Loads the current live content at the write target (
proposedContent.policyVersion, not the pending change’s ownpolicyVersionfield: those can legitimately differ; seePendingPolicyChange’s own doc comment on why an in-place patch to an existing version and a version bump are both allowed proposals). - Computes
contentHashAfter(a hash of the proposed content) and, if a prior version existed,contentHashBefore. - Looks up the most recent
PolicyChangeApprovalRecordfor this same(policyName, writeVersion), if any, and computespreviousRecordHash: 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). - Signs the resulting
PolicyChangeApprovalRecordand persists it viapolicyChangeApprovalRecordRepository.create(): before writing the live file. - Only then calls
policyRepository.save(policyName, writeVersion, proposedContent).
"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): writespolicy.jsonto 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_jsonrows in apoliciestable (migration20260916060000_add_policies_table.sql) via a direct Postgres connection.
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 topolicies/{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’scontentHashAfter, and separately checks that each record’spreviousRecordHashcorrectly 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.tsis 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 againstpolicy_change_approval_recordsvia a read-only Supabaseanoncredential, 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’sverify-policy-approvalsjob 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, aSERVICE-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 livepolicy.jsonfile gets written and a signed approval record gets persisted only once every layer has passed.examples/tutorials/116-supabase-policy-repository/run.ts: provesSupabasePolicyRepositorysatisfies the exact samePolicyRepositorycontract asFilePolicyRepository, hermetically, against a minimal fakepg.Pool(no real network or database). Also reproduces the realEROFSfailure 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: exercisesscripts/local-review-action.ts(sign and submit an approve/reject for an existing pending change in one process) andscripts/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 separatesignandsubmitshell 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, andpackages/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 maliciousproposedContent.policyVersion). packages/api/src/governance/PolicyChangeApprovalService.tsand its unit testpackages/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.tsandpackages/api/tests/unit/isHumanCaller.test.ts.packages/api/src/auth/PolicyChangeStepUpVerifier.tsandpackages/crypto/src/PolicyChangeStepUpAuthorizationCrypto.ts, pluspackages/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) anddocs/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
entryJSON block containing only the key hash and the step-up public key: safe to store or transmit, unlike the other two. The operator appendsentryto thePARMANA_API_KEYSenvironment variable (a JSON array); the checker keeps the bearer key and the private key file on their own machine. - The
PARMANA_API_KEYSentry shape for a checker, concretely: PARMANA_STORAGEdetermines whichPolicyRepositoryimplementation is live: anything other thanmemory(andNODE_ENV !== "test") selectsSupabasePolicyRepository, which additionally requiresDATABASE_URL.packages/governance-uiis 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 runscripts/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:
EROFSon the very first live approve.PolicyChangeApprovalService.approve()had never actually executed against a real, deployed instance before that moment. It failed immediately, becauseFilePolicyRepositoryneeds a writable filesystem and Vercel’s Functions don’t provide one. Fixed by addingSupabasePolicyRepository(above).- The first version of that fix introduced a second, real bug. It constructed
SupabasePolicyRepository(PostgresPoolFactory.create())eagerly, atapplication.ts’s module scope, rather than lazily. This violated a disciplinepackages/api/src/repositories.tshad already established for every other Supabase-backed repository in this codebase (alazyRepository()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: importingapplication.jsat all now opened a real Postgres connection immediately, using whateverDATABASE_URLhappened to be set at that moment: which brokeexamples/tutorials/89-readiness-probe’s “genuinely unreachable database” test scenario, since the pool singleton (PostgresPoolFactorycaches 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 makingpolicyRepositoryconstruction lazy, via the same Proxy patternrepositories.tsalready uses. PostgresPoolFactory.create()had noconnectionTimeoutMillis. Found via the same tutorial: connecting to a genuinely unreachable address hung indefinitely instead of failing fast. Fixed with a 5-second timeout.- The original 2026-08-19 proposals had gone stale. By 2026-09-16, all ten live
policy files had gained an
unboundSignalReasonsdocumentation field the original proposals never had (harmless, non-functional), and two of them (connector-capability,customer-refund) had gained aboundSignalsmapping the original proposals were also missing entirely: a real, functional gap, sinceboundSignalsis what lets the runtime derive a signal likepaymentAmountdirectly 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-createdpoliciestable. 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 viascripts/verify-policy-changes-approved.ts --full-scan.
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.