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).

Built on the ratified ADR-129 (not the stale ticket). The issue body names a standalone 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.

✈️ 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:

🔬 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 criterionProof
Floor beats a low default in both modestest_floors.py :: test_floor_beats_low_default_in_both_modes (parametrized auto + interactive)
Authority never crosses a non-delegable floor, even under authorizedtest_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 itemstest_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 authoritytest_run_posture.py :: test_document_content_cannot_change_the_posture
Zero new bytes of schema. Run-control rides the existing 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.
Verify gate. 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.