Story 1779: one-trip page bootstrap for the team and record pages

Why this exists. A workspace page that fires five or six parallel API calls on load is five or six chances to wake a cold Lambda. Each concurrent round-trip can force a new instance, each new instance pays a cold start, and the user gets the transient 503 class that Bug-1775 had to mop up reactively. The durable fix is to stop fanning out: let the browser ask once, and let the server do the fan-out internally over a warm pooled connection. Think of it as a waiter taking the whole table's order on one trip to the kitchen instead of one trip per dish.

Scope. Two read-only GET composition endpoints under /app. No new tables, no new business rules, no duplicated query logic — each endpoint calls the SAME service functions the per-resource routes already call. The per-resource endpoints stay exactly as they are; this only adds an aggregate door.

Satellite view: two new doors

GET /app/firm/team/bootstrap
  -> { members, seatsAndBilling, firmRoles }

GET /app/matters/{matter_id}/record/bootstrap
  -> { matter, participants, documents, savedViews, savedViewsAvailable }

The team page collapses three round-trips into one; the record page collapses four into one. Field names are intentional and frozen — pramaan-app (Story-1780) mirrors this wire shape via codegen.

Airplane view: compose, don't reimplement

PageBootstrapService.get_record_bootstrap(session, matter_id)
   |
   |-- MatterService.get_matter(...)                 <- anchor; 404 stops here
   |-- MatterIdentityService.list_matter_participants(...)
   |-- DocumentService.list_documents(..., limit=50)
   '-- DocumentSavedViewService.list_saved_views(...) <- best-effort, runs LAST

Every field is the verbatim response of its per-resource endpoint. The aggregate fans out to a fixed number of existing service queries (3 for team, 4 for record) — never one query per row — so it adds no N+1 (pramaan-docs ADR-130 DR-1). The underlying services already do their own set-based queries; this layer only sequences and assembles their typed responses.

Microscopic view: the three load-bearing details

1. Authz is identical by construction. Every call runs on the firm-scoped AppDbDep session, so RLS firm-scope is the same wall the per-resource endpoints stand behind. For the record page the matter is the anchor: a caller in the wrong firm gets a 404 from get_matter and never reaches any sub-resource query. A door a caller cannot open per-resource cannot be opened via the bootstrap.

2. Best-effort sub-parts degrade, never fail. Saved views run last, wrapped so any failure becomes an empty list plus savedViewsAvailable: false rather than a 500 for the whole page. Last-place ordering means a saved-view failure never discards the already-collected anchor payloads.

3. Team carries three blocks, not five — on purpose. The team page also shows invitations and invitationReconciliation, but the app-api Lambda has no VPC egress to api.clerk.com, PRAMAAN does not persist pending Clerk invitations (organizationInvitation.accepted is an ignored webhook), and reconciliation needs a live Clerk snapshot. Those two are browser→Clerk calls that never touch this gateway, so they are outside the SSR Lambda fan-out this work targets and stay client-side Clerk by design.

Observability (pramaan-docs ADR-124)

These are read-only composition handlers over already-instrumented service queries. They are covered by the auto-instrumented HTTP route span plus the auto DB spans, and there is no registry-listed ManualSpanName for a page-bootstrap read. No manual span is added — instrumentation-exempt under the rule that small composition over instrumented callees rides the caller span, mirroring the existing MatterConfigBootstrapService precedent.

Verification

make test-db
uv run ruff check src/app_api/page_bootstrap_dtos.py src/app_api/page_bootstrap_service.py \
  src/app_api/routes/firm.py src/app_api/router_matters.py tests/integration/test_app_page_bootstrap.py
uv run pytest tests/integration/test_app_page_bootstrap.py -v --tb=short
make openapi   # regenerates openapi/app-api.json + permission-map.json

Tests cover the composed shape for both endpoints (full DTO round-trip), the cross-firm denial path (wrong firm → 404), and the zero-rows first run (new firm with no roles, matter with no documents → empty arrays, not an error).

One-sentence recall: the bootstrap endpoints let the browser ask once and the server fan out over existing firm-scoped service queries, trading a cold-start-prone client fan-out for one warm round-trip without changing a single per-resource contract.