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.
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.
| Intent | Kind | Backend outcome |
|---|---|---|
query_matters (caller holds matters:read) | read | executed — firm's matter list, even when empty (0 rows) |
propose_matter_write (caller holds matters:write) | write_proposal | proposal — no mutation performed |
propose_matter_write (read-only caller) | write_proposal | denied — 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.
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.