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.
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:
| Field | Contract shape | Editable? |
|---|---|---|
relief_sought | array<string> | Yes |
adversary_best_shot | array<string> | Yes |
our_exposure | array<string> | Yes |
key_instruments (Story-2355, for reference) | array<string> | Yes |
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.
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.
| List | Frame field | Was | Now |
|---|---|---|---|
| Relief sought | relief_sought |
<ul> of <li>, read-only |
EditableList, addLabel={t("addRelief")} |
| Adversary's best shots | adversary_best_shot |
hand-rolled bordered card per shot, read-only | EditableList + rowClassName (the card is preserved),
addLabel={t("addShot")} |
| Our exposure | our_exposure |
list-disc <ul>, read-only |
EditableList, addLabel={t("addExposure")} |
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.
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.
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.
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.
EditableChoice, EditableSurface, the preset registry — untouched.pnpm codegen; no OpenAPI mirror change; no backend change.pnpm lint — 0 errors (3 pre-existing warnings, unrelated files).pnpm test — 189 files, 1237 tests, all passing.pnpm build — clean.git diff --check — clean.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.
5 file(s), +97 / -56 lines
| Path | Role | + | - |
|---|---|---|---|
| messages/en.json | Config | +6 | -0 |
| messages/hi.json | Config | +6 | -0 |
| src/components/matter-detail/brief/matter-brief-page.tsx | Component | +7 | -2 |
| src/components/matter-detail/brief/matter-brief-sections.tsx | Component | +66 | -52 |
| src/components/shared/editable-list.tsx | Component | +12 | -2 |