Dynamic rules
Pushing rule changes from the dashboard to running agents, without a redeploy.
"Block country X." "Add this IP to the deny list." "Lower the rate limit on /login because of credential stuffing." These are ops decisions, and they should propagate in seconds, not behind a CI pipeline.
Ops decisions shouldn't need a deploy
Without dynamic rules: edit SecurityConfig, PR review, CI, merge, deploy, rolling restart. Tens of minutes to hours.
With dynamic rules: add the rule in the dashboard, save, next agent poll picks it up. One poll interval.
Catch: only a subset of SecurityConfig can be pushed. Code-time concerns stay code-time.
How rules reach a running agent
Each agent polls the core on a fixed cadence. The response carries the current rules and a TTL. No push, no websocket, straight HTTP polling.
agent ────GET /api/v1/rules──→ core
X-API-Key: fg_...
X-Project-Id: proj_...
←──rules + ttl────────The agent sleeps dynamic_rule_interval seconds between polls and caches the live rules in memory. Each agent process polls independently, there's no cross-replica sync of that live state. Redis, when configured, only backs up the last-known rules for a restart (see Fallback chain below); it isn't a live sync channel between replicas.
Fields that flow over the wire
Fourteen fields in the dynamic-rules payload. Anything in this list can change at runtime from the dashboard. Each field applies one of two ways: additive fields extend what's already active without ever removing anything, replace fields overwrite the current value outright whenever the poll sends a non-empty one.
| Group | Fields | Wire semantics |
|---|---|---|
| IP rules | ip_blacklist, ip_whitelist | Additive - each poll bans the IPs in ip_blacklist and unbans the IPs in ip_whitelist; an IP banned by an earlier poll and missing from the current one stays banned |
| Geography | blocked_countries, whitelist_countries | Replace - a non-empty list overwrites the current value outright (ISO country codes; the global whitelist only exempts countries from the block list) |
| Rate limits | global_rate_limit, endpoint_rate_limits | Replace - endpoint_rate_limits overwrites the entire per-endpoint map in one shot, it is never merged endpoint-by-endpoint |
| Cloud / user-agent filters | blocked_cloud_providers, blocked_user_agents | Replace - a non-empty list overwrites the current SecurityConfig value outright |
| Pattern rules | suspicious_patterns | Additive - patterns accumulate across polls; dynamic rules can add a pattern but never remove one |
| IP ban toggle | enable_ip_banning | Scalar overwrite whenever the poll sends a value; a None in the payload is a no-op, not a disable |
| Penetration detection toggle | enable_penetration_detection | Scalar overwrite whenever the poll sends a value; a None in the payload is a no-op, not a disable |
| Auto-ban knobs | auto_ban_threshold, auto_ban_duration | Scalar overwrite whenever the poll sends a value; a None in the payload is a no-op, leaving the previous value (or SecurityConfig's flat default) in place. Per-class threat_ban_config overrides still take priority over these when a detection carries a class. |
| Rate-limit auto-ban toggle | enable_rate_limit_auto_ban | Scalar overwrite whenever the poll sends a value; a None in the payload is a no-op, not a disable |
An empty or omitted value on a replace field is a no-op, not a clear: the agent only overwrites SecurityConfig when the poll response carries a non-empty value for that field, so an empty list leaves whatever was already active (from an earlier poll, or the static SecurityConfig at boot) untouched. There is no dynamic-rules payload that clears a replace-semantics field back to empty - to lift a country, cloud-provider, or user-agent block, push a replacement list that omits the entry you want gone, or fall back to a code deploy of the static SecurityConfig. The additive fields work the same way in reverse: ip_whitelist is how you lift a specific IP ban over the wire (it calls the agent's unban path per IP), but suspicious_patterns has no wire-level removal at all. A pattern added via dynamic rules stays until the next code deploy.
Saving is a full replace
The dashboard's rules save (POST /api/v1/rules/{project}) is a full replace of the project's rule config, not a merge with whatever was there before. Leave a field out of the request body and it takes its own default; it does not carry over from the version being replaced. The one exception is an empty body, which re-issues the current version's own values as a new version, unchanged. That also covers a save right after a delete: an empty body then restores what was last configured instead of resetting the project to defaults.
ttl and expires_at are ordinary fields under that same full-replace rule. ttl is the cache TTL described above. expires_at is this rule version's own lifetime, independent of ttl; see Fallback chain for what happens once it passes.
Fields that stay in code
Some settings are intentionally outside the dynamic-rules schema. They're either security-critical, infrastructure-bound, or have implications that require code review.
| Field | Why static |
|---|---|
| Encryption keys | Key rotation requires coordinated handoff between agent and core |
| OTel config | Tied to your deployment infrastructure |
fail_secure | Flipping at runtime changes whether infra failures cause 500s or pass through |
threat_ban_config thresholds | Security policy - how aggressive the auto-ban escalator is |
To change any of these: edit app.py, ship through your normal deploy pipeline.
TTL vs polling interval
Two different timing knobs, and TTL doesn't work the way it sounds like it should.
ttl is a field on the fetched rules payload (default 300s), and it only gates the agent's own local cache: on each call, if less than ttl seconds have passed since the last successful fetch, the agent returns its in-memory copy and never touches the network. Setting dynamic_rule_interval well under ttl doesn't get you faster updates, it just wakes the loop up more often to hand back the same cached payload; a real fetch is only attempted once ttl seconds have actually elapsed since the last one succeeded.
A failed fetch doesn't expire anything either. On a network error the agent returns whatever it already had cached, the same thing it would return on a cache hit. There's no gap and no fall back to static config from a ttl/interval mismatch, and guard-core's rule-update loop never reads ttl at all, it applies whatever the agent's get_dynamic_rules() hands back, or does nothing if that's None.
Rule lifetime is a separate knob: expires_at on the rules payload, not ttl. See Fallback chain below for what actually happens once a rule set's expires_at passes.
Rule of thumb: your effective refresh cadence is whichever is larger, dynamic_rule_interval or ttl. A shorter interval doesn't shorten it, only a lower ttl from the backend, or an interval long enough to clear the current one, does.
Fallback chain: traffic never blocks
A failed poll doesn't take down enforcement, and a restart doesn't reset you to day-one defaults either. The agent walks a fallback chain.
- Apply new rules: poll succeeds, dashboard-managed rules become active, cache refreshed.
- Keep cached rules: poll fails, last successful payload stays in effect for this process, agent retries on the next interval.
- Restore the persisted last-known snapshot (guard-core 3.17.0+): this only matters right after a restart, before the update loop's first poll. With
enable_dynamic_rules=True, every rules payload that applies successfully is also persisted as a last-known snapshot, to Redis whenever a redis handler is configured, and to the file atdynamic_rules_cache_pathwhen you set one. At startup, guard-core hydrates from Redis first, then the file, and applies whichever snapshot it finds before the update loop starts, so a process restarted while the dashboard is unreachable comes back up running the rules it last actually applied, not base config. A snapshot is skipped, and the next store tried, if itsexpires_athas already passed, or if it's malformed or written by a newer schema version; either case logs an error. - Fall back to static
SecurityConfig: no live rules and nothing usable to hydrate from Redis or the cache file, values fromapp.pytake over as if dynamic rules weren't enabled.
The first live poll that succeeds after a restart supersedes the hydrated snapshot through the normal version check, same as any other rules update. Neither the Redis key nor the cache file expires on its own, clean them up yourself if you disable dynamic rules for good.
A dynamic-rules failure never blocks traffic, and neither does an outage that catches you mid-restart. The agent always has a policy: newer, older, hydrated, or boot-time.
SecurityConfig(
enable_dynamic_rules=True,
dynamic_rule_interval=60,
dynamic_rules_cache_path="/var/lib/myapp/dynamic-rules-snapshot.json",
)dynamic_rules_cache_path is optional. Without it, and without Redis configured, a restart during an outage falls straight through to static SecurityConfig, same as before 3.17.0.
Wiring
SecurityConfig(
enable_dynamic_rules=bool(api_key),
dynamic_rule_interval=60,
)enable_dynamic_rules is the master switch. Gated on the API key being present in the example - without an API key, the polling loop won't run (no point hitting an authless endpoint).
dynamic_rule_interval=60 polls five times as often as the default ttl window. Most of those ticks return the agent's cached payload without a network call, only the tick that lands after ttl has elapsed actually refetches.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Changed a rule in the dashboard and nothing happened | Change hasn't been polled yet, bounded by dynamic_rule_interval | Wait one interval. Check the agent's log for "rules updated". If still nothing, check Agents page for connection health. |
| Dashboard change saved but static config still applies | The field you changed isn't in the dynamic-rules schema | Push through a deploy of app.py. Common offenders: fail_secure, encryption keys, threat_ban_config thresholds. |
| Rule appears on some replicas but not others | Multi-worker or multi-pod - each agent process polls independently | Wait one full poll interval. Lower dynamic_rule_interval if propagation lag matters for incident response. |