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).
availability='deleted' catalogue row — a dangling offering the
picker can never resolve.
| Reference family | Table | Detachable? | 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).
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.
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 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.
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.
| Behaviour | Test |
|---|---|
| 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 |
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.
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.