Story-3158: batch reconcile matter-type participant labels + list preview

Story-2918 shipped the participant-label curation write surface as four per-op endpoints — attach, create-and-attach, reorder, detach. Staging UAT (coordinator #3157, Epic-2915) found the editor UX awkward: reordering while adding meant several round trips, each a partial state, and the Settings list showed only a count with no hint of which labels a type offered. This story replaces the four writes with one atomic batch reconcile and adds a label-name preview to the matter-types list.

Scope. One new write endpoint, one new list field, and the deletion of the four per-op writes — in this PR (root AGENTS.md R1). The mapping row lives in business.matter_type_participant_labels (firm-owned junction of matter type × participant label); catalog rows live in business.participant_labels. No schema change — the preview is DTO-only.

1. Batch reconcile

PUT /app/matter-types/{matter_type_id}/participant-labels (settings:write) takes an ordered desired set and reconciles the matter type's current client-side options to exactly that set, in that order, in one transaction:

PUT /app/matter-types/{id}/participant-labels
{ "participantLabels": [
    { "participantLabelId": "…" },   // an existing LIVE firm label, by id
    { "name": "Claimant" },          // resolve-or-create by name
    { "name": "Witness" }            // an archived name here RESTORES the label
] }
Reconcile stepBehaviour
Attach missing Any desired label not yet a client-side option is attached. A stale non-client-side mapping row is flipped back to client-side.
Resolve names A { name } item is resolved server-side (NFKC + casefold dedup, F2 _dedup_key): a live name is reused, an archived name is restored, a genuinely new name is minted. One resolve per DISTINCT name.
Detach absent Any current client-side option NOT in the set is detached — a real mapping-row hard delete under the firm DELETE grant (RFD-MATTER-005 D4b). An empty set detaches every option.
Persist order The set's list position becomes the row ordering, so the picker renders in the editor's chosen order.

The response is the full reconciled, ordered MatterTypeParticipantLabelListResponse. Duplicate ids and NFKC-equivalent names within the payload collapse to one attachment (first position wins), so a re-PUT of the same set is idempotent.

ValidationResult
Unknown / cross-firm / deleted / archived participantLabelId 422 pramaan.matter_type_participant_label.invalid (an archived label is restored only via the name path, never a raw id)
Matter type not live404 pramaan.matter_type.not_found
Item with both / neither participantLabelId and name 422 request-shape error (Pydantic)
R12 — no silent seam. The whole reconcile runs inside a registry-approved manual_span (functions.matter_type_participant_label.reconcile). Its outcome opens pessimistically as failed and is overwritten only after the write commits with the terminal result — reconciled, unchanged, or emptied. The "no change" and empty-set branches therefore never return silently: a validation raise or mid-write exception exports failed, so a trace that ends early can never masquerade as a success. Proven red-first in test_reconcile_span_names_the_outcome.

2. List-item label preview (Bug 2)

MatterTypeListItem gains attachedParticipantLabelPreview — the first three client-side options in display order ({ participantLabelId, name, participantLabelNumber, participantLabelNumberFormatted }) — alongside the existing attachedParticipantLabels count. The Settings list now shows what a type offers without a second read. It is DTO-only: no migration.

ADR-130 query budget. The preview is populated by ONE page-wide windowed query: a row_number() partitioned by matter-type id, ordered by ordering then participant_label_number, capped to the first N in SQL — never one read per row or per label. Both the reconcile write and the preview read are O(1) in the attached/desired label count; db_query_count assertions in test_reconcile_query_count_is_label_count_independent and test_preview_query_count_is_row_independent pin it.

3. Retiring the per-op writes (R1)

Deleted in THIS PR — endpoints, service methods, request DTOs, dead helpers, and the audit-coverage entries: POST …/attach, POST …/create-and-attach, PUT …/order, DELETE …/{participant_label_id}; service attach_label, create_and_attach_label, reorder_labels, detach_label (plus _attach_mapping, _get_mapping, _get_attachable_label). No compat shim, no deprecation window.

The matter-create path (#3055) is unaffected — it mints and attaches its "who we represent" label through _attach_client_side_options + resolve_or_create_for_mapping, never the retired editor endpoints, and those primitives stay. The read path (GET …/participant-labels, list_client_side_labels) stays: the editor still reads the current attachments when it opens.

Verification