Tier 1 — Rate and blast-radius policy¶
Status: implementation-ready specification; implementation not authorized. Activation gate: Milestone 2 (concurrency) / Milestone 9 (numeric defaults require lab evidence); requires ADR-015. Related: sealed_executor.md, disposable_lab_execution_model.md.
Purpose¶
Define atomic, store-backed damage-containment limits that apply regardless of authorization — a fully authorized, fully confirmed mutation can still be refused by this layer if it would exceed a concurrency or frequency bound. This is explicitly not an authorization mechanism (policy.py and the confirmation/reconciliation authorities remain the only authorization); it exists so a single misbehaving or compromised caller cannot turn "one approved capability" into "unbounded mutation of that capability."
Security goals¶
- G1: No more than one mutation can be
EXECUTING/ROLLING_BACKper canonical target at a time (already structurally guaranteed by the existing target-reservation mechanism instore.py— this spec does not duplicate that, only extends the same atomic pattern to coarser-grained scopes). - G2: A capability's or the whole system's total in-flight mutation count is bounded and enforced atomically, independent of how many distinct targets are involved.
- G3: A caller cannot bypass a cooldown by preparing a new contract for the same target immediately after a prior one completes.
- G4: Systemic trouble (many contracts stuck in
RECONCILIATIONsimultaneously) automatically halts new mutation preparation, without requiring a human to notice and intervene first. - G5: Rate-limit bookkeeping itself cannot be used to leak information (it must not record target identity, intent, or any sensitive value — counts and digests only, consistent with the existing audit-value- freedom principle).
Invariants¶
- I1: All counters are maintained in the same SQLite store, using the
same
BEGIN IMMEDIATEatomic-transaction pattern already used for contract state, so a rate check and the state transition it gates cannot race against each other. - I2: Rate/concurrency checks happen at
PREPAREtime (before a contract is created) for outstanding-count limits, and at thePREPARED -> EXECUTINGtransition for in-flight limits — mirroring the two-phase shape the rest of the system already uses (prepare vs. execute are already separate authorization moments). - I3: A refused
PREPAREdue to rate policy produces no contract row at all (nothing to clean up, no partial state) — refusal happens beforestore.create()is called. - I4: Cooldown timers use the store's own clock abstraction
(
Clock/_now(), already injectable for deterministic testing instore.py) — never wall-clock time read independently elsewhere, to keep rate logic testable the same way contract expiry already is. - I5: The global
RECONCILIATIONlockout (G4) is itself a rate-policy state, not a manual flag an operator sets — it activates automatically when the threshold is crossed and deactivates automatically once the count drops below it (no separate "re-enable" action needed, though an operator resolving reconciliations is what naturally clears it).
Trust boundaries¶
| Boundary | Trusted side | Untrusted side | Enforcement point |
|---|---|---|---|
| Rate policy vs. authorization | MutationPolicy/confirmation/reconciliation (authorization) |
Rate/concurrency counters (containment only) | Rate policy is checked in addition to, never instead of, authorization — a rate-policy pass never substitutes for a policy/confirmation check |
| Single caller vs. system-wide budget | Global/per-capability counters | Any individual PREPARE/execute() call |
Atomic counter increments inside the same transaction as the state change they gate |
State ownership¶
- New SQLite table
rate_counters(or equivalent), owned bysrc/pfsense_mcp/tier1/rate_policy.py(new module) and a corresponding schema addition instore.py(same_verify_schemaexact-match discipline as every other table). - Scopes tracked: per-
target_identity_digest, per-capability, and one global singleton row — three logical scopes, not a generic arbitrary- key system, to keep the schema closed and reviewable. rate_policy.pydoes not own contract state — it only reads/writes its own counters and is consulted bystore.py/executor.pyat the two checkpoints in I2.
Interfaces¶
# src/pfsense_mcp/tier1/rate_policy.py (new; not created yet)
@dataclass(frozen=True)
class RateLimits:
max_outstanding_prepared_per_target: int
max_global_in_flight: int
target_cooldown_seconds: int
reconciliation_lockout_threshold: int
class RatePolicy:
def __init__(self, limits: RateLimits) -> None: ...
def check_prepare_allowed(
self,
connection: sqlite3.Connection,
*,
target_identity_digest: str,
capability: Capability,
now: datetime,
) -> None:
"""Raises RateLimitExceededError if outstanding-PREPARED, cooldown,
or reconciliation-lockout limits would be violated. Must run
inside the same transaction as the eventual store.create() call
it gates — i.e. this is called from within store.py, not before
a separate connection is opened."""
def check_execute_allowed(self, connection: sqlite3.Connection, *, now: datetime) -> None:
"""Raises RateLimitExceededError if max_global_in_flight would be
exceeded. Called from within the same transaction as the
PREPARED -> EXECUTING _replace() call."""
def record_terminal(self, connection: sqlite3.Connection, *, target_identity_digest: str, now: datetime) -> None:
"""Records the cooldown start for a target reaching any terminal
state (VERIFIED, FAILED, ROLLED_BACK, ROLLBACK_FAILED)."""
RateLimitExceededError is a new Tier1Error subclass — refusal, not a
different kind of state transition; a rate-limited PREPARE attempt
leaves no trace beyond an ordinary refusal (I3), matching how a policy
refusal today leaves no contract row.
Failure modes¶
| Failure | Detection | Resulting state | Automatic retry |
|---|---|---|---|
Target already has an outstanding PREPARED contract |
Count query inside check_prepare_allowed |
RateLimitExceededError, no new contract created |
No |
| Target in cooldown window | Cooldown timestamp comparison | Same | No — caller must wait, not retry in a loop |
| Global in-flight limit reached | Count query inside check_execute_allowed |
RateLimitExceededError at the EXECUTING transition; contract remains PREPARED |
No |
| Reconciliation lockout active | Count of contracts in RECONCILIATION >= threshold |
All new PREPARE calls refused system-wide until the count drops |
No — resolved only by an operator resolving existing reconciliations |
Recovery behavior¶
- All counters are derived, at any point, from the existing contract
table's state (outstanding
PREPAREDcount, in-flightEXECUTING/ROLLING_BACKcount, andRECONCILIATIONcount are all justCOUNT(*) ... WHERE state = ...queries) except the cooldown timestamps, which need their own small table since cooldown outlives the state transition that triggered it. On restart, no counters need reconstruction beyond the cooldown table, which is itself durable SQLite state and survives restart normally. - This design deliberately avoids in-memory-only counters specifically so that a restart cannot reset rate limits to zero and accidentally permit a burst right after recovery — durability of the containment layer matters as much as durability of the authorization layer.
Non-goals¶
- This spec does not implement per-MCP-caller rate limiting — the trust
model (
THREAT_MODEL.mdTB1) already treats the local stdio channel as one undifferentiated caller; rate policy here is about capability/ target/global blast radius, not per-user quota. - This spec does not implement adaptive/ML-based anomaly detection —
fixed, reviewable, owner-approved numeric limits only (
ADR-015). - This spec does not implement a way to override the limits at runtime (no "admin bypass" flag) — changing limits requires a configuration change and restart, so a limit change is always a deliberate, reviewable action, not something reachable from an MCP tool call.
Required tests¶
- Outstanding-
PREPARED-per-target refusal and recovery once the existing one terminates. - Cooldown refusal immediately after a terminal state, success after the cooldown window elapses (using the injectable clock, not real sleep).
- Global in-flight limit refusal at the
EXECUTINGboundary specifically (not atPREPARE), and recovery once an in-flight operation terminates. - Reconciliation-lockout activation at exactly the threshold, and automatic deactivation once resolved below it.
- Concurrency test: simultaneous
PREPAREattempts for the same target from multiple connections — exactly one succeeds if the limit is 1 (same atomic-transaction discipline as the existing target-reservation tests). - Restart test: cooldown state survives a store restart.
Activation requirements¶
- [ ]
ADR-015accepted with numeric defaults validated bydisposable_lab_execution_model.md's lab evidence, not shipped as permanent guesses. The current implementation ships ADR-015's provisional defaults as the only defaults offered — no lab evidence exists yet to revise them. - [x]
rate_policy.pyimplemented and tested (tests/tier1/test_rate_policy.py, 7 tests). - [x]
store.pyschema extended (version bump to 4) and wired at the two checkpoints in I2, plusrecord_terminalwired into_replace()(fires for every state change reaching a cooldown state, regardless of which method drove it —transition(),resolve_reconciliation(), etc.). - [ ]
executor.pycallscheck_execute_allowedimmediately before its ownEXECUTINGtransition (seesealed_executor.md's verification flow — this spec adds one more pre-transition check to that sequence). Not yet applicable —executor.pydoes not exist until Phase 3;store.transition()'s ownEXECUTINGbranch already callscheck_execute_alloweddirectly today, so the check is fully enforced at the store layer independent of whether an executor exists yet.
Implementation checklist¶
- [x] Create
src/pfsense_mcp/tier1/rate_policy.py. - [x] Add
RateLimitExceededErrortoerrors.py. - [x] Extend
store.pyschema with arate_cooldownstable (schema version bumped to 4, following the exact discipline established for the anchor_state table's v3 bump). Deliberately unauthenticated (nomaccolumn) — containment, not an authorization/integrity boundary; documented inline in the schema SQL. - [x] Wire
check_prepare_allowed/check_execute_allowed/record_terminalintostore.py'screate()/transition()/_replace().
Review checklist¶
- [ ] Confirm every count query used for a limit check runs inside the
same
BEGIN IMMEDIATEtransaction as the state change it gates — a check-then-act pattern across two transactions would reintroduce exactly the race this design exists to prevent. - [ ] Confirm
RateLimitExceededErrornever contains target identity/intent content (G5) — counts and digests only. - [ ] Confirm the reconciliation lockout genuinely blocks all
capabilities'
PREPAREcalls, not just the capability that produced the reconciliations (systemic trouble should pause everything, not just the misbehaving capability, until an operator has assessed the situation).
Security checklist¶
- [ ] Confirm no rate-limit bypass exists via a code path that skips
store.create()/store.transition()(there should be exactly one way to create/transition a contract, so this is largely inherited from existing store discipline, but must be explicitly re-verified oncerate_policy.pyis wired in). - [ ] Confirm cooldown/limit values are read from configuration, not hardcoded, so they can be tightened without a code change if lab or production experience warrants it — but confirm there is no runtime (in-process, MCP-reachable) way to change them (Non-goals).
Test checklist¶
- [ ] Per-target outstanding-PREPARED test.
- [ ] Cooldown refusal/recovery test (injectable clock).
- [ ] Global in-flight limit test.
- [ ] Reconciliation lockout activation/deactivation test.
- [ ] Same-target concurrent PREPARE race test.
- [ ] Restart durability test for cooldown state.