Story 1567: Teeth on the Leash (eval gates for the runtime rails)
Why this exists (the catastrophe it prevents). Story 1562 built the runtime rails — the broker-side machinery that floors privilege detection, refuses non-delegable legal acts, fences content out of control axes, and pins a paused batch's posture. But a safety guard you cannot prove is firing is indistinguishable from dead config. The catastrophe this Story prevents is the quiet one: a refactor, a model swap, or a "harmless" config edit silently weakens a rail — the privilege floor stops flooring, "authorized" starts auto-filing pleadings, a document's own text re-sets the leash — and the unit tests stay green because they only ever checked the happy path. This Story is the standing proof. Eight deterministic gates assert each ADR-129 invariant, and — crucially — each gate is shown to fail a violating run, so a regression that re-opens a welded door turns the build red instead of shipping.
pramaan-agents for "rails + per-agent eval suites." But the rails are broker-resident in pramaan-functions (the same §D12 reconciliation Story 1562 followed): the standalone pramaan-agent-rails package does not exist yet, so its eval gates cannot live there. The gates therefore test the broker rails in this repo. Target repo = pramaan-functions. They mirror the Atlas evals/ shape (Case → dataset → evaluator), adapted to the broker resolvers.The one analogy: a smoke detector you test with a match
A smoke detector bolted to the ceiling tells you nothing about whether it works. The only way to trust it is to hold a match under it and watch it shriek. Every gate here is a smoke detector with its own match: the gate asserts the rail behaves on real inputs (the detector is wired in), and a companion teeth test holds a fabricated violation under the same predicate and asserts it shrieks (the match proves it's live). A gate that only confirmed the happy path would be a detector no one ever tested — reassuring, and worthless the day it matters.
tests/evals/agent_rails/_harness.py) keeps the recognisable Case/run_gate/invariant shape without pulling pydantic-evals into the Lambda bundle for what a function call can decide. No new dependency, no new migration; this Story is a test suite.🛰️ Satellite: what shipped
One test package, tests/evals/agent_rails/: a deterministic gate harness, eight gates (22 tests) in test_runtime_capability_gates.py, and a README.md coverage map. No source change, no route, no DTO, no migration — so no OpenAPI drift.
✈️ Airplane: the harness shape
Case(name, inputs, expected_output) <- one scenario, like pydantic-evals
run_gate(gate, cases, task, invariant) -> GateReport
task(inputs) = the rail under test (resolver/loader)
invariant(actual, expected) -> bool = the deterministic evaluator
GateReport.assert_green() = pytest assertion, names every failing case
# teeth: the SAME invariant is re-run on a fabricated violation
assert invariant(violating_actual, expected) is False
The key design choice: the invariant is a plain callable, reused twice — once by run_gate over the real scenarios, once by the teeth test against a hand-built violation. The predicate that guards real runs is the predicate proven to reject a bad one. There is no second, weaker copy of the rule that could drift.
🔬 Microscopic: the eight gates
| Gate | ADR-129 | What a green gate guarantees |
|---|---|---|
FloorBeatsDefaultBothModes | D3 | effective_level = max(default, floor) for every floored step across low/medium/high defaults in both Auto and Interactive; the composed build_run_posture path floors Blake's privilege step to High+ in both modes. |
NonDelegableBlockedEvenAuthorized | D4 | Every act on the closed non-delegable list resolves to non-AUTO_APPLY under authorized and under every other authority mode — legal acts → PROPOSE, safety bypasses → BLOCK. |
AuthorizedScopeExpiry | D4 | authorized auto-applies only while live and in-scope (every scope, future expiry); an expired or missing grant falls back to a proposal — no blank cheque. |
ResumePinsPosture | D7 | A resume after a mid-pause config drift carries the pinned {thinking_level, effective_level, authority_mode, effective_authority, policy_version}, never the drifted config; the pin survives serialisation unchanged. |
CapabilityClosed | D10 | The loader activates only pre-registered capabilities; an unregistered id is rejected (never fabricated); connectors are rejected in Auto/headless. |
ContentFenceOnControls | D9 | Two invocations with identical caller axes but adversarial content (an injection in input, history, and counsel_message) resolve to identical postures; assert_caller_set rejects content/derived/model_output. |
ResumeIdempotent | D6 | compute_resume_set replays only non-terminal items, dedupes by (document_id, extraction_run_id), yields 0 new effects for terminal items, is stable on a second replay, and survives the empty first-run state. |
FloorFiringTelemetry | D3 | A floored step emits agent_rail.floor_resolved with {requested_level, floor_applied, floored_step}, and every emitted key is on a low-cardinality allowlist — no raw document text reaches telemetry. |
The teeth, concretely
# Gate 1's evaluator, reused by both the real run and the violation:
def _floor_meets_minimum(actual, expected_floor) -> bool:
return actual.effective_level.at_least(expected_floor) and \
actual.effective_level == max(actual.requested_level, expected_floor)
# happy path: every floored step x default x mode -> assert_green()
# teeth: a run that left privilege_detection at `low`
violating = FloorResolution(effective_level=LOW, floored_step="privilege_detection", ...)
assert _floor_meets_minimum(violating, HIGH) is False # the gate WOULD fail this run
Verify
uv run ruff check tests/evals/ # all checks passed uv run pytest tests/evals/ -v # 22 passed (no DB, no network, no model) make openapi # no drift (no route/DTO change)
The gates are pure code assertions, so they run in well under a second and ride the same PR family as the rails (#1562) and adopters (#1563/#1564). Coverage — gate → ADR-clause → ticket-name → teeth-test — is documented in tests/evals/agent_rails/README.md.
One-sentence recall: a safety rail you can't watch fire is dead config — these eight gates hold a match under each ADR-129 invariant and prove it shrieks.