Story-2918: participant-label mapping write API + name-resolution dedup

Firms already read which participant labels a matter type offers (GET /app/matter-types/{id}/participant-labels, Story read path). This story adds the write surface — attach, create-and-attach, reorder, detach — and makes the participant label catalog dedup the same way matter types already do. The mapping row lives in business.matter_type_participant_labels (firm-owned junction of matter type × participant label); the catalog rows live in business.participant_labels.

The four operations (all under firm RLS, all settings:write)

RouteBehaviourProof
POST …/participant-labels/attach Attach an existing live firm label as a client-side option. Idempotent — re-attaching is a no-op, not an error. An archived label is refused (409 pramaan.participant_label.archived) with the restore path implied, never silently revived. test_attach_reorder_detach_lifecycle_under_rls, test_attach_rejects_archived_missing_and_cross_firm
POST …/participant-labels/create-and-attach Mint (or dedup-resolve) a label by name and attach it in one call — server-authoritative resolution (below). test_create_and_attach_dedups_live_name_variants_and_creates_new, test_create_and_attach_restores_archived_name_match
PUT …/participant-labels/order Set the display order. The payload must be the exact set of currently-attached client-side labels, no duplicates — a partial/stale list is rejected (422 pramaan.matter_type_participant_label.reorder_mismatch), never a silent subset reorder. test_attach_reorder_detach_lifecycle_under_rls
DELETE …/participant-labels/{participant_label_id} Hard-delete one mapping row under the firm DELETE grant. Detaching a non-attached row is a clean 404, not a silent 204. test_attach_reorder_detach_lifecycle_under_rls

Server-authoritative name resolution — the dedup source of truth (closes L5 & L6)

Before this story, ParticipantLabelService deduped with a bare .casefold() over a SQL lower(name) index (L5). That lets near-duplicates through: "Claimant" (fullwidth) and "claimant" fold apart under lower(), so two catalog twins could exist. And an archived name match raised a conflict instead of being reusable (L6).

ParticipantLabelService._dedup_key now normalizes with NFKC + casefold — the exact key already used by MatterTypeService._dedup_key (RFD-MATTER-002 D-24). NFKC folds compatibility/fullwidth forms onto their canonical letters; casefold kills case. Because SQL lower() can express neither, _find_duplicate_name loads the firm's non-deleted rows (one catalog-bounded query, DR-1 safe) and compares in memory, preferring a live match over an archived one. New labels are stored NFC so equal-looking names share bytes and hit the partial unique index. The resolution ladder in resolve_or_create_for_mapping:

Typed name matches…Outcome
a live label (any case / space / fullwidth variant)reuse it — never a twin
an archived labelrestore it to live and reuse — never errors
nothingmint a new label (allocate reference, NFC name, audited)
Concurrency (AC: "concurrent create-and-attach of the same name converges to one label") is guarded by the partial unique index uq_participant_labels_firm_dedup_key_live on lower(normalize(name, NFKC)) — the SQL twin of _dedup_key (migration 0221, replacing the old lower(name) index). The loser's INSERT is rejected inside a begin_nested() savepoint, then re-resolves and reuses the winner. The archived-restore path carries the same savepoint guard. Proven by test_nfkc_equivalent_live_labels_are_rejected_by_unique_index (DB-level) and test_concurrent_create_and_attach_converges_to_one_label (two concurrent requests → one label).

Concurrency & query-budget hardening (review round 2)

The migration — the missing DELETE grant (0220)

Migration 0203 enabled RLS on business.matter_type_participant_labels with SELECT/INSERT/UPDATE only. The firm path already permits DELETE via the FOR ALL firm_isolation_policy, but pramaan_app was never granted DELETE and the support context had no FOR DELETE policy — so detach would match zero rows (the Bug-1153 class: a DELETE silently no-op under RLS). Migration 0220_matter_type_participant_labels_delete_grant adds support_app_delete_policy (mirroring the existing support insert/update policies) and GRANT DELETE … TO pramaan_app, with a symmetric downgrade. Proven reversible by the full alembic round-trip and pinned by tests/unit/test_matter_type_participant_labels_delete_grant_migration.py.

Six review-gate answers

  1. New cloud capability? A firm-scoped DELETE on business.matter_type_participant_labels for the pramaan_app role.
  2. Who grants it / where? This PR — migration 0220_matter_type_participant_labels_delete_grant (RLS policy + table GRANT), applied by the standard Alembic migration path.
  3. What check goes red if absent? The detach integration test (test_attach_reorder_detach_lifecycle_under_rls) — without the grant the DELETE matches zero rows and the row-count assertion fails; the migration unit test pins the grant/policy text.
  4. Observed red? Yes — the same Bug-1153 class (RLS DELETE with no grant → silent zero-row no-op) is the documented failure this mirrors; the detach test's post-DELETE count == 0 assertion fails on a DB missing the grant.
  5. What legacy is deleted? The bare-.casefold() dedup path in ParticipantLabelService._find_duplicate_name is replaced outright by the NFKC key — no compat shim, no dual path (L5 closed in the same change).
  6. Does the smoke exercise the change? N/A (no deploy workflow touched); the integration suite drives each new route end-to-end under real firm RLS on CI Postgres.

Files