Story 1783: a surface-generic broker read substrate

Why this exists (the catastrophe it prevents). Atlas is a Surface Operator meant to span ~18 product surfaces, but the broker that turns a brain intent into a backend read was matter-locked. The server tool registry carried only query_matters and a global_search hardcoded to MatterService.list_matters. A result-rendering substrate built on that could serve exactly one surface: every non-matter surface (clients, forums, filings…) was a rewrite away, not a registration away. Story 1783 removes the matter coupling so a new surface's read is "register a spec + a handler", and proves it by adding the first non-matter read tool, query_clients, under its own permission.

This is the broker read substrate only (RFD-AGT-004 §7.6, B-SUBSTRATE-1). No app/render changes, no new wire kinds, no gen:catalog — just the generalization and the one proof tool.

The decisions that matter

1. The tool dispatch was already generic — two real locks remained

The executor already routes by READ_HANDLERS[spec.name], so adding query_clients needs zero edits to the shared dispatch path — one registry entry plus one handler. Two matter-locks remained, and both are removed:

2. query_clients is a sibling of query_matters, nothing more

It mirrors the template exactly: a ServerToolSpec(READ, "clients:read") in the registry, a handler that delegates to the canonical ClientService.list_clients (inheriting its firm predicate, ordering, and bounded query count), and a free-text mapper that turns loose brain params into a typed ClientListQuery. No new abstraction, factory, or base class was introduced — the substrate's generality is the existing READ_HANDLERS dict, now with a second, non-matter member.

3. Authority is each tool's own permission, re-resolved from the session

query_clients is gated on clients:read, not on query_matters's matters:read. The executor re-resolves the caller's REAL grants from the verified session (never client hints), so a matters-only caller is denied client reads, and a forged or unregistered tool is unreachable. global_search stays matters:read-scoped: only surfaces whose permission matches its gate are wired into SEARCH_PROVIDERS, so it can never under-gate a cross-surface read.

4. clients:read is a real, granted permission — not a welded-shut door

A tool gated on a permission no role holds is a door welded shut: registered, tested green, and unusable for every firm. So clients:read is added to the role matrix via Alembic migration 0130 and granted to the operational roles that already manage clients — firm_admin and firm_editor (the same roles that hold clients:write). firm_member stays deliberately matters:read-only per the authz role ladder, and support receives no client access (pramaan-docs ADR-098). The matrix mirror, migration seed, and namespace guards were updated together, exactly as the authz "How To Change" process prescribes.

Caller roleholds clients:read?query_clients
firm_admin / firm_editoryesexecutes, RLS-scoped to the firm
firm_membernodenied, fail closed (audited)
any (unknown/forged tool)—denied, "unknown server tool"

Verification

Pure tests prove the leash in isolation: query_clients is denied under matters:read alone, and the dock_chat catalog surfaces it only with clients:read. The integration suite drives the real route against a real DB through the full executor loop: a caller holding clients:read but not matters:read runs query_clients while query_matters is denied for the same caller — the regression guard proving the route no longer imposes a matters floor; a missing-permission caller is denied through the route (not a 403 gate) and audited; a brand-new firm returns an empty, well-formed client list (the zero-rows first run, where a seed would lie); a firm with seeded clients returns only its own — a same-named client in another firm stays invisible under RLS; and every call writes one agent_session.executed audit row, identical to query_matters. The existing matter tools, /app/me, and the permission-map snapshot remain green; no app/admin OpenAPI drift (the permission map records the route's floor removal).

One sentence to remember: adding a surface read is now "register a spec + a handler" gated by its own session-resolved permission — the broker substrate is no longer matter-locked, and query_clients is the proof.