Story 1927: the firm detail composite contract

Why this exists (the catastrophe it prevents). The admin firm detail page had a backend contract gap: pramaan-admin built a frontend-local FirmDetailViewModel by hand-joining firm base detail, support grants, activity, and support roster with Promise.all. That meant four round-trips for one page frame, duplicated assembly rules in the BFF, and no producer-owned OpenAPI contract for the shape the page actually needed. Any future consumer had to rediscover the same join and make the same freshness decisions by convention instead of by contract.

This Story moves the stable part of that join to the producer boundary. The functions API now returns a single FirmDetailView for the existing firm-detail operations: the existing firm detail envelope plus nested support grant and support roster payloads. The firm remains the anchor, so the same firm lookup owns authorization and 404 behavior before the fixed fan-out reads run.

The analogy to hold the whole way down: the backend now hands admin a case file cover sheet. The cover sheet includes stable firm metadata, current grants, and the permanent support roster. The live activity feed stays as the separate running log beside the file, because it is cursor-paginated and real-time.

🛰 Satellite view: one stable detail contract, one feed left separate

SurfaceBeforeAfter
Firm detail page baseThe admin BFF fetched firm detail and joined related payloads locally into a page-specific view model.GET /admin/firms/{firm_id} and GET /admin/firms/by-ref/{firm_ref} return FirmDetailView directly.
Support grantsFetched separately and joined in the BFF.Nested as grants: GrantListResponse, reusing the existing grant list service and DTO.
Support rosterFetched separately and joined in the BFF.Nested as roster: SupportRosterListResponse, reusing the existing roster list service and DTO.
Activity feedFetched as one of the BFF Promise.all calls.Deliberately remains GET /admin/activity?firmId=, because it is volatile, cursor-paginated, and real-time.

✈️ Airplane view: the four invariants the contract holds

InvariantMechanismWhat it prevents
Operation IDs stay stableThe route handler names remain get_firm and get_firm_by_ref, so FastAPI still generates adminGetFirm and adminGetFirmByRef.Breaking generated admin hooks for existing consumers just because the response model widened.
Firm owns authz and 404FirmService.get_detail_view and get_detail_view_by_firm_number call the existing firm detail methods first, then assemble the stable children.A composite endpoint returning child data for a firm that the caller cannot resolve, or changing the canonical not-found behavior.
No new SQL pathThe view reuses existing services: firm detail, support grants, and support roster. The fan-out is fixed, not row-driven.An N+1 detail page, duplicate query logic, or a second implementation of grant/roster filtering rules.
Activity keeps independent freshnessThe activity feed is excluded from FirmDetailView and remains on GET /admin/activity?firmId=.Caching or snapshotting a cursor-paginated audit feed inside a stable composite and losing real-time pagination semantics.

🔬 Microscopic view: the decisions that matter

1. The DTO extends the existing firm detail envelope

FirmDetailView lives in src/domains/firm/dto.py and extends the already-composite FirmDetailResponse. The new fields are existing producer DTOs, not page-local dictionaries:

class FirmDetailView(FirmDetailResponse):
    grants: GrantListResponse
    roster: SupportRosterListResponse

That keeps the field contract explicit in OpenAPI and keeps grants and roster shaped exactly like their granular endpoints.

2. Assembly is fixed fan-out from the firm anchor

The service path is FirmService.get_detail_view / get_detail_view_by_firm_number into _build_detail_view. It first loads the existing firm detail response, then calls the existing grant and roster services once each:

detail = await cls.get_detail(session, firm_id=firm_id)
grants = await SupportGrantService.list_grants(
    session, req=GrantListRequest(firm_id=firm_id)
)
roster = await FirmSupportStaffService.list_roster(session, firm_id=firm_id)

That gives one bounded composite assembly path: no loop over rows, no new SQL, and no page-specific filtering rules hidden in the route.

3. The old routes widen their response, not their identity

The two existing admin firm detail routes now declare response_model=FirmDetailView and return FirmDetailView. Their operation IDs remain adminGetFirm and adminGetFirmByRef, because the handler names stay get_firm and get_firm_by_ref. This is a producer-compatible widening of the payload, not an endpoint rename.

4. Members and pickers stay granular

The composite includes the support roster because it is stable firm-detail metadata. Firm members, staff/me picker data, and other cross-page selectors remain separate granular calls. That keeps this Story scoped to the admin detail frame and avoids turning the route into a broad page bootstrap endpoint.

How it was proven

One-sentence recall: admin firm detail now has a producer-owned FirmDetailView that nests stable grants and roster data without changing operation IDs, while the live activity feed remains a separate cursor-paginated endpoint.