diff --git a/docs/base-chain/specs/reference/b20/changelog/03-denim-b20-transfer-executor-enforcement.mdx b/docs/base-chain/specs/reference/b20/changelog/03-denim-b20-transfer-executor-enforcement.mdx
index 498869141..f1b99e28a 100644
--- a/docs/base-chain/specs/reference/b20/changelog/03-denim-b20-transfer-executor-enforcement.mdx
+++ b/docs/base-chain/specs/reference/b20/changelog/03-denim-b20-transfer-executor-enforcement.mdx
@@ -71,6 +71,20 @@ On `transfer`, the executor lookup is new. When `TRANSFER_EXECUTOR_POLICY` equal
`_transfer` reuses the executor result and skips the sender `isAuthorized` call. A default
`transfer` therefore still makes two `isAuthorized` calls.
+### `isAuthorized` — Malformed and Unknown ID Semantics
+
+The `IPolicyRegistry.isAuthorized` NatSpec was refined to distinguish two cases that previously
+shared a single description:
+
+- **Malformed ID**: the invert flag (bit 63) is cleared first; if the remaining top byte is outside
+ `PolicyType`, `isAuthorized` returns `false`.
+- **Well-formed but unknown ID**: treated as an empty set — `ALLOWLIST` and `UNION` return `false`;
+ `BLOCKLIST` and `INTERSECT` return `true`.
+- **Inverted unknown or malformed base**: always returns `false` (negation is not applied when the
+ base does not exist or is malformed).
+
+This is a documentation clarification only. No behavior changed.
+
## Examples
A holder moving their own tokens is now gated by the executor policy:
diff --git a/docs/base-chain/specs/reference/b20/changelog/03-denim-policyregistry-not-policy.mdx b/docs/base-chain/specs/reference/b20/changelog/03-denim-policyregistry-not-policy.mdx
index 1a967a355..5bbba8713 100644
--- a/docs/base-chain/specs/reference/b20/changelog/03-denim-policyregistry-not-policy.mdx
+++ b/docs/base-chain/specs/reference/b20/changelog/03-denim-policyregistry-not-policy.mdx
@@ -54,7 +54,7 @@ function invertedPolicyId(uint64 policyId) external view returns (uint64);
| Symbol | Selector | Status | Behavior |
| --- | --- | --- | --- |
| `invertedPolicyId(uint64)` | `0x6b468933` | New view | Toggles bit 63 (`policyId ^ INVERTED_POLICY_BIT`). Never reverts, reads no state, and is involutive. |
-| `isAuthorized(uint64,address)` | Unchanged | Extended | An inverted ID resolves the base and returns the negated result. Fail-closed on an unknown or malformed base. |
+| `isAuthorized(uint64,address)` | Unchanged | Extended | Clears the invert flag before type-checking. A malformed base returns `false`. A well-formed but unknown base uses empty-set semantics (`ALLOWLIST`/`UNION` → `false`; `BLOCKLIST`/`INTERSECT` → `true`). An inverted base returns the negated result only when the base exists; an inverted unknown or malformed base returns `false`. |
| `policyExists(uint64)` | Unchanged | Extended | Strips to base: `policyExists(invertedPolicyId(id)) == policyExists(id)`. |
| `policyAdmin(uint64)` | Unchanged | Extended | Strips to base. |
| `pendingPolicyAdmin(uint64)` | Unchanged | Extended | Strips to base. |
@@ -69,16 +69,28 @@ function invertedPolicyId(uint64 policyId) external view returns (uint64);
#### Authorization
-`isAuthorized` gains a leading invert branch. All non-inverted paths are unchanged.
+`isAuthorized` never reverts. The invert flag is cleared before the type check, so malformed-ID
+detection operates on the stripped value. The evaluation order is:
-```text Authorization Evaluation lines expandable wrap highlight={2-6}
+1. **Malformed ID** — after clearing the invert flag, if the top byte is outside `PolicyType`, return `false`.
+2. **Inverted ID (base exists)** — return the negated result of the base policy.
+3. **Inverted unknown or malformed base** — return `false`.
+4. **Unknown well-formed ID** — empty-set semantics: `ALLOWLIST` and `UNION` return `false`; `BLOCKLIST` and `INTERSECT` return `true`.
+5. **Known ID** — existing `ALLOWLIST` / `BLOCKLIST` / `UNION` / `INTERSECT` dispatch, unchanged.
+
+```text Authorization Evaluation lines expandable wrap highlight={2-10}
isAuthorized(policyId, account):
if policyId has INVERTED_POLICY_BIT set:
base = policyId without the bit
+ if base is malformed:
+ return false
if not policyExists(base): # fail-closed guard
return false
return not isAuthorized(base, account)
+ if policyId type byte is outside PolicyType: # malformed
+ return false
+
... existing ALLOWLIST / BLOCKLIST / UNION / INTERSECT dispatch ...
```
@@ -129,6 +141,7 @@ policyRegistry.createCompositePolicy(admin, IPolicyRegistry.PolicyType.INTERSECT
```
Fail-closed: for any never-created base, `isAuthorized(base | INVERTED_POLICY_BIT, account)` returns
+`false`. Malformed IDs (type byte outside `PolicyType` after clearing the invert flag) also return
`false`.
Round-trip: `invertedPolicyId(invertedPolicyId(id)) == id`, and
diff --git a/docs/build-on-base/issue-rwa/restrict-transfer-initiators.mdx b/docs/build-on-base/issue-rwa/restrict-transfer-initiators.mdx
index f7575aeed..0d4ef72d2 100644
--- a/docs/build-on-base/issue-rwa/restrict-transfer-initiators.mdx
+++ b/docs/build-on-base/issue-rwa/restrict-transfer-initiators.mdx
@@ -92,7 +92,7 @@ token.approve(transferAgent, amount);
The initiator moves units with `transferFrom(holder, recipient, amount)`. The holder's own `transfer(recipient, amount)` reverts `PolicyForbids(TRANSFER_EXECUTOR_POLICY, executorId)` before balance is checked.
-`Transfer(from, to, amount)` appears on the initiator's `transferFrom`. A holder's direct `transfer` reverts with `PolicyForbids(TRANSFER_EXECUTOR_POLICY, executorId)`; that revert confirms the restriction is active, not that something is misconfigured. `isAuthorized(executorId, account)` on the Policy Registry returns `true` for the initiator and `false` for the holder.
+`Transfer(from, to, amount)` appears on the initiator's `transferFrom`. A holder's direct `transfer` reverts with `PolicyForbids(TRANSFER_EXECUTOR_POLICY, executorId)`; that revert confirms the restriction is active, not that something is misconfigured. `isAuthorized(executorId, account)` on the Policy Registry returns `true` for the initiator and `false` for the holder. For a well-formed but unrecognized policy ID, `isAuthorized` treats it as an empty set: an ALLOWLIST or UNION returns `false`, a BLOCKLIST or INTERSECT returns `true`. A malformed ID always returns `false`. An inverted unknown or malformed base also returns `false`.
diff --git a/docs/build-on-base/issue-rwa/seize-and-cancel-units.mdx b/docs/build-on-base/issue-rwa/seize-and-cancel-units.mdx
index 21282b6fa..fb4905638 100644
--- a/docs/build-on-base/issue-rwa/seize-and-cancel-units.mdx
+++ b/docs/build-on-base/issue-rwa/seize-and-cancel-units.mdx
@@ -43,7 +43,9 @@ How the seize scope works:
| `SEIZE_EXEMPT_POLICY` | `from` | `isAuthorized` is **false** | Everyone is authorized, so nobody is seizable. |
| `SEIZE_RECEIVER_POLICY` | `to` | `isAuthorized` is **true** | Any destination is allowed. |
-Because the holder check is inverted, attach a **blocklist** to `SEIZE_EXEMPT_POLICY`. Accounts on the list are unauthorized and therefore seizable; every other holder stays exempt. Never attach an allowlist or `ALWAYS_BLOCK` to this scope: an empty allowlist authorizes nobody, so every holder would be seizable.
+Because the holder check is inverted, attach a **blocklist** to `SEIZE_EXEMPT_POLICY`. Accounts on the list are unauthorized and therefore seizable; every other holder stays exempt. Never attach an allowlist or `ALWAYS_BLOCK` to this scope: an empty allowlist returns false for every account, so every holder would be seizable.
+
+`isAuthorized` never reverts. A malformed policy ID returns false. A well-formed but unknown ID is treated as an empty set: ALLOWLIST and UNION return false; BLOCKLIST and INTERSECT return true. An inverted unknown or malformed policy ID returns false regardless of type.
## Seize, Cancel, and Verify
diff --git a/docs/build-on-base/issue-stablecoins/block-an-account.mdx b/docs/build-on-base/issue-stablecoins/block-an-account.mdx
index c249ac0bc..5f3ab88f5 100644
--- a/docs/build-on-base/issue-stablecoins/block-an-account.mdx
+++ b/docs/build-on-base/issue-stablecoins/block-an-account.mdx
@@ -74,6 +74,8 @@ See the [B20 token standard](/specifications/b20) for the complete interface, ro
This only stops outgoing transfers when the blocklist is bound to `TRANSFER_SENDER_POLICY`. Unblock with the same call and `false`.
+`isAuthorized` never reverts. A malformed policy ID returns `false`. A well-formed but unknown ID uses empty-set semantics: ALLOWLIST and UNION return `false`; BLOCKLIST and INTERSECT return `true`. Always validate that a policy exists with `policyExists(policyId)` at write time — storing an unknown ID risks unexpected authorization results at query time.
+
## Make a Blocked Account Recoverable
Blocking under `TRANSFER_SENDER_POLICY` freezes the account's outgoing transfers. It does not, on its own, let you move the balance out. Seize reads a separate scope, `SEIZE_EXEMPT_POLICY`, whose check is inverted: an account that is **not** authorized under the attached policy is seizable. Attach the same blocklist there so a single hold both freezes the account and makes it recoverable:
diff --git a/docs/build-on-base/issue-stablecoins/restrict-who-can-hold.mdx b/docs/build-on-base/issue-stablecoins/restrict-who-can-hold.mdx
index 03a3befa2..ee860f6b2 100644
--- a/docs/build-on-base/issue-stablecoins/restrict-who-can-hold.mdx
+++ b/docs/build-on-base/issue-stablecoins/restrict-who-can-hold.mdx
@@ -26,6 +26,8 @@ The Policy Registry is a singleton precompile that stores each member list once.
Scopes gate specific functions. `TRANSFER_SENDER_POLICY` and `TRANSFER_RECEIVER_POLICY` check the sender and recipient on every `transfer` and `transferFrom`. `MINT_RECEIVER_POLICY` checks the recipient on every `mint`. All three default to `ALWAYS_ALLOW` (`0`) until you bind a policy.
+`isAuthorized` never reverts. A **malformed** policy ID returns `false`. A well-formed but unknown ID is treated as an empty set: ALLOWLIST and UNION return `false`; BLOCKLIST and INTERSECT return `true`. An inverted ID negates the base result only when the base exists — an inverted unknown or malformed base returns `false`.
+
An **allowlist** authorizes only accounts in the set. An empty allowlist authorizes nobody, so seed your intended holders before binding the policy.
## Create and Bind a Holder Allowlist
diff --git a/docs/specifications/b20/introduction.mdx b/docs/specifications/b20/introduction.mdx
index a8ed537eb..488610fa7 100644
--- a/docs/specifications/b20/introduction.mdx
+++ b/docs/specifications/b20/introduction.mdx
@@ -143,6 +143,8 @@ Those lists live in the Policy Registry, a global singleton precompile, not on t
A token admin binds a policy ID to a policy scope with `updatePolicy`. A scope sits in a similar place to a hook: it runs on a specific function. When that function runs, the token asks the registry `isAuthorized(policyId, account)` and reverts with `PolicyForbids` if the check fails. Which scope runs on which function is in [Policies](/specifications/b20/concepts/policies).
+`isAuthorized` never reverts. A malformed policy ID returns `false`. A well-formed but unknown ID is treated as an empty set: ALLOWLIST and UNION return `false`; BLOCKLIST and INTERSECT return `true`. An inverted unknown or malformed ID also returns `false`. Callers that store policy IDs should validate `policyExists(policyId)` at write time to avoid unintended empty-set behavior.
+
A policy-gated transfer looks like this:
```mermaid Integrating Compliance Checks Diagram lines wrap expandable highlight={1}
@@ -171,4 +173,3 @@ sequenceDiagram
3. On `transfer`, the token asks the registry whether the receiver is authorized.
4. Authorized: the call continues. Denied: the call reverts with `PolicyForbids`.
5. Unset scopes default to always-allow. `approve` is not policy-gated.
-
diff --git a/docs/specifications/b20/reference/constants.mdx b/docs/specifications/b20/reference/constants.mdx
index c8cfa4df0..219ba51ac 100644
--- a/docs/specifications/b20/reference/constants.mdx
+++ b/docs/specifications/b20/reference/constants.mdx
@@ -44,6 +44,20 @@ description: "B20 precompile addresses, role identifiers, policy scopes, and val
| `SEIZE_EXEMPT_POLICY` | `keccak256("SEIZE_EXEMPT_POLICY")`
`0xedb5da348cfb67af08746d3afd1be81034b50d5c8576f31aff688f39dfd540ed` | Consulted for `from` on `seizeWithMemo`; an authorized `from` is seize-exempt, so `from` is seizable only when unauthorized under this policy. Named `SEIZE_HOLDER_POLICY` before Cobalt. |
| `SEIZE_RECEIVER_POLICY` | `keccak256("SEIZE_RECEIVER_POLICY")`
`0xbf15b19caf5c77422c038bc25f26b8b815c3a14f6d04c6616076b81bcfe07b3d` | Consulted for `to` on `seizeWithMemo`. |
+## `isAuthorized` edge-case semantics
+
+`isAuthorized` never reverts. The table below summarizes how it handles IDs that are malformed, unknown, or inverted.
+
+| Case | Result |
+|---|---|
+| Malformed ID (top byte outside `PolicyType` after clearing the invert flag, bit 63) | `false` |
+| Well-formed but unknown ID — `ALLOWLIST` or `UNION` | `false` (empty set) |
+| Well-formed but unknown ID — `BLOCKLIST` or `INTERSECT` | `true` (empty set) |
+| Inverted ID whose base exists | Negation of the base result |
+| Inverted unknown or malformed base | `false` |
+
+Callers that store policy IDs must validate `policyExists(policyId)` at write time to avoid relying on empty-set or malformed-ID fallback behavior.
+
## Feature and validation bounds
*Bitmasks and inclusive bounds used for pause features and B20Asset creation validation. See [`B20Constants`](https://github.com/base/base-std/blob/main/src/lib/B20Constants.sol).*
diff --git a/docs/specifications/b20/reference/interfaces/i-policy-registry/index.mdx b/docs/specifications/b20/reference/interfaces/i-policy-registry/index.mdx
index 79a07d469..ba0c35704 100644
--- a/docs/specifications/b20/reference/interfaces/i-policy-registry/index.mdx
+++ b/docs/specifications/b20/reference/interfaces/i-policy-registry/index.mdx
@@ -1,7 +1,7 @@
---
title: "IPolicyRegistry Reference"
sidebarTitle: "IPolicy Registry"
-description: "Generated B20 reference for IPolicyRegistry functions, events, and errors."
+description: "Interface for creating and querying B20 policy registry entries, including simple and composite policies."
---
@@ -18,7 +18,7 @@ description: "Generated B20 reference for IPolicyRegistry functions, events, and
| [`updateAllowlist`](/specifications/b20/reference/interfaces/i-policy-registry/update-allowlist) | `0x3388fb5b` | Sets `accounts` membership in an ALLOWLIST policy to `allowed` in one batch. |
| [`updateBlocklist`](/specifications/b20/reference/interfaces/i-policy-registry/update-blocklist) | `0x5c4e51b8` | Sets `accounts` membership in a BLOCKLIST policy to `blocked` in one batch. |
| [`updateComposite`](/specifications/b20/reference/interfaces/i-policy-registry/update-composite) | `0xbfe142c0` | Replaces a composite policy's child-policy set in full with `childPolicyIds`. |
-| [`isAuthorized`](/specifications/b20/reference/interfaces/i-policy-registry/is-authorized) | `0x55a1179e` | Returns whether `account` is authorized under `policyId`. Never reverts; unknown |
+| [`isAuthorized`](/specifications/b20/reference/interfaces/i-policy-registry/is-authorized) | `0x55a1179e` | Returns whether `account` is authorized under `policyId`. Never reverts. A malformed ID returns false; a well-formed but unknown ID follows empty-set semantics (ALLOWLIST/UNION → false, BLOCKLIST/INTERSECT → true). An inverted unknown or malformed base returns false. |
| [`MIN_COMPOSITE_CHILD_POLICIES`](/specifications/b20/reference/interfaces/i-policy-registry/min-composite-child-policies) | `0xb3ae29f7` | Minimum number of child policies a composite must reference, inclusive. Never reverts. |
| [`MAX_COMPOSITE_CHILD_POLICIES`](/specifications/b20/reference/interfaces/i-policy-registry/max-composite-child-policies) | `0x54309870` | Maximum number of child policies a composite may reference, inclusive. Never reverts. |
| [`policyExists`](/specifications/b20/reference/interfaces/i-policy-registry/policy-exists) | `0x330f5637` | Returns whether `policyId` is a built-in sentinel or a previously-assigned custom ID. Never reverts. |
diff --git a/docs/specifications/b20/reference/interfaces/i-policy-registry/is-authorized.mdx b/docs/specifications/b20/reference/interfaces/i-policy-registry/is-authorized.mdx
index 57eeebf25..36cf40350 100644
--- a/docs/specifications/b20/reference/interfaces/i-policy-registry/is-authorized.mdx
+++ b/docs/specifications/b20/reference/interfaces/i-policy-registry/is-authorized.mdx
@@ -1,6 +1,6 @@
---
title: "IPolicyRegistry.isAuthorized"
-description: "Generated B20 reference for isAuthorized(uint64,address)."
+description: "Returns whether an account is authorized under a given policy ID, with defined behavior for malformed, unknown, and inverted IDs."
---
@@ -18,17 +18,39 @@ 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.
+
+**Malformed ID** — The invert flag (bit 63) is cleared before the type check. If the remaining top byte is outside the valid `PolicyType` range, the ID is malformed and the function returns `false`.
+
+**Unknown ID (well-formed, not registered)** — Treated as an empty member set:
+
+| Policy type | Result |
+|---|---|
+| `ALLOWLIST` | `false` |
+| `UNION` | `false` |
+| `BLOCKLIST` | `true` |
+| `INTERSECT` | `true` |
+
+**Invert** — `isAuthorized(invertedPolicyId(id), account)` returns the negated result of the base policy when that base exists. Applies to every policy type. An inverted unknown or malformed base returns `false`.
+
+
+Callers that store policy IDs MUST validate `policyExists(policyId)` at write time.
+
+
+## Parameters
+
+| Name | Type | Description |
+|---|---|---|
+| `policyId` | `uint64` | Policy to query. |
+| `account` | `address` | Account to check. |
+
+## Returns
+
+`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 view function; no role restriction.
## Policy Interaction