diff --git a/docs/adr/0002-agent-wallet-and-session-keys.md b/docs/adr/0002-agent-wallet-and-session-keys.md
index c61b92a..b0c9054 100644
--- a/docs/adr/0002-agent-wallet-and-session-keys.md
+++ b/docs/adr/0002-agent-wallet-and-session-keys.md
@@ -11,7 +11,10 @@
[`#31`](https://github.com/hellno/deckard/issues/31) (wire evolution).
- **Supersedes / relates:** records the positioning behind the "agent wallet" goal. The executable
work lives as GitHub issues (`#33`, `#31`, and a sizing spike drafted alongside this ADR).
- Convention: work items live in issues; decisions + research live in `docs/`.
+ Convention: work items live in issues; decisions + research live in `docs/`. The agent's policy /
+ "Rules" vocabulary (cap / allowlist / expiry / approval-mode) that a session-key grant reuses — and
+ the rule that a software grant stays a *distinct enforcer* from a chain-enforced one — is defined in
+ [`ADR-0005`](0005-policy-and-authorization-model.md).
## Decision drivers (agreed with the maintainer)
diff --git a/docs/adr/0005-policy-and-authorization-model.md b/docs/adr/0005-policy-and-authorization-model.md
new file mode 100644
index 0000000..cf964e1
--- /dev/null
+++ b/docs/adr/0005-policy-and-authorization-model.md
@@ -0,0 +1,280 @@
+# ADR 0005 — Policy and authorization model
+
+- **Status:** Proposed (2026-06-23)
+- **Deciders:** @hellno (maintainer)
+- **Context inputs:** [`crates/deckard-contract/src/policy.rs`](../../crates/deckard-contract/src/policy.rs)
+ (the flat `Policy` + the pure `evaluate` / `evaluate_order` functions),
+ [`crates/deckard-signerd/src/policy_store.rs`](../../crates/deckard-signerd/src/policy_store.rs)
+ (loads `policy.json`, the loud fail-safe default),
+ [`crates/deckard-signerd/src/daemon.rs`](../../crates/deckard-signerd/src/daemon.rs)
+ (single-mutex serialization, execute-time cap re-check, #108 reserve-before-sign),
+ [`docs/agent-authorization-map.md`](../agent-authorization-map.md),
+ [`docs/adr/0002-agent-wallet-and-session-keys.md`](0002-agent-wallet-and-session-keys.md),
+ [`docs/adr/0003-crate-trust-boundary.md`](0003-crate-trust-boundary.md),
+ [`docs/build/30-mcp-shape.md`](../build/30-mcp-shape.md). A 2026-06-23 deep-research pass
+ (primary-source, adversarially verified) on authorization standards/libraries, and **two** codex
+ cross-model adversarial reviews — one of the thesis, one of an earlier draft of this ADR that caught
+ the spec bugs corrected below.
+- **Relates:** [`#29`](https://github.com/hellno/deckard/issues/29) (policy variants),
+ [`#31`](https://github.com/hellno/deckard/issues/31) (wire evolution),
+ [`#33`](https://github.com/hellno/deckard/issues/33) (EIP-7702 session keys),
+ [`#48`](https://github.com/hellno/deckard/issues/48) (per-origin permissions). Convention: the
+ executable work lives in GitHub issues; the decision + the "why" live here.
+
+## Context
+
+The agent's spending fence is a single **flat struct** (`policy.rs:16-37`):
+
+```rust
+struct Policy {
+ per_tx_cap_wei, daily_cap_wei, spent_today_wei: U256,
+ allow_to: Vec
, // EMPTY = any address ← sentinel foot-gun
+ auto_shield_min_wei: U256, // advisory ONLY — evaluate() does not switch on it
+ require_approval: ApprovalMode, // Never | OverCap | Always ← ONE global mode
+ revoked: bool,
+ allow_swap_tokens: Vec, // EMPTY = any token ← second sentinel
+}
+```
+
+This does not scale. Every new capability bolts another bespoke top-level key onto the struct, and the
+approval mode is **global** — it cannot say "auto-shield freely but card every send." The roadmap is
+many more wallet actions (send, bridge, message-signing, contract calls). On the flat model each one
+is a hand-maintained field threaded through `evaluate`, the loader, the demo policy, and the tests —
+the "a key per rule, maintained forever" sprawl we want to kill. It also default-**allows** on the
+most dangerous axis: an empty `allow_to` means "any recipient."
+
+Three structural facts constrain the redesign (all verified in code; ignoring them was how the first
+draft of this ADR went wrong):
+
+1. **There are TWO pure decision functions, not one.** `evaluate(intent, policy)` decides
+ `Intent`s (`IntentKind` = Send/Shield/Unshield/ContractCall — **there is no `Swap` kind**);
+ `evaluate_order(order, policy, wallet, now)` decides swaps from a separate `SwapOrder` wire type
+ and **never inspects caps** (`policy.rs:195-196`). Both are pure and shared by `MockSigner` and
+ `deckard-signerd` (the parity contract) — the crown jewel to preserve.
+2. **`Policy` lives in the frozen `deckard-contract` crate** with a byte-stable JSON+CBOR round-trip
+ charter (`lib.rs` round-trip tests; #28/#31 rules), and is returned whole over CBOR by the
+ `PolicyGet` RPC (`daemon.rs:343-346`). Changing its shape is an **intentional, versioned breaking
+ change** to a freeze-first crate — see §6.
+3. **Value is `uint256` wei**, compared in `U256`. This rules out every general-purpose authorization
+ engine as a cap enforcer (§3).
+
+## Decision
+
+**Replace the flat `Policy` with a small, default-deny, per-action *typed rule list*, evaluated by the
+same pure function(s), with all value comparisons in native Rust `U256`.**
+
+### 1. The shape
+
+`policy.json` becomes a versioned object with one **global** daily wall and an array of typed rules
+(the RFC 9396 "array of typed objects" shape):
+
+```jsonc
+{
+ "version": 1,
+ "default": "deny", // no matching rule ⇒ Deny{no_rule}; "allow" is FORBIDDEN
+ "daily_cap_wei": "200000000000000000", // ONE global daily wall (single SpendStore + #108)
+ "auto_shield_min_wei": "10000000000000000", // ADVISORY agent hint (via policy_get); NOT enforced
+ "rules": [
+ { "action": "send", "approval": "over_cap", "per_tx_cap_wei": "50000000000000000",
+ "recipients": ["0xA1b2…"] }, // omitted ⇒ DenyAll; "any" ⇒ Any
+ { "action": "shield", "approval": "never" },
+ { "action": "swap", "tokens": ["0xToken…"] } // evaluate_order reads tokens only; always NeedsApproval
+ ]
+}
+```
+
+In Rust, each action's constraints live **inside its own variant**, so adding a capability is one
+localized variant + one match arm — never a new top-level field:
+
+```rust
+struct Policy {
+ version: u32,
+ default_effect: Effect, // = Deny; Allow is intentionally NOT representable in v1
+ revoked: bool,
+ daily_cap_wei: U256, // ONE global daily wall — mirrors the single SpendStore (#108)
+ auto_shield_min_wei: U256, // ADVISORY: agent reads via policy_get to decide WHETHER to propose;
+ // evaluate() does NOT switch on it (preserves today's semantics)
+ rules: Vec, // loader REJECTS duplicate actions (loud), so "find rule" is unambiguous
+}
+
+#[serde(tag = "action")] // internally tagged ⇒ the JSON above
+enum Rule {
+ Send { approval: ApprovalMode, per_tx_cap_wei: Option, #[serde(default)] recipients: Allowlist },
+ Shield { approval: ApprovalMode }, // value==0; no recipient (own 0zk), no per-tx cap
+ Unshield { approval: ApprovalMode, per_tx_cap_wei: Option }, // forward-compat; NOT yet reachable (see §2)
+ Swap { #[serde(default)] tokens: Allowlist }, // read by evaluate_order only
+ ContractCall { approval: ApprovalMode, #[serde(default)] targets: Allowlist }, // forward-compat; NOT yet reachable
+}
+
+enum Allowlist { DenyAll, Any, Only(Vec) } // a real lattice — no "empty Vec = any" sentinel
+impl Default for Allowlist { fn default() -> Self { Allowlist::DenyAll } } // #[serde(default)] needs this
+enum ApprovalMode { Never, OverCap, Always }
+enum Effect { Deny } // present-but-empty `rules` ⇒ everything denied
+```
+
+Per-tx caps and recipient/token allowlists are **per action**; the daily cap is **one global wall**
+(a per-action daily cap with a single `SpendStore` counter would be incoherent — `Send` would consume
+`Unshield`'s budget). Per-action daily budgets are a deferred, explicit `SpendStore`-schema change, not
+v1.
+
+**User-facing name:** in the app and docs this is called **"Rules"** (e.g. "the agent's Rules"). The
+internal `deckard-contract` type stays `Policy` and the file stays `policy.json` to avoid extra wire
+churn — a later cosmetic rename is optional, not part of v1.
+
+### 2. Evaluation semantics (preserve the pure-function invariant)
+
+`evaluate` / `evaluate_order` keep their signatures and their `Decision { Allow | Deny{reason} |
+NeedsApproval{request_id} }` return, with `deny_reasons` tags and the `RequestId::ZERO` placeholder.
+`evaluate` becomes:
+
+1. `revoked` ⇒ `Deny{REVOKED}` — STOP stays a top-level brake, not a rule.
+2. Find the rule whose `action` matches `intent.kind`. **No rule ⇒ `Deny{NO_RULE}`** (default-deny).
+ Unknown action in the file, or a duplicate action, ⇒ the loader **fails loudly** (typed deser +
+ duplicate check), never a verdict.
+3. Calldata-shape check (the existing `calldata_ok` invariant: `Send` empty, `Shield`/`Unshield`/
+ `ContractCall` non-empty) ⇒ `Deny{UNDECODABLE}` on mismatch.
+4. Constraints: per-tx cap (rule) and the global daily cap, both in `U256` (`spent_today + value`);
+ recipients/targets via the `Allowlist` lattice (`DenyAll` or off-`Only` ⇒ `Deny{OFF_ALLOWLIST}`).
+5. `approval`: `Never` = allow-within-cap else `Deny{OVER_CAP}`; `OverCap` = allow-within-cap else
+ `NeedsApproval`; `Always` = `NeedsApproval`.
+
+**Swaps** stay on `evaluate_order`, unchanged except that it reads the `Swap` rule's `tokens`
+allowlist (replacing the old `allow_swap_tokens`); it inspects no caps and **always** returns
+`NeedsApproval` for a well-formed order. The `Swap` rule therefore carries `tokens` only — no
+`approval`/`per_tx_cap` (those would be dead fields that lie to the policy author).
+
+**Reachability honesty:** the v1 daemon denies anything but `Send`/`Shield` with `unsupported_v1`
+*before* `evaluate` (`daemon.rs:546`), and the one signed `ContractCall` (the shaped relayer-approve)
+is admitted by `shaped_approve_admission` and **skips `evaluate`** (`daemon.rs:529-540`, `:709-749`).
+So `Unshield` and `ContractCall` rules are defined **forward-compat but not yet reachable** — an
+author writing a permissive `Unshield`/`ContractCall` rule is not granting live authority. The ADR
+states this so no one is misled.
+
+**Consent and the daily cap (documenting EXISTING behavior, not a new rule):** a human-approved write
+proceeds even if over the daily cap — `resolve` sets `approved` (`daemon.rs:483-486`) and `execute`
+skips the cap re-check for approved requests (`daemon.rs:1131`). The daily cap is a hard wall only for
+*auto-allowed* (no-human) writes, which `execute` **does** re-evaluate against current `spent_today`.
+This is consistent with default-deny: an `ApprovalMode::Never` over-cap write has no card → `Deny`;
+`OverCap`/`Always` raise a card whose approval carries the overage.
+
+Send autonomy is therefore **entirely policy-expressed** — "always card" vs "within cap" vs "only to
+these recipients" is what the `send` rule says, not a hardcoded branch.
+
+### 3. No general policy engine enforces wei caps — native `U256` only
+
+Verified 2026-06-23 against primary specs (3-0 adversarial vote): every mature, audited
+general-purpose authorization engine maxes out at a **64-bit signed integer** (max ≈ 9.2 × 10¹⁸),
+~58 orders of magnitude below the `uint256`/wei ceiling (2²⁵⁶−1 ≈ 1.15 × 10⁷⁷):
+
+- **AWS Cedar** — the only integer type is `Long` (i64); the decimal extension is 4 digits; arithmetic
+ past `Long` throws. ([cedar docs: datatypes](https://docs.cedarpolicy.com/policies/syntax-datatypes.html))
+- **Biscuit** — spec: "An integer is a signed 64 bits integer"; operations fail on overflow.
+ ([SPECIFICATIONS.md](https://github.com/eclipse-biscuit/biscuit/blob/main/SPECIFICATIONS.md))
+
+So **cap enforcement lives in native Rust `U256`** in our own `evaluate`. No engine dependency can
+do it correctly, and a silent narrowing-to-i64 would be a value bug in the trust core.
+
+### 4. One vocabulary, two enforcers — do NOT unify the mechanisms
+
+The constraint *vocabulary* (cap, allowlist, expiry, approval-mode) is shared. The *enforcer* is not:
+
+- **`#48` per-origin grants** are **software-enforced** — `deckard-signerd` refuses to sign out of scope.
+- **`#33` EIP-7702 session keys** are **chain-enforced** — an audited on-chain delegate refuses.
+
+These have different trust roots, failure modes, and revocation. Modeling them as "the same caveat
+mechanism" invites the single most dangerous error in the repo: claiming chain-strength for a software
+check (`MANIFESTO.md` forbids it; `agent-authorization-map.md` keeps them separate). This ADR unifies
+**naming only**. The grant/attenuation design stays with `#33`/`#48`, **deferred** (see ADR 0002).
+
+When grants do land, composition is **monotonic narrowing** (a grant can only restrict the global
+fence, never widen it) — and the `Allowlist` lattice (`Any` = ⊤, `DenyAll` = ⊥) is why that
+intersection is well-defined instead of the old "empty Vec accidentally means any."
+
+### 5. Defaults, the fail-safe, and migration
+
+- **Built-in default** (no file): shield `approval: never` (auto-allow to self), send `approval: always`
+ with `recipients: "any"` (every send is human-approved — maintainer default 2026-06-23), swap always cards.
+- **Three named presets ship** (the `#29` variants), all default-deny: **shield-only** (one shield rule,
+ everything else denied — the safest useful robot); **ask-me-everything** (send/shield/swap all
+ `approval: always`, a human taps every action); **locked** (panic — `rules: []`, everything denied;
+ a switchable frozen rulebook, distinct from the runtime `revoked` STOP brake).
+- **Malformed or legacy-v0 `policy.json` ⇒ most-restrictive deny-all + a loud log.** Deliberate change
+ from today's "permissive-but-capped" fallback (`policy_store.rs:24-36`). Deny is the safe direction;
+ the loudness keeps it from looking like silent corruption.
+- **`Allowlist` serde:** "omitted ⇒ `DenyAll`" is **not** free — it requires `#[serde(default)]` on the
+ field plus `impl Default for Allowlist = DenyAll`. Without that, a missing `recipients` is a *parse
+ error* → fallback (deny-all) — a different code path. The implementation MUST add the explicit
+ default.
+- **No silent semantic migration.** The old `allow_to: [] = any` does **not** auto-translate; a v0 file
+ is detected (no `version` key) and **rejected loudly** with "rewrite to v1," never reinterpreted —
+ otherwise every existing policy's recipient axis would flip from "any" to "deny-all" invisibly.
+- **`policy.demo.json` is rewritten to v1** with explicit `recipients` (`"any"` only where intended);
+ `just demo` must **upgrade-not-skip** a v0 file (today it installs-if-absent, `justfile:125-129`, so
+ it would leave a stale flat file → deny-all → the demo's hero auto-shield silently stops). Document a
+ `rm ~/.deckard/demo/policy.json` fallback.
+
+## Standards posture (surveyed 2026-06-23, primary-source)
+
+| Standard / library | Disposition | Why |
+|---|---|---|
+| **RFC 9396** Rich Authorization Requests (Proposed Standard) | **Adopt the shape** | `authorization_details` = array of typed objects with a required `type`; our `rules[]` keyed by `action` mirrors it. No runtime dep. |
+| **ERC-7715 / ERC-7710** (both **Draft** as of 2026-06-23; reference impl audited by Consensys Diligence, Aug 2024, 0 critical) | **Adopt the shape (later)** | The delegation + caveat-enforcer model for `#33`/`#48` grants. Draft ⇒ not frozen; adopt the model, not Solidity-as-dependency. |
+| **NIST SP 800-162 (ABAC)** / XACML | **Reference for vocabulary** | PEP (`signerd` gate) / PDP (`evaluate`) / PAP (authoring, *next*) / PIP (balances, `spent_today`, sim). Decision model maps to our `Allow`/`Deny`/`NeedsApproval` (≈ obligation). |
+| **AWS Cedar** (`cedar-policy`, Lean-4-proven 7 properties, DRT, Apache-2.0) | **Design bar, NOT a dependency** | Best-in-class proof story, but the proofs cover Cedar's *semantics* not our binary (DRT is empirical), the structural checks are `Vec::contains`/`<`, and a second engine + serde boundary is *more* audit surface for the one place we keep parity tight. We hold `evaluate` to Cedar's properties (default-deny, forbid-trumps-permit, order-independence) via property tests instead. 64-bit `Long` also can't do wei. |
+| **Biscuit** (`biscuit-auth`) | **Reject as a dependency; reference the model** | No completed third-party audit (FAQ: "informally audited"); 64-bit ints. Its offline append-only monotonic-narrowing attenuation is the right *concept* for `#33`/`#48` grants. |
+
+**No single standard or library fits the whole problem.** The fit is a composition: RAR/ERC-7715
+*shape*, ABAC/macaroon *vocabulary*, native-`U256` *enforcement*, single-pure-fn *implementation*.
+
+## 6. Frozen-contract implications
+
+Changing `Policy` is an **intentional, versioned breaking change** to the freeze-first
+`deckard-contract` crate. `Intent`, `Decision`, the `deny_reasons` vocabulary, and the RPC enums stay
+frozen — only `Policy` changes shape. Concretely:
+
+- A **v0 file is detected by the absent `version` key** and rejected with a loud "unsupported legacy
+ policy" message, not a generic parse-fail (which would read as corruption and silently deny-all).
+- The JSON **and CBOR round-trip tests are rewritten** for the new shape (not deleted), preserving the
+ byte-stable charter for v1.
+- `PolicyGet` returns the new shape; every by-name reader of the flat fields is updated (see Cost).
+
+## Invariants this must respect
+
+- **`evaluate` / `evaluate_order` stay single pure functions** shared by `MockSigner` and the daemon
+ (the parity contract). Extensibility comes from adding `Rule` variants, never from a data-driven
+ interpreter or a second engine.
+- **Typed, strict deserialization**; **loader rejects duplicate actions**; unknown/malformed ⇒ loud
+ load failure ⇒ fail-safe deny-all, never a silent verdict.
+- **Default-deny.** No matching rule ⇒ deny. `DenyAll`, not "empty = any." `default: "allow"` is
+ forbidden (a wallet must not be allow-by-default).
+- **Wei stays `U256`** end to end; the daily cap stays one global counter (single `SpendStore`).
+- **STOP / `revoked` zeroizes and overrides everything**, re-checked at execute (TOCTOU guard), unchanged.
+
+## Consequences
+
+- **Positive:** adding a wallet action is a localized `Rule` variant + match arm + a rule in the demo
+ policy — no god-struct churn; per-action approval modes; default-deny by construction; the
+ `Allowlist` lattice removes the empty-Vec foot-gun and makes future grant-intersection sound.
+- **Cost (full surface — a breaking change to a freeze-first crate):** `policy.rs` (`evaluate`,
+ `evaluate_order`, `calldata_ok`); `policy_store.rs` (loader, default, v0 detection, fallback);
+ the JSON+CBOR round-trip tests (`lib.rs`); `mock.rs` / `MockSigner::new`; the MCP acceptance test
+ (`tests/harness_slice.rs`); `deckard-mcp/src/sidecar.rs` `policy_json()` (+ its test fixtures);
+ `deckard-app/src/welcome.rs` `agent_policy_rows` (the GUI policy card); `policy.demo.json`; the
+ `just demo-check` jq paths + intended-values (`justfile:467-485`); and `just demo` upgrade-not-skip.
+ A CHANGELOG entry documents the demo-machine upgrade step.
+- **Cap-reservation: verify, don't rebuild.** The daemon already serializes behind one mutex and
+ re-evaluates auto-allows at execute (`daemon.rs:1131`); #108 added durable reserve-before-sign
+ (`daemon.rs:1164`). The work is to **verify** that path covers the first *value-bearing* auto-allowed
+ `Send` (today's only auto-allow is `value == 0` shield), not to build new reservation machinery.
+- **Deferred:** in-app policy authoring / a `SetPolicy` RPC (file-based + named variants first, per
+ `#29`); per-action daily budgets (a `SpendStore`-schema change); the `#33`/`#48` grant/attenuation
+ model; `#31` wire-kind discovery (reframed as protocol message-kind discovery only, decoupled from
+ authorization).
+
+## Status / next step
+
+**Proposed.** The executable work ships as **one** issue (the policy-model v2 foundation + agent-send
+over MCP + named variants + the cap-reservation verification), not a scatter of sub-issues. `#29`
+retargets to this schema; `#31` is reframed and deferred; `#33`/`#48` are annotated to adopt this
+vocabulary while keeping distinct enforcers. Promote to Accepted once the foundation merges with
+`evaluate`/`evaluate_order` parity green.
diff --git a/docs/agent-authorization-map.md b/docs/agent-authorization-map.md
index 2184fee..6ed3576 100644
--- a/docs/agent-authorization-map.md
+++ b/docs/agent-authorization-map.md
@@ -4,7 +4,7 @@
> This doc holds the thinking, not the task list. Work items live in **GitHub issues**, decisions live in
> **`docs/adr/`**, build state lives in **`STATUS.md`**. When those move, this doc does not need to.
>
-> Last reconciled: **2026-06-15** (`/plan-ceo-review`).
+> Last reconciled: **2026-06-24** (policy model — ADR-0005).
## What we want (the thesis)
@@ -56,6 +56,23 @@ something. Building authorization for money we have never moved is premature.
This reverses an earlier "spike #33 first" call, on purpose.
+## How the agent's Rules are shaped (ADR-0005)
+
+The policy gate above is moving off a single flat struct onto a small, default-deny, **per-action rule
+list** — the user-facing **"Rules."** Decision and rationale: **ADR-0005**.
+
+- **Default-deny, per action.** Each rule names an action (send / shield / swap …) and its limits (cap,
+ recipients, approval mode). No matching rule ⇒ denied. Adding a capability is one rule, not a new
+ field on a god-struct.
+- **Caps are native `uint256`.** No general policy engine (Cedar, OPA, Biscuit) enforces the spend caps:
+ all are 64-bit-limited and cannot represent wei (verified 2026-06-23). We enforce in Rust and keep
+ `evaluate` one pure function (the mock⇄daemon parity contract).
+- **Vocabulary, not architecture (PEP/PDP/PAP/PIP).** The gate is the PEP, `evaluate` the PDP, authoring
+ the PAP (deferred), balances/`spent_today`/sim the PIP — borrowed from NIST SP 800-162 for naming only.
+- **One vocabulary, two enforcers.** Models 2 and 3 above (session keys, per-origin grants) reuse the
+ Rules *vocabulary* (cap / allowlist / expiry / mode) but stay **distinct enforcers** — software
+ (daemon) vs chain (7702). Never collapse them; software is not "cannot exceed."
+
## Invariants any future agent work must respect
Grounded in `THREAT-MODEL.md` and `DESIGN.md`. Non-negotiable: