Story-2922: curation-safe seed + split where-used counts

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:

  1. Curation-safe seed (D5 / OQ-2) — a re-seed can never resurrect a pair an administrator deliberately removed.
  2. Split where-used counts (D7 / D8) — each catalogue list separates the usage that is archive-only from the usage that is detachable, so the app can offer the right affordance.

1. Curation-safe seed — a per-firm marker

The hazard. Provisioning seeds the shipped default mappings (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.
The fix. A per-firm marker row in 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.

Why a per-firm marker, not per-row tombstones?

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).

Existing firms

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.

ScenarioBehaviourProof
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
The marker table carries firm-isolation RLS (plus staff/support-read policies mirroring the sibling mapping catalog). It is provisioning config metadata — a "seeded: yes" flag — not confidential content, so it is classified alongside business.matter_type_participant_labels in the support-config table set (test_support_app_session_reads_no_confidential_tables).

2. Split where-used counts — archive-only vs detachable

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.

ListNew fieldCountsMeaning
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.

Bounded query shape (ADR-130 DR-1/DR-2)

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
GuardProof
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

Contract change

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.

Migration

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.