Story-1254 · mirrors auditEventView.actorImageUrl

Member avatars carry a real photo — imageUrl joined live from identity

The problem

In-content member avatars (Add-Matter team step, the Settings members table, member pickers) were initials-only because FirmMemberListItem carried no image. The audit feed already solved the identical problem for actors: auditEventView.actorImageUrl — "Acting human's current avatar URL (joined live from identity at read time); null for agent/system or users without a photo." Members deserved the same treatment.

The avatar is already synced into identity.users.image_url by the Clerk webhook — the same source that feeds the audit actor join. So this Story is a minimal expose / join, not new sync infrastructure.

What changed

PieceChange
DTO FirmMemberListItemAdds image_url: str | None (serialized imageUrl via the camelCase alias generator). Description mirrors the audit field: "Member's current avatar URL (joined live from identity at read time); null for users without a photo."
Service AppFirmService._member_itemPopulates image_url=user.image_url. Every call path (list, firm-role assign, reactivate, deactivate) already passes the full User entity, so one line covers the list endpoint and every member-detail response.
Read pathNo new query. list_members already JOINs identity.users and selects the full User row; image_url rides that existing join — identical pattern to the audit query's User.image_url.label("actor_image_url") outerjoin.
Contractmake openapi regenerated; openapi/app-api.json carries imageUrl on the schema so pramaan-app regenerates src/gen. admin-api.json unchanged (app-only surface).

Null-safety / first-run state

The field is nullable by construction: identity.users.image_url is str | None, so a member without a Clerk photo surfaces imageUrl: null — never an empty string, never a missing key. The app renders the initials fallback on null. The integration test exercises both states in one request: a member with an avatar and the caller without one.

Verification

Integration: real request against a real (empty-seeded) firm DB —
  GET /app/firm/members returns [caller, member]; imageUrl == [null, member_avatar].
  Member's identity.users.image_url set via SQL; caller left null. Full-shape
  round-trip (model_dump by_alias == body) guards the whole contract.
Contract: make openapi regenerated + idempotent; imageUrl present on
  FirmMemberListItem schema in openapi/app-api.json.
33 firm routes + dtos + contract tests pass. ruff clean.
0 new mypy errors (image_url str|None matches User.image_url).
No app changes here. A separate pramaan-app follow-up renders <img> + initials fallback on the Add-Matter team step, members table, and pickers, consuming this contract.