---
name: sup-policy-rule-dev
description: >-
  Write a CONFORMING Sup Wallet delegation-policy rule in Move — a tiny package
  that lets a wallet owner gate how a delegate may spend. A rule is an AUTH rule
  (proves WHO the principal is — address, cap, contract, ZK, machine, 8183
  assessor) or a CAVEAT rule (proves a spend is allowed — budget, recipient,
  time, oracle, KYC). It plugs into `SupWallet::policy` via the `SpendRequest`
  hot potato using only the public interface — no core changes, no permission.
  Use when a developer wants to build/author a new delegation rule, add a custom
  spending condition, or make "any contract a delegate". Protocol execution is
  separately implemented as reviewed SDK integrations in onchain-tools-service.
license: MIT
---

# Sup Policy Rule — Developer Skill

You help a developer **write a Move rule** that a Sup wallet owner can attach to
their `DelegationPolicy`. A delegated spend emits a `SpendRequest` hot potato;
`policy::confirm_spend` releases funds only once **≥1 auth rule** (OR) and **every
caveat rule** (AND) the owner attached have stamped it. Your rule is one of those
stamps. It never holds a `Coin` and never moves funds itself.

Your job: a package that (a) **compiles**, (b) is **compliant** (touches a spend
only by stamping, after checking its condition; never mutates the request), and
(c) **registers** cleanly in `policy_rule_registry`.

## The two hard requirements

A package is a Sup policy rule iff:

1. **It declares a witness type** — a struct with `has drop` and nothing else:
   `public struct MyRule has drop {}`. Its fully-qualified name
   `0x<pkg>::<module>::MyRule` is the **rule-type**: the registry key, and the
   exact `R` the owner attaches with `policy::add_auth_rule<R>` /
   `add_caveat_rule<R>`. Only the defining module can build `MyRule {}`, which is
   what makes the stamp unforgeable.
2. **It influences a spend ONLY by stamping the request** — call
   `policy::add_auth_receipt(MyRule {}, req)` (auth) or
   `policy::add_caveat_receipt(MyRule {}, req)` (caveat) after your condition
   holds. Never take a raw vault `Coin` as a parameter; never call
   `confirm_spend` / `pay_*` yourself; never `transfer` vault funds.

## The safety boundary (why compliance is structural)

`policy::begin_spend` returns a `SpendRequest` with **no abilities** (no
`drop`/`store`/`copy`). It must reach `confirm_spend` or the whole transaction
aborts. Your rule receives it as `&mut SpendRequest`, but the only mutation the
`policy` API exposes is appending your own witness to a receipt set. You can:

- **read the facts** — `policy::spend_amount(req)`, `spend_recipient(req)`,
  `spend_coin(req)`, `spend_wallet_id(req)`, `spend_policy_version(req)`;
- **stamp** — `add_auth_receipt` / `add_caveat_receipt`;
- **abort** — if your condition fails.

You **cannot** change the amount or recipient. So a buggy or malicious rule can
only block the people who opted into it — it can never redirect or inflate a
payment. Don't try to route funds from a rule; that's not what a rule is.

## Auth or caveat? Pick one role per witness

| Your rule answers | Kind | Gating | `confirm_spend` needs |
| :--- | :--- | :--- | :--- |
| "Is the caller the right principal?" (address / cap / ZK / contract / agent) | **AUTH** | OR | ≥1 attached auth rule stamped |
| "Is this spend permitted?" (budget / recipient / time / oracle / KYC / 8183 job done) | **CAVEAT** | AND | every attached caveat rule stamped |

A self-contained cap may stamp **both** (see `sub_delegate`: a `ScopedCap` proves
the principal *and* enforces its own budget, stamping `ScopedAuth` +
`ScopedBudget`).

## Pattern — a CAVEAT rule (with an owner config object)

```move
module my_rule::rule;

use SupWallet::wallet::{Self, Wallet};
use SupWallet::policy::{Self, SpendRequest};

const ECondition: u64 = 1;
const EWrongWallet: u64 = 2;
const ENotOwner: u64 = 3;

public struct MyRule has drop {}                 // the rule-type witness

/// Owner-managed config, bound to one wallet. Shared so any spender can read it.
public struct Config has key, store {
    id: UID,
    wallet_id: ID,
    owner: address,
    // ... your parameters (limits, allowlist, window, oracle id, ...)
}

public fun create_and_share(wallet: &Wallet, ctx: &mut TxContext) {
    // Owner-gate WITHOUT the core's package-private assert_owner — read the
    // public owner accessor and compare to the sender. This is the whole trick
    // third parties use.
    let owner = wallet::owner(wallet);
    assert!(tx_context::sender(ctx) == owner, ENotOwner);
    transfer::share_object(Config { id: object::new(ctx), wallet_id: wallet::id(wallet), owner /*, ...*/ });
}

/// The rule. Read-only on the request apart from the stamp.
public fun enforce(cfg: &Config, req: &mut SpendRequest) {
    assert!(cfg.wallet_id == policy::spend_wallet_id(req), EWrongWallet);
    assert!(/* your condition over spend_amount / spend_recipient / spend_coin */, ECondition);
    policy::add_caveat_receipt(MyRule {}, req);
}
```

## Pattern — an AUTH rule (principal proof, e.g. a bearer cap)

```move
public struct MyAuth has drop {}

public struct MyCap has key, store { id: UID, wallet_id: ID, policy_version: u64 /*, ... */ }

public fun mint(wallet: &Wallet, ctx: &mut TxContext): MyCap {
    assert!(tx_context::sender(ctx) == wallet::owner(wallet), ENotOwner);
    MyCap { id: object::new(ctx), wallet_id: wallet::id(wallet), policy_version: policy::version(wallet) }
}

public fun prove(cap: &MyCap, req: &mut SpendRequest) {
    assert!(cap.wallet_id == policy::spend_wallet_id(req), EWrongWallet);
    // Bind to the policy version for revocation: revoke_all bumps it, and the
    // request snapshot already equals the live version (else confirm aborts).
    assert!(cap.policy_version == policy::spend_policy_version(req), ERevoked);
    policy::add_auth_receipt(MyAuth {}, req);
}
```

> Revocation is free if you bind caps to `policy::version` like this — the owner's
> `policy::revoke_all` invalidates every outstanding cap at once. Don't invent a
> separate revocation scheme unless you need per-cap selective revoke.

## How a spend is composed (one PTB)

```move
let mut req = policy::begin_spend<CoinT>(&wallet, amount, recipient);
my_rule::enforce(&cfg, &mut req);          // your caveat (and/or an auth rule's prove)
// ... any other attached rules stamp ...
policy::confirm_spend<CoinT>(&mut wallet, req, ctx);   // releases funds to recipient
```

The owner enables your rule once: `policy::add_caveat_rule<my_rule::rule::MyRule>(&mut wallet, ctx)`
(or `add_auth_rule` for an auth witness). Until they do, your rule has zero power
over their funds.

## Move.toml

```toml
[package]
name = "my_rule"
edition = "2024.beta"

[dependencies]
Sui = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/sui-framework", rev = "framework/testnet", override = true }
SupWallet = { local = "../../sup_wallet" }   # or a git/published dep

[addresses]
my_rule = "0x0"
```

## Compliance checklist (run before "done")

- [ ] Witness struct is `has drop` **only** (no `copy`/`store`/`key`).
- [ ] Rule touches the spend **only** via `add_auth_receipt` / `add_caveat_receipt` — it never takes a vault `Coin`, never calls `confirm_spend` / `pay_*`, never `transfer`s vault funds.
- [ ] `enforce`/`prove` reads the request through the `policy::spend_*` accessors and **asserts `spend_wallet_id` matches** any config/cap it carries.
- [ ] Auth vs caveat is deliberate: auth ⇒ `add_auth_receipt` (OR), caveat ⇒ `add_caveat_receipt` (AND).
- [ ] Owner-gating for any config/mint uses the **public `wallet::owner`** accessor + sender check (the core `assert_owner` is package-private and not needed).
- [ ] If you mint caps, they bind to `policy::version` so `revoke_all` revokes them.
- [ ] A test composes the rule through `begin_spend → enforce → confirm_spend`, plus an `#[expected_failure]` for the deny path. (See the reference package's tests.)
- [ ] Consider freezing the package — owners trust immutable rules more.

## Register it (open marketplace)

After it compiles, list it so owners/UIs can discover it. `rule_type` is the
witness type string; `kind` is `registry::kind_auth()` or `kind_caveat()`:

```
policy_rule_registry::registry::register(
    registry, package_id, rule_type, kind, manifest_uri, manifest_hash, clock, ctx)
```

On-chain stores only the anchor; put docs / params schema / audits off-chain at
`manifest_uri` (hash-pinned). The registry is a directory, **not** a trust
authority — listing grants no power; only an owner attaching the rule does.

## Manifest schema (so the UI auto-renders a form)

Serve a JSON manifest at `manifest_uri`. The Sup web app fetches it and
**auto-renders a configure form** from `params`, then builds the attach +
configure PTB from `setup`/`configure` — so your rule is usable with **no custom
UI**. Rules with no manifest still work (owners add them bare, or via the AI),
they just don't get a form. Shape:

```jsonc
{
  "name": "Per-tx limit",
  "kind": "caveat",                              // "auth" | "caveat"
  "ruleType": "0xRULEPKG::rule::MaxPerTx",       // the witness type string
  "summary": "Caps the amount of any single spend.",
  "params": [
    { "key": "max", "label": "Max per tx (SUI)", "type": "amount", "decimals": 9 }
  ],
  // optional: create/share a config object before configuring
  "setup":     { "target": "0xRULEPKG::rule::create_and_share", "args": ["wallet"] },
  // optional: apply the params (owner-gated; takes the wallet object)
  "configure": { "target": "0xRULEPKG::rule::set_max", "args": ["wallet", "param:max"] }
}
```

- **param.type**: `amount` (human→atomic via `decimals`), `u64`/`u32`/`u16`/`u8`,
  `bool`, `address`, `string`, `timestamp` (datetime → unix-ms u64).
- **arg tokens** in `setup`/`configure`: `"wallet"` (the vault object),
  `"param:<key>"` (a form value, encoded by its type), or a typed literal
  `"obj:0x…" | "addr:0x…" | "u64:123" | "u8:1" | "bool:true" | "str:hello"`.
- The app composes one signed PTB: `[initialize?] → add_<kind>_rule(ruleType) →
  setup? → configure?`. Your `configure`/`setup` fns **must be owner-gated**
  (they take the wallet object) — the manifest only describes how to *call* a
  rule the owner is opting into; it grants nothing on its own.
- Allowlist-style rules that manage a dynamic set after attach (add/remove
  entries) keep that as separate owner txs — the manifest configures the
  attach-time settings only.

## SDK / app wiring

The TS SDK (`sup-wallet-sdk`) already exposes `addCaveatRule({ walletId, ruleType })`
/ `addAuthRule(...)`, the `*RuleType()` helpers, and PTB builders that thread the
hot potato (`capSpend`, `scopedSpend`, `capSpendWithAllowlist`). For a brand-new
rule, mirror `capSpendWithAllowlistTx`: `begin_spend` → `<yourpkg>::rule::enforce`
→ `confirm_spend`, all on the same `req` handle.

## Reference rules (imitate these)

- **Recipient allowlist** (CAVEAT, with an owner config object, public-interface
  only — best starting template) —
  historical [`rule.move`](https://github.com/ZzyzxLabs/zzyzx-full-repo/blob/16fceb6d1de5bc13a1cfe580034abd7efc9a4538/ZZYZX-Contract/Sup_Contract/official/policy_rule_recipient_allowlist/sources/rule.move).
- **Cap auth** (AUTH, bearer object → delegate is anything that holds an object) —
  historical [`cap_auth.move`](https://github.com/ZzyzxLabs/zzyzx-full-repo/blob/16fceb6d1de5bc13a1cfe580034abd7efc9a4538/ZZYZX-Contract/Sup_Contract/sup_wallet/sources/cap_auth.move).
- **Sub-delegate** (AUTH + CAVEAT in one self-contained budgeted cap) —
  historical [`sub_delegate.move`](https://github.com/ZzyzxLabs/zzyzx-full-repo/blob/16fceb6d1de5bc13a1cfe580034abd7efc9a4538/ZZYZX-Contract/Sup_Contract/sup_wallet/sources/sub_delegate.move).
- **The engine** (read this once to internalize the contract) —
  historical [`policy.move`](https://github.com/ZzyzxLabs/zzyzx-full-repo/blob/16fceb6d1de5bc13a1cfe580034abd7efc9a4538/ZZYZX-Contract/Sup_Contract/sup_wallet/sources/policy.move), and the design in
  `.../sup_wallet/DELEGATION_POLICY.md` + `ARCHITECTURE.md`.

> The surface is intentionally tiny: a witness + a function that checks a
> condition and stamps. Learn the hot-potato contract once and every rule is
> "read the request → check your thing → stamp (or abort)." Don't reach for the
> wallet's funds — a rule's only power is to say no.
