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.
settings:write)| Route | Behaviour | Proof |
|---|---|---|
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 |
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 label | restore it to live and reuse — never errors |
| nothing | mint a new label (allocate reference, NFC name, audited) |
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).
lower(name), so two live rows that fold together under NFKC but differ byte-for-byte (an
ASCII name vs its fullwidth variant) both passed the in-memory scan and the index → twin. The
functional index lower(normalize(name, NFKC)) now enforces the exact dedup key at the DB.
Matter types carry the same gap today and are left as a filed follow-up (not fixed here).seed_default_participant_labels and
seed_default_mappings now resolve names through _dedup_key, so a fullwidth
existing "Claimant" is recognised as the ASCII default and not duplicated
(test_seed_defaults_recognize_a_fullwidth_existing_label)._attach_mapping takes a
SELECT … FOR UPDATE lock on the matter-type row before allocating max()+1, so
concurrent attaches get distinct positions
(test_concurrent_attach_orderings_stay_unique).UPDATE … SET ordering = CASE …; query count no
longer grows with N (test_reorder_issues_one_bounded_statement).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.
DELETE on
business.matter_type_participant_labels for the pramaan_app role.0220_matter_type_participant_labels_delete_grant (RLS policy + table GRANT), applied by the
standard Alembic migration path.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.count == 0 assertion fails on a DB missing the grant..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).alembic/versions/e9a1c3b5d7f0_0220_matter_type_participant_labels_delete_grant.py — DELETE grant + support policy.alembic/versions/f0b2d4a6c8e1_0221_participant_labels_nfkc_dedup_index.py — NFKC functional unique index (P1).src/domains/participant_label/service.py — NFKC _dedup_key, in-memory
_find_duplicate_name, NFC store, resolve_or_create_for_mapping, restore path.src/domains/matter_type_participant_label/service.py — attach / create-and-attach / reorder / detach.src/domains/matter_type_participant_label/dtos/… — attach / create-and-attach / reorder request DTOs.src/domains/matter_type/router.py — the four write routes (mirrors the existing read route's home).openapi/app-api.json, openapi/permission-map.json — regenerated contract snapshots.tests/integration/test_app_matter_type_participant_labels_write.py,
tests/unit/test_matter_type_participant_labels_delete_grant_migration.py — coverage.