# Bundle Structure

- Canonical URL: https://docs.fairvisor.com/docs/policy/bundle/
- Section: docs
- Last updated: n/a
> Top-level structure of the Fairvisor policy bundle JSON file.


A **policy bundle** is a single JSON file that defines all enforcement rules for an edge instance. The file is loaded on startup and can be hot-reloaded without restarting the process.

## Top-level fields

```json
{
  "bundle_version": 1,
  "issued_at":  "2026-01-01T00:00:00Z",
  "expires_at": "2030-01-01T00:00:00Z",
  "global_shadow": { ... },
  "kill_switch_override": { ... },
  "policies": [ ... ],
  "kill_switches": [ ... ],
  "defaults": {}
}
```

| Field | Type | Required | Description |
|---|---|---|---|
| `bundle_version` | integer | **yes** | Monotonic counter. Must be > 0. Hot-reload only applies a new bundle if this value is strictly greater than the currently loaded version. |
| `issued_at` | string | no | ISO 8601 UTC timestamp, e.g. `"2026-01-15T10:00:00Z"`. Informational only. |
| `expires_at` | string | no | ISO 8601 UTC. The bundle is **rejected at load time** if the current clock is past this timestamp. An already-loaded bundle continues to run normally after its expiry passes — there is no per-request re-check. |
| `global_shadow` | object | no | Runtime override block. When active (`enabled=true` and not expired), all policies are treated as shadow mode at runtime. |
| `kill_switch_override` | object | no | Runtime override block. When active, kill-switch checks are skipped. |
| `policies` | array | **yes** | Array of [policy objects](#policy-object). At least one required. |
| `kill_switches` | array | no | Array of [kill switch entries](/docs/policy/kill-switches/). Evaluated before all policy rules. |
| `defaults` | object | no | Free-form defaults object; passed through without validation. |

## Runtime override blocks

Both override blocks are optional and validated at bundle load time.

```json
{
  "global_shadow": {
    "enabled": true,
    "reason": "incident-2026-02-20",
    "expires_at": "2026-02-20T19:00:00Z"
  },
  "kill_switch_override": {
    "enabled": true,
    "reason": "incident-2026-02-20",
    "expires_at": "2026-02-20T19:00:00Z"
  }
}
```

Validation rules when `enabled=true`:

- `reason` is required, non-empty, max 256 chars
- `expires_at` is required, ISO 8601 UTC, and must be in the future at load time

Runtime behavior:

- `global_shadow`: converts matched policy outcomes to shadow semantics (client path is allow; calculations still run)
- `kill_switch_override`: bypasses kill-switch pre-check
- No extra client response headers are added for these modes; observability is via logs and metrics

## Monotonic versioning

Each time you update a bundle you **must** increment `bundle_version`. The hot-reload timer will silently skip the file if the version is not higher than the current one, logging `version_not_monotonic` at debug level.

```bash
# Safe incremental update
jq '.bundle_version += 1' policy.json > policy.json.new && mv policy.json.new policy.json
```

## Policy object

```json
{
  "id": "my-api-limits",
  "spec": { ... }
}
```

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | string | **yes** | Unique identifier across all policies in the bundle. Used in log lines, metrics labels, and response headers. Must be non-empty. |
| `spec` | object | **yes** | Policy specification (see below). |

## Policy spec

```json
{
  "selector": { ... },
  "mode": "enforce",
  "rules": [ ... ],
  "fallback_limit": { ... },
  "loop_detection": { ... },
  "circuit_breaker": { ... }
}
```

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `selector` | object | **yes** | — | Route matching definition. See [Selectors](/docs/policy/selectors/). |
| `mode` | string | no | `"enforce"` | `"enforce"` or `"shadow"`. See [Shadow Mode](/docs/policy/shadow-mode/). |
| `rules` | array | **yes** | — | Array of [rule objects](/docs/policy/rules/). |
| `fallback_limit` | object | no | — | Rule-like object applied when no rule matches a request. Same structure as a rule. |
| `loop_detection` | object | no | — | See [Loop Detection](/docs/policy/loop-detection/). |
| `circuit_breaker` | object | no | — | See [Circuit Breaker](/docs/policy/circuit-breaker/). |

## Minimal example

One policy applied to all `/api/v1/` routes. Every unique IP address gets its own token bucket: up to 100 requests per second steady-state, with a burst allowance of 200. Requests beyond that are rejected with `429`.

```json
{
  "bundle_version": 1,
  "policies": [
    {
      "id": "api-v1",
      "spec": {
        "selector": { "pathPrefix": "/api/v1/" },
        "rules": [
          {
            "name": "global-rps",
            "limit_keys": ["ip:address"],
            "algorithm": "token_bucket",
            "algorithm_config": {
              "tokens_per_second": 100,
              "burst": 200
            }
          }
        ]
      }
    }
  ],
  "kill_switches": []
}
```

## Bundle signing (optional)

When `FAIRVISOR_BUNDLE_SIGNING_KEY` is set, the edge verifies an HMAC-SHA256 signature prepended to the bundle file. The signature is placed on the **first line** as a base64-encoded string, followed by a newline, followed by the JSON payload:

```
<base64-hmac-sha256-signature>
{"bundle_version":1, ...}
```

This prevents loading a tampered or accidentally overwritten bundle. The constant-time comparison prevents timing attacks.

## Hot reload

Policy bundles are reloaded on a configurable interval (`FAIRVISOR_CONFIG_POLL_INTERVAL`, default 30 s). The edge re-reads the file and applies the bundle only if `bundle_version` has increased. No traffic is dropped during reload.

<div class="callout callout-note">
<span class="callout-icon">ℹ️</span>
<p><strong>Load-time check only.</strong> <code>expires_at</code> is validated when the bundle is loaded. A bundle that is already running continues to run after its expiry timestamp passes — there is no per-request re-check. To retire an expired bundle, push a replacement with a higher <code>bundle_version</code>. The <code>global_shadow</code> and <code>kill_switch_override</code> blocks each have their own <code>expires_at</code> that <em>is</em> evaluated on every request.</p>
</div>

To force an immediate reload via a running SaaS-connected edge:

```bash
fairvisor status  # confirm current version
# Then push a new bundle via the SaaS dashboard
```

