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
| Scenario | Observed 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
- The two scenarios Story-2916 had asserted as 422 — a live firm label that is not yet a client-side option, and a new typed name in an empty catalogue — were observed failing (they returned 201 mint/attach against the old test) before those tests were rewritten to the new contract.
- The focused ASGI + PostgreSQL suite passes all ten create-side cases, including the editor (non-admin) picked-existing and minted-name paths, the idempotent no-duplicate case, the archived-name restore, and the cross-firm rejection.
-
test_create_matter_route_requires_only_matters_createreads the live permission map and assertsPOST /app/mattersneeds exactly["matters:create"]with nosettings:write. -
make openapiproduced no snapshot diff; the OpenAPI and permission-map contract tests pass unchanged. No Alembic migration ships — this is a code + test change with no schema delta.
Files
-
src/domains/matter/service.py— replaces_validate_client_side_participant_labelswith_attach_client_side_participant_labels(resolve-or-create by id/name, attach via F2's_attach_mapping, invalidate the bootstrap cache) and_resolve_live_firm_participant_label(the genuinely-invalid-id guard). The old reject-unknown validator and itspramaan.matter.participant_label_not_allowedcode are deleted. -
tests/integration/test_app_matters_create_side_participant_label_attach.py— renamed from the allowed-set file; full-route coverage for the editor caller, mint/restore/attach, idempotency, cross-firm rejection, advisory posture, and the route-gate proof. -
tests/factories.py,tests/integration/conftest.py— expose the seeded firm's editor (lead_clerk_user_id/lead_app_client()) so OQ-3 tests call as a non-admin matters:create user. -
docs/walkthroughs/story-3055-walkthrough.html— this acceptance walkthrough.