PR: #740: feat(add-matter): attach picked-or-minted participant label at Create [Story-2919] by @ayush-pramaan
View PR →

Story-2919 · pramaan-app · 28 Jul 2026

The list teaches itself

The Add-Matter drawer’s second question — Who do we represent? — already lets counsel pick a participant label or type a brand-new one (Story-2917). But a label chosen against a matter type that has never seen it was a one-time answer: the next matter of that type started from the same blank. This Story makes the choice stick. Picking or minting a label while creating a matter quietly curates it onto that matter type, at the end of its list — so the firm’s vocabulary builds itself from real work, and the next opener sees the label already offered.

The one idea

Attach at Create, never on click — and on the create permission, not the curation permission. Nothing is written when the user picks or types in the popover. The label is staged client-side and rides the ordinary POST /app/matters body; the backend resolves-or-creates it and attaches it to the matter type inside the create transaction (Story-3055). So a cancelled drawer leaves no label and no attachment behind (MMW-27), and because the write is a side-effect of matter creation (matters:write), any matter-creator — editor or admin — may attach and mint, with no settings:write (OQ-3).

Why “never on click” is the whole point

The tempting shortcut is to POST the new label the instant the user commits the inline mint, the way A1 shipped it. That is wrong twice over, and this Story fixes both:

The bug in on-click mintWhy it fails
Orphans on cancel. A label written the moment you type it survives when you close the drawer without creating anything — a firm-catalogue row nobody asked to keep. Matter types mint the same way and already defer to Create; participant labels now match.
Wrong permission. Every participant-label write endpoint — create, attach, create-and-attach — is settings:write, the curation editor’s gate. Driving the drawer through them would block an ordinary editor from creating a matter with a new label, violating OQ-3. The A1 on-click call used useAppCreateParticipantLabel — a settings:write write — so it was already a latent OQ-3 defect. It is removed here.

The trace

counsel picks "Cross-respondent" (new) in Q2 → drawer STAGES it: representingSide = { adversarial, participantLabel: { kind: "staged", name: "Cross-respondent" } } → NOTHING is written. Cancel now → nothing exists. ✓ → counsel presses Create → drawerMatterCreateRequest → party "us" carries participantLabelName only (no id) → POST /app/matters (matters:write — editor or admin) → backend (Story-3055) in ONE transaction: resolve_or_create_for_mapping("Cross-respondent") — NFKC dedup / restore-archived / mint _attach_mapping(matter_type, label, is_client_side_option=true, ordering=max+1) — end of list → next drawer opened on this matter type → "Cross-respondent" is offered, curated.

For an existing pick the party carries participantLabelId instead of a name, and the backend attaches by id. Either way the drawer sends one thing — the chosen label on the us party — and the server does the resolve + attach.

Two kinds of chosen label

Existing pick — carries an id
Who we represent Respondent · PL-002

Sends participantLabelId (+ name). Backend attaches that id to the matter type if it isn’t already offered.

Staged mint — name only, no write yet
Who we represent Cross-respondent

Sends participantLabelName only. Backend resolves-or-creates then attaches — all at Create, all under matters:write.

Duplication can’t sneak in (layer 2)

The mint row never manufactures a twin. Typing a name that exactly matches a live label selects that label instead of minting (no id churn). Typing a name that matches an archived label offers a Restore — the backend restores the archived row rather than creating a look-alike (layer 3, NFKC dedup). Archived rows are fetched only to power this affordance; they are never listed as pickable options.

New participant label
Intervener Restore

“Intervener” was archived — it will be restored, not duplicated.

What shipped (app side)

PieceWhat it does
add-matter-drawer-types.ts New DrawerParticipantLabelSelection — an existing pick (carries the catalogue id) or a staged mint (name only). Mirrors DrawerTypeSelection’s staged pattern. RepresentingSideValue now holds this selection.
representing-side-field.tsx Removed the optimistic on-click create + its POST, reconciliation refs, and failure toast (the A1 latent settings:write bug). Minting now just stages { kind: "staged", name } — synchronous, no write, no orphan.
use-participant-label-picker-data.ts Dropped useAppCreateParticipantLabel and the createLabel seam entirely — the drawer reads only. Catalogue now fetched with includeArchived: true so the mint row can offer restore.
participant-label-picker.tsx Trigger + highlight read the selection (staged shows its name, highlights no row). The mint row detects an archived-name match and switches its commit to Restore with an explanatory hint; live-name match still selects, never mints.
live-matters-wizard-payload.ts + add-matter-launcher.tsx participantLabelId is now optional on the adversarial input: a pick sends id + name, a staged mint sends name only. The launcher maps the selection kind; no client-side label create/rollback remains.
i18n — en + hi AddMatterDrawer.restoreParticipantLabel and participantLabelArchivedHint added in both locales.
Stories + tests New StagedMint (picker + field) and ArchivedRestore (picker) stories. Unit tests rewritten: staged-mint selects synchronously with no write, switching to Advisory drops it, an archived name offers Restore, and the payload sends name-only for a staged mint.

The backend half (Story-3055, pramaan-functions)

The attach itself is server work — the app cannot (and per OQ-3 must not) call a settings:write endpoint from the drawer. MatterService.create_matter (already matters:write) reuses the primitives Story-2918 built — ParticipantLabelService.resolve_or_create_for_mapping (NFKC dedup / archived-restore) and MatterTypeParticipantLabelService._attach_mapping (idempotent, max(ordering)+1 → end of the curated list, D2c) — as a side-effect of creation. The matter-create wire shape is unchanged, so no codegen churn: the app already sends the label on the us party.

Verification

pnpm lint — 0 errors.
pnpm typecheck — clean.
pnpm test — green (1637 tests), incl. the staged-mint, drop-on-Advisory, archived-restore, and payload-name-only tests.
pnpm build — succeeds.

End-to-end (system boundary). The full “next opener sees it curated” behaviour requires Story-3055 to be deployed to the target environment, not merely merged to functions main. Against a build without #3055 live, a staged mint of a genuinely new name is rejected by the create validator (unknown name) — so this app change lands paired with #3055 and the UAT below runs once #3055 is live.

UAT (once Story-3055 is live)

StepExpected
As an editor (no settings:write), open Add-Matter, pick an uncurated matter type, choose Adversarial, mint a new label, Create. ✓ Matter created; reopening the drawer on that matter type offers the new label, curated at the end.
Open Add-Matter, mint a label in Q2, then press Cancel. ✓ No label appears in the firm catalogue and no attachment is added.
In Q2’s mint row, type the exact name of an archived label. ✓ The commit reads Restore; creating restores that label rather than making a duplicate.

Files changed

13 file(s), +238 / -183 lines

Path Role + -
messages/en.jsonConfig+2-0
messages/hi.jsonConfig+2-0
src/components/add-matter/add-matter-drawer-types.tsComponent+18-3
src/components/add-matter/participant-label-picker.stories.tsxComponent+30-0
src/components/add-matter/participant-label-picker.tsxComponent+53-15
src/components/add-matter/representing-side-field.stories.tsxComponent+14-7
src/components/add-matter/representing-side-field.tsxComponent+14-52
src/components/add-matter/use-participant-label-picker-data.tsComponent+13-41
src/components/matters/add-matter-launcher.tsxComponent+10-2
src/components/matters/live-matters-wizard-payload.tsComponent+14-4
tests/unit/live-matters-wizard-payload.test.tsTest+22-0
tests/unit/representing-side-field.test.tsxTest+45-59
tests/unit/use-add-matter-drawer-form.test.tsxTest+1-0