<!-- https://zunderlabs.com/docs/reference/rules-schema · Markdown version of the page -->

# Rules codes (shared rules schema v1)

The one format for a set of Guard rules, shared by the website, the docs and `zunder-guard init --rules`. Fields, units, defaults, bounds, the zr1_ encoding, and examples.

<!--
Canonical spec. The site mounts this file at /docs/reference/rules-schema/.
Three implementations must agree with it: the website (stores and edits rules),
the docs (<Personal> snippets), and `zunder-guard init --rules` (planned).
Defaults and bounds follow the code as of 6 Oct 2026:
  - RiskLimits::default() and RiskLimits::validate()   crates/zunder-risk/src/limits.rs
  - SiteRules::default() and SiteRules::validate()     crates/zunder-risk-wasm/src/judge.rs (site_defaults)
Reconciled 6 Oct 2026: no requireStop; stopPolicy "attach" is the default; defaultStopDistancePct added.
If either changes, this file changes in the same commit.
-->

A set of Guard rules travels as a short text, a **rules code**, that starts with `zr1_`. The website stores your rules as one; the docs fill it into commands; `zunder-guard init --rules` reads it.

```text
zr1_eyJ2IjoxLCJtYXhMZXZlcmFnZSI6NSwibWF4TG9zc0F0U3RvcFBjdCI6Miwic3RvcFBvbGljeSI6ImF0dGFjaCIsImRlZmF1bHRTdG9wRGlzdGFuY2VQY3QiOjIsIm1pbkxpcURpc3RhbmNlUGN0IjoxMCwibWF4UG9zaXRpb25QY3QiOjIwMCwibWF4T3BlblJpc2tQY3QiOjYsImRhaWx5TG9zc1N0b3BQY3QiOjYsImRyYXdkb3duSGFsdFBjdCI6MjUsIm1hcmtldHMiOlsiKiJdfQ
```

That is the defaults. Decoded:

```json
{"v":1,"maxLeverage":5,"maxLossAtStopPct":2,"stopPolicy":"attach","defaultStopDistancePct":2,"minLiqDistancePct":10,"maxPositionPct":200,"maxOpenRiskPct":6,"dailyLossStopPct":6,"drawdownHaltPct":25,"markets":["*"]}
```

:::note[Planned]
`zunder-guard init --rules` is planned for Guard 1.0. The schema itself is fixed here so the website can use it now.
:::

## Fields

Percentages are **percent**: `2` means 2%. The code and `guard.toml` use **fractions** (`0.02`). The conversion is exact decimal division by 100, never floating point.

| Field | Type | Unit | Default | Accepted | Code key (`guard.toml`) | Rule |
|---|---|---|---|---|---|---|
| `v` | integer | — | `1` | exactly `1` | — | — |
| `maxLeverage` | number | × equity | `5` | > 0, ≤ 100 | `risk.max_leverage` | (c) |
| `maxLossAtStopPct` | number | % of equity | `2` | > 0, ≤ 100, ≤ `maxOpenRiskPct` | `risk.risk_per_trade` = value / 100 | (g) |
| `stopPolicy` | string | — | `"attach"` | `"attach"`, `"refuse"` | `policy.stop_policy` | (b) |
| `defaultStopDistancePct` | number, optional | % of price | `2` | ≥ 0.1, ≤ 25 | `policy.default_stop_distance` = value / 100 | (b) |
| `minLiqDistancePct` | number | % of price | `10` | ≥ 1, ≤ 50 | `policy.min_liquidation_distance` = value / 100 | (d) |
| `maxPositionPct` | number | % of equity | `200` | > 0, ≤ 5000, ≤ `maxLeverage` × 100 | `policy.max_position_fraction` = value / 100 | (f) |
| `maxOpenRiskPct` | number | % of equity | `6` | > 0, ≤ 100, ≥ `maxLossAtStopPct` | `risk.max_open_risk` = value / 100 | (e) |
| `dailyLossStopPct` | number | % of day-start equity | `6` | > 0, ≤ 100 | `risk.daily_loss_stop` = value / 100 | (h) |
| `drawdownHaltPct` | number | % of peak equity | `25` | > 0, ≤ 100 | `risk.drawdown_stop` = value / 100 | (i) |
| `markets` | array of strings | — | `["*"]` | `["*"]`, or 1–1,000 distinct coin names | `policy.allowed_coins` (unset for `["*"]`) | (a) |

Where the defaults and bounds come from:

- `maxLeverage`, `maxLossAtStopPct`, `maxOpenRiskPct`, `dailyLossStopPct`, `drawdownHaltPct`: `RiskLimits::default()` and `RiskLimits::validate()`. These defaults are Zunder's recorded risk frame (`docs/decisions.md`, 4 Oct 2026).
- `stopPolicy`, `defaultStopDistancePct`, `minLiqDistancePct`, `maxPositionPct`, `markets`: `SiteRules::default()` and `SiteRules::validate()` (`site_defaults` in `crates/zunder-risk-wasm/src/judge.rs`), which are Guard's policy defaults as well; Guard's policy config is the source of truth. The website reads every default from the engine (`default_playground_limits`) and never repeats the numbers.
- **There is no `requireStop`.** Every entry needs a stop: with `"attach"` (the default) Guard sets one `defaultStopDistancePct` away from the entry and sizes from it; with `"refuse"` an entry without a stop is vetoed.

The accepted ranges stop nonsense (such as `0.25` meant as 25%, or `25` meant as 0.25). They are not advice. The website's sliders offer narrower ranges; anything a slider produces is inside these bounds.

### Rules for each field

- **Numbers** are finite, written in plain decimal (no exponent), with at most **4 decimal places**. Decoders read the number's text as a decimal, not as a float, so `0.1` stays `0.1`.
- **`defaultStopDistancePct`** may be left out (it then takes its default); it is used only with `"attach"` but must be inside its bounds either way.
- **`maxPositionPct`** may not exceed `maxLeverage` × 100: a position larger than the leverage cap could never be opened.
- **`markets`**: `["*"]` means all markets. Otherwise Hyperliquid coin names exactly as the venue writes them (`BTC`, `kPEPE`, `xyz:GOLD`), each 1–32 characters from `A–Z a–z 0–9 : @ / . _ -`, no duplicates. `"*"` may not be mixed with names. An empty list is refused: it would refuse every entry, which is the kill switch's job.

## What is not in v1, on purpose

- **The network.** A code never moves anyone to testnet or mainnet. `init` defaults to paper.
- **The equity cap** (`risk.max_trading_equity_usd`), round-trip costs (`policy.round_trip_cost_bps`, default 11) and execution settings. They depend on the account and the machine, not on a shareable rule set.
- **Anything personal.** No address, no key, no name.

## Encoding

```text
code = "zr1_" + base64url(utf8(json))
```

- **base64url** is RFC 4648 section 5 (`-` and `_` instead of `+` and `/`), **without** `=` padding.
- **Producing** a code: the JSON object with the fields in the order of the table above, no whitespace, numbers in their shortest plain form (`2`, not `2.0`). The same rules give the same code, so codes can be compared as text.
- **Reading** a code:
  1. It must start with `zr1_`. Another prefix is another version: refuse it with a message, never guess.
  2. Decode base64url (add the padding back if your decoder needs it). The decoded text must be valid UTF-8 and at most 32 KiB.
  3. Parse JSON. It must be one object.
  4. **Unknown fields are refused.** A typo is an error, not a silently ignored rule.
  5. **Missing fields take their default.** `zr1_eyJ2IjoxfQ` (`{"v":1}`) is the defaults.
  6. Check every field against its bounds, and the two cross-checks (`maxLossAtStopPct ≤ maxOpenRiskPct`; `"*"` alone).
  7. On any failure, refuse the whole code. Never apply part of a rule set.
- **Versioning.** Any change to a field's meaning, unit or set of fields makes a new version with a new prefix (`zr2_`). Because unknown fields are refused, even adding a field needs a new version.
- **In URLs**, put a code in the fragment (`/connect#rules=zr1_…`), never the query string, so it is never sent to a server.

### Reference implementation (TypeScript)

```ts
const PREFIX = "zr1_";

export function encodeRules(rules: RulesV1): string {
  const json = JSON.stringify({
    v: 1,
    maxLeverage: rules.maxLeverage,
    maxLossAtStopPct: rules.maxLossAtStopPct,
    stopPolicy: rules.stopPolicy,
    defaultStopDistancePct: rules.defaultStopDistancePct,
    minLiqDistancePct: rules.minLiqDistancePct,
    maxPositionPct: rules.maxPositionPct,
    maxOpenRiskPct: rules.maxOpenRiskPct,
    dailyLossStopPct: rules.dailyLossStopPct,
    drawdownHaltPct: rules.drawdownHaltPct,
    markets: rules.markets,
  });
  const bytes = new TextEncoder().encode(json);
  let binary = "";
  for (const byte of bytes) binary += String.fromCharCode(byte);
  return PREFIX + btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

export function decodeRulesText(code: string): string {
  if (!code.startsWith(PREFIX)) throw new Error("not a zr1_ rules code");
  const body = code.slice(PREFIX.length).replace(/-/g, "+").replace(/_/g, "/");
  const binary = atob(body + "=".repeat((4 - (body.length % 4)) % 4));
  const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
  if (bytes.length > 32 * 1024) throw new Error("rules code too long");
  return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
  // then: parse strictly, refuse unknown fields, fill defaults, check bounds (steps 3–7)
}
```

`JSON.stringify` writes whole numbers without a decimal point and never writes an exponent for values in these ranges, so it produces the canonical form.

## Examples

**Defaults** (above).

**Tighter:** 3x, 1% at the stop, refuse entries without a stop, 4% daily stop, BTC and ETH only.

```json
{"v":1,"maxLeverage":3,"maxLossAtStopPct":1,"stopPolicy":"refuse","defaultStopDistancePct":2,"minLiqDistancePct":10,"maxPositionPct":200,"maxOpenRiskPct":6,"dailyLossStopPct":4,"drawdownHaltPct":25,"markets":["BTC","ETH"]}
```

```text
zr1_eyJ2IjoxLCJtYXhMZXZlcmFnZSI6MywibWF4TG9zc0F0U3RvcFBjdCI6MSwic3RvcFBvbGljeSI6InJlZnVzZSIsImRlZmF1bHRTdG9wRGlzdGFuY2VQY3QiOjIsIm1pbkxpcURpc3RhbmNlUGN0IjoxMCwibWF4UG9zaXRpb25QY3QiOjIwMCwibWF4T3BlblJpc2tQY3QiOjYsImRhaWx5TG9zc1N0b3BQY3QiOjQsImRyYXdkb3duSGFsdFBjdCI6MjUsIm1hcmtldHMiOlsiQlRDIiwiRVRIIl19
```

`zunder-guard init --rules` with this code writes:

```toml
[risk]
risk_per_trade = "0.01"
max_open_risk = "0.06"
max_leverage = "3"
daily_loss_stop = "0.04"
drawdown_stop = "0.25"

[policy]
stop_policy = "refuse"
default_stop_distance = "0.02"
min_liquidation_distance = "0.1"
max_position_fraction = "2"
allowed_coins = ["BTC", "ETH"]
```

**Codes that fail, or that do not mean what they seem:**

| JSON | Why |
|---|---|
| `{"v":1,"maxLossAtStopPct":8}` | 8 > `maxOpenRiskPct` (default 6) |
| `{"v":1,"drawdownHaltPct":0.25}` | accepted, but means 0.25%: check percent versus fraction |
| `{"v":1,"maxLeverage":0}` | must be above 0 |
| `{"v":1,"markets":[]}` | empty list |
| `{"v":1,"markets":["*","BTC"]}` | `"*"` mixed with names |
| `{"v":1,"maxleverage":5}` | unknown field (`maxleverage`, lower-case l) |
| `{"v":1,"requireStop":true}` | unknown field: `requireStop` is not in v1 (`stopPolicy` covers it) |
| `{"v":1,"maxLeverage":1,"maxPositionPct":200}` | 200% > 1 × 100 |
| `{"v":1,"minLiqDistancePct":0}` | below 1: the rule cannot be switched off |
| `{"v":2}` | wrong version |
