PCL Composite Policies — LogicalPolicy and ForEachPolicy

mechanism compliance

Compose leaf policy templates into AND / OR trees, or fan a single child policy across a subject set (e.g. every agent owner), with well-defined reduction rules over pass / fail / skip stage results.

LogicalPolicy and ForEachPolicy are structural policy templates that combine other policies rather than enforcing a single rule directly. LogicalPolicy bundles children under an AND or OR quantifier; ForEachPolicy fans a single child across a subject set such as the agent owners resolved for the transaction's caller. Each child produces one of three stage results — pass, fail, or skip — and the composite's reduction behavior is defined precisely over these outcomes. Understanding the pass / skip distinction matters when a child policy is inapplicable (e.g., a VOLUME_POLICY whose asset does not match the transaction's asset).

Struct shape

Both composites live in IPcl.sol and follow the same PolicySet wrapping convention as the leaf templates:
enum LogicalQuantifier { Unspecified, And, Or }
enum ForEachQuantifier { Unspecified, Any, Every }
enum ForEachSubject { Unspecified, AgentOwners }

struct LogicalPolicy {
    LogicalQuantifier quantifier;
    PolicySet[] children;
}

struct ForEachPolicy {
    ForEachQuantifier quantifier;
    ForEachSubject subject;
    PolicySet child;
}

Stage results — pass, fail, skip

Every leaf evaluation returns one of three stage results:

  • pass — the policy applied to this operation and accepted it.
  • fail — the policy applied to this operation and rejected it (with a typed ReasonCode).
  • skip — the policy did not apply to this operation and has no opinion (for example, the record-only after-execution stage of a validate-only policy).


How an inapplicable child resolves is per-policy, not universal. VOLUME_POLICY evaluates inapplicable operations as pass: bound to "uother", it sees a transaction on aokrw and yields pass, and a zero-amount transfer on the matching asset also yields pass. PERIODIC_VOLUME_POLICY, by contrast, yields skip on an asset mismatch or a zero value. This matters for OR composition: pass resolves the OR group as accepted, whereas skip does not.

AND semantics

For LogicalQuantifier.And, every child must accept or be skipped:

Child resultsGroup result
all pass (or mix of pass and skip)pass
all skipskip
any failfail — returns the first deterministic failure with its ReasonCode

All children are evaluated and the results are then reduced — the group fails if any child failed; evaluation does not stop at the first fail.

OR semantics

For LogicalQuantifier.Or, one accepting child is enough:

Child resultsGroup result
any passpass — the group resolves as accepted
all failfailAnyOfRejected with the child reverts bundled
all skipskip
mix of skip and fail (no pass)skip — the group has no opinion; a skipping child means the OR cannot cleanly reject

The important consequence: in an OR of [EAS_POLICY, DENYLIST_POLICY], if EAS_POLICY skips (e.g. the caller has no attestation lookup context yet) and DENYLIST_POLICY fails, the OR returns skip, and the enforcement layer treats a skip composite as non-blocking. Design OR branches so at least one child unambiguously accepts (pass) the intended callers.

ForEachPolicy — Any vs Every

ForEachPolicy runs a single child policy against each element of a subject set (currently AgentOwners — the owners of the agent identity attached to the transaction caller). The quantifier decides how per-element results roll up:

  • Any — pass if the child passes for any subject element.
  • Every — pass only if the child passes for every subject element.


Use ForEachPolicy with subject = AgentOwners to apply a leaf policy (e.g. an EAS_POLICY that requires KYC attestation) to every owner of the agent wallet that initiated the transaction, rather than to the agent wallet itself.

Practical example

Require either a valid EAS attestation or that the caller passes an OKRW periodic-volume cap — but only reject when at least one branch produces a concrete rejection.
// Compose EAS_POLICY OR OKRW_EAS_PERIODIC_VOLUME_LIMIT_POLICY.
// If both are inapplicable to the current call, the OR skips (non-blocking).
// If either accepts, the OR passes.
LogicalPolicy memory outer = LogicalPolicy({
    quantifier: LogicalQuantifier.Or,
    children: new PolicySet[](2)
});

EasPolicy memory eas = EasPolicy({
    easContract:   EAS_ADDR,
    indexContract: INDEX_ADDR,
    schemaUid:     KYC_SCHEMA_UID
});
outer.children[0] = PolicySet({
    templateId: "EAS_POLICY",
    policy:     abi.encode(eas),
    selector:   ""
});

OkrwEasPeriodicVolumeLimitPolicy memory cap = OkrwEasPeriodicVolumeLimitPolicy({
    easContract:        EAS_ADDR,
    indexContract:      INDEX_ADDR,
    schemaUid:          KYC_SCHEMA_UID,
    maxAmount:          10_000_000 ether,
    resetPeriodSeconds: 86_400
});
outer.children[1] = PolicySet({
    templateId: "OKRW_EAS_PERIODIC_VOLUME_LIMIT_POLICY",
    policy:     abi.encode(cap),
    selector:   ""
});

PolicySet memory root = PolicySet({
    templateId: "LOGICAL_POLICY",
    policy:     abi.encode(outer),
    selector:   ""
});

Depth and nesting limits

Composites can be nested (a LogicalPolicy child can itself be another LogicalPolicy or ForEachPolicy), but the chain enforces a maximum evaluation depth. Exceeding it reverts with MaxDepthExceeded(maxDepth). Keep trees shallow — most production policies compose at most two levels (an outer OR / AND wrapping a handful of leaves). Nested child PolicySets carry empty selectors — the selector belongs on the outer (top-level) PolicySet, not on nested children; ChildSelectorNotEmpty is the declared error for this rule.
Source: maroo
ESC
Type to search