diff --git a/docs/base-chain/specs/reference/b20/constants-and-addresses.mdx b/docs/base-chain/specs/reference/b20/constants-and-addresses.mdx
index 65c57caed..13eb9768a 100644
--- a/docs/base-chain/specs/reference/b20/constants-and-addresses.mdx
+++ b/docs/base-chain/specs/reference/b20/constants-and-addresses.mdx
@@ -45,7 +45,7 @@ These addresses are identical on every network where B20 is active.
| Name | Value |
|---|---|
| `ALWAYS_ALLOW` | `0` |
-| `ALWAYS_BLOCK` | `(uint64(uint8(IPolicyRegistry.PolicyType.ALLOWLIST)) << 56) \| 1` |
+| `ALWAYS_BLOCK` | `(uint64(uint8(IPolicyRegistry.PolicyType.ALLOWLIST)) << 56) \| 1` |
Custom policy IDs use this layout:
@@ -53,6 +53,8 @@ Custom policy IDs use this layout:
[8-bit PolicyType][56-bit counter]
```
+Simple and composite policies share the same global counter. The counter starts at `2` because `0` and `1` are reserved for `ALWAYS_ALLOW` and `ALWAYS_BLOCK`.
+
Policy type bytes:
| PolicyType | Byte |
@@ -62,6 +64,12 @@ Policy type bytes:
| `UNION` | `0x02` |
| `INTERSECT` | `0x03` |
+`UNION` and `INTERSECT` are composite policy types. A `UNION` policy authorizes an account if any child policy authorizes it. An `INTERSECT` policy authorizes an account only if every child policy authorizes it. Each composite references two to four existing simple (`ALLOWLIST` or `BLOCKLIST`) policies. Composite policies cannot reference other composites.
+
+
+A well-formed but never-created `INTERSECT` policy ID returns `true` for every account (vacuously authorized). Always call `policyExists(policyId)` before storing a composite policy ID; an invalid `INTERSECT` ID behaves like `ALWAYS_ALLOW`.
+
+
## Variant bytes
| Variant | Byte | Address shape |
diff --git a/docs/base-chain/specs/reference/b20/errors-and-events.mdx b/docs/base-chain/specs/reference/b20/errors-and-events.mdx
index d4888150c..5a4f9c2f0 100644
--- a/docs/base-chain/specs/reference/b20/errors-and-events.mdx
+++ b/docs/base-chain/specs/reference/b20/errors-and-events.mdx
@@ -22,8 +22,8 @@ description: "Reverse lookup B20 errors and events by selector or topic."
| error | `BatchSizeTooLarge` | `0x083e2f67` | `IPolicyRegistry` | A membership batch exceeded the registry limit. |
| event | `BlocklistUpdated` | `0x2ff63c102b1b9fd7f5d39f83039c5d6aaf50a414a4f2def2704e41be2628f1e3` | `IPolicyRegistry` | One or more accounts had their BLOCKLIST membership set to `blocked` in a single batch. |
| event | `BurnedBlocked` | `0x0b552e96653fd6842da37c477005d3b5c08a8c7d3631b1f43787b2dc9a1006a3` | `IB20` | Emitted by the deprecated `burnBlocked` in addition to `Transfer(from, address(0), amount)`. |
-| error | `ChildPoliciesOutsideOfRange` | `0x697ec868` | `IPolicyRegistry` | A composite policy was created or updated with a child-policy count outside the |
-| event | `CompositePolicyUpdated` | `0x4ff6adaab31b0df87aa7b8b7320c52b8b3b5eede3bf28a6baaaa8b8b7e1d6363` | `IPolicyRegistry` | A composite policy's child set was set or replaced in full with `childPolicyIds`. Emitted |
+| error | `ChildPoliciesOutsideOfRange` | `0x697ec868` | `IPolicyRegistry` | A composite policy was created or updated with a child-policy count outside the allowed range of 2 to 4. |
+| event | `CompositePolicyUpdated` | `0x4ff6adaab31b0df87aa7b8b7320c52b8b3b5eede3bf28a6baaaa8b8b7e1d6363` | `IPolicyRegistry` | A composite policy's child set was set or replaced in full with `childPolicyIds`. Emitted by `createCompositePolicy` and `updateComposite`. |
| error | `ContractPaused` | `0xf9df5ac9` | `IB20` | The `PausableFeature` covering this operation is currently paused. |
| event | `ContractURIUpdated` | `0xa5d4097edda6d87cb9329af83fb3712ef77eeb13738ffe43cc35a4ce305ad962` | `IB20` | Emitted by `updateContractURI`. Per ERC-7572, parameterless: integrators re-fetch `contractURI()`. |
| error | `DelegateCallNotAllowed` | `0x0d89438e` | `IActivationRegistry` | The precompile was invoked via `DELEGATECALL` or `CALLCODE`. |
@@ -38,7 +38,7 @@ description: "Reverse lookup B20 errors and events by selector or topic."
| event | `FeatureActivated` | `0x8c7a0ecdbb8d96e867e43ec1aef80027976ee493c18bef399fa799ed19752451` | `IActivationRegistry` | Emitted when `feature` is activated. |
| event | `FeatureDeactivated` | `0x15bf65a782c3258c63268ba9d7aed710cf9f9315d3687d9a4632ccdad7926c84` | `IActivationRegistry` | Emitted when `feature` is deactivated. |
| error | `FeatureNotActivated` | `0xb9b2a425` | `IActivationRegistry` | Feature is not activated. |
-| error | `IncompatiblePolicyType` | `0xf1011ef5` | `IPolicyRegistry` | The operation is incompatible with the policy's type. |
+| error | `IncompatiblePolicyType` | `0xf1011ef5` | `IPolicyRegistry` | The operation is incompatible with the policy's type. `createPolicy` and `createPolicyWithAccounts` revert with this error when called with `UNION` or `INTERSECT`. |
| error | `InitCallFailed` | `0x4eae0860` | `IB20Factory` | One of the `initCalls` reverted. The factory bubbles the underlying revert reason |
| error | `InsufficientAllowance` | `0x192b9e4e` | `IB20` | `spender`'s allowance is less than `needed` for the requested `transferFrom`. |
| error | `InsufficientBalance` | `0xdb42144d` | `IB20` | `sender`'s balance is less than `needed` for the requested transfer or burn. |
@@ -46,7 +46,7 @@ description: "Reverse lookup B20 errors and events by selector or topic."
| error | `InternalCallMalformed` | `0x4e2f143e` | `IB20Asset` | An inner call dispatched by `announce` was shorter than four bytes. |
| error | `InvalidAmount` | `0x2c5211c6` | `IB20` | An amount argument was zero where a non-zero value is required. Not used for ERC-20 amount arguments. |
| error | `InvalidApprover` | `0x8bc146c4` | `IB20` | The approval's `owner` address is invalid (typically `address(0)`). |
-| error | `InvalidChildPolicy` | `0x46508ef6` | `IPolicyRegistry` | Composite policies are not simple policies. Child policies must be existing |
+| error | `InvalidChildPolicy` | `0x46508ef6` | `IPolicyRegistry` | A child policy ID passed to `createCompositePolicy` or `updateComposite` is not a valid simple policy. Child policies must be existing `ALLOWLIST` or `BLOCKLIST` policies; composite policies and the built-in sentinels `ALWAYS_ALLOW` and `ALWAYS_BLOCK` are not valid children. |
| error | `InvalidCurrency` | `0x997c1de8` | `IB20Factory` | The stablecoin `currency` was non-empty but contained a non-`A`-`Z` byte. |
| error | `InvalidDecimals` | `0xca950391` | `IB20Factory` | The asset `decimals` was outside the allowed inclusive range |
| error | `InvalidMetadataKey` | `0x86ea3abb` | `IB20Asset` | `updateExtraMetadata` was called with an empty `key`. |
@@ -75,26 +75,4 @@ description: "Reverse lookup B20 errors and events by selector or topic."
| event | `PolicyAdminStaged` | `0xdbf3b34a4c956c56ca05cd4b8f9293a4347ad61994445a4b89817d4a19561136` | `IPolicyRegistry` | A new admin was staged. `pendingAdmin == address(0)` clears a prior nomination. |
| event | `PolicyAdminUpdated` | `0x98925cfb1bc09c5b43dd0dd56d3d95aa04fb3300927580cc588c3f5dd58c15e1` | `IPolicyRegistry` | The active admin changed. `newAdmin == address(0)` indicates renunciation; |
| event | `PolicyCreated` | `0xdc870ce85be577234b8548f42b93f84bbff6d0c1f38ee725c809c49932a13885` | `IPolicyRegistry` | A new policy was created. |
-| error | `PolicyForbids` | `0xa43fec12` | `IB20` | A policy slot denied the operation. |
-| error | `PolicyNotFound` | `0xcccad523` | `IB20` | The provided policy ID does not exist in the policy registry. |
-| error | `PolicyNotFound` | `0x720caa4f` | `IPolicyRegistry` | The referenced policy ID does not exist. |
-| event | `PolicyUpdated` | `0x8b4790f7ff717fc8f60f07ae099e47ef318dc04b37ae98056b50a22b79056626` | `IB20` | Emitted by `updatePolicy` when a token's policy slot is changed. Initial slot assignment at |
-| event | `RoleAdminChanged` | `0xbd79b86ffe0ab8e8776151514217cd7cacd52c909f66475c3af44e129f0b00ff` | `IB20` | Emitted by `setRoleAdmin` when the admin role for `role` changes. |
-| event | `RoleGranted` | `0x2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d` | `IB20` | Emitted when `account` is granted `role`. `sender` is the originating caller. |
-| event | `RoleRevoked` | `0xf6391f5c32d9c69d2a47ea670b442974b53935d1edc7fd64eb21e047a839171b` | `IB20` | Emitted when `role` is revoked from `account`. `sender` is the originating caller |
-| error | `UIMultiplierUpdateExists` | `0x4481a68e` | `IB20Asset` | `updateUIMultiplier` was called while a live pending update already exists |
-| event | `Seized` | `0xa9aec5d8b86e2fa2fd6ac3af62f2622e3dfdab1967d4cbbb56a5df7d74cb887c` | `IB20` | Emitted by `seizeWithMemo` in addition to `Transfer(from, to, amount)` (and the |
-| error | `StaticCallNotAllowed` | `0xbeaba5b7` | `IActivationRegistry` | A state-mutating entry point was invoked from a `STATICCALL` frame. |
-| error | `SupplyCapExceeded` | `0x4b344b11` | `IB20` | The mint would push `totalSupply` past the configured cap. |
-| event | `SupplyCapUpdated` | `0x6d14f44808ce024f263432bc38d019a9951fbe674e9898b54844dbc8dc09c23a` | `IB20` | Emitted by `updateSupplyCap`. |
-| event | `SymbolUpdated` | `0x64e8b5c6dcea43dd79766bb3b8af7c45968d12b68c960cf2da23856f34d598d4` | `IB20` | Emitted by `updateSymbol`. Carries the new symbol string. |
-| error | `TokenAlreadyExists` | `0x15ef3a57` | `IB20Factory` | A token already exists at the deterministic address derived from |
-| event | `Transfer` | `0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef` | `IB20` | ERC-20 transfer event. Emitted on every successful transfer (including memo'd variants), |
-| error | `Unauthorized` | `0x8e4a23d6` | `IActivationRegistry` | Caller is not the activation admin. |
-| error | `Unauthorized` | `0x82b42900` | `IB20` | Caller failed a positional authorization check that is not expressible as "missing role X". |
-| error | `Unauthorized` | `0x82b42900` | `IPolicyRegistry` | Caller is not the admin required by the attempted operation. |
-| event | `UIMultiplierUpdated` | `0x2205df4534432b2f60654a3fdb48737ffdaf3e9edb1a498bd985bc026b15b055` | `IB20Asset` | Emitted when the UI multiplier is updated. |
-| event | `Unpaused` | `0xdc6281474ea3dea2a856e225c7ba3edf427de9164495817c4d49a595e683fed4` | `IB20` | Emitted by `unpause`. `features` is the argument to the call (not the resulting paused state). |
-| error | `UnsupportedPolicyType` | `0xcdd98a4a` | `IB20` | `policyScope` is not a slot this token (or its variant) supports. |
-| error | `UnsupportedVersion` | `0xc0d8b4e0` | `IB20Factory` | The leading `version` byte in `params` does not match any known encoding for the requested variant. |
-| error | `ZeroAddress` | `0xd92e233d` | `IPolicyRegistry` | A required address argument was the zero address. |
+| error | `PolicyForbids` | `
diff --git a/docs/base-chain/specs/reference/b20/index.mdx b/docs/base-chain/specs/reference/b20/index.mdx
index a76152704..3f83cb0d5 100644
--- a/docs/base-chain/specs/reference/b20/index.mdx
+++ b/docs/base-chain/specs/reference/b20/index.mdx
@@ -53,10 +53,16 @@ State-changing PolicyRegistry calls are ActivationRegistry-gated. Read functions
|---|---|
| `BLOCKLIST` | Account is authorized unless listed. |
| `ALLOWLIST` | Account is authorized only if listed. |
-| `UNION` | Composite: account is authorized if any child simple policy authorizes it. |
-| `INTERSECT` | Composite: account is authorized only if every child simple policy authorizes it. |
+| `UNION` | Composite: account is authorized if any child simple policy authorizes it (OR). |
+| `INTERSECT` | Composite: account is authorized only if every child simple policy authorizes it (AND). |
-Composite policies reference existing simple `ALLOWLIST` or `BLOCKLIST` child policies. They cannot reference composites or built-ins as children.
+Composite policies reference two to four existing simple `ALLOWLIST` or `BLOCKLIST` child policies. They cannot reference other composites or built-in sentinel policies as children. This constraint is enforced at write time, so `isAuthorized` on a composite never recurses beyond depth 1.
+
+Evaluation is live: each `isAuthorized` call reads the current membership of each evaluated child. `UNION` short-circuits on the first authorizing child; `INTERSECT` short-circuits on the first non-authorizing child. Child order affects gas cost but never the authorization result.
+
+
+`isAuthorized` on an uncreated `UNION` ID returns `false` (deny-all). `isAuthorized` on an uncreated `INTERSECT` ID returns `true` (allow-all), which behaves identically to `ALWAYS_ALLOW`. Always call `policyExists(policyId)` before storing a policy ID in a token scope.
+
### Policy IDs
@@ -71,13 +77,13 @@ Counters `0` and `1` are reserved for built-ins:
| Built-in | Value | Behavior |
|---|---:|---|
| `ALWAYS_ALLOW` | `0` | Authorizes every account. |
-| `ALWAYS_BLOCK` | `(uint64(ALLOWLIST) << 56) \| 1` | Denies every account. |
+| `ALWAYS_BLOCK` | `(uint64(ALLOWLIST) << 56) \| 1` | Denies every account. |
-Custom policy creation starts at counter `2`.
+Custom policy creation starts at counter `2`. Simple and composite policies share the same global counter; composite policies do not use a separate counter.
### Admin Model
-Each policy has one admin. Admin transfer is two-step: `stageUpdateAdmin(policyId, newAdmin)` followed by `finalizeUpdateAdmin(policyId)` from the pending admin. `renounceAdmin(policyId)` permanently freezes membership or child-policy updates for that policy.
+Each policy has one admin. Admin transfer is two-step: `stageUpdateAdmin(policyId, newAdmin)` followed by `finalizeUpdateAdmin(policyId)` from the pending admin. `renounceAdmin(policyId)` permanently freezes membership or child-policy updates for that policy. A renounced child policy remains effective in composites that reference it; its current authorization results continue to apply.
### Read Interface
@@ -87,10 +93,29 @@ Each policy has one admin. Admin transfer is two-step: `stageUpdateAdmin(policyI
| `policyExists(policyId)` | Returns whether a policy exists. |
| `policyAdmin(policyId)` | Returns the current admin or zero. |
| `pendingPolicyAdmin(policyId)` | Returns the staged admin or zero. |
-| `compositePolicyChildIds(policyId)` | Returns child policy IDs for composite policies. |
+| `compositePolicyChildIds(policyId)` | Returns child policy IDs for composite policies; empty for non-composites. |
`isAuthorized` collapses uncreated IDs to empty-set semantics. Callers that write policy IDs into token scopes must validate `policyExists` unless writing a built-in.
+### Composite Policy Interface
+
+```solidity
+function createCompositePolicy(address admin, PolicyType policyType, uint64[] calldata childPolicyIds)
+ external
+ returns (uint64 newPolicyId);
+
+function updateComposite(uint64 policyId, uint64[] calldata childPolicyIds) external;
+
+function compositePolicyChildIds(uint64 policyId) external view returns (uint64[] memory);
+
+function MIN_COMPOSITE_CHILD_POLICIES() external view returns (uint256);
+function MAX_COMPOSITE_CHILD_POLICIES() external view returns (uint256);
+```
+
+`createCompositePolicy` accepts `UNION` or `INTERSECT` as `policyType` and requires `childPolicyIds` to contain at least `MIN_COMPOSITE_CHILD_POLICIES` (`2`) and no more than `MAX_COMPOSITE_CHILD_POLICIES` (`4`) entries. `updateComposite` replaces the entire child set and enforces the same range. Neither function supports partial updates or an empty child set.
+
+`createPolicy` and `createPolicyWithAccounts` revert with `IncompatiblePolicyType` when called with `UNION` or `INTERSECT`.
+
## Policy Integration
B20 tokens store one `uint64 policyId` per supported policy scope.
@@ -106,6 +131,8 @@ B20 tokens store one `uint64 policyId` per supported policy scope.
All scopes default to `ALWAYS_ALLOW` at creation. `approve` and `permit` are not policy-gated.
+A composite policy ID is passed to a B20 policy scope exactly like a simple policy ID. B20 stores policy scopes as an opaque `uint64` and calls `isAuthorized` generically; no B20 contract changes are required to use composite policies.
+
## Mint
`mint` and `mintWithMemo` are gated by `MINT_ROLE`, checked against `MINT_RECEIVER_POLICY`, and bounded by `supplyCap`.
diff --git a/docs/base-chain/specs/reference/b20/interfaces/IB20.mdx b/docs/base-chain/specs/reference/b20/interfaces/IB20.mdx
index 132933833..08d497006 100644
--- a/docs/base-chain/specs/reference/b20/interfaces/IB20.mdx
+++ b/docs/base-chain/specs/reference/b20/interfaces/IB20.mdx
@@ -76,40 +76,4 @@ description: "Generated B20 reference for IB20 functions, events, and errors."
| `RoleRevoked` | `0xf6391f5c32d9c69d2a47ea670b442974b53935d1edc7fd64eb21e047a839171b` | Emitted when `role` is revoked from `account`. `sender` is the originating caller |
| `RoleAdminChanged` | `0xbd79b86ffe0ab8e8776151514217cd7cacd52c909f66475c3af44e129f0b00ff` | Emitted by `setRoleAdmin` when the admin role for `role` changes. |
| `LastAdminRenounced` | `0xe8d3a9872e7ca325571ff1e4c51ddd69090a0345240cc605ccde365ec867cc67` | Emitted by `renounceLastAdmin` in addition to the standard |
-| `Paused` | `0x43e072977b8112813d7c2aa0b63d3c121c1fe3e714e6d2eacb5735fe4027e976` | Emitted by `pause`. `features` is the argument to the call (not the resulting paused state). |
-| `Unpaused` | `0xdc6281474ea3dea2a856e225c7ba3edf427de9164495817c4d49a595e683fed4` | Emitted by `unpause`. `features` is the argument to the call (not the resulting paused state). |
-| `PolicyUpdated` | `0x8b4790f7ff717fc8f60f07ae099e47ef318dc04b37ae98056b50a22b79056626` | Emitted by `updatePolicy` when a token's policy slot is changed. Initial slot assignment at |
-| `SupplyCapUpdated` | `0x6d14f44808ce024f263432bc38d019a9951fbe674e9898b54844dbc8dc09c23a` | Emitted by `updateSupplyCap`. |
-| `ContractURIUpdated` | `0xa5d4097edda6d87cb9329af83fb3712ef77eeb13738ffe43cc35a4ce305ad962` | Emitted by `updateContractURI`. Per ERC-7572, parameterless: integrators re-fetch `contractURI()`. |
-| `NameUpdated` | `0x74321da206c1b9fa34367f7ece59ca49371dcd13820b9a5c3767ae1ecceed51a` | Emitted by `updateName`. Carries the new name string. |
-| `SymbolUpdated` | `0x64e8b5c6dcea43dd79766bb3b8af7c45968d12b68c960cf2da23856f34d598d4` | Emitted by `updateSymbol`. Carries the new symbol string. |
-| `EIP712DomainChanged` | `0x0a6387c9ea3628b88a633bb4f3b151770f70085117a15f9bf3787cda53f13d31` | ERC-5267 domain-change signal. Emitted exactly once per successful `updateName` call, |
-
-## Errors
-
-| Error | Selector | Summary |
-|---|---|---|
-| `NonPayable` | `0x6fb1b0e9` | ETH was attached to a call targeting a nonpayable token selector. |
-| `AccessControlUnauthorizedAccount` | `0xe2517d3f` | `account` does not hold `neededRole`. |
-| `Unauthorized` | `0x82b42900` | Caller failed a positional authorization check that is not expressible as "missing role X". |
-| `ContractPaused` | `0xf9df5ac9` | The `PausableFeature` covering this operation is currently paused. |
-| `InsufficientAllowance` | `0x192b9e4e` | `spender`'s allowance is less than `needed` for the requested `transferFrom`. |
-| `InsufficientBalance` | `0xdb42144d` | `sender`'s balance is less than `needed` for the requested transfer or burn. |
-| `InvalidSender` | `0x4c14f64c` | The transfer's source address is invalid (typically `address(0)`). |
-| `InvalidReceiver` | `0x9cfea583` | The transfer's destination address is invalid (typically `address(0)`). |
-| `InvalidApprover` | `0x8bc146c4` | The approval's `owner` address is invalid (typically `address(0)`). |
-| `InvalidSpender` | `0x4e15efda` | The approval's `spender` address is invalid (typically `address(0)`). |
-| `InvalidAmount` | `0x2c5211c6` | An amount argument was zero where a non-zero value is required. Not used for ERC-20 amount arguments. |
-| `EmptyFeatureSet` | `0x4861ff45` | An empty array was passed to a function that requires at least one element. |
-| `InvalidSupplyCap` | `0x0a3780ce` | The proposed supply cap is outside the permitted range: below the current |
-| `SupplyCapExceeded` | `0x4b344b11` | The mint would push `totalSupply` past the configured cap. |
-| `PolicyForbids` | `0xa43fec12` | A policy slot denied the operation. |
-| `PolicyNotFound` | `0xcccad523` | The provided policy ID does not exist in the policy registry. |
-| `UnsupportedPolicyType` | `0xcdd98a4a` | `policyScope` is not a slot this token (or its variant) supports. |
-| `AccountNotSeizable` | `0x91dbbc8d` | `seizeWithMemo` was called against a `from` that is currently authorized under |
-| `AccountNotBlocked` | `0x64a5cb46` | The deprecated `burnBlocked` was called against a `from` that is currently authorized under |
-| `ExpiredSignature` | `0xbd2a913c` | An EIP-2612 `permit` was submitted with a `deadline` strictly less than `block.timestamp`. |
-| `InvalidSigner` | `0x7ba5ffb5` | ECDSA recovery on an EIP-2612 `permit` returned `signer`, which does not match the claimed `owner`. |
-| `LastAdminCannotRenounce` | `0x361513e7` | `renounceRole(DEFAULT_ADMIN_ROLE, ...)` was called by the sole remaining admin. |
-| `NotSoleAdmin` | `0x2a98e73b` | `renounceLastAdmin()` was called when other accounts also hold `DEFAULT_ADMIN_ROLE`. |
-| `AccessControlBadConfirmation` | `0x6697b232` | The `callerConfirmation` argument to `renounceRole` was not `msg.sender`. |
+| `Paused` | `0x43e072977b8
diff --git a/docs/base-chain/specs/reference/b20/interfaces/IB20/policyId.mdx b/docs/base-chain/specs/reference/b20/interfaces/IB20/policyId.mdx
index efeef8e08..cbc7aa15a 100644
--- a/docs/base-chain/specs/reference/b20/interfaces/IB20/policyId.mdx
+++ b/docs/base-chain/specs/reference/b20/interfaces/IB20/policyId.mdx
@@ -40,7 +40,7 @@ Read-only or ERC-20-standard access rules unless the NatSpec states otherwise.
## Policy interaction
-Reads or writes a token policy-scope pointer into the PolicyRegistry.
+Reads or writes a token policy-scope pointer into the PolicyRegistry. The returned `uint64` is an opaque policy ID that may refer to a simple policy (`ALLOWLIST` or `BLOCKLIST`) or, as of Cobalt, a composite policy (`UNION` or `INTERSECT`). B20 passes the ID to `isAuthorized` without inspecting its type.
## Example
diff --git a/docs/base-chain/specs/reference/b20/interfaces/IB20/updatePolicy.mdx b/docs/base-chain/specs/reference/b20/interfaces/IB20/updatePolicy.mdx
index 6d31d5103..8cc65b0d1 100644
--- a/docs/base-chain/specs/reference/b20/interfaces/IB20/updatePolicy.mdx
+++ b/docs/base-chain/specs/reference/b20/interfaces/IB20/updatePolicy.mdx
@@ -39,7 +39,11 @@ Updates the policy ID assigned to `policyScope`. Takes effect immediately for th
## Policy interaction
-Reads or writes a token policy-scope pointer into the PolicyRegistry.
+Reads or writes a token policy-scope pointer into the PolicyRegistry. The `newPolicyId` may reference a simple policy (`ALLOWLIST` or `BLOCKLIST`) or a composite policy (`UNION` or `INTERSECT`). B20 treats the value as an opaque `uint64` and delegates all authorization logic to the PolicyRegistry.
+
+
+Before storing a composite policy ID, call `policyExists(policyId)` on the PolicyRegistry. An invalid `INTERSECT` ID with no children returns `true` for every account, behaving like `ALWAYS_ALLOW`.
+
## Example
diff --git a/docs/base-chain/specs/reference/b20/interfaces/IPolicyRegistry.mdx b/docs/base-chain/specs/reference/b20/interfaces/IPolicyRegistry.mdx
index c8cddcc9b..54eb86e45 100644
--- a/docs/base-chain/specs/reference/b20/interfaces/IPolicyRegistry.mdx
+++ b/docs/base-chain/specs/reference/b20/interfaces/IPolicyRegistry.mdx
@@ -10,20 +10,20 @@ description: "Generated B20 reference for IPolicyRegistry functions, events, and
|---|---|---|
| [`createPolicy`](/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/createPolicy) | `0xca5d55f6` | Creates a new simple policy with no initial members. Permissionless. |
| [`createPolicyWithAccounts`](/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/createPolicyWithAccounts) | `0xa2d3044f` | Creates a new simple policy seeded with `accounts` as initial members. Permissionless. |
-| [`createCompositePolicy`](/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/createCompositePolicy) | `0x6fdd1491` | Creates a new composite policy that combines existing simple policies under a logic |
+| [`createCompositePolicy`](/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/createCompositePolicy) | `0x6fdd1491` | Creates a new `UNION` or `INTERSECT` composite policy referencing two to four existing simple policies. |
| [`stageUpdateAdmin`](/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/stageUpdateAdmin) | `0x1d7ae695` | Stages a proposed new admin for `policyId`. The active admin does not change |
| [`finalizeUpdateAdmin`](/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/finalizeUpdateAdmin) | `0x33031a9c` | Completes a two-step admin transfer. Promotes the caller to active admin and clears the pending slot. |
| [`renounceAdmin`](/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/renounceAdmin) | `0xefdb7fa3` | Permanently relinquishes administration of `policyId`. The member set is frozen |
| [`updateAllowlist`](/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/updateAllowlist) | `0x3388fb5b` | Sets `accounts` membership in an ALLOWLIST policy to `allowed` in one batch. |
| [`updateBlocklist`](/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/updateBlocklist) | `0x5c4e51b8` | Sets `accounts` membership in a BLOCKLIST policy to `blocked` in one batch. |
-| [`updateComposite`](/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/updateComposite) | `0xbfe142c0` | Replaces a composite policy's child-policy set in full with `childPolicyIds`. |
+| [`updateComposite`](/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/updateComposite) | `0xbfe142c0` | Replaces a composite policy's entire child set with two to four existing simple policies. |
| [`isAuthorized`](/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/isAuthorized) | `0x55a1179e` | Returns whether `account` is authorized under `policyId`. Never reverts; unknown |
-| [`MIN_COMPOSITE_CHILD_POLICIES`](/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/MIN_COMPOSITE_CHILD_POLICIES) | `0xb3ae29f7` | Minimum number of child policies a composite must reference, inclusive. Never reverts. |
-| [`MAX_COMPOSITE_CHILD_POLICIES`](/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/MAX_COMPOSITE_CHILD_POLICIES) | `0x54309870` | Maximum number of child policies a composite may reference, inclusive. Never reverts. |
+| [`MIN_COMPOSITE_CHILD_POLICIES`](/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/MIN_COMPOSITE_CHILD_POLICIES) | `0xb3ae29f7` | Minimum number of child policies a composite must reference, inclusive. Returns `2`. Never reverts. |
+| [`MAX_COMPOSITE_CHILD_POLICIES`](/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/MAX_COMPOSITE_CHILD_POLICIES) | `0x54309870` | Maximum number of child policies a composite may reference, inclusive. Returns `4`. Never reverts. |
| [`policyExists`](/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/policyExists) | `0x330f5637` | Returns whether `policyId` is a built-in sentinel or a previously-assigned custom ID. Never reverts. |
| [`policyAdmin`](/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/policyAdmin) | `0x09dd0a47` | Returns the current admin of `policyId`, or `address(0)` for built-in sentinels, |
| [`pendingPolicyAdmin`](/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/pendingPolicyAdmin) | `0x017548b7` | Returns the currently-staged pending admin for `policyId`, or `address(0)` when |
-| [`compositePolicyChildIds`](/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/compositePolicyChildIds) | `0x7c40df74` | Returns the child-policy set of the composite `policyId`. |
+| [`compositePolicyChildIds`](/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/compositePolicyChildIds) | `0x7c40df74` | Returns the child-policy set of the composite `policyId`. Returns an empty array for non-composites. |
## Events
@@ -34,7 +34,7 @@ description: "Generated B20 reference for IPolicyRegistry functions, events, and
| `PolicyAdminUpdated` | `0x98925cfb1bc09c5b43dd0dd56d3d95aa04fb3300927580cc588c3f5dd58c15e1` | The active admin changed. `newAdmin == address(0)` indicates renunciation; |
| `AllowlistUpdated` | `0x18c46532f90187ba11e436e21da087b684801d7f0787f2043f26f079c91e9ef0` | One or more accounts had their ALLOWLIST membership set to `allowed` in a single batch. |
| `BlocklistUpdated` | `0x2ff63c102b1b9fd7f5d39f83039c5d6aaf50a414a4f2def2704e41be2628f1e3` | One or more accounts had their BLOCKLIST membership set to `blocked` in a single batch. |
-| `CompositePolicyUpdated` | `0x4ff6adaab31b0df87aa7b8b7320c52b8b3b5eede3bf28a6baaaa8b8b7e1d6363` | A composite policy's child set was set or replaced in full with `childPolicyIds`. Emitted |
+| `CompositePolicyUpdated` | `0x4ff6adaab31b0df87aa7b8b7320c52b8b3b5eede3bf28a6baaaa8b8b7e1d6363` | A composite policy's child set was set or replaced in full with `childPolicyIds`. Emitted by both `createCompositePolicy` and `updateComposite`. |
## Errors
@@ -43,9 +43,9 @@ description: "Generated B20 reference for IPolicyRegistry functions, events, and
| `NonPayable` | `0x6fb1b0e9` | ETH was attached to a call targeting a nonpayable policy registry selector. |
| `Unauthorized` | `0x82b42900` | Caller is not the admin required by the attempted operation. |
| `PolicyNotFound` | `0x720caa4f` | The referenced policy ID does not exist. |
-| `IncompatiblePolicyType` | `0xf1011ef5` | The operation is incompatible with the policy's type. |
+| `IncompatiblePolicyType` | `0xf1011ef5` | The operation is incompatible with the policy's type. `createPolicy` and `createPolicyWithAccounts` revert with this error when called with `UNION` or `INTERSECT`. |
| `ZeroAddress` | `0xd92e233d` | A required address argument was the zero address. |
| `BatchSizeTooLarge` | `0x083e2f67` | A membership batch exceeded the registry limit. |
| `NoPendingAdmin` | `0xb4539afa` | `finalizeUpdateAdmin` was called with no pending admin staged. |
-| `ChildPoliciesOutsideOfRange` | `0x697ec868` | A composite policy was created or updated with a child-policy count outside the |
-| `InvalidChildPolicy` | `0x46508ef6` | Composite policies are not simple policies. Child policies must be existing |
+| `ChildPoliciesOutsideOfRange` | `0x697ec868` | A composite policy was created or updated with a child-policy count outside the `[2, 4]` range enforced by `MIN_COMPOSITE_CHILD_POLICIES` and `MAX_COMPOSITE_CHILD_POLICIES`. |
+| `InvalidChildPolicy` | `0x46508ef6` | A child policy ID is not an existing simple policy. Composite policies and the built-in `ALWAYS_ALLOW` and `ALWAYS_BLOCK` sentinels are not valid children. |
diff --git a/docs/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/MAX_COMPOSITE_CHILD_POLICIES.mdx b/docs/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/MAX_COMPOSITE_CHILD_POLICIES.mdx
index 30284ab3e..704f7434a 100644
--- a/docs/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/MAX_COMPOSITE_CHILD_POLICIES.mdx
+++ b/docs/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/MAX_COMPOSITE_CHILD_POLICIES.mdx
@@ -18,19 +18,19 @@ function MAX_COMPOSITE_CHILD_POLICIES() external view returns (uint256);
## Description
-Maximum number of child policies a composite may reference, inclusive. Never reverts.
-Return: Maximum permitted child-policy count.
+Returns the maximum number of child policies a composite policy may reference, inclusive. The current value is `4`. This bound limits worst-case `isAuthorized` gas and the authorization audit surface. Never reverts.
## Access control
-Read-only or ERC-20-standard access rules unless the NatSpec states otherwise.
+Read-only. No caller restrictions.
## Policy interaction
-This is part of the singleton PolicyRegistry surface used by B20 policy scopes.
+This is part of the singleton PolicyRegistry surface used by B20 policy scopes. Pass this value as the upper bound when validating the `childPolicyIds` array before calling `createCompositePolicy` or `updateComposite`.
## Example
-```solidity
-IPolicyRegistry(target).MAX_COMPOSITE_CHILD_POLICIES();
+```solidity Title Fetch composite child policy limits
+uint256 max = IPolicyRegistry(target).MAX_COMPOSITE_CHILD_POLICIES(); // 4
+uint256 min = IPolicyRegistry(target).MIN_COMPOSITE_CHILD_POLICIES(); // 2
```
diff --git a/docs/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/MIN_COMPOSITE_CHILD_POLICIES.mdx b/docs/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/MIN_COMPOSITE_CHILD_POLICIES.mdx
index 32fe2f064..7a2375d13 100644
--- a/docs/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/MIN_COMPOSITE_CHILD_POLICIES.mdx
+++ b/docs/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/MIN_COMPOSITE_CHILD_POLICIES.mdx
@@ -18,19 +18,18 @@ function MIN_COMPOSITE_CHILD_POLICIES() external view returns (uint256);
## Description
-Minimum number of child policies a composite must reference, inclusive. Never reverts.
-Return: Minimum permitted child-policy count.
+Returns the minimum number of child policies a composite policy must reference, inclusive. The current minimum is `2`. Never reverts.
## Access control
-Read-only or ERC-20-standard access rules unless the NatSpec states otherwise.
+Read-only. No authorization required.
## Policy interaction
-This is part of the singleton PolicyRegistry surface used by B20 policy scopes.
+This is part of the singleton PolicyRegistry surface used by B20 policy scopes. The returned value is enforced by both `createCompositePolicy` and `updateComposite` — passing fewer than `MIN_COMPOSITE_CHILD_POLICIES` children reverts with `ChildPoliciesOutsideOfRange`.
## Example
-```solidity
+```solidity Title Read minimum child count
IPolicyRegistry(target).MIN_COMPOSITE_CHILD_POLICIES();
```
diff --git a/docs/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/compositePolicyChildIds.mdx b/docs/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/compositePolicyChildIds.mdx
index 33a83fc94..a4724979f 100644
--- a/docs/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/compositePolicyChildIds.mdx
+++ b/docs/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/compositePolicyChildIds.mdx
@@ -1,10 +1,8 @@
---
title: "IPolicyRegistry.compositePolicyChildIds"
-description: "Generated B20 reference for compositePolicyChildIds(uint64)."
+description: "Returns the child-policy set of a composite policy."
---
-
-
## Signature
```solidity
@@ -18,26 +16,39 @@ function compositePolicyChildIds(uint64 policyId) external view returns (uint64[
## Description
-Returns the child-policy set of the composite `policyId`.
-Dev: Child-policies are listed in the order they were last written.
-Dev: Returns an empty array for simple policies, built-in sentinels, unknown IDs, and
-malformed IDs. Never reverts.
-Dev: An empty return unambiguously means "not a composite".
-Dev: The registry preserves the caller's ordering verbatim and neither sorts nor
-de-duplicates.
-Param: policyId Policy to query.
-Return: Child policy IDs, or an empty array.
+Returns the child-policy IDs stored for `policyId` in the order they were last written. The registry preserves caller ordering verbatim and does not sort or deduplicate entries.
+
+Returns an empty array for simple policies, built-in sentinels (`ALWAYS_ALLOW`, `ALWAYS_BLOCK`), unknown IDs, and malformed IDs. Never reverts. An empty return unambiguously means "not a composite."
+
+## Parameters
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `policyId` | `uint64` | Policy to query. |
+
+## Returns
+
+| Type | Description |
+| ---- | ----------- |
+| `uint64[] memory` | Child policy IDs, or an empty array if `policyId` is not a composite. |
+
+## Behavior notes
+
+- Evaluation is live. Each `isAuthorized` call on a composite reads the child IDs returned by this function at call time — not a snapshot from creation or the last `updateComposite`.
+- Child IDs are packed four per 256-bit slot. The two-to-four-child constraint means each composite occupies one element slot in the `children` mapping.
+- Duplicate child IDs are preserved; the registry does not deduplicate them.
## Access control
-Read-only or ERC-20-standard access rules unless the NatSpec states otherwise.
+Read-only. No caller restrictions.
## Policy interaction
-This is part of the singleton PolicyRegistry surface used by B20 policy scopes.
+This is part of the singleton `PolicyRegistry` surface used by B20 policy scopes.
## Example
-```solidity
-IPolicyRegistry(target).compositePolicyChildIds(arg0);
+```solidity Title Query composite children
+uint64[] memory children = IPolicyRegistry(target).compositePolicyChildIds(policyId);
+// Returns empty array if policyId is not a composite.
```
diff --git a/docs/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/createCompositePolicy.mdx b/docs/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/createCompositePolicy.mdx
index 9a6ed58a9..9e1779e44 100644
--- a/docs/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/createCompositePolicy.mdx
+++ b/docs/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/createCompositePolicy.mdx
@@ -1,14 +1,14 @@
---
title: "IPolicyRegistry.createCompositePolicy"
-description: "Generated B20 reference for createCompositePolicy(address,uint8,uint64[])."
+description: "Creates a new composite policy that combines existing simple policies under a UNION (OR) or INTERSECT (AND) logic gate."
---
-
-
## Signature
```solidity
-function createCompositePolicy(address admin, PolicyType policyType, uint64[] calldata childPolicyIds) external returns (uint64 newPolicyId);
+function createCompositePolicy(address admin, PolicyType policyType, uint64[] calldata childPolicyIds)
+ external
+ returns (uint64 newPolicyId);
```
| Field | Value |
@@ -18,33 +18,78 @@ function createCompositePolicy(address admin, PolicyType policyType, uint64[] ca
## Description
-Creates a new composite policy that combines existing simple policies under a logic
-gate.
-Dev: Child policies must be simple policies (ALLOWLIST or BLOCKLIST), never another composite.
-The child-policy set is capped at 4.
-Dev: Reverts with `IncompatiblePolicyType` when `policyType` is not UNION or INTERSECT.
-Dev: Reverts with `ZeroAddress` when `admin` is `address(0)`.
-Dev: Reverts with `ChildPoliciesOutsideOfRange` when `childPolicyIds.length` is not in
-`[MIN_COMPOSITE_CHILD_POLICIES, MAX_COMPOSITE_CHILD_POLICIES]`.
-Dev: Reverts with `PolicyNotFound` when any child policy does not exist.
-Dev: Reverts with `InvalidChildPolicy` when any child policy is not a simple policy or a built-in policy.
-Dev: Panics with arithmetic overflow (Panic 0x11) when the policy counter has reached its maximum value.
-Param: admin Initial admin authorized to update child policies and transfer or renounce
-administration.
-Param: policyType UNION or INTERSECT.
-Param: childPolicyIds Existing simple policy IDs to combine.
-Return: newPolicyId The newly assigned composite policy ID.
+Creates a composite policy that evaluates authorization by combining the results of two to four existing simple policies. `PolicyType` ABI-encodes as `uint8`.
+
+- `UNION` — authorizes an account if **any** child policy authorizes it (OR).
+- `INTERSECT` — authorizes an account only if **every** child policy authorizes it (AND).
+
+Authorization is live: each call reads the current membership of every evaluated child. `UNION` short-circuits on the first authorizing child; `INTERSECT` short-circuits on the first non-authorizing child. Because composite children are rejected at write time, authorization never recurses beyond depth 1.
+
+A composite policy ID can be stored in a B20 policy scope exactly like a simple policy ID. B20 calls `isAuthorized` generically against the opaque `uint64` and requires no code changes.
+
+
+Always call `policyExists(policyId)` before storing a composite ID. A well-formed but never-created `INTERSECT` ID has no children and returns `true`, behaving identically to `ALWAYS_ALLOW`.
+
+
+## Parameters
+
+| Parameter | Type | Description |
+|---|---|---|
+| `admin` | `address` | Initial admin authorized to update child policies and transfer or renounce administration. |
+| `policyType` | `PolicyType` | Must be `UNION` (2) or `INTERSECT` (3). |
+| `childPolicyIds` | `uint64[]` | Existing simple (`ALLOWLIST` or `BLOCKLIST`) policy IDs to combine. Must contain between `MIN_COMPOSITE_CHILD_POLICIES` (2) and `MAX_COMPOSITE_CHILD_POLICIES` (4) entries. |
+
+## Returns
+
+| Name | Type | Description |
+|---|---|---|
+| `newPolicyId` | `uint64` | Newly assigned composite policy ID. |
+
+## Revert conditions
+
+Reverts are checked in this order:
+
+| Order | Error | Condition |
+|---|---|---|
+| 1 | `ZeroAddress` | `admin` is `address(0)`. |
+| 2 | `IncompatiblePolicyType` | `policyType` is not `UNION` or `INTERSECT`. |
+| 3 | `ChildPoliciesOutsideOfRange` | `childPolicyIds.length` is outside `[2, 4]`. |
+| 4 | `PolicyNotFound` | Any child ID does not exist. |
+| 5 | `InvalidChildPolicy` | Any child is itself a composite or a built-in sentinel (`ALWAYS_ALLOW` / `ALWAYS_BLOCK`). |
+
+Panics with `Panic(0x11)` (arithmetic overflow) if the policy counter has reached its maximum value.
+
+## Events emitted
+
+Emitted in this order on success:
+
+1. `PolicyCreated(policyId, creator, policyType)`
+2. `PolicyAdminUpdated(policyId, address(0), admin)`
+3. `CompositePolicyUpdated(policyId, creator, childPolicyIds)`
## Access control
Permissionless creation, but state-changing registry calls require the feature to be active.
-## Policy interaction
+## Gas
-This is part of the singleton PolicyRegistry surface used by B20 policy scopes.
+Each child policy evaluated by `isAuthorized` requires a membership storage read. The worst-case cost occurs when all four children are evaluated. Place the child most likely to short-circuit first to minimize gas.
## Example
-```solidity
-IPolicyRegistry(target).createCompositePolicy(arg0, arg1, arg2);
+```solidity Title="Create a UNION composite policy"
+IPolicyRegistry registry = IPolicyRegistry(target);
+
+uint64[] memory children = new uint64[](2);
+children[0] = sharedAllowlistId;
+children[1] = tokenSpecificAllowlistId;
+
+uint64 compositePolicyId = registry.createCompositePolicy(
+ admin,
+ IPolicyRegistry.PolicyType.UNION,
+ children
+);
+
+// Verify the policy exists before storing the ID
+require(registry.policyExists(compositePolicyId), "policy not found");
```
diff --git a/docs/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/createPolicy.mdx b/docs/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/createPolicy.mdx
index 6d6caf119..d9d46ba3e 100644
--- a/docs/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/createPolicy.mdx
+++ b/docs/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/createPolicy.mdx
@@ -19,11 +19,23 @@ function createPolicy(address admin, PolicyType policyType) external returns (ui
## Description
Creates a new simple policy with no initial members. Permissionless.
-Dev: Reverts with `ZeroAddress` when `admin` is `address(0)`.
-Dev: Reverts with `IncompatiblePolicyType` when `policyType` is a composite gate.
-Param: admin Initial admin authorized to modify membership and transfer or renounce administration.
-Param: policyType BLOCKLIST or ALLOWLIST.
-Return: newPolicyId The newly assigned policy ID.
+
+Reverts with `ZeroAddress` when `admin` is `address(0)`.
+
+Reverts with `IncompatiblePolicyType` when `policyType` is `UNION` or `INTERSECT`. Use `createCompositePolicy` to create composite policies.
+
+## Parameters
+
+| Parameter | Type | Description |
+|---|---|---|
+| `admin` | `address` | Initial admin authorized to modify membership and transfer or renounce administration. |
+| `policyType` | `PolicyType` | Must be `BLOCKLIST` or `ALLOWLIST`. |
+
+## Returns
+
+| Name | Type | Description |
+|---|---|---|
+| `newPolicyId` | `uint64` | The newly assigned policy ID. |
## Access control
diff --git a/docs/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/createPolicyWithAccounts.mdx b/docs/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/createPolicyWithAccounts.mdx
index c7111ae28..864dadb2c 100644
--- a/docs/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/createPolicyWithAccounts.mdx
+++ b/docs/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/createPolicyWithAccounts.mdx
@@ -19,14 +19,27 @@ function createPolicyWithAccounts(address admin, PolicyType policyType, address[
## Description
Creates a new simple policy seeded with `accounts` as initial members. Permissionless.
-Dev: Reverts with `ZeroAddress` when `admin` is `address(0)`. Takes precedence over `BatchSizeTooLarge`.
-Dev: Reverts with `IncompatiblePolicyType` when `policyType` is a composite policyType.
-Dev: Reverts with `BatchSizeTooLarge` when `accounts.length` exceeds the registry limit.
-Dev: Panics with arithmetic overflow (Panic 0x11) when the policy counter has reached its maximum value.
-Param: admin Initial admin authorized to modify membership and transfer or renounce administration.
-Param: policyType BLOCKLIST or ALLOWLIST.
-Param: accounts Initial member set.
-Return: newPolicyId The newly assigned policy ID.
+
+**Revert conditions:**
+
+- `ZeroAddress` — `admin` is `address(0)`. Takes precedence over `BatchSizeTooLarge`.
+- `IncompatiblePolicyType` — `policyType` is `UNION` or `INTERSECT`. Use `createCompositePolicy` for composite policies.
+- `BatchSizeTooLarge` — `accounts.length` exceeds the registry limit.
+- `Panic(0x11)` — arithmetic overflow when the policy counter has reached its maximum value.
+
+## Parameters
+
+| Parameter | Type | Description |
+|---|---|---|
+| `admin` | `address` | Initial admin authorized to modify membership and transfer or renounce administration. |
+| `policyType` | `PolicyType` | `BLOCKLIST` or `ALLOWLIST` only. `UNION` and `INTERSECT` are rejected. |
+| `accounts` | `address[]` | Initial member set. |
+
+## Returns
+
+| Name | Type | Description |
+|---|---|---|
+| `newPolicyId` | `uint64` | The newly assigned policy ID. |
## Access control
@@ -36,6 +49,10 @@ Permissionless creation, but state-changing registry calls require the feature t
This is part of the singleton PolicyRegistry surface used by B20 policy scopes.
+
+Passing `policyType` byte `2` (`UNION`) or `3` (`INTERSECT`) now reverts with `IncompatiblePolicyType`. Use `createCompositePolicy` to create composite policies.
+
+
## Example
```solidity
diff --git a/docs/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/isAuthorized.mdx b/docs/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/isAuthorized.mdx
index 347aee02d..fd3af7e7b 100644
--- a/docs/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/isAuthorized.mdx
+++ b/docs/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/isAuthorized.mdx
@@ -3,8 +3,6 @@ title: "IPolicyRegistry.isAuthorized"
description: "Generated B20 reference for isAuthorized(uint64,address)."
---
-
-
## Signature
```solidity
@@ -18,21 +16,64 @@ function isAuthorized(uint64 policyId, address account) external view returns (b
## Description
-Returns whether `account` is authorized under `policyId`. Never reverts; unknown
-or malformed IDs collapse to empty-member-set semantics (ALLOWLIST -> false,
-BLOCKLIST -> true).
-Dev: Callers that store policy IDs MUST validate `policyExists(policyId)` at write time.
-Param: policyId Policy to query.
-Param: account Account to check.
-Return: Whether `account` is authorized.
+Returns whether `account` is authorized under `policyId`. Never reverts.
+
+The evaluation logic depends on the policy type:
+
+```text
+isAuthorized(policyId, account):
+ if policy is ALLOWLIST:
+ return account is in the policy
+
+ if policy is BLOCKLIST:
+ return account is not in the policy
+
+ if policy is UNION:
+ for each child policy:
+ if isAuthorized(child, account):
+ return true
+ return false
+
+ if policy is INTERSECT:
+ for each child policy:
+ if not isAuthorized(child, account):
+ return false
+ return true
+```
+
+For composite policies (`UNION` and `INTERSECT`), evaluation is live — each call reads the current membership of every evaluated child. Short-circuiting applies: `UNION` stops at the first authorizing child, and `INTERSECT` stops at the first non-authorizing child. Child order can therefore affect gas cost but never the authorization result.
+
+Unknown or never-created policy IDs collapse to empty-member-set semantics:
+
+- `ALLOWLIST` → `false`
+- `BLOCKLIST` → `true`
+- `UNION` with no children → `false`
+- `INTERSECT` with no children → `true`
+
+
+A never-created `INTERSECT` ID returns `true` for every account, behaving like `ALWAYS_ALLOW`. Callers that store policy IDs MUST call `policyExists(policyId)` before storing them.
+
+
+## Parameters
+
+| Name | Type | Description |
+|---|---|---|
+| `policyId` | `uint64` | Policy to query. |
+| `account` | `address` | Account to check. |
+
+## Returns
+
+| Type | Description |
+|---|---|
+| `bool` | `true` if `account` is authorized under `policyId`, `false` otherwise. |
## Access control
-Read-only or ERC-20-standard access rules unless the NatSpec states otherwise.
+Read-only. No caller restrictions.
## Policy interaction
-This is part of the singleton PolicyRegistry surface used by B20 policy scopes.
+This is part of the singleton PolicyRegistry surface used by B20 policy scopes. A composite policy ID is passed to a B20 policy slot exactly like a simple policy ID; B20 stores policy IDs as an opaque `uint64` and calls `isAuthorized` generically.
## Example
diff --git a/docs/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/policyExists.mdx b/docs/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/policyExists.mdx
index e7b698e50..4fafe0848 100644
--- a/docs/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/policyExists.mdx
+++ b/docs/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/policyExists.mdx
@@ -30,6 +30,10 @@ Read-only or ERC-20-standard access rules unless the NatSpec states otherwise.
This is part of the singleton PolicyRegistry surface used by B20 policy scopes.
+
+Consumers that store a composite policy ID in a B20 policy slot MUST call `policyExists(policyId)` before storing it. A well-formed but never-created `INTERSECT` ID returns `true` for every account, behaving like `ALWAYS_ALLOW`.
+
+
## Example
```solidity
diff --git a/docs/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/renounceAdmin.mdx b/docs/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/renounceAdmin.mdx
index 673ea0dbb..3c11e6601 100644
--- a/docs/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/renounceAdmin.mdx
+++ b/docs/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/renounceAdmin.mdx
@@ -20,9 +20,17 @@ function renounceAdmin(uint64 policyId) external;
Permanently relinquishes administration of `policyId`. The member set is frozen
and the policy can never be re-administered; `isAuthorized` queries continue to work.
-Dev: Reverts with `PolicyNotFound` when `policyId` does not exist.
-Dev: Reverts with `Unauthorized` when the caller is not the current admin.
-Param: policyId Policy whose administration is being renounced.
+
+If `policyId` is a child of one or more composite policies, renouncing freezes future
+membership changes on that child but does not remove it from any composite. Each composite
+continues to evaluate the child's current authorization results unchanged.
+
+## Revert conditions
+
+| Condition | Error |
+|---|---|
+| `policyId` does not exist | `PolicyNotFound` |
+| Caller is not the current admin | `Unauthorized` |
## Access control
diff --git a/docs/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/updateComposite.mdx b/docs/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/updateComposite.mdx
index 42b10a73f..23d0f673f 100644
--- a/docs/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/updateComposite.mdx
+++ b/docs/base-chain/specs/reference/b20/interfaces/IPolicyRegistry/updateComposite.mdx
@@ -18,27 +18,32 @@ function updateComposite(uint64 policyId, uint64[] calldata childPolicyIds) exte
## Description
-Replaces a composite policy's child-policy set in full with `childPolicyIds`.
-Dev: Reverts with `PolicyNotFound` when `policyId` does not exist.
-Dev: Reverts with `IncompatiblePolicyType` when `policyId` is not a composite (UNION or INTERSECT).
-Dev: Reverts with `Unauthorized` when the caller is not the current admin. A renounced composite
-(admin `address(0)`) can never be updated.
-Dev: Reverts with `ChildPoliciesOutsideOfRange` when `childPolicyIds.length` is not in
-`[MIN_COMPOSITE_CHILD_POLICIES, MAX_COMPOSITE_CHILD_POLICIES]`; there is no clear-the-list
-path (the composite child-policy range, not the 64-account batch limit).
-Dev: Reverts with `PolicyNotFound` when any child policy does not exist.
-Dev: Reverts with `InvalidChildPolicy` when any child policy is itself a composite
-(not a simple policy).
-Param: policyId Composite policy to update.
-Param: childPolicyIds Complete new set of existing simple policy IDs.
+Replaces a composite policy's entire child set with `childPolicyIds`. No partial update or clear-the-list operation is supported.
+
+The new child set must contain between `MIN_COMPOSITE_CHILD_POLICIES` (`2`) and `MAX_COMPOSITE_CHILD_POLICIES` (`4`) existing simple policies, subject to the same validation rules as `createCompositePolicy`. Authorization evaluates each child's current membership live on every call — the update takes effect immediately.
+
+## Revert conditions
+
+The canonical revert order is:
+
+1. `PolicyNotFound` — `policyId` does not exist.
+2. `IncompatiblePolicyType` — `policyId` is a simple policy, not a `UNION` or `INTERSECT` composite.
+3. `Unauthorized` — caller is not the current admin. A composite whose admin has been renounced (`address(0)`) can never be updated.
+4. `ChildPoliciesOutsideOfRange` — `childPolicyIds.length` is outside `[2, 4]`.
+5. `PolicyNotFound` — a child policy ID does not exist.
+6. `InvalidChildPolicy` — a child policy is itself a composite or a built-in sentinel (`ALWAYS_ALLOW` or `ALWAYS_BLOCK`).
+
+## Events emitted
+
+Emits only `CompositePolicyUpdated(policyId, updater, childPolicyIds)`. The admin does not change, so no `PolicyAdminUpdated` event is emitted.
## Access control
-Callable by the policy admin for the target policy.
+Callable by the current policy admin for the target composite policy.
## Policy interaction
-This is part of the singleton PolicyRegistry surface used by B20 policy scopes.
+This is part of the singleton PolicyRegistry surface used by B20 policy scopes. A composite policy ID is passed to a B20 policy slot as an opaque `uint64`, identical to a simple policy ID — no B20 contract changes are required.
## Example