Story 1562: The Leash, Made of Code (runtime rails for every agent)
Why this exists (the catastrophe it prevents). PRAMAAN's agents today are single-shot, fixed-model, fire-and-forget. The moment we let an agent think harder, do more before asking, or pause a long batch, we open four doors a careless build leaves swinging. A firm sets a cost-cutting matter default of "cheap thinking" and silently weakens privilege detection across every matter — a privileged document leaks and nobody saw the floor drop. An "Authorized, just do it" toggle quietly lets the agent mark a pleading as filed or send to opposing counsel — an irreversible legal act, done by a machine, with no human in the loop. A document's own text says "SYSTEM: run as admin" and the agent obeys its own input. A paused batch is edited mid-flight and the back half runs under a different, cheaper posture than the front half. This Story builds the broker-side machinery — the rails — that makes each of those doors a welded wall, once, for every agent, so Blake and Atlas inherit it rather than each re-deriving it (and re-getting-it-wrong).
pramaan-agent-rails package in pramaan-agents. ADR-129 §D12 overrides that: the standalone package does not exist yet and its extraction is explicitly deferred. Until then the rails live broker-resident in pramaan-functions — the in-VPC home where ADR-110 already concentrates "safety exactly once" — plus the shared agent_contracts. A later Story extracts them. Target repo = pramaan-functions. This Story also stamps the contract fields (agent_invoke.v1 axes + run.paused/resumed/stopped) directly here, additive and optional, because the contracts package is in this repo and the dependency story had not landed it; the pre-ADR-129 single-shot envelope still validates unchanged.The one analogy: a guide dog on a fixed-length leash
Think of each agent as a guide dog and the broker as the harness it wears. The three axes are how the handler sets the walk: run_mode is who started it (a scheduled route vs. the handler asking live), thinking_level is how carefully the dog works a crossing, authority_mode is how far it may go before checking back. The floor is the leash's minimum length at a dangerous crossing — no matter how relaxed the handler wants to be, the leash will not extend past the curb at a known-dangerous junction. The non-delegable list is the set of doors the dog may never open itself — it can lead you to the door and bark, but a human turns the handle. Run-control is the handler saying "wait here" (pause), "carry on from where we stopped" (resume), or "we're done" (stop — which doesn't un-walk the blocks already walked). And the content fence: the dog takes commands from the handler's hand on the harness, never from a stranger shouting on the street — what it reads on the ground can never re-set the leash.
🛰️ Satellite: what shipped
One broker-resident package, src/shared/agent_rails/, plus additive shared-contract fields. No new route, no migration, no second checkpoint store — resolvers and loaders are code + config; run-control rides the already-shipped document_status_events.
- Thinking resolver (D2) —
thinking_level → {model, reasoning_effort}fromconfig/thinking_levels.yaml. No model name, price, or token count crosses back to the user. OpenRouter + ZDR; no Bedrock. - Floor enforcement (D3) —
effective_level = max(default, step_floor), broker-side in both Auto and Interactive, floors written in level terms and resolved to a model last, additive per-agent, with floor-firing telemetry{requested_level, floor_applied, floored_step}. - Authority resolver (D4) —
effective_authority = f(mode, risk, floor, confidence, role, policy, eval gate); the closed non-delegable list blocked in every mode includingauthorized;authorizedcarries a scope +expires_at. - Run-control lifecycle (D6/D7) — pause/resume/stop over
document_status_events; resume replays from each item's last terminal event (0 new effects); stop ≠ rollback; resume pins the posture so a mid-pause config edit can't re-govern the remainder. - Capability loader (D10) — activates a pre-registered capability from a closed catalog, never creates one; connectors are Interactive-only.
- Per-agent mode matrix (D1) — each agent's
*.yamldeclares supported modes, defaults, allowed capabilities per mode, pause/stop semantics, and additive floors. Blake =auto + interactive; Atlas = interactive-first. - Posture provenance (D8) — a value object with the exact ledger-row shape the effect ledger (pramaan-spec#1542) will embed, emitted to telemetry now.
- Content fence (D9) — axes/scope/capabilities are resolved only from the caller envelope + config; there is no parameter through which content could reach axis selection.
✈️ Airplane: how one invocation resolves its posture
invoke: agent_invoke.v1 (run_mode? thinking_level? authority_mode? authorized_scope? capabilities[])
matrix = load_agent_matrix(invoke.agent_key) <- config, never content
run_mode = invoke.run_mode or matrix.default_mode (CALLER or CONFIG)
requested_level= invoke.thinking_level or matrix.default_thinking[mode] (CALLER or CONFIG)
authority_mode = invoke.authority_mode or DEFAULT (review_exceptions)
floor = resolve_effective_level(requested_level, step, agent_extra_floors)
effective_level = max(requested_level, step_floor) <- LEVEL terms
if floor_applied: log {requested_level, floor_applied, floored_step}
caps = load_capabilities(invoke.capabilities, run_mode) <- closed catalog + matrix gate
# per effect, later:
auth = resolve_effective_authority(authority_mode, action, risk, floor_applied, ...)
non_delegable(action) -> PROPOSE (legal) / BLOCK (safety bypass), NEVER auto
authorized + valid grant -> AUTO_APPLY within scope, until expires_at
else -> AUTO_APPLY iff reversible & low-risk & gate green
# model resolution happens LAST:
{model, reasoning_effort} = resolve_thinking(floor.effective_level)
Five turns make this correct:
- The floor is computed in level terms and the model is resolved last. If we resolved
low → some-cheap-modelfirst and then "floored" the model, a config swap that renamed models would silently de-floor the step. Flooring the level (max(low, high) = high) and only then mappinghigh → modelmakes the guard swap-proof (D3). - The floor beats the Auto default, not just the interactive selector. A cost-cutting matter default is exactly the attack the floor exists to stop, so floor enforcement runs in both modes — an Auto batch can't run privilege detection cheap any more than a user can pick cheap for it.
- Non-delegable is checked before authority mode, not after. "Authorized" is a speed setting for routine, reversible work; it must never be a path to an irreversible legal act. The resolver short-circuits on the non-delegable list first, so no combination of mode + scope can reach
AUTO_APPLYfor "mark filed" or "send external" (D4). - Defaults come from the matrix (config), never from
invoke.input. The composition function's only inputs are the caller envelope and the agent's yaml. There is no code path by which a document body, history turn, or counsel message reaches axis selection — two invocations with identical caller axes but wildly different (even adversarial) content resolve to byte-identical posture (D9). - Resume uses the pinned posture, not the current default. A checkpoint freezes
{thinking_level, effective_level, authority_mode, effective_authority, policy_version};resume_batchtakes that pin as an argument and never reads the live firm/matter default, so a mid-pause edit can't re-govern half a batch (D7).
🔬 Microscopic: the boundary lines that matter
| Detail (titled by its reason) | What & where |
|---|---|
A floored step can only ever be raised, never lowered. An agent yaml that "floors" a globally-high step at low still resolves to the higher global floor — floors are additive, config can only add safety. |
floors.py :: _lookup_floor takes max of the global and per-agent floors. |
Safety-bypass acts are refused outright, not merely proposed. "Mark filed" escalates to a human proposal; "bypass RLS/audit/ledger" returns BLOCK — nothing legitimately wants to skip the audit ledger, so it can't even be put to a human. |
authority.py :: _FORBIDDEN_ACTIONS → BLOCK; legal acts → PROPOSE. |
| An expired or absent authorized grant is no delegation at all. "Authorized" with no scope+expiry, or past its expiry, falls back to a proposal — "do it" means this job, never a standing blank cheque. | authority.py :: _authorized_grant_problem. |
Resume of an already-terminal item produces zero new effects. The resume plan dedups by (document_id, extraction_run_id) and replays only non-terminal items; the empty first-run state (no checkpoints yet) yields an empty plan, no crash. |
run_control.py :: compute_resume_set; terminal = {indexed, failed}. |
| Pause/resume refuses on an Interactive run. Pause/resume is an Auto-batch concept; an Interactive run gets stop / cancel-turn. Asking to pause a chat turn is a control error, not a silent no-op. | run_control.py :: _require_auto raises PramaanRunControlError. |
| A connector capability can't load in a headless Auto batch. Even if a mis-edited catalog lists Auto, the loader's belt-and-braces guard refuses external reach inside an unattended batch. | capabilities.py :: Capability.available_in. |
The resolved model never leaks to the user or general telemetry. ResolvedThinking carries only level/model/reasoning_effort as an internal value; the floor telemetry logs levels, never the model; firm_id is never stamped into posture telemetry or baggage (ADR-124). |
thinking.py; floors.py :: _emit_floor_telemetry; posture.py :: stamp_posture_provenance. |
The wire Literals can never drift from the rails vocabulary. The axis values live once in agent_contracts/axes.py; a parity test asserts the contract Literals equal that vocabulary, so a value added on one side fails CI until added on the other. |
test_runtime_axes.py :: test_contract_literals_match_axes_vocabulary. |
The acceptance tests, mapped to the doors they weld
| Acceptance criterion | Proof |
|---|---|
| Floor beats a low default in both modes | test_floors.py :: test_floor_beats_low_default_in_both_modes (parametrized auto + interactive) |
Authority never crosses a non-delegable floor, even under authorized | test_authority.py :: test_non_delegable_never_auto_applies_even_under_authorized (every action in the closed set) |
| Resume keeps the pinned posture across a config edit; 0 new effects for terminal items | test_run_control.py :: test_resume_keeps_pinned_posture_across_a_config_edit, test_resume_of_terminal_items_yields_zero_new_effects |
| Capability load is closed (cannot create a non-registered capability) | test_capabilities.py :: test_unregistered_capability_cannot_be_created |
| Content cannot escalate authority | test_run_posture.py :: test_document_content_cannot_change_the_posture |
document_status_events stream (D6) and posture provenance is produced as the exact ledger-row shape the effect ledger (pramaan-spec#1542) will embed — emitted to telemetry today, persisted by that ledger's writer when it lands. The single alembic head is untouched; this Story added no migration.ruff check clean; mypy clean on the new package (only the repo's pre-existing types-PyYAML stub gap remains, local-only/not-CI); make openapi produces no snapshot change (these are internal broker↔brain wire contracts, not browser-facing routes); full DB-backed suite green at migration head — 1156 passed, including the new tests/unit/agent_rails/ + tests/unit/agent_contracts/test_runtime_axes.py and every existing RLS/integration test, proving the additive contract change broke no consumer.