Date: 2026-08-03
Status: designed, awaiting implementation plan
Supersedes: the footer/participant/typography decisions in
docs/plans/2026-07-07-stage-6.3-export-plan.md and
docs/plans/2026-07-19-markdown-export-plan.md
The exported .docx/.md transcript reads as a machine dump rather than a usable
legal document:
- The footer carries
PRIVILEGED & CONFIDENTIALplus, on re-transcribed sessions, a model description. Neither is wanted; the transcript's own name is absent. - Participants render as
Sam (Local),Bob (Counsel, Remote). The capture side is an implementation detail and means nothing to a reader. - Word renders the document in Times New Roman. This was never chosen:
AddStylespins a font size but no face, and there is no theme part, so Word falls back. - Turns are back-to-back paragraphs with zero spacing.
- A turn can run for pages with the speaker named only at the top, so flipping to a page mid-turn leaves the reader with no attribution.
- Export is reachable only from the Sessions page. Both transcript surfaces (the Record console's live view and the read view) force a navigate-away.
All user-approved during brainstorming on 2026-08-03:
- Footer is exactly
{transcript name}+ page number. Version/model provenance moves up into the first-page metadata block, not deleted. - Participants drop the Local/Remote side tag. Role is kept when set.
- Arial replaces the Times New Roman fallback.
- Speaker continuity across pages is solved twice over: in-body
(cont'd)labels and a Word running head. The labels are the correctness mechanism; the running head is the convenience. - Export buttons are added to both the Record console and the read view.
- PDF export is out of scope permanently — Word exports to PDF natively.
In scope (this spec): everything about what an exported transcript looks like, plus the two new entry points and the live-export safety work they require.
Out of scope, deferred to Round 2 (export scope & dialog): .txt export,
matter-level combined transcript, excerpt export, remembering export choices, a
cadence-interval knob, filename templates, and the summary section (which needs
SummaryRef wiring that does not exist — SessionTextView.Summary is hardcoded
null at SessionProjectionLoader.cs:103).
Never in scope: hashing recorded-session audio at export time (would hash a large FLAC on every export); PDF rendering.
SessionTextView is documented as the neutral, app-independent metadata
projection behind session.txt. Version ids, model names, audio hashes and the
in-progress flag are export concerns and do not belong there.
Introduce a separate record, composed in MaintenanceService where footerText
composes today, so both renderers stay pure serializers:
/// <summary>Export-only provenance (design 2026-08-03). Deliberately NOT part of
/// SessionTextView: session.txt is the neutral projection and must not grow
/// export-specific fields.</summary>
public sealed record ExportProvenance
{
public string VersionId { get; init; } = TranscriptVersions.Root;
public string Model { get; init; } = "";
public string Backend { get; init; } = "";
/// <summary>Imported sessions only (ImportedSourceInfo); null for recorded.</summary>
public string? AudioFileName { get; init; }
public string? AudioSha256 { get; init; }
/// <summary>Session has no EndedAtUtc - exported mid-recording.</summary>
public bool InProgress { get; init; }
}DocxRenderer.Write and MarkdownRenderer.Write take ExportProvenance and lose
their string footerText parameter — the footer is now derived from meta.Title.
{meta.Title} at the left margin, Page N of M at the existing right tab on the
usable width. The NUMPAGES field pairs with the existing PAGE field, both with
the cached 1 placeholder Word replaces on pagination.
Settings.DocxFooterText is deleted. This is safe: LocalScribeJson.Options
does not set UnmappedMemberHandling, so the default Skip means existing
settings.json files carrying "docxFooterText" load unchanged and ignore it. No
schema bump, no migration. SettingsTests.cs:22 and :171-182 assert the field and
must be removed; MaintenanceServiceVersionsTests.cs:190-218 construct settings with
it and must be updated.
A new HeaderPart, referenced Type = Default:
{first matter · date} {STYLEREF speaker}
────────────────────────────────────────────────────────────────────────
Left: the first matter display (or the title when untagged) and the start date,
composed by us so it can be truncated — STYLEREF cannot truncate. Right: a
right-tabbed { STYLEREF "Transcript Speaker" } field. Bottom border on the header
paragraph.
Trap: the field argument is the style name (
"Transcript Speaker"), never thestyleId(TranscriptSpeaker). Word's field parser only ever resolvesw:name— the ID is an internal token it never exposes — so an ID argument resolves to nothing and every page from 2 on shows Error! No text of specified style in document. once Word paginates. This is the same name/ID split as"Transcript Turn"/TranscriptTurn.
The header is suppressed on page 1, where the metadata block already names
everything. That requires TitlePg() in SectionProperties plus a
HeaderReference { Type = First } pointing at an empty header part.
Trap: with
TitlePgon, page 1 also loses the footer unless aFooterReference { Type = First }is supplied. Point it at the same footer part id — page 1 must still showPage 1 of N.
Word's documented header behaviour is what makes this work: it searches the current page top-to-bottom for the style, and if not found on the page, searches from the top of the page backward to the start of the document. A page holding only continuation text therefore still resolves to whoever is speaking.
Trap:
TranscriptSpeakermust be a pure character style (Type = StyleValues.Character), never a linked paragraph+character style. Word will not see a linked style applied to only part of a paragraph, and the speaker name is exactly that — a run inside the turn paragraph.
The default (no \l switch) returns the first labelled speaker on the page, which
can differ from the speaker at the very top when a new speaker starts partway down.
The continuation labels in §7 reduce this to a near-non-issue by guaranteeing a label
near the top of nearly every page; it is not otherwise correctable and is the reason
the in-body labels are the primary mechanism rather than the running head.
- Arial in
DocDefaults→RunPropertiesBaseStyle→RunFonts { Ascii, HighAnsi, ComplexScript }, so headings, footer, header, markers and line numbers all inherit. Font size stays pinned at 11pt (FontSize { Val = "22" }) — the text column arithmetic inTextColumnTwipsdepends on it. - 6pt after each turn:
SpacingBetweenLines { After = "120" }on theTranscriptTurnstyle (twentieths of a point). - Widow/orphan control: explicit
WidowControl()onTranscriptTurn. Keeping ≥2 lines together is sufficient to stop a lone speaker label stranding at a page bottom;KeepLinesis deliberately not used, as it would push whole multi-page turns onto a new page. - Speaker names in caps via the
Capsrun property on theTranscriptSpeakercharacter style.
Trap: caps must come from the
Capsformat, never from uppercasing the string.STYLEREFreturns the underlying text, so uppercasing the data would destroy the real name in the document body to achieve a display effect. ApplyCapsto the header field's run as well so both places render caps while the text stays intact.
LineNumberType.CountBy changes from 5 to 1. Restart-per-page is unchanged.
Every-line numbering is what page:line citation (12:5) requires. The fixed
25-lines-per-page deposition grid is deliberately not adopted — it would force
exact line spacing and constrain typography hard for a convention the user has not
asked for.
The metadata block keeps its existing SuppressLineNumbers, so line 1 is still the
first line of transcript content.
Both formats gain four things. Order:
{Title} (heading)
App: Webex
Date: 2026-08-03 14:22 - 15:08 (46 min) ← end + duration are new
Matter(s): Smith v Jones (SJ-2024-01)
Participants: Sam, Bob (Counsel) ← side tag dropped
Medium: Call
Description: ...
Transcript version: v2 · large-v3-turbo · cuda ← new
Audio: recording.m4a ← new, imported sessions
Audio SHA-256: a1b2c3... ← new, imported sessions
Speakers heard: Alice Parker, Bob Jones ← new
IN-PROGRESS RECORDING — ... ← new, live exports only
{disclaimer, italic, thin rule under}
The metadata block is not caps-styled in either format. The Caps run property
from §4 applies only to speaker names in turn labels and the running head.
- Date line:
session.txtalready composesstart - end (N min)atSessionTextRenderer.cs:22-26. Extract that into a sharedMetadataFormat.DateLine(SessionTextView)helper called by all three renderers, so they cannot drift. This is the one refactor this spec takes on in existing code. - Transcript version: always rendered, originals included —
TranscriptVersions.ShortId("v1")returns"v1", so no special-casing. - Audio: rendered only when
ImportedSourceInfoexists (imported sessions), which already carriesFileNameand aSha256computed at copy time. Recorded sessions render nothing here; hashing their audio is out of scope. - Speakers heard: distinct
row.DisplayNameover non-marker rows, in first appearance order. Distinct from Participants, which is user-curated metadata. - In-progress banner: see §8.
The disclaimer stays non-optional and stays last, keeping its bottom rule.
SessionProjectionLoader.cs:91-92 becomes name-only, role-when-set:
var participants = meta.Participants.Select(p =>
string.IsNullOrEmpty(p.Role) ? p.Name : $"{p.Name} ({p.Role})").ToList();This is the shared projection, so session.txt changes identically. That is
intentional — one source of truth; a renderer-local mapping would let the two drift.
TimestampCadence.Chunk(DisplayRow row, int intervalMs) gains a second trigger:
public static IReadOnlyList<CadenceChunk> Chunk(DisplayRow row, int intervalMs, int maxChars)A new chunk starts at a segment boundary when either trigger fires:
maxChars— always on atContinuationMaxChars = 900. At 11pt Arial in the ~4.5" text column that is ~10-11 rendered lines, roughly a quarter page, so a label lands near the top of essentially every page. This is a correctness feature, not a preference, and is therefore not behind a checkbox.intervalMs— unchanged 15s cadence, still gated behind the existing "extra timestamps" checkbox and still forced off when timestamps are off.
Continuation labels now carry the name:
| timestamps | label |
|---|---|
| on | [04:30] Alice Parker (cont'd): |
| off | Alice Parker (cont'd): |
The label text is the stored name in both formats. .docx displays it in caps via
the TranscriptSpeaker character style; .md has no such mechanism and renders it
as stored.
Chunk 0 is unchanged. Breaks stay on segment boundaries, so a label never lands mid-sentence.
Trap: the existing contract that an unchunked row carries
row.Textverbatim (never theSegmentsre-join, becauseSectionGrouper's null-payload merge means they can differ) must be preserved. Rows withSegments.Count == 0— live rows and legacy fixtures — must still pass through whole regardless ofmaxChars. WithmaxCharsalways on, many more rows now split and take the re-join path; the existing guard is what keeps that safe.
TextColumnTwips measures the longest turn label to size the text column. It
currently skips continuation stamps because they are always narrower than the 1.5"
floor. Named continuation labels are not narrower, so the pre-pass must now measure
them too.
Applies: participants, all metadata additions, (cont'd) labels, the in-progress
banner.
Does not apply: everything in §3–§5, which is page furniture with no meaning in markdown.
The markdown footer block is dropped entirely. With the footer reduced to the
transcript name, and the name already the H1 at the top of the document, the trailing
--- + name block is pure repetition. MarkdownRenderer.Write loses its
footerText parameter along with the block.
Both reuse ExportDialogViewModel and ExportDialog unchanged. The openExport
factory at App.xaml.cs:715-721 is currently closed over sessionsVm.Rows for its
title lookup; generalise it to take (sessionId, title) so all three callers share
one factory.
-
Read view: an
Export...button in the existing toolbar besideAsk(ReadViewWindow.xaml:117). The session is always finalised here, so there is no special handling at all. -
Record console: an
Exportpill besideCompactin the Recording/Paused button row (LiveViewWindow.xaml:319-326). Gated onSession.CurrentSessionIdbeing non-null. Deliberately not added to the compact pill, which is a minimal always-on-top surface.The console has no cached title to pass —
SessionViewModelexposesCurrentSessionId(:95) but no title. The factory falls back to the session id for the default filename, matching whatopenExportalready does when a row has dropped out of the cached list. No async meta load is introduced on this path.
This is a prerequisite for the console button, not a nice-to-have.
TranscriptStore.ReadAllDetailedAsync reads via File.ReadAllLinesAsync, which
opens with FileShare.Read. That permits concurrent readers but excludes writers.
The live capture pipeline appends via File.AppendAllTextAsync, which needs write
access. While an export reads transcript.jsonl, a concurrent append can therefore
fail with IOException — losing an evidentiary transcript line.
Today this is latent because nothing reads a recording session. The console export
button makes it reachable, so the read path must open with FileShare.ReadWrite,
exactly as NeedsNewlinePrefix already does at TranscriptStore.cs:57:
using var fs = new FileStream(_path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var reader = new StreamReader(fs);Torn-tail tolerance already exists and needs no change — ReadAllDetailedAsync
catches JsonException per line and skips it.
Other live-session consequences, all handled by existing behaviour:
EndedAtUtcis null →endedLocalis null → the shared date-line helper already renders the start-only form.- Diarisation has not run, so speaker labels are the generic Local/Remote split.
DurationMsis not final.
Because the resulting document is materially weaker than the same session exported
after Stop, a live export is labelled. ExportProvenance.InProgress is set when
session.EndedAtUtc is null, and drives:
- a bold line in the metadata block, both formats:
IN-PROGRESS RECORDING — transcript incomplete, speaker separation not yet applied. - in
.docxonly, a second header line carrying the same text, so every page states it. Since the header is suppressed on page 1, the metadata-block line is what covers page 1.
Existing suites needing updates: DocxRendererTests (footer assertion at :57,
line-number CountBy, new styles), MarkdownRendererWriteTests (footer block
removal), SettingsTests (:22, :171-182 — DocxFooterText removal),
MaintenanceServiceVersionsTests (:190-218).
New coverage, one test per behaviour:
- Footer contains the title and both
PAGE/NUMPAGESfields; carries no privilege string and no model name for a re-transcribed version. - Page-1 header reference resolves to the empty part; page-1 footer reference resolves to the shared footer part.
TranscriptSpeakeris emitted withType = character.DocDefaultscarries Arial on all three script slots.TranscriptTurncarries 6pt after-spacing and widow control.LineNumberType.CountBy == 1.- Metadata block renders end+duration, version line, speakers-heard; renders the audio lines for an imported session and omits them for a recorded one.
- Participants render without side tags, with role preserved — asserted in the
loader, the two export renderers, and
session.txt. Chunksplits onmaxCharswithintervalMs = 0; splits on whichever trigger fires first when both are set; passes aSegments.Count == 0row through whole even withmaxCharsset; preservesrow.Textverbatim on an unchunked row.- Continuation labels carry the name in both timestamp modes.
TextColumnTwipsaccounts for named continuation labels.InProgressrenders the banner in both formats and the extra.docxheader line; a finalised session renders neither.TranscriptStoreread succeeds while a writer holds the file open for append (theFileShare.ReadWriteregression test).
.txt export · matter-level combined transcript · excerpt export (requires a
mandatory "EXCERPT — not the complete transcript" banner on every page, per the
locked no-content-deletion rule) · remember export choices · cadence-interval knob ·
filename template · summary section (needs SummaryRef wiring).