Story-2677

Server-side matter-detail entry gate

An unstaffed firm editor could open the detail page of any matter in the firm. This story adds a single server-side authorization gate on the matter-detail entry read so that only a firm admin or a user staffed on the matter can open it. Content visibility is unchanged: editors still see every matter in the register list, in the API, and in the settings audit log. The only boundary added is a workflow gate on entering a matter's detail page.

Before and After

Caller Before After
Unstaffed firm editor Opens the matter detail (200) Refused (403 pramaan.matter.not_on_team); the app shows a modal and returns to the register list
Firm admin Opens the matter detail (200) Unchanged — opens (200)
Staffed editor or member Opens the matter detail (200) Unchanged — opens (200)
Register list read Editor sees all matters Unchanged — editor still sees all matters
Internal / agent read Reads the matter Unchanged — not subject to the human-staffing gate

What Changed

The gate lives in MatterService.get_matter, the top-level read the app's matter-detail route depends on. It is reached only by the two app-facing endpoints below, so gating the shared service method covers the whole entry surface in one place.

GET /app/matters/{matter_id}                    detail entry
GET /app/matters/{matter_id}/record/bootstrap   record page (wraps get_matter)

get_matter(matter_id):
    matter = load matter (RLS-visible)          # unchanged; hidden -> 404 first
    require_matter_detail_entry(matter)         # NEW gate
    ... build and return the detail view

require_matter_detail_entry(matter):
    if no app-user principal:  return           # internal / agent / worker
    if firm_admin:             return
    if staffed on the matter:  return           # matter_assignments row
    raise PramaanForbiddenError(
        code = "pramaan.matter.not_on_team",     # http 403
    )

Staffed Means Editor or Member

"Staffed" is any business.matter_assignments row for the current user and this matter — a staffed editor and a staffed member both pass. The gate reuses the existing staffing lookup (_assignment_role) and the existing PramaanForbiddenError; it introduces no new primitive.

Only the App User Is Gated

The gate keys off the authenticated app-user principal (app.current_user_id). Internal, agent, service, and worker sessions (framer, deep-read, outbox, context-embed, cross-matter references, agent runs) set only the firm and role context and never a user id, so the gate returns early and those reads pass straight through. They are system contexts, not a human opening a workspace. This mirrors the existing off-team-view audit path, which distinguishes an app-user read from a system read the same way.

What Stays The Same

No change to business.can_read_matter or any content-table row-level security — this is an application-layer entry check, not a content-hiding change. The matters list (GET /app/matters) is untouched, so editors keep seeing all matters in the register. A matter that row-level security already hides is still a 404 (the load runs before the gate), not a 403.