Bundle Structure

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

{
  "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": {}
}
FieldTypeRequiredDescription
bundle_versionintegeryesMonotonic counter. Must be > 0. Hot-reload only applies a new bundle if this value is strictly greater than the currently loaded version.
issued_atstringnoISO 8601 UTC timestamp, e.g. "2026-01-15T10:00:00Z". Informational only.
expires_atstringnoISO 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_shadowobjectnoRuntime override block. When active (enabled=true and not expired), all policies are treated as shadow mode at runtime.
kill_switch_overrideobjectnoRuntime override block. When active, kill-switch checks are skipped.
policiesarrayyesArray of policy objects. At least one required.
kill_switchesarraynoArray of kill switch entries. Evaluated before all policy rules.
defaultsobjectnoFree-form defaults object; passed through without validation.

Runtime override blocks

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

{
  "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.

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

Policy object

{
  "id": "my-api-limits",
  "spec": { ... }
}
FieldTypeRequiredDescription
idstringyesUnique identifier across all policies in the bundle. Used in log lines, metrics labels, and response headers. Must be non-empty.
specobjectyesPolicy specification (see below).

Policy spec

{
  "selector": { ... },
  "mode": "enforce",
  "rules": [ ... ],
  "fallback_limit": { ... },
  "loop_detection": { ... },
  "circuit_breaker": { ... }
}
FieldTypeRequiredDefaultDescription
selectorobjectyesRoute matching definition. See Selectors.
modestringno"enforce""enforce" or "shadow". See Shadow Mode.
rulesarrayyesArray of rule objects.
fallback_limitobjectnoRule-like object applied when no rule matches a request. Same structure as a rule.
loop_detectionobjectnoSee Loop Detection.
circuit_breakerobjectnoSee 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.

{
  "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.

ℹ️

Load-time check only. expires_at 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 bundle_version. The global_shadow and kill_switch_override blocks each have their own expires_at that is evaluated on every request.

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

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