Story-2921: lifecycle guards — archive-only labels + cascade-on-type-delete

Story-2918 (F2) gave firms the curation surface (attach / create-and-attach / reorder / detach) and the DELETE grant on the mapping table; Story-2922 (D7/D8) split the where-used counts so the Settings lists can tell one kind of usage from another. This story adds the lifecycle guards that keep the two sides of that mapping consistent when a catalogue row is removed. The two rules are deliberately asymmetric (RFD-MATTER-005 D4a / D4b), and both defend one invariant: an offering is never history (D6).

1. D4a — a participant label is archive-only while attached

The hazard. A participant label attached to a matter type is offered in matter intake. If a firm could DELETE (soft-delete) it out from under the matter types that offer it, every mapping row would point at a availability='deleted' catalogue row — a dangling offering the picker can never resolve.
The fix. The mapping table joins the participant-label delete guard. A label is now in-use — DELETE refused, archive is the removal path — when EITHER family has a row:
Reference familyTableDetachable?Blocks delete?
recordedOnMatters proceeding_participant_labels.source_participant_label_id No — it is durable history Yes
attachedToMatterTypes matter_type_participant_labels (client-side options) Yes — detach from every type, then delete Yes (new in D4a)

usageCount is now the sum of the two so the derived flags stay can_delete == not is_in_use. The two counts stay split on the wire (Story-2922) so the app can still tell archive-only history from a detachable attachment. This reverses Story-2922's interim "attachments never block deletion" stance for the participant-label side — the split was always the seam D4a would fold across (see that story's "== recordedOnMatters today" note). The scalar guard (_in_use_references → _usage_count) is the union of both families; the list keeps one bounded grouped COUNT per family (ADR-130 DR-1/DR-2).

Archive-impact payload

Because archiving a label hides it from every matter type at once (the picker filters live labels), the app needs a confirm dialog that names the fallout. New endpoint:

GET /app/participant-labels/{id}/archive-impact  →  ParticipantLabelArchiveImpactResponse

It lists every matter type that offers the label as a client-side option, ordered by matter-type number, and flags each one where the label is the last live client-side option — archiving it there drops the type to zero options, so matter intake falls back to the whole firm catalogue. Two bounded reads (affected types, then a grouped live-option count), settings:write.

2. D4b — deleting a matter type cascades its attachments

The other side is the mirror image. A matter type owns its offering list, so deleting it removes the offerings — it never blocks on them. delete_matter_type issues a real firm-scoped row DELETE of every mapping row for the type (under the migration-0220 grant) before soft-deleting the type. The participant labels themselves survive in the firm catalogue; only the mapping rows go, so nothing is left dangling at a deleted type. Deletion still blocks only on real matters (matters.matter_type_id), never on attachments.

The asymmetry in one line. Attach a label to a type: on the label side that attachment blocks delete (archive-only, D4a); on the type side the same attachment is cascade- deleted when the type is deleted (D4b). Same row, opposite lifecycle — because a label is shared catalogue, a type owns its own offering list.

Orphan-sweep migration

The cascade stops new orphans; migration 0223 clears the historical backlog — mapping rows already dangling because a matter type or a participant label was soft-deleted before this story (L1/L2). One firm-scoped DELETE of every mapping row whose matter type OR participant label is availability='deleted'. Idempotent (deleted rows never return), and the downgrade is a deliberate no-op — an unreachable orphan carries nothing to reconstruct. Round-trip proven on a fresh DB: upgrade → downgrade base → upgrade → alembic check reports no drift.

3. D6 — offering is not history

Detach, archive, and delete all operate on the catalogue. None of them touches a proceeding_participant_labels copy (with its source_participant_label_id) that a matter already recorded. Those copies are immutable history: a matter created last month keeps the exact label it was created with, whatever the firm later does to the catalogue.

Proof

BehaviourTest
An attached label cannot be deleted (409, archive-only); archive works; detach-from-every-type frees it to be deleted. test_attached_participant_label_is_archive_only_until_detached
Archive-impact names the affected matter types (ordered) and flags the last-attachment fallback. test_archive_impact_names_types_and_flags_last_attachment
Deleting a matter type removes its attachment rows; the labels survive live in the catalogue. test_delete_matter_type_cascades_attachments_and_labels_survive
Detach / archive / matter-type-delete never rewrite a recorded proceeding copy. test_offering_changes_never_rewrite_a_recorded_matter_copy
Orphan sweep clears pre-existing dangling rows (soft-deleted type OR label) and is idempotent. test_orphan_sweep_clears_dangling_rows_and_is_idempotent, test_sweep_orphan_matter_type_participant_labels_migration
Story-2922 split count updated: an attached label's usageCount now equals recorded + attached. test_participant_label_list_splits_recorded_and_attached
OpenAPI enumerates the archive-impact endpoint and its response fields. test_app_participant_label_archive_impact_contract

Contract change

One new route (GET /app/participant-labels/{id}/archive-impact, settings:write) plus its response schemas. openapi/app-api.json and openapi/permission-map.json are regenerated and committed. No breaking removal.

Review gate. New cloud capability: none — this uses the mapping DELETE grant that F2's migration 0220 already landed (code + grant + check travelled together there). The named check for the cascade path is test_delete_matter_type_cascades_attachments_and_labels_survive; for the sweep, test_orphan_sweep_clears_dangling_rows_and_is_idempotent (observed red first: it planted two dangling rows and asserted the survivor set). Legacy deleted in this PR: Story-2922's "attachments never block deletion" stance on the participant-label side — its code path, comments, and AGENTS.md all move to the D4a truth here; nothing stays temporary. The archive smoke exercises the changed path.

Closes pramaan-io/pramaan-spec#2921. Scenarios PLM-23, 26, 27, 28, 29, 30, 45, 46.