diff --git a/docs/PolicyRegistry/README.md b/docs/PolicyRegistry/README.md
index c2475e4..bc8e57a 100644
--- a/docs/PolicyRegistry/README.md
+++ b/docs/PolicyRegistry/README.md
@@ -1,15 +1,22 @@
# PolicyRegistry
-The PolicyRegistry is a singleton precompile for list-based access policies — allowlists and blocklists. Any caller can create a policy and nominate its admin; B20 tokens and other consumers reference policies by `uint64` ID for authorization checks. See [`IPolicyRegistry`](../../src/interfaces/IPolicyRegistry.sol) for the full Solidity interface.
+The PolicyRegistry is a singleton precompile for list-based and composite access policies. Any caller can create a policy and nominate its admin; B20 tokens and other consumers reference policies by `uint64` ID for authorization checks. See [`IPolicyRegistry`](../../src/interfaces/IPolicyRegistry.sol) for the full Solidity interface.
## Policy Types
-Two policy types are supported today:
+Four policy types are supported, split into two kinds:
+
+**Simple** policies decide from an address set:
- **`BLOCKLIST`** — accounts are authorized by default; the admin maintains a list of accounts to explicitly deny.
- **`ALLOWLIST`** — accounts are denied by default; the admin maintains a list of accounts to explicitly authorize.
-Additional types (union / intersect composition of existing policies) are planned for a future hardfork via additive `PolicyType` enum values and sibling creator functions.
+**Composite** policies decide by combining existing simple policies under a logic gate:
+
+- **`UNION`** (OR) — authorized if *any* child policy authorizes the account.
+- **`INTERSECT`** (AND) — authorized only if *every* child policy authorizes the account.
+
+A composite's child set is 2–4 existing simple (`ALLOWLIST`/`BLOCKLIST`) policy IDs — never another composite, and never a built-in sentinel (`ALWAYS_ALLOW`/`ALWAYS_BLOCK`). Composites reference their children live: `isAuthorized` reads current child membership on every call. So updating a child's membership immediately changes what the composite authorizes.
## Policy IDs
@@ -36,16 +43,21 @@ The `PolicyRegistry` is gated by the [`ActivationRegistry`](../ActivationRegistr
- `policyExists`
- `policyAdmin`
- `pendingPolicyAdmin`
+- `compositePolicyChildIds`
+- `MIN_COMPOSITE_CHILD_POLICIES`
+- `MAX_COMPOSITE_CHILD_POLICIES`
**Gated** — revert with `FeatureNotActivated` while the feature is inactive:
- `createPolicy`
- `createPolicyWithAccounts`
+- `createCompositePolicy`
- `stageUpdateAdmin`
- `finalizeUpdateAdmin`
- `renounceAdmin`
- `updateAllowlist`
- `updateBlocklist`
+- `updateComposite`
Because reads are never gated, a consumer — a B20 token calling `isAuthorized` on transfer, or an indexer reading membership and admin state — sees the same behavior whether or not the feature is active.
@@ -70,6 +82,26 @@ Use `createPolicyWithAccounts(admin, policyType, accounts)` for the seeded varia
Reverts: `ZeroAddress` (if `admin` is `address(0)`), `BatchSizeTooLarge` (seeded variant only).
+### Create Composite Policy
+
+A caller combines 2–4 existing simple policies under a `UNION` or `INTERSECT` gate and nominates an admin for the composite.
+
+```mermaid
+sequenceDiagram
+ participant Creator
+ participant PolicyRegistry
+
+ Creator->>PolicyRegistry: createCompositePolicy(admin, policyType, childPolicyIds)
+ Note over PolicyRegistry: validate children
allocate new policyId
store type, admin, children
+ PolicyRegistry-->>Creator: emit PolicyCreated(policyId, creator, policyType)
+ PolicyRegistry-->>Creator: emit PolicyAdminUpdated(policyId, 0, admin)
+ PolicyRegistry-->>Creator: emit CompositePolicyUpdated(policyId, creator, childPolicyIds)
+```
+
+Every entry in `childPolicyIds` must be an existing simple (`ALLOWLIST`/`BLOCKLIST`) policy — never another composite and never a built-in sentinel (`ALWAYS_ALLOW`/`ALWAYS_BLOCK`). The set size must fall within `[MIN_COMPOSITE_CHILD_POLICIES, MAX_COMPOSITE_CHILD_POLICIES]` (2–4, inclusive).
+
+Reverts: `ZeroAddress` (if `admin` is `address(0)`), `IncompatiblePolicyType` (`policyType` isn't `UNION`/`INTERSECT`), `ChildPoliciesOutsideOfRange` (child count outside `[2, 4]`), `PolicyNotFound` (a child doesn't exist), `InvalidChildPolicy` (a child is a composite or a built-in sentinel).
+
### Update Membership
The policy admin sets `accounts` to a uniform membership state — all included or all excluded — in a single batch.
@@ -88,6 +120,24 @@ sequenceDiagram
Reverts: `PolicyNotFound` (unknown `policyId`), `IncompatiblePolicyType` (wrong call for the policy's type), `Unauthorized` (caller isn't current admin), `BatchSizeTooLarge`.
+### Update Composite Children
+
+The composite's admin replaces its child-policy set in full with `updateComposite`.
+
+```mermaid
+sequenceDiagram
+ participant PolicyAdmin
+ participant PolicyRegistry
+
+ PolicyAdmin->>PolicyRegistry: updateComposite(policyId, childPolicyIds)
+ Note over PolicyRegistry: validate children
replace child set in full
+ PolicyRegistry-->>PolicyAdmin: emit CompositePolicyUpdated(policyId, updater, childPolicyIds)
+```
+
+`childPolicyIds` is a full replacement, a child omitted from the new set no longer governs the composite. The new set must still satisfy the same size and child-validity rules as creation.
+
+Reverts: `PolicyNotFound` (unknown `policyId` or a child that doesn't exist), `IncompatiblePolicyType` (`policyId` isn't `UNION`/`INTERSECT`), `Unauthorized` (caller isn't current admin — a renounced composite can never be updated), `ChildPoliciesOutsideOfRange` (child count outside `[2, 4]`), `InvalidChildPolicy` (a child is a composite or a built-in sentinel).
+
### Transfer Admin
A two-step transfer: the current admin proposes a successor, then the proposed admin accepts. The active admin doesn't change until the second step.
diff --git a/src/interfaces/IPolicyRegistry.sol b/src/interfaces/IPolicyRegistry.sol
index 210560b..8969163 100644
--- a/src/interfaces/IPolicyRegistry.sol
+++ b/src/interfaces/IPolicyRegistry.sol
@@ -124,14 +124,16 @@ interface IPolicyRegistry {
/// @notice 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 Child policies must be simple policies (ALLOWLIST or BLOCKLIST), never another composite
+ /// and never a built-in sentinel (ALWAYS_ALLOW / ALWAYS_BLOCK). 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 Reverts with `InvalidChildPolicy` when any child policy is not a simple policy or is a
+ /// built-in sentinel (ALWAYS_ALLOW / ALWAYS_BLOCK).
/// @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
@@ -209,8 +211,8 @@ interface IPolicyRegistry {
/// `[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).
+ /// @dev Reverts with `InvalidChildPolicy` when any child policy is not a simple policy — i.e.
+ /// it is itself a composite or a built-in sentinel (ALWAYS_ALLOW / ALWAYS_BLOCK).
///
/// @param policyId Composite policy to update.
/// @param childPolicyIds Complete new set of existing simple policy IDs.