Story 1926: one staff profile contract — the backend now carries the grant detail

Why this exists (the catastrophe it prevents). The admin staff detail page had to build one profile from two backend reads: GET /admin/staff/{id} returned a StaffListItem, while GET /admin/staff/{id}/grants returned StaffGrantDetailResponse. The frontend adapter adaptStaffDetail then hand-joined those shapes client-side. That made the browser responsible for a backend-owned contract: one screen could render only if two hooks completed, two response models stayed aligned, and the adapter remembered which fields were real and which were placeholders.

The failure mode was not only extra round-trips. The admin adapter also carried firmAssignments and activity as fake empty arrays. Moving that whole shape server-side without real data sources would make the stub look authoritative. This Story fixes the stable part only: the backend now assembles the already-available staff row plus the already-available grant detail into one explicit StaffDetailView contract, and leaves the fake client-only fields out of the producer contract.

The analogy to hold the whole way down: the staff profile is now a single dossier, not two pages stapled together in the browser. The first page is still the staff identity row; the second page is still the grant ledger. The backend staples only those real pages together, checks the dossier exists once, and refuses to insert blank placeholder pages for sections that have no source yet.

🛰 Satellite view: one upgraded endpoint, same operation ID

ChangeSurfaceEffect
Composite DTOStaffDetailView(StaffListItem)Extends the staff list item and nests the existing grant-detail fields: activeGrants[], recentGrantHistory[], and eligibleGrantFirms[].
Server assemblyPlatformStaffService.get_staff_detail_viewReuses the existing staff detail service and grant-detail service. No new SQL shape or business rule was introduced.
Endpoint upgradeGET /admin/staff/{user_id}Now returns StaffDetailView. The operation ID remains adminGetStaff, keeping codegen blast radius minimal for the admin consumer.

The sibling grants endpoint remains available. This Story upgrades the staff detail contract in place so the frontend can move from a two-hook join to one generated staff-detail response without a new route name.

✈️ Airplane view: the four contract rules this holds

InvariantMechanismWhat it prevents
One anchor owns authz and 404get_staff_detail_view calls get_staff_detail first. That staff row is the anchor resource. Once it exists and is visible, the grant-detail assembly skips the duplicate staff existence lookup.Two independent not-found checks for one profile, and avoidable extra anchor round-trips inside the composite path.
No new SQL, no new business rulesThe composite service reuses get_staff_detail and get_staff_grant_detail. The grant eligibility and history rules stay exactly where they already lived.A detail-view-only fork of support grant logic that could drift from the dedicated grants endpoint.
Fixed fan-out, no N+1The endpoint performs a bounded staff read plus the existing fixed grant-detail reads. The tests include a query-count guard comparing a zero-grant staff member with a staff member that has grant and eligibility rows.A staff detail endpoint whose query count grows with grants, firms, or UI rows.
Stable producer contractThe route declares response_model=StaffDetailView, keeps canonical error responses, inherits router tags, and regenerates openapi/admin-api.json.An implicit Pydantic return shape or OpenAPI drift that leaves the admin generated client guessing.

🔬 Microscopic view: the decisions that matter

1. StaffDetailView extends the list item, but activeGrants becomes the grant array

The app precedent is a detail view extending its list item, so the new DTO follows that pattern. The one deliberate wrinkle is activeGrants: on StaffListItem it is a count, but the ticket required the grant-detail field activeGrants[]. The detail view therefore overrides that inherited field with the standalone array schema from StaffGrantDetailResponse.

class StaffDetailView(StaffListItem):
    active_grants: list[StaffGrantItem]
    recent_grant_history: list[StaffGrantItem]
    eligible_grant_firms: list[EligibleGrantFirm]

The generated OpenAPI schema reflects this: StaffListItem.activeGrants remains an integer for list rows, while StaffDetailView.activeGrants is an array of StaffGrantItem.

2. The staff row is the dossier cover sheet

The composite service starts by reading the same staff detail row the old endpoint returned. That read owns the profile's 404 behavior. Only after the staff row exists does the service append grant data.

staff = await cls.get_staff_detail(session, user_id=user_id)
grants = await cls.get_staff_grant_detail(
    session,
    user_id=user_id,
    staff_exists_checked=True,
)

The staff_exists_checked flag is intentionally narrow. The public grants service path still performs its own staff existence check; only the composite path skips the duplicate because its anchor already ran.

3. The endpoint changed shape, not identity

The route still lives at GET /admin/staff/{user_id} and the operation ID remains adminGetStaff. That keeps the generated hook identity stable while changing the response model from StaffListItem to StaffDetailView. The admin consumer still needs a codegen refresh, but it does not need to adopt a new operation name.

4. Fake frontend sections stay fake until there is a source

firmAssignments and activity were called out as an open risk because they are currently faked as [] in the admin adapter. This backend Story did not find or cite a real producer data source for either. They are intentionally not included in StaffDetailView, and the empty arrays were not moved server-side. Serving those sections for real remains a follow-up.

How it was proven

One-sentence recall: the staff detail endpoint now returns one backend-owned dossier, StaffDetailView, assembled from the existing staff row and grant detail services with the same adminGetStaff operation ID and no server-side copy of the fake firmAssignments/activity arrays.