Story-2918 (F2) gave firms the write surface to curate which participant labels a matter type offers — attach, create-and-attach, reorder, detach. This story makes that curation durable and makes the Settings catalogue lists legible. Two independent pieces:
MatterTypeParticipantLabelService.seed_default_mappings). An administrator then detaches
Civil Suit → Plaintiff because their practice never uses it. If the seed ever runs again —
a re-provision, a future backfill, the 0204-style catalogue expansion — the naive "insert what's missing"
logic sees the pair absent and re-adds it. The administrator's deliberate removal is silently
undone.
business.matter_type_participant_label_seed_markers (PK firm_id). The seed gates
the entire pass on it: if the firm carries the marker, the seed returns immediately — a true
no-op. The marker and the mappings are written in the same transaction, so a firm can never be
left marked-but-unseeded or seeded-but-unmarked.
Per-row tombstones (a "deleted this pair, don't re-add it" record for each removal) would also work, but they carry more state, need their own lifecycle, and must be consulted on every seed iteration. The per-firm marker is the simplest state that makes the invariant true: "seed a firm once, then hand curation off to the firm forever." After the first seed, the shipped defaults are the firm's to keep, edit, or remove — the seeder never touches the firm again. The choice is documented at the decision site (entity docstring + service docstring + migration).
Migration 0222 backfills a marker for every firm that exists at migration time. The
0204 backfill was those firms' authoritative first seed, so from this migration forward none of their
curated removals can be undone. New firms get their marker the first time
seed_default_mappings runs during provisioning.
| Scenario | Behaviour | Proof |
|---|---|---|
| Seed, admin removes a pair, seed re-runs | Removed pair stays gone — the marker makes the re-run a no-op. | test_reseed_never_readds_admin_removed_pair |
| Seed, admin adds a bespoke pair, seed re-runs | The curated addition survives; no second marker row is minted. | test_reseed_is_noop_and_leaves_marker_singular |
| Seed twice back-to-back (idempotency) | Second call is a no-op; the shipped default set is intact. | test_seed_is_idempotent_and_firm_scoped |
business.matter_type_participant_labels in the
support-config table set (test_support_app_session_reads_no_confidential_tables).
A single usageCount could not tell the Settings surface why a catalogue row is in
use, and therefore which removal affordance to show. This story exposes the two kinds separately on the
list responses.
| List | New field | Counts | Meaning |
|---|---|---|---|
GET /app/participant-labels |
recordedOnMatters |
proceeding_participant_labels.source_participant_label_id |
Archive-only. A proceeding has recorded this label; it is durable history and blocks deletion. |
attachedToMatterTypes |
matter_type_participant_labels.participant_label_id |
Detachable. Matter types offer it as a client-side option; the attachment can be removed (Story-2918 detach) and never blocks deletion. | |
GET /app/matter-types |
inUseMatterCount |
matters.matter_type_id |
Archive-only. Matters reference this type; it blocks deletion. |
attachedParticipantLabels |
matter_type_participant_labels.matter_type_id |
Detachable. Participant labels the type offers; detachable, never blocks deletion. |
The existing usageCount / isInUse / canDelete fields are unchanged:
they remain the delete-guard aggregate, which today equals the archive-only count by construction
(recordedOnMatters for labels, inUseMatterCount for types). The detachable
attachment count is never folded into usageCount — attaching a label to a
matter type must not make it undeletable.
Each new count is one grouped COUNT over its reference table, keyed by catalog id, via the
existing bulk_count_in_use_references helper — not one count per row. The list
endpoints therefore add exactly one fixed query, independent of how many rows the page holds.
attachedToMatterTypes: SELECT participant_label_id, count(*)
FROM business.matter_type_participant_labels
WHERE firm_id = :firm AND participant_label_id IN (:page_ids)
GROUP BY participant_label_id
| Guard | Proof |
|---|---|
| Participant-label list carries both counts with exact values; derived flags follow the archive-only count. | test_participant_label_list_splits_recorded_and_attached |
| Matter-type list carries both counts with exact values. | test_matter_type_list_splits_matters_and_attachments |
| Query count is row-count-independent (no N+1) — a 1-row page and a 5-row page issue the same number of queries. | test_matter_type_list_query_count_is_row_independent,
test_participant_label_list_query_count_is_row_independent (Story-1598) |
| OpenAPI contract enumerates the new required fields on both list items. | test_app_matter_types_openapi_exposes_catalog_contract,
test_app_participant_labels_openapi_exposes_catalog_contract |
Adding count fields to the list responses is a producer contract change:
openapi/app-api.json is regenerated and committed. New response fields only — no request or
route change, no breaking removal.
0222_matter_type_participant_label_seed_markers creates the table (firm-isolation + support
RLS, SELECT, INSERT grant to pramaan_app), backfills a marker for every existing
firm, and provides a clean downgrade. Round-trip proven: upgrade → downgrade base → upgrade →
alembic check reports no drift.
Closes pramaan-io/pramaan-spec#2922. Scenarios PLM-33, PLM-34, PLM-44.