Story 1651: Server-plane tool executor

Why this exists (the catastrophe it prevents). Story-1647 gave the brain a way to emit typed tool intents and a boundary that authorizes a turn — but nothing actually runs a tool. The tempting shortcut is to let the brain (in pramaan-agents) read and write firm data directly "under Postgres authz". That is the catastrophe: the brain would become a data-control authority, the browser's clientTools/permissions blocks would effectively decide what executes, and a tampered tab could invent a tool and have the backend run it. Story-1651 makes the backend the only execution plane for server tools: the brain proposes, the backend re-resolves who you really are, runs reads under your permissions, refuses to auto-run anything irreversible, and writes an audit row for every call — allowed or denied.

This is the backend execution plane for pramaan-functions only. The brain (pramaan-agents) and the WebSocket rail are sibling children of the Agentic Interaction Epic (#1646). Production transport stays the WS rail; this Story ships the executor + an HTTP boundary that exercises it end-to-end against a real DB.

What this ships

1. The leash + the pure authz classifier — src/shared/agent_tools/registry.py

Every server tool the executor will run is declared in code by a human PR. A tool the brain names that is not registered is unreachable — there is no dynamic-registration path, so "delete", "bulk-mutate", anything irreversible stays impossible by simply not being listed. Each spec pins the required_permission the server re-checks, and a kind that fixes what happens once authorized.

SERVER_TOOL_REGISTRY = {
  "query_matters":        ServerToolSpec(READ,           "matters:read"),
  "global_search":        ServerToolSpec(READ,           "matters:read"),
  "propose_matter_write": ServerToolSpec(WRITE_PROPOSAL, "matters:write"),
}

classify_server_tool(name, granted)   # pure, no DB:
  unknown tool          -> denied, fail closed
  perm not in granted   -> denied, missing required permission
  else                  -> allowed

One authority source. The Story-1647 resolver imported a local SERVER_TOOL_PERMISSIONS dict. That dict is now derived from this one registry and imported by the resolver, so the boundary that authorizes intents and the executor that runs them can never disagree about which tool needs which permission.

2. The executor — src/shared/agent_tools/executor.py

The only path by which a plane="server" intent touches firm data. It enforces, in order:

1. authority = granted   # the caller's REAL grants, resolved at the boundary
                         # from the verified Clerk membership/role matrix —
                         # NOT the client's permissions/clientTools blocks
2. classify -> leash + permission   # unknown or unpermitted => denied
3. READ  -> run handler under RLS, return data
   WRITE -> return a PROPOSAL, never mutate
4. emit one agent_session.executed audit row (allowed AND denied)

An authorized READ delegates to the canonical domain service (MatterService.list_matters) so it inherits the firm predicate, ordering, and bounded query count (DR-1/HR-19) — reads are never re-implemented here. An authorized WRITE/irreversible tool is never executed; the executor returns a proposal for human confirmation, and a count check in the tests proves no row was written.

3. The boundary — POST /app/agents/tools/execute

Where Story-1647's /session-context/resolve authorizes a turn, this endpoint executes one server-plane intent. It runs under the caller's RLS session (AppDbDep) with a matters:read floor; per-tool authority is re-resolved inside the executor against the caller's real permissions, which the client's orientation blocks cannot widen. A non-server intent is rejected at the boundary (HR-21 boundary validation).

The decisions that matter

Client context is orientation; the server re-resolves authority

The security spine (RFC-APB-002 reviewer locks AGENT2 #1 / AGENT3 / AGENT4). The executor decides on granted = set(current_user.permissions) — session truth — so a forged clientTools entry grants nothing: an unknown tool fails closed, and a known tool is denied unless the real caller holds its permission.

IntentKindBackend outcome
query_matters (caller holds matters:read)readexecuted — firm's matter list, even when empty (0 rows)
propose_matter_write (caller holds matters:write)write_proposalproposal — no mutation performed
propose_matter_write (read-only caller)write_proposaldenied — missing matters:write
forged_admin_tool—denied — unknown server tool (fail closed)
navigate_to (a client-plane intent)—422 — the server plane runs server intents only

Audit every call, outside any private transcript

Every execution — allowed and denied — writes one typed agent_session.executed row carrying agent_session_id, surface_key, tool, the allowed/denied decision, and trace_id. It writes in the caller's transaction, so it rolls back with a failed call and never logs an action that did not happen.

A real system-boundary bug this caught. The first pass reused the matter-scoped agent_run audit subject, whose area is matter. The audit_log_area_scope check is (area = 'matter') = (matter_id IS NOT NULL) — and these tools (query_matters, global_search, a firm-wide write proposal) carry no matter_id. A unit test against a fixture would have passed; the real POST returned 500 on the audit insert. The fix is a firm-area agent_session audit subject (data-only registry edit, no migration). This is exactly the "done = system boundary, not component boundary" lesson: the audit emit was only ever exercised by a real request against a real DB.

ADR-124 observability

The executor runs inside the route's T1 auto-instrumented FastAPI/Lambda span; it opens no manual span (a plumbing span here is a PR reject), logs low-cardinality ids only, and adds a T2 agent.key / event.type enrichment via the shared set_observability_attributes helper. Firm data never enters a trace.

Verification

Pure unit tests (tests/unit/agent_tools/test_registry.py) prove the registry is internally consistent (every READ tool has a handler; write tools have none), that the permission map is one source the resolver imports, and that classify_server_tool fails closed on unknown tools and denies a missing permission — no DB, so the decision is unambiguous. Integration tests (tests/integration/test_app_agent_tools.py) drive the real route against a real DB through the full ASGI pipeline: a read executes on an empty firm (the zero-rows first run a fixture would hide) and on a seeded matter, a write returns a proposal and a count check proves nothing was written, an unknown tool is denied and the denial is audited, a client-plane intent is rejected, and the audit row's metadata is asserted field by field. The audit route-coverage guard ties the new route to its agent_session.executed action, and the OpenAPI snapshots are regenerated.

One sentence to remember: the brain proposes, the backend re-resolves who you are and runs only what you may — reads execute, writes only propose, and every call leaves an audit row.