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.
🛰 Satellite view: one stable detail contract, one feed left separate
| Surface | Before | After |
|---|---|---|
| Firm detail page base | The 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 grants | Fetched separately and joined in the BFF. | Nested as grants: GrantListResponse, reusing the existing grant list service and DTO. |
| Support roster | Fetched separately and joined in the BFF. | Nested as roster: SupportRosterListResponse, reusing the existing roster list service and DTO. |
| Activity feed | Fetched 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
| Invariant | Mechanism | What it prevents |
|---|---|---|
| Operation IDs stay stable | The 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 404 | FirmService.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 path | The 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 freshness | The 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
- System-boundary route tests (
tests/routes/test_admin_firms.py) run real requests through the admin router against the Docker test DB:- First-run shape: a newly-created firm returns the full
FirmDetailViewshape with emptygrants.page.itemsand emptyroster.items, and noactivityfield. - Bounded query count: an empty firm and a populated firm are both fetched through
GET /admin/firms/{firm_id}; the observed route query counts match, proving the composite does not scale with child row count. - Valid grant fixture: the populated firm grant is created by the firm CSM owner, matching the support-grant rule that grants are self-granted and the grantee must be the CSM owner or a permanent support roster member.
- First-run shape: a newly-created firm returns the full
- Contract mirror:
make openapiregenerates the producer snapshots;openapi/admin-api.jsonshows both firm detail GET responses as#/components/schemas/FirmDetailView, with operation IDs unchanged. - Focused verify: the Docker-backed route suite for the changed surfaces passes (
tests/routes/test_admin_firms.pyandtests/routes/test_admin_firms_by_ref.py), and ruff is clean on the changed files. Broader local mypy still reports existing SQLModel/SQLAlchemy typing issues outside this Story.
FirmDetailView that nests stable grants and roster data without changing operation IDs, while the live activity feed remains a separate cursor-paginated endpoint.