Audience: teams integrated againstPolicyRegistryon Beryl, creating and administering simpleALLOWLIST/BLOCKLISTpolicies, 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 fromsrc/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:
ZeroAddress:admin == address(0).IncompatiblePolicyType:policyTypeisn’tUNION/INTERSECT.ChildPoliciesOutsideOfRange:childPolicyIds.lengthis outside[2, 4].PolicyNotFound: any child doesn’t exist. This is checked as one pass over the whole set, before the next check.InvalidChildPolicy: any child is a composite or a built-in sentinel. This is a second pass.
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:
PolicyNotFound:policyIddoesn’t exist.IncompatiblePolicyType:policyIdis a simple policy, not a composite.Unauthorized: the caller isn’t the current admin. A renounced composite (adminaddress(0)) can never be updated.ChildPoliciesOutsideOfRange: the new count is outside[2, 4].PolicyNotFound: any new child doesn’t exist.InvalidChildPolicy: any new child is a composite or a built-in sentinel.
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.