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.
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.
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 step | Behaviour |
|---|---|
| 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.
| Validation | Result |
|---|---|
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 live | 404 pramaan.matter_type.not_found |
Item with both / neither participantLabelId and name |
422 request-shape error (Pydantic) |
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.
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.
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.
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.
make openapi + gen-permissions committed (four paths removed, one PUT
added; reconcile + preview DTOs added).ruff / pytest green; reconcile atomicity (add / remove / mint /
restore / reorder), empty-set-detaches-all, idempotent re-PUT (spanned outcome),
NFKC-dup collapse, invalid-id 422, RLS isolation, and both O(1) query-count guards.UNAUDITED_ROUTES (intake config; a minted/restored label is audited under the label
subject by ParticipantLabelService).