# Shadow Mode

- Canonical URL: https://docs.fairvisor.com/docs/policy/shadow-mode/
- Section: docs
- Last updated: n/a
> Dry-run enforcement: track would-reject decisions without blocking traffic.


Shadow mode runs the full enforcement logic on every request but never blocks traffic. Instead it records what *would* have happened and emits `would_reject` signals that you can observe in logs and metrics.

Shadow mode is designed for:
- **Canary rollout** of a new policy — validate it's correct before enforcing
- **Capacity planning** — measure how often a limit would fire in production
- **Debugging** — understand why a policy unexpectedly rejects traffic

## Enabling shadow mode

Set `spec.mode` to `"shadow"` on any policy:

```json
{
  "id": "candidate-rate-limit",
  "spec": {
    "mode": "shadow",
    "selector": { "pathPrefix": "/api/v2/" },
    "rules": [{
      "name": "new-per-org-limit",
      "limit_keys": ["jwt:org_id"],
      "algorithm": "token_bucket",
      "algorithm_config": {
        "tokens_per_second": 50,
        "burst": 100
      }
    }]
  }
}
```

The default value is `"enforce"`.

You can also activate incident-wide shadow behavior with top-level `global_shadow` in the bundle (see [Bundle Structure](/docs/policy/bundle/)).

## How it works

When a policy's mode is `shadow`:

1. All limiters (token bucket, cost budget, LLM limiter) are invoked normally
2. **Shared dict keys are namespaced** with a `shadow:` prefix, so shadow counters are isolated from production counters
3. If a limiter returns `denied`, the decision is **wrapped**: `action` is set to `allow`, `would_reject = true` is set, and the original rejection reason is preserved as `original_reason`
4. The response to the client is `200 OK` — no `Retry-After` or `X-Fairvisor-Reason` headers are set
5. Log lines contain `"mode":"shadow"` and `"would_reject":true`

Counter isolation means shadow mode policies accumulate spend independently and do not share state with enforce-mode policies covering the same paths.

## Log output

```json
{
  "action": "allow",
  "mode": "shadow",
  "would_reject": true,
  "original_reason": "token_bucket_exceeded",
  "policy_id": "candidate-rate-limit",
  "rule_name": "new-per-org-limit"
}
```

## Filtering shadow would-rejects

```bash
docker logs fairvisor -f \
  | fairvisor logs --action=allow \
  | jq 'select(.would_reject == true)'
```

## Promoting a shadow policy to enforce

1. Observe the policy in shadow mode until you're satisfied the threshold is correct
2. Increment `bundle_version`
3. Change `mode` from `"shadow"` to `"enforce"`
4. Deploy the updated bundle

Shadow counters are isolated, so they will not carry over. The enforce policy starts with fresh counters.

## Shadow mode and loop detection

Loop detection in shadow mode uses the same `shadow:` key prefix for its fingerprint counters. Would-detects are logged but traffic is not throttled or rejected.

## Shadow mode and circuit breaker

The circuit breaker in a shadow-mode policy checks and updates shadow-namespaced rate keys. If the breaker would have tripped, `would_reject = true` is set but no request is blocked.

## Global shadow override (incident mode)

`global_shadow` is a top-level bundle override that temporarily forces all matched policies to behave like shadow mode:

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

Key properties:

- TTL-based: expires automatically at `expires_at`
- Runtime-only behavior: policy objects do not need to be rewritten
- Client response headers are unchanged; use logs and metrics to confirm active state

