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: a change to a policy file was not part of anything the system checked. In practice this meant any caller with write access to thepolicies/ 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). That function checks
isHumanCaller(req.callerCredentialHolderType)
(packages/api/src/auth/isHumanCaller.ts), 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 from these four
handlers and also from governanceGuards.ts, policy-in-effect.ts and execution-intents.ts. 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 at lines 397 and 528). 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), 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:43). 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) 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): stores(policy_name, policy_version) -> content_jsonrows in apoliciestable via a direct Postgres connection.
application.ts chooses the policy repository from config.storage.provider: memory or test configurations get FilePolicyRepository, and any real database configuration gets SupabasePolicyRepository.
Deploy-time and periodic integrity checking
At startup the check is a gate.assertPolicyGovernanceIntegrityAtStartup() runs before the
port binds, and the server does not start on a mismatch or if the check cannot run. The
periodic re-run described below stays fail-open and logs.
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’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 with--full-scanon every push tomainand on every 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 a hand-signed envelope lives only 120 seconds, so chaining the steps by hand across separate commands is error-prone.
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(28 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(12 cases) for the integrity checker specifically.- To actually run the real flow against a real deployment, use Policy lifecycle and approvals (the full lifecycle, every command, and a troubleshooting table) and Credentials for maker, checker, approver and agent. 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 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.
Execution-time verification
Execution time policy governance verification is enforced.RuntimeEngine refuses execution against any policy with no approval record, an invalid approval-record signature, or content that no longer matches its approval record, and ExecutionGateway runs the same check again at release, requiring the approved hash, the hash signed into the authorization, and the live hash to agree. Since 2026-09-20 this is on everywhere except when NODE_ENV is test or development and PARMANA_ALLOW_UNGOVERNED_POLICY=true. Setting POLICY_EXECUTION_VERIFICATION_ENFORCED=true forces enforcement in those environments too; it cannot switch enforcement off in production. The consequence for operators is that every policy a deployment executes against must have a genuine signed approval record before that deployment is promoted, or executions under it are refused.