PCL Composite Policies — LogicalPolicy and ForEachPolicy
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
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
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
LogicalQuantifier.And, every child must accept or be skipped:| Child results | Group result |
|---|---|
all pass (or mix of pass and skip) | pass |
all skip | skip |
any fail | fail — 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
LogicalQuantifier.Or, one accepting child is enough:| Child results | Group result |
|---|---|
any pass | pass — the group resolves as accepted |
all fail | fail — AnyOfRejected with the child reverts bundled |
all skip | skip |
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
// 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
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.