PR: #597: feat(brief): relief, adversary's best shots, our exposure onto EditableList [Story-2360] by @ankitmaheshwari-com
View PR →

Story-2360: three more Brief lists become editable

Story-2355 built EditableList and gave the Brief's Key instruments rail an add/edit/remove affordance. Three lists on the same page were still walls: what the matter asks for (Relief sought), what the other side will hit us with (Adversary's best shots), and where we are weak (Our exposure). Vega wrote them; counsel could only read them. If the framer missed a head of relief, it stayed missed.

This Story is pure reuse. No new kind, no new editor, no new data path — the three lists are promoted onto the primitive that already exists.

The gate that had to pass first

A list is only worth making editable if the write path will actually accept it. The frame edit endpoint (PATCH …/frame) declares fields as a free-form object in OpenAPI — additionalProperties: true — so the contract's real allowlist is enforced server-side, and the OpenAPI mirror alone cannot answer the question. The truth lives in pramaan-functions:

src/domains/matter_frame/frame_contract.py

_META_FIELDS = frozenset({"frame_status", "missing_core_fields"})
EDITABLE_FRAME_FIELDS = frozenset(FrameMirror.model_fields) - _META_FIELDS

Every FrameOutput field except Vega's two honesty-gate meta fields is editable. And all three targets are present in frame_output.v1.schema.json, with exactly the shape key_instruments already proved:

FieldContract shapeEditable?
relief_soughtarray<string>Yes
adversary_best_shotarray<string>Yes
our_exposurearray<string>Yes
key_instruments (Story-2355, for reference)array<string>Yes
GATE: PASS — no pramaan-functions change needed. The edit path validates the merged frame against the same FrameOutput model (_validated_frame → FrameMirror.model_validate), so a string[] body for any of the three is accepted exactly as key_instruments is today.

One nuance worth naming, because it looks like a blocker and is not. frame_contract.py calls relief_sought a Category-1 canonical overlap (it overlaps matters.outcome_sought_text). That note governs the accept/ratify path — accepting a Vega proposal on relief_sought is lock-only, because the canonical column stays primary via its own editor. It does not restrict the edit path, which is what this Story uses. Edit and ratify are different doors; only the edit door is opened here.

What changed, per list

Each list keeps the section chrome it already had — the soft card and label for Relief, the card-per-shot for Adversary, the bordered amber-labelled box for Exposure — and swaps only its inner markup (a <ul>, or a hand-rolled div stack) for EditableList.

ListFrame fieldWasNow
Relief soughtrelief_sought <ul> of <li>, read-only EditableList, addLabel={t("addRelief")}
Adversary's best shotsadversary_best_shot hand-rolled bordered card per shot, read-only EditableList + rowClassName (the card is preserved), addLabel={t("addShot")}
Our exposureour_exposure list-disc <ul>, read-only EditableList, addLabel={t("addExposure")}

The one primitive change, and why it earns its place

The ticket budgets "zero new primitive code — or a justified minimal change." One prop was needed. EditableList owned its row layout but gave a caller no way to decorate a row, and Adversary's best shots renders each shot as a bordered card. Without a seam, promoting that list would have silently flattened its cards into bare lines — a design regression, and a violation of the ticket's "byte-identical rendering for untouched data".

/**
 * Chrome on each item ROW, committed and draft alike — for a list whose items
 * read as cards rather than bare lines (the Brief's adversary best shots).
 * Row LAYOUT stays the primitive's; this only decorates it.
 */
rowClassName?: string;

It applies to the draft "add" row too, so the row you are typing into looks like the row you are about to create. Layout, remove affordance, and commit semantics remain the primitive's — a caller can decorate a row, never restructure it.

Add-first: the empty state is the affordance

Every one of these blocks used to be wrapped in items.length > 0 ? … : null. A matter Vega had not yet framed — the first real matter of a new firm — therefore rendered no Relief block, no Adversary block, no Exposure block at all, and counsel had no way to type one. The read-only wall was worst exactly where the product is first judged. That conditional is now gone from all three: the block always renders, and its "Add …" button IS the empty state. Same law Key instruments adopted in Story-2355; same AGENTS.md doctrine that "fixtures lie in your favor" — the zero-row case is the one nobody looks at, and it is where the feature was broken.

The write path (unchanged — that is the point)

All three route through the saveList callback the page already had for key_instruments. Add, edit, and remove each hand back the whole next array, which is exactly the frame edit contract's full-value-per-field semantics:

const saveList = (field: string) => (next: string[]) =>
  edit.mutate({ matter_id: matterId, data: { fields: { [field]: next } } });

Optimism and rollback are inherited, not rebuilt: useAppEditMatterFrame's onMutate patches the cached Brief so the row lands instantly, onError restores the snapshot and toasts, onSettled reconciles with server truth. No contentFormat is declared — these are plain string lists, never Markdown, so the plain preset's getText() serialization is correct and the four free-prose fields stay the only Markdown-capable ones.

Provenance is inherited the same way: each row asks fieldProvenance(brief, "<field>"), so a canonical or locked value is not editable and carries no remove, and a Vega-proposed value keeps its dotted marker. An empty value resolves to no provenance — which is what keeps a fresh Brief writable rather than locking it as "canonical".

One small consistency fix rides along: adversary_best_shot and our_exposure now have entries in PROPOSAL_LABEL_KEY, so a pending Vega proposal on either shows its human label in the proposal strip instead of the raw field key.

i18n

Six new keys, in both locales (messages/en.json and messages/hi.json) — one add label and one placeholder per list: addRelief / reliefPlaceholder, addShot / shotPlaceholder, addExposure / exposurePlaceholder. The primitive never holds a display literal; the caller resolves every label.

What was NOT touched

Verification

Staging seam, named honestly. The gate was proved by reading the authoritative FrameOutput contract and the server-side edit validator in pramaan-functions — it is a contract proof, not a live round-trip. No live staging write of relief_sought / adversary_best_shot / our_exposure was performed from this branch. Staging UAT (add, edit, remove on each of the three lists, plus the empty first-run state) runs post-merge on stg.app.pramaan.io. Per AGENTS.md, a green build and a passing test are not deployed-runtime proof.

Files changed

5 file(s), +97 / -56 lines

Path Role + -
messages/en.jsonConfig+6-0
messages/hi.jsonConfig+6-0
src/components/matter-detail/brief/matter-brief-page.tsxComponent+7-2
src/components/matter-detail/brief/matter-brief-sections.tsxComponent+66-52
src/components/shared/editable-list.tsxComponent+12-2