Ctrl/Cmd+B — but those shortcuts
only fire on a block with a light or full preset, and all four Brief
blocks are on plain. The gun was loaded and nobody connected the trigger. This Story
connects it — and the reason that took an Epic, two repos, and a gate is the whole lesson.
The naive fix is one line: preset="light" on four call sites. Detonate it.
A preset does not decide which buttons appear. It decides what bytes get written to the database:
| Preset | Marks | What it SAVES |
|---|---|---|
plain | none | editor.getText() → plain text |
light | bold, italic, link | editor.getMarkdown() → Markdown |
full | + headings, lists, quote | editor.getMarkdown() → Markdown |
Flip the preset and the moment counsel bolds a phrase, defective pipes stops being
stored as defective pipes and starts being stored as **defective pipes**.
Every consumer that prints those bytes literally now leaks raw asterisks at a user. This is a
data-contract change wearing a UI toggle's clothes.
The original framing named three consumers. Two of them do not exist, one exists somewhere nobody expected, and the one that does break was never named at all.
| Claimed consumer | Reality |
|---|---|
| PDF/DOCX export | ❌ Does not exist — in any repo. document_render in
pramaan-functions is inbound: LibreOffice converting uploaded Office files to PDF for
viewing. Built here as a tested seam, because an exporter is planned. |
| Search / matching | ⚠️ Real, but unreachable from TypeScript. Search is Postgres FTS + a trigram
index + a byte-literal ILIKE fallback, over a blob that Python renders
server-side. No function in this repo can influence those bytes. It became the
gate: pramaan-spec#2274. |
| LLM / agent (Vega) | ⚠️ The app never sends frame text — the broker assembles current_frame
server-side. Markdown passes through untouched. No app work. |
| (unnamed) Vega proposal strip | ✅ The one real leak in this repo. vega-proposal-strip.tsx
rendered proposed values through a hardcoded <p>. Flip the preset without
fixing it and counsel sees **bold** in the strip while the Brief directly above
renders real bold — the same field, contradicting itself. |
search target" is wrong — it would be a function that
looks authoritative and affects nothing, which is the most dangerous kind of code. The
layer ships three targets, not four, and the strip that keeps search honest lives in Python.
BriefContent(value, format, target)
target=ui -> React nodes: <strong> / <em> / text (view mode, proposal strip)
target=export -> [{ text, bold, italic }] (a future PDF/DOCX emitter)
target=agent -> the Markdown string, unchanged (LLM consumers)
format=plain -> PASSTHROUGH on every target. Never parsed.
The obvious build is Markdown → HTML → sanitizer whitelist →
dangerouslySetInnerHTML. We emit React elements instead. A text node
holding <script>alert(1)</script> renders as those literal characters —
React escapes it — so injection is impossible by construction rather than by a
filter that has to be correct. The repo has no sanitizer dependency, and now needs none. The
whitelist is enforced by what the file can emit: <strong>,
<em>, text. Nothing else has a code path.
Legal prose is full of literal asterisks. A regex cannot tell a delimiter from a character that merely looks like one, because that distinction is contextual. This is not theoretical:
| A real citation | Regex \*(.+?)\* | CommonMark parser |
|---|---|---|
Breach at cl. 5 * and cl. 7 * |
Breach at cl. 5 and cl. 7both markers eaten |
Breach at cl. 5 * and cl. 7 * |
The regex pairs two unrelated footnote markers into a phantom emphasis span and deletes
both, silently eating the text between them. Nothing reports it. The parser refuses to open emphasis
on a * followed by whitespace (not left-flanking), so both survive.
Both repos parse with the same CommonMark implementation —
markdown-it here, markdown-it-py in pramaan-functions — so the screen
and the search index cannot disagree about which asterisk is a delimiter.
MatterBriefPage
|
|-- save(field) ------------------- declares contentFormat: {field: "markdown"}
| for the four prose fields [the WRITE path]
|
|-- BriefHeader
| `-- FrameSummaryHistory preset="light" <-- frame_summary
| `-- EditableFieldWithHistory
| |-- EditableSurface (edit) --.
| `-- SnapshotPreview (view) --+-- pre-hydration fallback
| | renders through
|-- EditableField preset = isMarkdownCapableBriefField(field) |
| | <-- core_dispute / theory_of_case / settlement_posture v
| `-- EditableFieldWithHistory ............................. MarkdownContent
| ^
`-- VegaProposalStrip |
`-- BriefContent(target=ui) --------------------------------- -'
[THE LEAK, now fixed]
One predicate drives two things. isMarkdownCapableBriefField(field)
picks the editor's preset and decides whether the write path declares
contentFormat: "markdown". They cannot drift apart: a field that saves Markdown
is exactly a field that says it does.
EditableSurface and SnapshotPreview render a raw-text fallback before
TipTap mounts (SSR + first paint). On a Markdown preset that fallback dumped **bold** on
screen for a frame before the editor swapped it. Both now render through
MarkdownContent, which is why the primitive lives in shared/ and the
Brief-facing vocabulary wraps it in brief/ — a feature module cannot be imported
from shared/ without inverting the layering.
| File | Change |
|---|---|
shared/markdown-content.tsx | New. The parser + React
renderer. Emits <strong>/<em>/text, nothing else. |
brief/brief-content.tsx | New. The Brief-facing layer: three targets, the format reader, the four-field vocabulary. |
brief/vega-proposal-strip.tsx | The leak. Renders
through BriefContent instead of a raw <p>. |
shared/editable-surface.tsx, shared/snapshot-preview.tsx |
Pre-hydration fallbacks no longer flash literal asterisks. |
brief/matter-brief-page.tsx | The write path declares
contentFormat alongside the bytes. |
brief/matter-brief-sections.tsx | The preset flip. The last and smallest step — and the only line that changes what is saved. |
src/gen/**, openapi/app-api.json | Codegen-sync of
contentFormat from the merged pramaan-functions contract (7 files, additions only). |
15 file(s), +854 / -9 lines
| Path | Role | + | - |
|---|---|---|---|
| package.json | Config | +2 | -0 |
| pnpm-lock.yaml | Config | +66 | -0 |
| src/components/matter-detail/brief/brief-content.test.tsx | Component | +144 | -0 |
| src/components/matter-detail/brief/brief-content.tsx | Component | +132 | -0 |
| src/components/matter-detail/brief/matter-brief-page.tsx | Component | +15 | -1 |
| src/components/matter-detail/brief/matter-brief-sections.tsx | Component | +17 | -1 |
| src/components/matter-detail/brief/presentation.test.ts | Component | +18 | -1 |
| src/components/matter-detail/brief/presentation.ts | Component | +23 | -1 |
| src/components/matter-detail/brief/vega-proposal-strip.stories.tsx | Component | +20 | -0 |
| src/components/matter-detail/brief/vega-proposal-strip.tsx | Component | +11 | -1 |
| src/components/shared/editable-surface.tsx | Component | +14 | -1 |
| src/components/shared/markdown-content.stories.tsx | Component | +110 | -0 |
| src/components/shared/markdown-content.test.tsx | Component | +135 | -0 |
| src/components/shared/markdown-content.tsx | Component | +129 | -0 |
| src/components/shared/snapshot-preview.tsx | Component | +18 | -3 |
Underline, headings, links, lists, tables. If any is requested later, the store moves to structured TipTap JSON — we do not extend the Markdown-in-field model. And search: that is pramaan-spec#2274, the gate, which merged first.