Policy
Policy is a list of rules over privileged operations. Rules are evaluated in order and the first match decides; anything that reaches the end is decided by the default rule, the last row of every rule list. It blocks, and it cannot be removed, reordered or duplicated — only switched off.
What a policy covers depends on whether organizations are enabled. With them off there is one policy and it governs the whole zone — every user, since every user is a zone user. With them on, policy is an organization's: each publishes its own, and a signer belonging to no organization is governed by none. The zone's own policy is unavailable in that configuration, so one left behind by enabling organizations stops governing anything rather than quietly refusing traffic. Switching back makes it govern again, unchanged.
Policy sits on top of contract permissions, it does not replace them. Contract permissions decide whether an identity may touch a method at all; policy then decides whether this particular operation may proceed, and under what limits or approvals. Both have to allow it. See Permissions for the layer underneath.
Governance is a feature the Prividium API registers, not a separate deployment. Enable it with
POLICY_RULES_ENABLED=true on the API. The admin panel needs no flag of its own — it reads the registered features from
GET /info and shows the Policy section only when the API actually serves it.
Off is a real off switch rather than a permission change: the routes are never registered, so they answer 404. Turning
it back on changes nothing about the data — the policy_* tables and their migrations are left alone either way.
Do not confuse this with
POLICY_PORT, which mounts the sequencer's validator listener. That listener is where governance is enforced at the chain boundary; the routes here are where it is authored. WithPOLICY_RULES_ENABLEDoff, the listener still serves/admit— it just consults no governance overlay.
What a rule matches on
Every condition is optional, and leaving one out means "any". A rule that constrains nothing matches every operation of its type, which the rule list flags.
| Condition | Matches |
|---|---|
| Operation | Exactly one of: deployment, contract call, transfer, token approval, mint, burn, raw, typed message |
| Initiator | Selected users, machine identities, or holders of a role |
| Source | Selected wallets, or the wallets of a role's holders |
| Destination | Selected addresses or contracts, or what the destination is (see below) |
| Method | Selected 4-byte selectors |
| Primary type | Selected EIP-712 primary types (typed messages only) |
| Parameters | A decoded argument in a set of addresses, or within a numeric range |
| Asset | The native token, or selected token contracts |
| Amount | A minimum and maximum for a single operation |
| Cumulative limit | A total across a rolling time window |
The action is Allow, Block, or Require approval.
A published policy governs everything, not only what it mentions. Any submitted operation no rule matches is blocked by the default rule — inner calls are the exception, and are covered under Where policy is enforced. So a ruleset that only names a token blocks contract deployments and plain native transfers for that organization until you add allow rules for them. The same is true of a ruleset whose every rule is disabled: the default rule blocks all of them.
The default rule
It sits pinned at the bottom of the rule list, and it is the rule that decides most traffic. Switching it off makes the opposite posture explicit: an operation no rule above matched is allowed through to the permission layer rather than blocked.
That is a real choice and it is treated as one. It changes the draft's digest, so a reviewer who approved a digest cannot find the deny switched off underneath it; it shows in the draft comparison as a change to the last rule, in the sentence the reviewer reads before approving; and the policy editor carries a warning for as long as the draft has it off. Reaching production still takes the same dual control as any other publication.
Switching it off is not how you turn governance off. Unset POLICY_RULES_ENABLED on the API for that: with the default
rule off, rules that block or require approval still bite.
A spent limit is still a limit. A rule that would have permitted an operation but whose window is used up refuses it
with policy.limit_spent, whichever way the default rule is set. It is not an operation the rules never addressed, so
it never falls to the default rule — nor, for an inner call, to the permission layer, which would let an operation over
the cap through where the same one under the cap is refused. Turning the default rule off lets through what your rules
never addressed; it does not lift the limits they set. A later rule that does match still wins, so "allow up to the cap,
ask for approval above it" works as written.
Destination classification
A rule can match on what the destination is, rather than naming it:
| Class | Meaning |
|---|---|
contract | A contract registered to this organization, or a zone-level one |
wallet | An address associated to some user |
unknown | Neither — the one-time-address case |
There is no allowlist to maintain: this is derived from the contract registry and wallet associations the deployment
already holds. So "block any transfer to an unrecognized address" is one rule with classification: [unknown].
Named addresses and classifications are alternatives within the condition, the same as every other dimension: a rule listing both matches an operation that satisfies either.
Classification is deliberately not part of an operation's identity. Registering a contract, or associating a wallet, changes how a future operation classifies but never changes the digest of one already decided — so an approval granted earlier cannot be stranded by an unrelated registry change.
Signing requests are checked before signing only
raw and typed_message are signing requests, not transactions. They never reach the chain, so a rule over them binds
whoever asks before signing and has no chain-boundary backstop — unlike every transaction type, which is checked
again as it enters the chain. Treat them as a control on your own initiating service, not as an enforcement guarantee.
Because so little is knowable about them, most conditions do not apply:
raw— initiator and source only.typed_message— initiator, source, primary type, and the domain's verifying contract (matched as the destination).
A rule that sets an inapplicable condition is a validation error, not a silent never-match.
Amounts are in base units
There is no currency conversion anywhere in the engine. A limit of 15000000000 on a 6-decimal stablecoin means 15,000
tokens. Nothing is denominated in USD or EUR.
Amount bounds apply per call and per transaction
A rule's maximum is checked twice: no single call may exceed it, and neither may everything one transaction moves under that rule. Without the second check a transaction could move any multiple of the bound by splitting the movement across the inner calls it makes.
Nothing is netted. What comes back is not a movement from the governed address, so it never matches the rule and never reduces the total — moving an amount out and back inside one transaction does not reset what has been counted.
With a maximum of 15,000:
| The transaction | Result |
|---|---|
| one transfer of 15,000 | allowed |
| five transfers of 15,000 | refused — 75,000 in total |
| one transfer of 20,000 | refused — over the maximum on its own |
| out 15,000, back 15,000 | allowed; a second round trip in the same transaction is refused |
A minimum works the other way round: it says which movements the rule is about, so one below it falls through to the next rule rather than being refused.
Operation type is derived, never declared
The engine works out the operation type, asset, amount, and decoded arguments from the transaction itself. A caller cannot label its own operation to dodge a rule. An operation whose contract or arguments cannot be decoded is refused rather than evaluated against partial information.
Cumulative limits: pooled or per-value
A cumulative limit needs a window and a scope:
- One shared limit — every matching operation draws on the same allowance.
- Per source / initiator / destination — each distinct value gets its own allowance of the same size.
Concurrent requests cannot together exceed a limit; the check and the record are taken under a lock.
Seeing what a limit holds
Policy → Limits lists every enabled rule that carries a limit over time, with what it has consumed, what remains, and when the window frees. A pooled limit reads as one row; a rule scoped by person, source or destination reads as one row per distinct value, ordered by how much each has consumed. The window frees as its oldest charge ages out, not all at once, so "frees" is a moment rather than a reset time.
Search by wallet address or by person to answer the question an operator actually arrives with — "why was this refused?". An allowance nobody has drawn on has no row of its own, so a search for a wallet or a person adds one reading zero rather than coming back empty. This applies to a rule scoped by a single source or person; a destination or multi-key rule adds none.
The figures are computed on each read by the same query the engine charges against, so they are the numbers an evaluation at that instant would have used — two reads seconds apart can legitimately differ, and a decision stops counting the moment it can no longer reach the chain. Nothing is cached; Refresh re-reads on demand.
A cap on a single operation is a different thing and is not shown here: it holds nothing and frees nothing. It is part of the rule, on the Rules tab.
The tab only reads. Resetting or adjusting a consumed allowance is deliberately not offered there.
Which decisions consumed it
Policy → Decisions lists the recorded decisions, newest first, filterable by rule and outcome and searchable by a wallet address on either side of the operation or by a decision id. Each row opens the rule-by-rule trace behind that decision.
A decision is recorded when an operation is evaluated before submission. A rule that consumes state — a limit over time, or an approval — cannot be admitted any other way, so everything charged against such a rule appears here. An operation decided at the chain boundary against rules that consume nothing leaves no record, because nothing was reserved.
Drafts and publication
Editing never touches the live policy. You work on a single draft per policy — one per organization, or one for the zone where organizations are off — then publish it as a new immutable version.
- Edit the draft. Add, edit, disable, reorder, or remove rules. Order matters — reordering is a real change.
- Read the validation report. Errors block publication; warnings are for the reviewer to weigh.
- Publish. Publication is an approval-gated request, not an immediate write. Clicking Publish opens a request and records your own approval on it; the ruleset takes effect the moment the request reaches its threshold. The published version records its author, its reviewer, and an integrity digest.
By default the threshold is one approval from an administrator who is not the draft's author, so a reviewer's single click publishes and nothing waits — the same act it was before publication became governed. Raise the threshold and the request waits in Policy → Approvals until enough approvers act.
The draft's author may ask for publication, but their own approval is never counted. That is dual control: the author can put a change forward, and somebody else decides whether it takes effect.
Publishing supersedes the previous version, which stays readable in the version history. To roll back, load an earlier version into the draft and publish it as a new version — history is never rewritten.
How many approvals publication needs
Policy → Approvals (the button on the policy page) sets this for whichever policy you are editing — an organization's, or the zone's:
| Setting | Default | Meaning |
|---|---|---|
| Approvals required | 1 | How many eligible approvals a draft needs before it takes effect |
| Approver role | The administrators of that scope | Who may approve. Naming a role limits approval to that role's holders |
| Requests expire after | 168 hours | An expired request publishes nothing; the change has to be put forward again |
Left as administrators, zone operators may approve as well, which matches who can publish. A threshold higher than the number of eligible approvers is refused when you save it, rather than at the point someone is trying to ship.
Weakening these settings waits a day. Strengthening the gate — more approvals, or a narrower set of approvers — applies the moment you save it. Weakening it is accepted but held: the settings in force keep governing for 24 hours, and the dialog shows what is waiting and from when. Saving a setting that is no weaker before then replaces the waiting change and takes effect at once.
Fewer approvals, or an approver who is not eligible today, both count as weakening — repointing the approver role at one you can hand to a colleague of your choosing loosens the gate exactly as lowering the count does. Changing only the expiry never waits, because a request freezes its own expiry when it opens.
The delay is there because admin_write on its own reaches these settings. Without it, one administrator could drop the
threshold and repoint the approver role in the moment before publishing, and so authorize their own policy change. The
change is audited when it is accepted, under its own action, so the day it spends waiting is a day it can be seen and
reverted in.
A publication request behaves like any other approval request: its approver set is frozen when it opens, a single rejection settles it, and it is bound to the exact rules that were reviewed. Editing the draft while a request is open invalidates that request, because the approvals it holds were given to content the draft no longer has. Discarding the draft cancels the request but keeps it on record, digest included, so an approval that was given stays visible.
Validation
| Finding | Severity | Meaning |
|---|---|---|
| Unknown role / user / machine identity / contract | Error | The rule points at something outside this organization |
| Cumulative limit on an amountless operation | Error | A contract call or deployment carries no amount, so the limit cannot apply |
| Approval threshold above the role's membership | Error | The quorum could never be met |
| Inapplicable condition | Error | The operation cannot supply that condition, so the rule could never match |
| Shadowed rule | Warning | An earlier rule matches everything this one would, so it can never decide |
| Unconstrained rule | Warning | The rule matches every operation of its type |
| Asset that is not a registered contract | Warning | Governing an unregistered token is allowed, so this flags a likely typo |
Version history and offline copies
Policy → Version history lists every version with its status, rule count, author, reviewer, publication time, and integrity digest. Selecting one shows the rules exactly as they were published.
Two things you can do from there:
- Export a version as JSON — the rules, the digest, the schema fingerprint, and who published it. This is the offline copy an auditor keeps; the digest is what they check a claim against.
- Load into draft — the rollback path. It copies that version's rules into the draft for review; publishing then creates a new version. Earlier versions are never rewritten or resurrected in place.
An author or reviewer who is a member of the organization is shown by name. A zone operator is not a member, so their
identifier is shown instead; audit_logs carries the full actor either way.
Approvals
A Require approval rule opens a request instead of deciding immediately. An approval group is an existing role plus a threshold — there is no separate group entity to maintain.
- The eligible approvers are frozen when the request opens, so a later role change cannot move its quorum.
- A single rejection settles the request; it is not out-voted by later approvals.
- One approver counts once, however many times they act.
- The request is bound to the exact operation. Change any field and you get a different request; the original approval cannot be reused for it.
- Publishing a new policy version invalidates open requests raised under the old one.
- Whether the initiator may approve their own operation, and whether machine identities may approve, are per-rule choices.
- Publication requests share this queue. They gate a ruleset rather than an operation, so they carry no source, destination, or amount, and only identities that may approve one can see it.
Where policy is enforced
Two places, and they agree by design:
Neither place governs a read. A simulated eth_call and gas estimation move nothing, so they are left to the permission
layer — otherwise preparing a transaction would be refused by the very rule the caller is about to ask a decision for.
Before signing. The initiator submits the operation and gets back allow, block, or approval-required, along with a decision id. This is where an approval request is opened and where cumulative allowance is consumed.
At the chain boundary. Every transaction is checked again as it enters the chain. If a decision was already obtained for that exact operation, it is honoured; otherwise the ruleset is applied on the spot.
An operation matching a rule that consumes state — a cumulative limit or an approval — cannot be admitted without
going through the pre-submission step first, because nothing at the chain boundary can reserve allowance or wait for
an approver. Such a transaction is refused with policy.decision_required.
Inner calls. The chain boundary also checks the calls a contract makes on the signer's behalf, and every kind of rule reaches them — a limit or an approval included. An inner call governed by a rule that consumes state draws on a decision of its own, recorded for that call rather than for the operation that was submitted. That is what makes an asset-scoped limit usable on a routed transfer: "cap USDC per day" does not match a call to a router, which carries no asset, but it does match the transfer the router makes.
An inner call with no decision to draw on is refused with policy.decision_required. The refusal names the kind of
operation and where it went, and stops there: the calldata and the amount are computed from state the submitter may hold
no disclosure on. Obtaining a decision needs the exact call, which today only whoever wrote the contract interaction has
— an initiator who did not write it cannot complete the loop from the refusal alone. Where the rule's window is already
spent it is refused with policy.limit_spent instead: no decision can be obtained for it until the window frees, so
asking for one would be an instruction nobody can follow. The same call made twice in one transaction needs two
decisions: a batch of equal payouts is one governed operation several times over, and one decision covering all of them
would charge the limit once for the whole batch.
An inner call no rule matches is left to the capability gate rather than blocked. Asking about such a call and getting a default-deny answer does not change that — a decision only speaks for an inner call if a rule matched it. A call a rule turned down only for exceeding its maximum is not that case: the ruleset does address that call, so the default rule blocks it as it would a submitted one.
Nothing is spent unless the whole transaction is admitted: a trace one inner call refuses consumes no allowance from the others.
Where organizations are enabled, signers belonging to none are not governed by any policy.
How long a decision stays good
A decision is tied to the ruleset version that produced it, and it expires.
- Publishing supersedes it. A decision granted by an earlier version stops being honoured the moment a new version is published, so a rule you add to block something takes effect even for operations already evaluated under the old policy. The boundary simply re-evaluates.
- It ages out after 5 minutes. Measured from whichever is later, when the decision was recorded or when its approval was granted. Past that the decision is no longer honoured and the operation must be submitted again.
- One transaction spends it. The first transaction to reach the chain boundary with that decision claims it, and the
boundary keeps admitting that same transaction — it is asked more than once per transaction. A second transaction
carrying the same operation is refused with
policy.decision_requiredand has to be decided in its own right, so one approval moves one transaction and a cumulative limit is charged once per transaction rather than once per operation. A replacement — a fee bump, a cancel, a re-send under a new nonce — is a different transaction, so it needs its own decision, and the one it replaces keeps its charge against the cumulative window until that window passes. Size a daily limit with a little room for the replacements a busy week produces.
Do not confuse that window with a rule's approval expiry, capped at 7 days: that is how long an approval request may stay open awaiting approvers, not how long the resulting decision is honoured.
Guard the policy listener's bearer token like a chain secret. Anyone who can reach the listener with it can spend a governed signer's decision against a transaction hash of their choosing, which leaves that signer's real transaction refused until they ask for a decision again. Keep the listener on the network the sequencer uses and nothing wider.
Binding needs the transaction's hash, and not every check carries one. The hash is optional on the wire, so an older sequencer works but binds nothing at all: there, one decision admits every transaction carrying the same operation until it ages out, while the allowance is charged once.
A current sequencer sends it when a transaction is submitted over RPC, and that is the check that binds. Three paths still ask without it: gas estimation, the re-check while a block is sealed, and any transaction that never went through RPC — an L1→L2 deposit or an upgrade transaction, which are taken straight from the priority queue. Those paths admit an operation rather than a transaction, so they can ride a decision another transaction already holds.
In practice: a cumulative limit or an approval is a hard per-transaction cap for ordinary user traffic submitted over RPC. Do not size it as a hard cap on chain-initiated transactions.
Approved calls count toward that total too. Two inner calls each approved on their own still add up against the rule's per-call limit, and the transaction is refused if the total goes over it. Split it, or set the limit to what one transaction may move in aggregate.
Decision ids on a refusal
| Rule id | Meaning |
|---|---|
policy.rule.block | A rule blocked it |
policy.default_deny | No rule matched, so the default rule blocked it |
policy.default_allow | No rule matched and the default rule is switched off, so it was let through |
policy.limit_spent | A rule would have permitted it, but its limit for the window is spent |
policy.decision_required | Needs a pre-submission decision that was never obtained |
policy.approval_missing | A decision exists, but its approval is not granted |
policy.frame_unclassifiable | An inner call frame could not be decoded, so it was refused rather than guessed |
overlay.unavailable | Governance is enabled but could not be reached; the boundary fails closed |
policy.trace_too_large | The transaction has more governed inner calls than policy will walk |
policy.transaction_amount_exceeded | The calls one rule matched add up past its maximum, though each was within it |
Machine access
An M2M application needs the org_policy_access system permission to use the policy engine, in addition to being linked
to the organization. Being linked alone is not enough — see
System permissions.
With organizations switched off there is no organization to be linked to: the application needs the same permission held zone-wide, through a role that names no organization. A grant inherited from an organization does not reach the zone's policy.
An application can evaluate and simulate operations. It cannot submit one that matches a cumulative limit or an approval
rule: the chain boundary only honours decisions recorded for the signing wallet's user, so such an operation from a
machine identity is refused with policy.decision_required.
The inspector
The inspector answers "what would happen to this operation, and why" without recording anything or consuming allowance. It shows the normalized operation, its digest, every rule visited in order, and which condition ruled each one out — against either the active policy or the draft.
Use it to check a draft before publishing it.
Explaining a decision that already happened
To explain something that was actually decided rather than a hypothetical, open the decision itself. It shows the same rule-by-rule trace, but for the recorded outcome.
- From Approvals, use Why? on any row.
- By link:
/policy?decision=<decisionId>opens the trace directly, so a refusal can be explained from an audit entry or a chat message without reconstructing the operation by hand.
Trying it locally
Publication needs two administrators, because the author's own approval is never counted. The local stack seeds only one
(admin@local.dev); every other seeded user holds the user realm role, and approvers are drawn from the holders of a
role carrying admin_write. So grant a second user an administrator role before starting, or publication will open a
request that nobody present can approve.
- Give a second seeded user (say
test@local.dev) an administrator role — theadminrealm role in Keycloak, or an organization role carryingadmin_writefrom Roles in the admin panel. - Sign in as
admin@local.dev. With organizations enabled, pick one in the organization switcher — policy is that organization's, so nothing is shown until one is selected. With them switched off, Policy opens directly on the zone's own. - Under Policy, open the draft, add a rule, and save it, then request publication.
- Sign in as the second administrator and publish the draft. Requesting it as
admin@local.devrecords no approval of their own — that is dual control working.