Story-3011

Matter access-contacts read — who to ask for access

Story-2677 refuses an unstaffed firm editor entry to a matter's detail page (403 pramaan.matter.not_on_team), and the app shows a not-authorized modal. But that editor had no way to learn who could grant access: the roster read is admin-or-staffed only, and the matter list item carries no lead. This story adds one minimal read that names exactly the people who can help — the matter's lead counsel and the firm's active admins — and nothing else.

The New Endpoint

GET /app/matters/{matter_id}/access-contacts

{
  "lead":  { "userId": "...", "name": "...", "email": "..." } | null,
  "admins": [ { "userId": "...", "name": "...", "email": "..." }, ... ]
}

lead is the current lead counsel, or null in the accepted no-lead runtime state. admins is every active firm admin of the matter's firm. The payload carries only userId, name, and email for those people — no matter content, no full roster, no team-role or membership-status internals.

Authorization — Firm Isolation Only

The read is gated on firm isolation alone: the matter must belong to the caller's firm. It deliberately does not reuse the admin-or-staffed roster gate or the detail-entry gate, so the very editor that Story-2677 refused entry is allowed to read the access contacts — that is the whole point. Firm admins and staffed users pass as a superset of "same firm". A matter outside the caller's firm is simply not found.

Caller Result
Unstaffed same-firm firm editor 200 — lead + admins (the entry-gate 403 does not apply here)
Firm admin, staffed editor, staffed member 200 — superset of "same firm"
Matter with no lead assigned 200 — lead: null, admins still named
Caller in a different firm 404 — no cross-firm existence leak

What Changed

matter_access_contacts(matter_id):
    matter = load matter (RLS + firm predicate)   # not in firm -> 404
    lead   = lead-counsel assignment -> user       # null in no-lead state
    admins = active firm_admin memberships -> users # one firm-scoped join
    return { lead, admins }                         # names/emails only

The lead comes from the same lead-counsel assignment the roster disclosure uses. Admins are resolved in one firm-scoped join over active firm memberships with the admin capability — no per-admin lookup. The read emits no domain events and writes nothing.

Deliberate Minimal Disclosure

This is an approved product decision: it intentionally reveals the matter's lead identity and firm admins to same-firm editors so a blocked editor knows whom to ask for access. The disclosure is bounded to names and emails of the lead and admins; nothing about the matter's substance crosses the boundary.

Walkthrough exemption. This is a backend, read-only endpoint with no user-facing surface of its own; the user-facing walkthrough ships with the paired app ticket #3009, which wires the not-authorized modal to these named contacts. This file is provided so the Story closes-check is satisfied even when CI cannot read the spec issue body.