Story 1599: catalog list endpoints — finish the 1+N → 1+k sweep

Why this exists (the catastrophe it prevents). Every PRAMAAN request runs inside a Lambda talking to RDS over the network, so each SQL statement is a round-trip and latency is round-trip accumulation, not slow SQL (pramaan-docs ADR-130). Story-1598 (B1) shipped the shared bulk_count_in_use_references contract and fixed the worst fanouts (the matter-config bootstrap + participant labels). But the standalone settings catalogs — the screens a firm opens to manage clients, document types, filing types, forums, matter types, proceeding types, and firm roles — were still asking the database one more question per row: “how many things use this row?”. A firm with 50 forums paid ~50 extra round-trips on every list load, and the cost grew with the firm’s data. This Story converts the last seven list endpoints to a grouped, page-wide count so the query count is bounded by the number of reference tables (fixed), never the number of rows.

Scope & doctrine. Master plan n-plus-one-audit-implementation-plan.md — WS-3 remaining catalog LIST sites + WS-8 (O(1)-query tests), ยงD.3 per-domain notes. Builds directly on B1 (pramaan-spec #1598): the shared BulkInUseReference / bulk_count_in_use_references and the _bulk_usage_counts twins already on forum / filing_type / proceeding_type — this Story reuses them and wires the list endpoints to consume them. No DTO / OpenAPI change — response bodies are byte-identical, so make openapi shows no diff. No new observability span (ADR-124: the grouped queries ride SQLAlchemy T1 auto-instrumentation under the existing request span).

๐Ÿ›ฐ๏ธ Satellite: ask once for the whole page, not once per row

BEFORE  (forbidden N+1, HR-19 / DR-1)        AFTER  (bounded, this Story)
  SELECT page of catalog rows  โ”€โ”€ 1          SELECT page of catalog rows   โ”€โ”€ 1
  for row in page:                           counts = GROUP BY subject_col
      count(refs) WHERE id = row โ”€โ”€ Nร—k           WHERE id IN (page ids)    โ”€โ”€ k tables
  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€                  โ†’ dict{id: count}, zip in memory
  total = 1 + Nร—k  (grows with rows)         โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
                                             total = O(1) + k  (k = #ref tables, FIXED)

One sustained analogy: a teacher taking attendance. The slow way walks to each desk and asks “are you here?” (one trip per student). The fast way reads the whole room once and ticks a list. The grouped COUNT ... GROUP BY subject_column WHERE subject_column IN (page ids) reads the whole room once, per reference table.

โœˆ๏ธ Airplane: the seven endpoints and their reference tables

EndpointReference table(s) countedBeforeAfter
GET /app/clientsmatters1 + N1 + 1
GET /app/document-typesdocuments (incl. soft-deleted)1 + NO(1) + 1
GET /app/filing-typesmatter_template_filing_types, proceeding_filing_types1 + 2NO(1) + 2
GET /app/forumsproceedings1 + NO(1) + 1
GET /app/matter-typesmatters, matter_templates1 + 2NO(1) + 2
GET /app/proceeding-typesmatter_template_proceeding_types, matter_proceeding_types1 + 2NO(1) + 2
GET /app/firm/rolesfirm_memberships (member count)1 + NO(1) + 1

The constant k is the count of reference tables for that catalog — bulk_count_in_use_references loops over tables, never over rows. firm_role is the one shape that does not use the shared in-use helper: its member count is a plain display count (not a delete-guard), so it gets a domain-specific grouped loader _bulk_member_counts. Its active_count (the can_delete driver) was already O(1) — the page length.

๐Ÿ”ฌ Microscopic: the conversion, and the invariants it must not break

# forum/service.py โ€” list_forums, AFTER
forums = result.scalars().all()
usage_counts = await cls._bulk_usage_counts(        # 1 grouped query / ref table
    session, firm_id=firm_id, forum_ids=[f.id for f in forums],
)
return ForumListResponse(
    forums=[cls._view(f, usage_count=usage_counts[f.id]) for f in forums]
)

Preserve, do not regress:

InvariantHow it is held
V3 — body shape + order byte-identicalThe page query is unchanged; the bulk dict is keyed by id and zipped in the same order. Golden tests assert model_validate(body).model_dump() == body.
V4 — firm_id predicate on every countbulk_count_in_use_references applies entity.firm_id == firm_id itself, so the firm filter can never be forgotten.
Summed semantics (NOT COUNT(DISTINCT))Counts are summed across reference tables — a matter type used by a matter AND a template counts twice. Locked by the matter_type/filing_type/proceeding_type summed-count goldens.
document_type soft-delete inclusion (ยง6)The bulk BulkInUseReference carries predicates=(), matching the scalar predicate VERBATIM: a referencing document with availability='deleted' STILL counts. A dedicated golden seeds 1 live + 1 deleted document and asserts usageCount == 2.
Single-row delete guard unchangedThe scalar _usage_count / _member_count stays for create/update/delete paths; only the LIST path moved to bulk.
Auto-merge guard (reviewer-mandated). Both families present in tests/integration/test_story_1599_catalog_list_bulk_counts.py: Plus an empty-firm guard: all seven lists return [] on a freshly provisioned firm (system-boundary, not component-boundary).

Verify

make test-db && make test          # 11 new guards + 41 existing domain/B1 tests green
uv run ruff check                    # changed files clean
make openapi                         # NO diff (no contract change)
git diff --exit-code -- openapi/*.json

One-sentence recall: a catalog list must answer “how many use each row?” with one grouped question per reference table, never one question per row — and the answer must be byte-for-byte what the per-row path gave, soft-deletes and summed double-counts included.