Skip to main content
Audience: teams integrated against PolicyRegistry on Beryl, creating and administering simple ALLOWLIST/BLOCKLIST policies, and referencing policy IDs on B20 policy slots. This note covers only the composite (UNION/INTERSECT) policy support landing at the Cobalt hardfork.

Summary

At Cobalt, PolicyRegistry gains composite policies: a policy that authorizes by combining 2–4 existing simple policies under a UNION (OR) or INTERSECT (AND) gate. Create one with the new createCompositePolicy, and mutate it in full with the new updateComposite. Nothing you call today breaks: every Beryl selector, event topic, and error keeps its exact 4-byte selector or topic0 and stays dialable at Cobalt. The only change to existing behavior is that createPolicy and createPolicyWithAccounts gain one new, previously unreachable, revert path, rejecting a composite policyType with the already-existing IncompatiblePolicyType error. Until Cobalt activates, only the Beryl (simple-policy) surface exists on-chain, and every composite selector below is undialable.

Mapping table

The selectors and topic0s below are computed directly from src/interfaces/IPolicyRegistry.sol with cast sig and cast sig-event. Every Beryl symbol keeps its selector at Cobalt.

PolicyType enum

Functions

isAuthorized(uint64,address) 0x55a1179e, policyExists(uint64) 0x330f5637, policyAdmin(uint64) 0x09dd0a47, pendingPolicyAdmin(uint64) 0x017548b7, updateAllowlist(uint64,bool,address[]) 0x3388fb5b, updateBlocklist(uint64,bool,address[]) 0x5c4e51b8, stageUpdateAdmin(uint64,address) 0x1d7ae695, finalizeUpdateAdmin(uint64) 0x33031a9c, and renounceAdmin(uint64) 0xefdb7fa3 carry over unchanged.

Events

PolicyAdminStaged, PolicyAdminUpdated, AllowlistUpdated, and BlocklistUpdated carry over unchanged. They aren’t emitted for composites, because composites have no membership set of their own.

Errors

New at Cobalt: adopt these

createCompositePolicy(admin, policyType, childPolicyIds)

Creates a UNION/INTERSECT policy over 2–4 existing simple policy IDs. Each check fires before the next, in this order:
  1. ZeroAddress: admin == address(0).
  2. IncompatiblePolicyType: policyType isn’t UNION/INTERSECT.
  3. ChildPoliciesOutsideOfRange: childPolicyIds.length is outside [2, 4].
  4. PolicyNotFound: any child doesn’t exist. This is checked as one pass over the whole set, before the next check.
  5. InvalidChildPolicy: any child is a composite or a built-in sentinel. This is a second pass.
On success, it emits PolicyCreated(policyId, creator, policyType), PolicyAdminUpdated(policyId, 0, admin), then CompositePolicyUpdated(policyId, creator, childPolicyIds).

updateComposite(policyId, childPolicyIds)

Replaces a composite’s child-policy set in full. A child omitted from the new set no longer governs the composite; there’s no partial-update or clear-the-list path. Checks run in this order:
  1. PolicyNotFound: policyId doesn’t exist.
  2. IncompatiblePolicyType: policyId is a simple policy, not a composite.
  3. Unauthorized: the caller isn’t the current admin. A renounced composite (admin address(0)) can never be updated.
  4. ChildPoliciesOutsideOfRange: the new count is outside [2, 4].
  5. PolicyNotFound: any new child doesn’t exist.
  6. InvalidChildPolicy: any new child is a composite or a built-in sentinel.
It emits CompositePolicyUpdated(policyId, updater, childPolicyIds).

Live, depth-1 evaluation

isAuthorized on a composite calls each child policy’s isAuthorized; it never uses a snapshot taken at creation or the last update. UNION returns true on the first authorizing child (it short-circuits). INTERSECT returns false on the first non-authorizing child. Recursion never exceeds depth 1, because every child is validated to be a simple (ALLOWLIST/BLOCKLIST) policy at write time, so a composite’s children can never themselves be composites.

Guarantees and edge cases

Q: Can a composite’s child be another composite (nested composites)? No. createCompositePolicy and updateComposite revert InvalidChildPolicy(childPolicyId) for any child whose type is UNION/INTERSECT. Nesting is impossible by construction. Q: Can a built-in sentinel (ALWAYS_ALLOW/ALWAYS_BLOCK) be a composite child? No, for the same InvalidChildPolicy revert. To mix always-allow or always-block behavior into a composite gate, use a real ALLOWLIST/BLOCKLIST policy that reproduces the effect you want instead. Q: Can I pass the same child ID twice, or shrink a composite below 2 children? Duplicates are allowed. The registry neither sorts nor deduplicates the stored child list; the only cost is extra evaluation, since UNION/INTERSECT are idempotent under duplicates. Shrinking below 2 isn’t possible: every updateComposite call enforces the same [2, 4] range as creation, so there’s no path to an empty or undersized composite. Q: If a child policy’s admin renounces, does the parent composite break? No. renounceAdmin on the child only clears its admin and freezes its future membership changes. The child still exists, and isAuthorized on it still resolves normally, so the composite keeps evaluating it exactly as before. Q: Is composite mutation gated separately from simple-policy mutation? No. createCompositePolicy and updateComposite are gated by the same ActivationRegistry flag that gates createPolicy, updateAllowlist, and others. There’s no composite-specific activation flag. compositePolicyChildIds, MIN_COMPOSITE_CHILD_POLICIES, MAX_COMPOSITE_CHILD_POLICIES, and isAuthorized on a composite ID are all always callable, whether or not the feature is active. Q: Can a B20 token’s policy slot (for example, TRANSFER_SENDER_POLICY or SEIZE_HOLDER_POLICY) reference a composite ID? Yes. B20 stores every policy slot as an opaque uint64 policyId and calls isAuthorized, so a composite ID works exactly like a simple one, and no B20-side change was needed. As with any policy ID, validate policyExists(policyId) before writing it to a slot.