Skip to content
Prividium

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. With POLICY_RULES_ENABLED off, 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.

ConditionMatches
OperationExactly one of: deployment, contract call, transfer, token approval, mint, burn, raw, typed message
InitiatorSelected users, machine identities, or holders of a role
SourceSelected wallets, or the wallets of a role's holders
DestinationSelected addresses or contracts, or what the destination is (see below)
MethodSelected 4-byte selectors
Primary typeSelected EIP-712 primary types (typed messages only)
ParametersA decoded argument in a set of addresses, or within a numeric range
AssetThe native token, or selected token contracts
AmountA minimum and maximum for a single operation
Cumulative limitA 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:

ClassMeaning
contractA contract registered to this organization, or a zone-level one
walletAn address associated to some user
unknownNeither — 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 transactionResult
one transfer of 15,000allowed
five transfers of 15,000refused — 75,000 in total
one transfer of 20,000refused — over the maximum on its own
out 15,000, back 15,000allowed; 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.

  1. Edit the draft. Add, edit, disable, reorder, or remove rules. Order matters — reordering is a real change.
  2. Read the validation report. Errors block publication; warnings are for the reviewer to weigh.
  3. 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:

SettingDefaultMeaning
Approvals required1How many eligible approvals a draft needs before it takes effect
Approver roleThe administrators of that scopeWho may approve. Naming a role limits approval to that role's holders
Requests expire after168 hoursAn 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

FindingSeverityMeaning
Unknown role / user / machine identity / contractErrorThe rule points at something outside this organization
Cumulative limit on an amountless operationErrorA contract call or deployment carries no amount, so the limit cannot apply
Approval threshold above the role's membershipErrorThe quorum could never be met
Inapplicable conditionErrorThe operation cannot supply that condition, so the rule could never match
Shadowed ruleWarningAn earlier rule matches everything this one would, so it can never decide
Unconstrained ruleWarningThe rule matches every operation of its type
Asset that is not a registered contractWarningGoverning 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_required and 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 idMeaning
policy.rule.blockA rule blocked it
policy.default_denyNo rule matched, so the default rule blocked it
policy.default_allowNo rule matched and the default rule is switched off, so it was let through
policy.limit_spentA rule would have permitted it, but its limit for the window is spent
policy.decision_requiredNeeds a pre-submission decision that was never obtained
policy.approval_missingA decision exists, but its approval is not granted
policy.frame_unclassifiableAn inner call frame could not be decoded, so it was refused rather than guessed
overlay.unavailableGovernance is enabled but could not be reached; the boundary fails closed
policy.trace_too_largeThe transaction has more governed inner calls than policy will walk
policy.transaction_amount_exceededThe 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.

  1. Give a second seeded user (say test@local.dev) an administrator role — the admin realm role in Keycloak, or an organization role carrying admin_write from Roles in the admin panel.
  2. 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.
  3. Under Policy, open the draft, add a rule, and save it, then request publication.
  4. Sign in as the second administrator and publish the draft. Requesting it as admin@local.dev records no approval of their own — that is dual control working.