Story-3055

Matter-create mints and attaches the participant label at Create

RFD-MATTER-005 OQ-3. Any active firm member, editor, or admin creating a matter can attach or mint the "Who we represent" participant label from the Add-Matter drawer, at matter Create. The work rides the matters:create gate that guards POST /app/matters; it never touches a settings:write surface. This closes the OQ-3 gap and unblocks A2, and it removes the reason for A1's mint-on-cancel bug — once Create mints and attaches, the drawer can defer the mint to Create.

What changed in the create path

Story-2916 taught MatterService.create_matter to validate the client-side (us) participant label against an allowed set, and to reject an unknown one. Story-3055 replaces that validator with a resolve-or-create-then-attach step that reuses F2's (#2918) catalog primitives directly — never F2's Settings-only endpoints — inside the same matters:create transaction as the matter.

POST /app/matters   (representing_side = adversarial)
        │
        ▼
for the "us" party's client-side participant label:

  has participantLabelId ─→ _resolve_live_firm_participant_label
        │                     (firm-scoped, live; mirrors _write_parties)
        │                     cross-firm / deleted / archived id → 422 invalid
        │
  has participantLabelName → ParticipantLabelService.resolve_or_create_for_mapping
        │                     NFKC dedup → reuse live · restore archived · MINT new
        ▼
  MatterTypeParticipantLabelService._attach_mapping
        (idempotent · already-attached → no-op · max(ordering)+1 = lands at end
         · FOR UPDATE-serialized)
        ▼
  invalidate the firm's config bootstrap cache once

Advisory matters carry no "us" label and never enter this step.

Why an editor's write succeeds without settings:write

The permission gate and the database authority are two different axes. POST /app/matters gates on matters:create only — the committed permission map records appCreateMatter → ["matters:create"], and a test asserts the live map still says exactly that. The catalog tables the step writes — participant_labels and matter_type_participant_labels — enforce firm-scoped RLS with no role predicate (WITH CHECK (firm_id = app.current_firm_id_or_null())), and the app DB role already holds INSERT/UPDATE plus the SELECT … FOR UPDATE lock on matter_types, all shipped by F2. So the editor's write passes RLS and needs no new grant.

Acceptance behavior

ScenarioObserved result
Editor (matters:create, non-admin) picks an existing live label by id 201; the label becomes a client-side option on the matter type.
Editor types a brand-new name 201; the label is minted (live) and attached; the matter is created, not rejected.
Picked live label was attached as a non-client-side option 201; the single mapping row flips to client-side — never duplicated.
Picked label is already a client-side option 201; still exactly one mapping row (idempotent, no duplicate).
Empty catalogue + adversarial typed name 201; the name is minted and attached and the matter is created (was 422).
Typed name matches an archived label 201; the archived row is restored to live and reused — never twinned — and attached.
Picked label id belongs to another firm 422 pramaan.participant_label.invalid; no matter row.
Advisory create; empty-catalogue advisory create 201 without a participant label; no mapping written — unchanged.

Why the API contract stays unchanged

MatterCreateRequest already carries participantLabelId and participantLabelName on the us party, and the response DTOs are untouched. make openapi produces no snapshot diff and the committed permission map is unchanged, so no consumer needs new codegen. Existing matters are never rewritten (D6): the step runs only on the create transaction and only touches the firm catalog, never a prior matter.

Query shape (pramaan-docs ADR-130)

The step's cost is bounded by the number of us client-side parties (one in practice), not by any table's row count. The test_matter_create_query_count_is_party_independent guard still holds: a 1-party and a 5-party adversarial create carry the same single resolve-attach-invalidate overhead, because only the one us party is processed.

Red-first and verification evidence

Files