Bristlenose · person identity · report

Moderator identity: what we have, what breaks, what we need

A code-truth report on the one-name-per-code limit as it meets moderators and observers: every prior investigation and decision indexed, the as-built mechanism and schema mapped, and the consequences for the researcher drawn as frames so the failure modes can be designed out rather than argued about.

Read against commit 1446e4d (1 Oct 2026). "Shipped" frames cite the file that draws them. "Measured" claims were observed by someone running code (the transcripts plan, 30 Sep); "read" claims come from reading the code at HEAD without executing it.

Palette

In five sentences

  1. The mechanism is settled and the symptom is live. A speaker code carries exactly one name everywhere in a study (docs/design-people.md §0), participant codes are numbered study-wide, and moderator and observer codes restart at m1/o1 in every session (s05b_identify_speakers.py:451). Two moderators in one study therefore share one slot, and one name.
  2. The decision is taken, not built. Decision 1 (24 Aug 2026): identity lives above codes; the escape is naming, and naming session 9's moderator "Mike" renumbers him to m2. Decision 2 (25 Aug): moderators and observers are named in exports, participants are not. None of the UX that carries either decision exists in the tree, and the register that says the People lens "ships" is wrong.
  3. The researcher's one available correction makes it worse. The pencil on m1 opens an editor in every session at once (measured broken in Chromium and WebKit), a commit renames every session, the server writes one Person row of twelve, and the next run or reload reverts or spreads the edit by whichever row the database returns first.
  4. It travels. Moderator names are preserved in the HTML export by design, so the wrong colleague is credited with running a session in the one deliverable the recipient cannot check, and the MCP overview answers an agent with the same name for every session.
  5. Decided 1 Oct 2026, route C (Part 5b): unknown is stored as unknown. A session's moderator is null until a platform label proposes an identity or a person picks one; identities are minted on that evidence and numbered as they appear; m? renders the gap; proposed badges use the shipped AutoCode treatment in the app and read plain in the export; nothing asks the researcher to do anything. Names and identities move to the database; people.yaml is read once and retired as a store.
  6. The cloud-import transcripts plan is right to defer naming moderators from platform labels, and its study-wide-code idea (Q4) is the cheapest route to a fix, provided the key is the platform's participant id, not a display string, which decision 1's "never auto-merge on name equality" would otherwise forbid.
ArtefactProduct pixels in a screenshot frame. Shipped frames are the app at 1446e4d, drawn from the cited source; proposed frames draw the settled design and say so in their chrome.
Invisible stateWhat the files and tables hold at that moment: people.yaml, the persons and session_speakers rows, the request on the wire. Red is the wrong value.
CommentaryHatched and ruled. Cause, consequence, who sees it, whether it leaves the machine. Never the product.
Part 1 Where the finding came from

A review finding on the cloud-import transcripts plan

The sentence under review is a finding against docs/design-cloud-import-transcripts.md (status: proposal; the HEAD commit of this branch). Its own §0b records the same fact from the other side, and §3 item 1d acts on it:

"1d names participants only in the first slice. people.yaml and the SPA name editor both key on the speaker code, and every session's moderator is m1. So writing moderator names would make the last session processed win, and the SPA editor for m1 is itself broken: it opens in every session with an m1, the editors steal focus, and the edit collapses (SessionsTable.tsx:443, measured in Chromium and WebKit). Moderator names wait for study-wide moderator identity."

design-cloud-import-transcripts.md §0b, "Names", 1 Oct 2026

The literal wording of the finding ("the rule that every named speaker gets a name will hit that one-name-per-code limit for moderators…") is not in the tree. It belongs to the review log of the 30 Sep eight-agent pass, which the plan cites as the maintainer's private review notes and which this clone does not hold. The plan's "every named speaker" became "every named participant" in 1d as a result of it; that narrowing is the finding, applied.

The plan is inconsistent about the deferred step it points at. §0 "Deliberately later" calls it 1c, study-wide moderator codes by platform participant id, not display name; §3 files the same idea under 1d as "platform-named moderators get study-wide codes by display name: Martin is m1 in every session, Mike m2", and §8 Q4 asks the owner which. The difference between those two keys is the whole of challenge 5 below.

Part 2 Prior investigations, plans, specs and decisions, indexed

Everything that has already been said about this

The governing document is docs/design-people.md (27 Aug 2026, status pending). It deliberately does not restate the collision; it points at the roadmap's §11c for the mechanics and settles what to do about it. This report follows the same rule: it indexes, it measures, and it does not re-argue a settled decision.

A · Design docs and specs

DocumentWhat it says about moderators, codes and namesDecided or deferred
design-people.md§0: a code has one name everywhere in the study; p codes are study-wide, m/o restart per session and that is right for the one-moderator study (~95%). Scale table: the extra person in a session is an observer in ~95% of cases; the m1 collision is a tail case. §A J10: "separate one code that is two people" is the one defect that travels into an export. §B4: the bank (moderators and observers only) is blocked on an instance database. §B9: the grouped, creatable picker. §B10: compound undo (rename + renumber as one act). §C4: no FK from quotes or segments to persons, which is why one-name-per-code is free and why the collision is representable. §C5: the name machine, with cleared sticky. §D: naming is step 1, "what naming implies" (the renumber) is step 4. §J: role is a recode; persons.uuid; narrowed PUT payload; the downgrade hazard of any new people.yaml block.Decision 1 settled 24 Aug 2026: identity above codes; rename in a session renumbers (m1 in s9 → m2 Mike); the (session, code) rekey of people.yaml rejected because it would let one code carry two names. Never auto-merge on name equality. Decision 2 settled 25 Aug: the participant line governs anonymisation; moderators and observers are named, instance-wide and singular. Open: J4 render-time derivation of codes; H6's two picker specs contradict on one control; the French moderator word; the job-title label.
design-transcript-speaker-editing-roadmap.md §11§11c owns the collision mechanics: last session's m1 wins; the static sessions table shows no moderator on earlier sessions; merge_people() keeps a typed name against stats that are now another session's (mislabelling, not loss); the WARNING and its pinned test; the eight readers a rekey would have touched. 11a/11b (person_links, merge UI) demoted to a back-fill.Superseded note, 25 Aug 2026: the rekey "was considered and rejected"; the collision is "the name model working correctly on a code that names two people"; the work moves to the renumber-and-remap path.
design-speaker-editing.mdFour transcript operations; naming is "a pick, not a keystroke" (trued 25 Aug); the quote-attribution recommendation superseded by §B9; open: pipeline re-runs overwriting corrections.Operation 1 corrected to bank-not-free-text. Open questions at lines 216–226.
design-speaker-role-detection.mdWhy role detection failed on non-UXR formats (FOSSDA: "Moderated by Zach" with interviewees coded m1); options B and C shipped (word-count asymmetry, generalised prompt). Keeps the enum value researcher.Shipped Apr 2026. Renaming the enum "worth doing" but not done; the [RESEARCHER] round-trip pins it.
design-html-report.md § People file, § Moderator identificationThe people file; auto name/role extraction (LLM beats label; human edits never overwritten); "Phase 1: per-session only, no cross-session moderator linking"; "Phase 2 (not yet implemented): cross-session moderator linking (same_as field)".Phase 2 is stale: superseded by decision 1 but not marked so. Candidate for a truing pass.
design-multi-project.md §2The person identity model across projects: person_links, the UUID requirement, folder scoping, transitivity; "a freelance moderator runs 3 studies: m1 in each, Person rows separate"; principle 4, "I'm m1" is opt-in; §7 "profile → moderator connection: UX research needed".Status note (H0): suggestion-driven linking demoted to the back-fill; the bank prevents the duplicates it would reconcile.
design-cloud-import.mdLine 1135/1151: "'you' is the organiser, not necessarily the moderator… must not auto-assign the signed-in user as moderator"; roster names are provisional; §8 whether to trust platform transcripts, open.Decided: the signed-in account is never auto-made m1.
design-cloud-import-transcripts.md§0b Names (quoted above); §2 measured: "all sessions' m1 share one name, sometimes the wrong person's; a platform name loses to the LLM's guess ('Pri')"; §3 1d; §0a reuse map row "Moderator codes: deferred"; §8 Q4.Deferred out of the first slice; Q4 open to the owner; the §0/§3 key disagreement unresolved.
design-platform-transcripts.md3c "preserve platform speaker names" and 5c, both unchecked.Open.
design-export-html.md · design-export-sharing.md · SECURITY.mdThe anonymisation boundary: p* blanked, m*/o* kept; SECURITY.md:192 corrects "stripped by default" (it is not); line 194 states the participant line.Decision 2 encoded; the hint string was corrected (en reads "Moderators and observers keep their names").
glossary.md · design-decisions.mdRows for moderator / participant / observer / speaker / turn (H1); researcher stays the stored value. design-decisions.md:382–384 still says exports strip names by default and uses the retired "research team" rationale.Three stale claims in design-decisions.md; truing owed.
Mockupsperson-actions-everywhere.html (bench 9: "Mike is now m2 in session 9. Martin stays m1 in sessions 1–8", with Undo); people-lens-scopes.html (bench 2 recommended the (session, code) rekey, now superseded; H7 judged the file UPDATE, edits pending); people-provenance-paths.html (origin marks "From the transcript / Heard in the session / You set this"; bench 8: a confirmed name must be a recorded state).STATUS.md is wrong for two of them: it lists person-actions-everywhere.html as "IMPLEMENTED — person actions ship across surfaces" and people-lens-scopes.html as "IMPLEMENTED — the people lens ships". The router has no People route and frontend/src has zero onContextMenu handlers. Gap G17.

B · Plans, TODO and tracker items

WhereWhat it recordsState
TODO.md:265–269 (22 Aug 2026)The two-session repro; the stages CLAUDE.md line that said the opposite corrected; "Not re-keyed, deliberately"; the aggregated WARNING added; "a log line is the weakest available surface for 'your report is missing a moderator'".Open design call: which MessageKind (design-pipeline-diagnostic-popover.md) the collision earns.
TODO.md:583 (27 Aug 2026)Two more surfaces key on the bare code and were unrecorded: grounding.py resolves names first-wins (the MCP and chat-lens answer carries one moderator's name for every session), and the DOM test ids bn-name-m1 collide. "Three surfaces, three policies for one datum." Sequencing inverts: no failing test for the header or provenance chips can be written until the renumber exists, because people.yaml cannot hold two m1 names; the sessions.py header dedup is blocked, not deferred.Open; sequencing settled (renumber first).
README.md:313 · docs/ROADMAP.md:38"Cross-session moderator linking … (currently each session tracks moderators independently)" / "Cross-session speaker linking".Stale framing (linking is the back-fill, naming is the mechanism).
GitHub #25, #26#25 "Moderator identification Phase 2: cross-session linking" (opened 4 Feb 2026) asked for same_as, auto-linking signals and a linking UI.Closed not planned 27 Sep 2026: "Superseded by docs/design-people.md". #26 closed the same day.
Pinned teststests/test_people.py::test_multi_session_moderator_codes_collide_and_are_warned pins "the limitation, not a desired end state" and requires the loss be announced. test_moderator_identification.py::test_two_researchers pins m1+m2 within one session. SessionsTable.test.tsx "m1 appears in both sessions" renders two pencils for one person. SessionsSidebar.test.tsx's MULTI_MODERATOR fixture uses m1 in s1 and m2 in s2, a shape the pipeline cannot produce across sessions.The transcripts plan says the collision test "stays as is". Re-home, never delete, when the renumber lands (CLAUDE.md: a test pinning a contract outlives the default it is phrased around).

C · What shipped, and when

DateCommit / releaseChange
31 Jan 202683746ae9 · 0.4.1The people file and display names.
1 Feb 20260.6.2Names editable; auto name/role extraction. "LLM results take priority over metadata, human edits never overwritten."
2–3 Feb 2026202136fc · 0.6.8Moderator identification Phase 1 with per-session codes. The counters that restart date from here.
11 Feb 2026f2c42b51 · 0.9.1Moderator and observer names in project stats ("Moderated by …").
14 Feb 20269dd2f42fPerson and SessionSpeaker schema; the docstring says the join "makes cross-session moderator linking possible". Nothing has ever written a shared person_id.
23 Feb 202645380392Names architecture: YAML canonical, DB materialised, write-through.
28 Feb 202602767c2aInline name editing in the sessions table, keyed by code.
16 Apr 20263bb355ed · bec69b96Speaker detection rewrite and the four speaker design docs.
17 Jul 2026ae522367Moderator detection fix: the stored role is never "moderator"; surfaces key on the m prefix.
31 Jul 20266a84dca6 · 0.23.0MCP Anonymise switch; grounding.resolve_speaker_names (first-wins by code).
19–20 Aug 2026773707fb · 57777276The first §11c text and the provenance mockup; "a moderator lost to a code collision now says so" (the WARNING). The commit body: "This does not fix that."
22 Aug 2026c106f8a9 · 0.27.0"Not re-keyed by (session_id, code)"; Re-analyse counts renamed speakers (desktop).
27 Aug 202652146963 · c188db76 · deb62808 · 5a43c829design-people.md lands; glossary vocabulary; [OBSERVER] in the extraction prompt and job titles blanked under anonymise; TODO.md:583.
31 Aug 20260.29.0Exports not anonymised by default; speaker ID runs before PII redaction.
21–22 Sep 20265bcd9c9b · 12c7c9b4design-people's "nothing here is built" corrected (the file and endpoints do ship); the i18n wiring plan archived, unblocking the dead speaker-role keys.
27 Sep 2026#25 closedPhase 2 linking closed not-planned, superseded by design-people.
30 Sep–1 Oct 20263a302803 · 1446e4dThe whole-transcript splitting experiment; the cloud-import transcripts plan with the finding this report answers.

D · The prompts

E · Open questions explicitly recorded somewhere

Part 3 What we have now: the mechanism, the schema, and who wins

How a moderator's name gets to the screen

Seven hops, every one of them keyed by the bare speaker code. Read left to right: the pipeline mints codes, the people file keeps one entry per code, the serve importer fans that one entry out into one Person row per session, and the routes fold it back by code in three different ways.

s05b · assign codes mod_counter = 0 per session m1 m1 m1 … m1 s05b · LLM, first 5 min person_name per session s12 → "Martin", s9 → "Mike" pipeline.py:2152 pid_speaker_info[code] = info last session iterated wins people.yaml participants: dict[code] one entry for m1, 12 sessions people.py:244 merge_people: editable kept by key, computed replaced importer.py:870 new Person per (session, code) name = people[code] persons × 12 all seeded "Martin Storey" session_speakers (sid, m1) GET /people dict by code, no ORDER BY last row returned wins PUT /people .first() for the code one row of twelve written /sessions /dashboard /transcripts /quotes per (session, code) row grounding.py:449 names.setdefault(code, name) first row wins (MCP, chat) SessionsTable.tsx:439 people[code].short_name before the per-session name
Commentary — the three stores and their keys
people.yaml
PeopleFile.participants: dict[str, PersonEntry] keyed by code (models.py:476). compute_participant_stats overwrites stats[code] per session and logs one WARNING per run naming the sessions (people.py:194–225). merge_people keeps the editable block by key, so a typed name outlives the stats it was typed against.
bristlenose.db
session_speakers is unique on (session_id, speaker_code) and is the only foreign key to persons (server/models.py:286–311). The importer creates a new Person per (session, code) and seeds its name from the flat people[code] (importer.py:870–908). The join that "makes cross-session moderator linking possible" has never been written to by anything.
transcripts-raw/*.txt
Every line carries its code in brackets, [00:16] [m1] (Speaker B) …; the importer re-derives role from the prefix (importer.py:870–877). A recode that is not render-time has to rewrite these.
Not stored anywhere
The original speaker labels, the label→code map, name origin, "this guess was checked", and a cleared state. Absence is the only signal, and the pipeline refills absence (people.py:373–400).

The schema, as it stands

persons — instance-scoped, no project_id id · full_name · short_name · role_title persona · notes · created_at no unique constraint; "two Jim Smith rows are fine" session_speakers session_id FK · person_id FK speaker_code "m1" · speaker_role "researcher" words_spoken · pct_* (never written) UNIQUE(session_id, speaker_code) person_id — the only FK to persons sessions session_id "s9" · session_number UNIQUE(project_id, session_id) transcript_segments speaker_code "m1" (plain String(20)) no FK — by convention quotes participant_id "p4" · session_id "s9" (strings) stable key (project, session, participant_id, start)
Commentary — why the schema can hold the collision, and why it cannot represent the fix yet

Nothing in the schema says a code names one person: the join key from a quote or a segment to a person is a string resolved by convention, so (s1, m1) and (s9, m1) are two session_speakers rows pointing at two persons rows that both read "Martin Storey". That is design-people §C4's point: the denormalisation is why renaming by code is free, and why the schema never objects to one code carrying two humans. The rows that could carry the fix exist (session_speakers.person_id could point two sessions at one person; a second moderator could be m2) and nothing writes them. There is no role endpoint, no rename-with-renumber endpoint, and no create-a-person endpoint (routes/data.py exposes GET/PUT /people only).

Which name wins when one code has several

SurfaceKeyRule at 1446e4dWhere
people.yamlcodeOne entry; computed stats from the last session in order; a typed name stays with the key whatever the stats now describepeople.py:194–225, :244–275
Auto-fill from the LLMcodeThe last session iterated supplies m1's guess, even when that session's guess is empty; observers are never filled at allpipeline.py:2152–2162, people.py:373–400
GET /peoplecodeIterates every speaker row with no ORDER BY; the last row returned winsroutes/data.py:307–332
PUT /peoplecode.first() speaker row for the code; writes one Person of N; then YAML write-through by coderoutes/data.py:335–374, :223–299
/sessions, /dashboard, /transcripts, /quotes, clips(session, code)The per-session Person row, which the importer seeded from the one YAML entry, so they agree with each other and disagree with nothing until a PUT splits themroutes/sessions.py:139–150, routes/dashboard.py:613–622
"Moderated by …" headernameDe-duplicated by name, so two moderators with one name read as oneroutes/sessions.py:152–158
Sessions sidebarname"Multiple moderators" means more than one distinct name; shows only moderators[0]'s codeSessionsSidebar.tsx:28–37, :302–304
React sessions tablecodePrefers /people's short name over the per-session name; the editor state is keyed by codeSessionsTable.tsx:131, :439–443
MCP overview, chat lenscodenames.setdefault: the first non-empty name wins; {} for everyone when Anonymise is ongrounding.py:418–450
CSV / XLSX(session, code)full_name only; participants only (no moderator rows exist in a quotes export)export_core.py:311–330
Static report (sealed)codeRows grouped by computed.session_id, so the moderator appears under the last session only; dropped from rows entirely when there is exactly one moderator codes12_render/dashboard.py:61–101
Three policies for one datum. YAML is last-session-wins, GET /people is last-row-wins, PUT /people is first-row-wins, the MCP is first-row-wins, and the per-session routes are correct by construction and seeded wrong. None of them is a bug in isolation; together they mean the researcher cannot predict what a rename will do, which is the property frames F3–F5 draw.
Part 4 Failure modes, illustrated — the pinned cast: ward handover app, round 2, 12 sessions, Jan 2026. Martin Storey (you) moderated every session except 9, which Mike Alvarez covered. Jane Smith (client-side, Meridian Health) observed s1 and s3; Tom Blake observed s3.

What the researcher sees, and what the files hold

F1Twelve sessions, one moderator slot

The Sessions lens the morning after the run. Every row carries m1, and every m1 carries the one name the slot can hold. Session 9 was Mike's.

Sessions lens · serve mode · 1446e4dshipped · read from code

Sessions

Moderated by Martin Storey
#
Speakers
Start
Duration
m1Martin Storeyp1Sarah Cheno1Observer
6 Jan 2026
48:02
m1Martin Storeyp2Amara Nwosu
6 Jan 2026
39:51
m1Martin Storeyp8Femi
13 Jan 2026
51:17
m1Martin Storeyp9Priya Raman
14 Jan 2026
42:10
m1Martin Storeyp10Owen Daly
15 Jan 2026
44:36
m1Martin Storeyp12Nadia Haddad
16 Jan 2026
37:58
people.yaml after the run
participants:
  m1:
    computed:
      session_id: s12      # last session wins
      words_spoken: 2104
      pct_time_speaking: 23.4
    editable:
      full_name: Martin Storey  # heard in s12
      short_name: Martin Storey
  p1:
    computed: {session_id: s1, …}
    editable: {full_name: Sarah Chen, …}
  … p2 … p12 …
  o1:
    computed: {session_id: s3, …}  # s1's o1 gone
    editable: {full_name: "", …}
(no entry for session 9's moderator exists, and none can)
Commentary
Cause
assign_speaker_codes resets mod_counter on every call (s05b_identify_speakers.py:451–456), so Mike is m1 in s9 exactly as Martin is in s1. compute_participant_stats keys its dict by code (people.py:198); the last session's m1 survives into people.yaml. The importer seeds twelve Person rows from that one entry (importer.py:885–890), so every per-session surface agrees, and all twelve agree on the wrong colleague for session 9.
Who sees it
Nobody who was not in the room. The row for #9 is pixel-identical to the eleven correct ones. The "Moderated by" header dedupes by name (routes/sessions.py:152–158) and the sidebar counts moderators by distinct name (SessionsSidebar.tsx:34–37), so neither can show a second moderator exists. This is the invisible-wrongness design-people §"The stance" names as the thing that is not acceptable.
What is lost
Session 1's observer row: o1 is Jane in s1 and Tom in s3, and the s3 stats overwrote hers (F9). Session-level moderator stats for eleven sessions.
Travels?
Yes, unchanged, into the HTML export and the MCP roster (F6, F7).

F2Which session's guess won is an accident of order

The same study, three run orders. The name on m1 is whatever the last session's five-minute LLM sample produced, filled only if the field was empty, with no record of where it came from.

people.yaml · three outcomes of one pipelineshipped · read from code

A · sessions iterate s1 … s12 — s12 is Martin's

m1:
  editable:
    full_name: Martin Storey
# correct for 11 sessions, wrong for s9

B · Mike's session happened to be processed last

m1:
  editable:
    full_name: Mike Alvarez
# wrong for 11 sessions, right for one

C · the last session's sample named nobody

m1:
  editable:
    full_name: ""
# eleven sessions heard "Martin"; the slot is empty

D · the measured case (transcripts plan §2)

m1:
  editable:
    full_name: Pri
# a platform label lost to the model's guess

<output>/.bristlenose/bristlenose.log — the only place the loss is stated

WARNING bristlenose.people: Speaker code reused across sessions: m1 in s1, s2, s3, s4, s5, s6, s7, s8, s9, s10, s11, s12 (only s12 survives); o1 in s1, s3 (only s3 survives). people.yaml holds one entry per code, so the earlier sessions' stats are discarded. Moderator and observer codes restart at 1 each session and cross-session speaker identity is not yet modelled — see Layer 11 in docs/design-transcript-speaker-editing-roadmap.md.
Commentary
Cause
pipeline.py:2152–2162 builds pid_speaker_info[code] = info across all sessions; for a shared code the last assignment wins, including an assignment whose person_name is empty. auto_populate_names then fills full_name only when it is empty (people.py:389–394). Outcome C is the quiet one: a name the model heard eleven times is discarded because the twelfth window did not repeat it.
What is missing
Origin. Nothing records that "Martin Storey" was heard in s12 rather than typed by the researcher, so the next frame's correction and this frame's guess are indistinguishable on disk (design-people §C1, "no recorded name origin"; §C5's guess state is the only one that invites action, and it cannot be rendered because it is not stored).
The warning
One aggregated line per run, in a log under a hidden directory. TODO.md:269 already calls a log line "the weakest available surface" and leaves the pill/popover decision open. The Mac app's project row reads Analysed with a tick.

F3The pencil on session 9 opens twelve editors

The researcher knows session 9 was Mike's and reaches for the one control the lens offers. The edit state is keyed by code, so every m1 enters edit mode at once; the editors contend for focus and the edit collapses.

Sessions lens · after clicking the pencil on #9shipped · measured in Chromium and WebKit (plan §0b)
#
Speakers
Start
Duration
m1Martin Storeyp1Sarah Chen
6 Jan 2026
48:02
m1Martin Storeyp2Amara Nwosu
6 Jan 2026
39:51
m1Martin Storeyp8Femi
13 Jan 2026
51:17
m1Mip9Priya Raman
14 Jan 2026
42:10
m1Martin Storeyp10Owen Daly
15 Jan 2026
44:36
m1Martin Storeyp12Nadia Haddad
16 Jan 2026
37:58
Commentary
Cause
const [editingCode, setEditingCode] = useState<string | null>(null) (SessionsTable.tsx:131) and const isEditing = editingCode === sp.speaker_code (:443). One code, one editing state, twelve rows. The transcripts plan measured the rest: the editors steal focus from each other and the edit collapses.
Assistive tech
Twelve buttons announce "Edit name for m1" (sessions.editName), and twelve nodes carry data-testid="bn-name-m1" (:466, :501), so no test and no screen-reader user can address session 9's control. TODO.md:583.
Not a data defect
Keying the editor by (session, code) is a pure UI fix that touches no store. It stops the collapse; it does not stop F4.

F4Rename one, rename all; reload, and it depends on row order

Suppose the editor survives and the researcher commits "Mike Alvarez" on session 9. The lens applies it to every session, the server writes one Person row of twelve, and the next load picks whichever row the query returns last.

Sessions lens · ① the commitshipped · read from code

Sessions

Moderated by Mike Alvarez
#
Speakers
Start
Duration
m1Mike Alvarezp1Sarah Chen
6 Jan 2026
48:02
m1Mike Alvarezp8Femi
13 Jan 2026
51:17
m1Mike Alvarezp9Priya Raman
14 Jan 2026
42:10
m1Mike Alvarezp12Nadia Haddad
16 Jan 2026
37:58
on the wire, then in the database
PUT /api/projects/1/people
{ "m1": { "full_name": "Martin Storey",
          "short_name": "Mike Alvarez",
          "role": "" } }
— the whole map, keyed by code (api.ts:283–295)

persons                         session_speakers
#1  short_name Mike Alvarez  ← (s1,  m1)   .first()
#2  short_name Martin Storey   ← (s2,  m1)
#3  short_name Martin Storey   ← (s3,  m1)
 …
#9  short_name Martin Storey   ← (s9,  m1)   Mike's session
 …
#12 short_name Martin Storey   ← (s12, m1)

people.yaml  m1.editable.short_name: Mike Alvarez
Sessions lens · ② after reloadshipped · read from code

Sessions

Moderated by Mike Alvarez, Martin Storey
#
Speakers
Start
Duration
m1Martin Storeyp1Sarah Chen
6 Jan 2026
48:02
m1Martin Storeyp9Priya Raman
14 Jan 2026
42:10
m1Martin Storeyp12Nadia Haddad
16 Jan 2026
37:58
what each reader answers for "m1"
GET /people          → Martin Storey   last row returned (s12)
/sessions row #1     → Mike Alvarez     person #1
/sessions row #9     → Martin Storey   person #9, untouched
moderator_names      → [Mike Alvarez, Martin Storey]
                       two names, "one" moderator
MCP overview         → Mike Alvarez     first row (s1)
people.yaml          → Mike Alvarez     write-through by code
next pipeline run    → Mike Alvarez     everywhere (F10)

The grid shows /people's answer in every row
(SessionsTable.tsx:439), so the edit "reverted".
Commentary
Cause
Three code paths, three keys. The optimistic update renames the code in every session (SessionsTable.tsx:169–183). put_people takes .first() of the speaker rows for the code and writes that one Person (routes/data.py:350–366). get_people iterates every speaker row with no ordering and lets the last overwrite (:322–329). SQLite returns rows in insertion order unless a plan says otherwise, which is why "reverted" is the likely rendering and not the guaranteed one.
The invariant, broken in the DB only
design-people §0 says a code has one name everywhere. The SPA and people.yaml keep that promise; the DB now holds two names for m1, which no surface was written to expect. The honest interim write is "update every Person row for the code", which is what the importer's seeding already assumes; the honest read is a deterministic order. Both are small, and both are within the settled model.
Second trap
If GET /people failed at load, peopleMap is null, the PUT never fires, and the rename changes the screen only (:186–197); the transcripts plan's §0b records a Mac-app editing regression of this shape, unconfirmed.

F5The sidebar and the header cannot say "two moderators"

Both derive "how many moderators" from distinct names. With one slot there is one name, so the second moderator is undetectable; with F4's split there are two names for one slot, so one moderator reads as two.

Sessions sidebar + dashboard header · the two renderingsshipped · read from code

before any edit — one name, one slot

Sessions
1p1Sarah Chen
8p8Femi
9p9Priya Raman
10p10Owen Daly
Moderated by Martin Storey

after F4 — two names, one slot

Sessions
1m1p1Sarah Chen
8m1p8Femi
9m1p9Priya Raman
10m1p10Owen Daly
Moderated by Mike Alvarez, Martin Storey
Commentary
Cause
SessionsSidebar.tsx:34–37: hasMultipleModerators = new Set(moderators.map(sp => sp.name)).size > 1, then every row shows moderators[0]'s code badge, which is m1 on every row, which says nothing. The header joins distinct names (routes/sessions.py:152–158, routes/dashboard.py:613–622).
Why the dedup cannot simply go
TODO.md:583: removing it yields "Moderated by Sarah, Sarah, Sarah", a faithful render of collided data. The header is blocked on the renumber; once a second moderator is m2 the dedup is by code and correct for free.

F6It travels: the client's copy credits the wrong colleague

The researcher exports the report with participant names removed, as the dialog offers. Moderator names are kept by design (decision 2). Session 9's page, in the file handed to Meridian Health, says Martin ran it.

Export dialog, then the exported report's session 9 page · file://shipped · read from code

Session 9 · p9

Moderator Martin Storey
00:00m1Thanks for making the time. Before we start, can you tell me a bit about your ward?
00:14p9We're a thirty-bed surgical ward, handover's at seven and half past seven…
00:41m1And who's in the room for that?
_anonymise_data, routes/export.py:111–202
/people
  p9  full_name  ""   short_name ""   role ""
  m1  full_name  Martin Storey        kept: m*
/transcripts/s9
  speakers[m1].name  Martin Storey
/sessions
  moderator_names   [Martin Storey]
/dashboard
  moderator_header  "Moderated by Martin Storey"

Recipient's view: a fact about who conducted
the interview, stated once per page, and the
one fact in the file they cannot verify.
Commentary
Why this is the serious one
design-people §A J10: separating one code that is two people is "the one defect in this document that travels into an export, where the recipient cannot detect it". The mechanism is correct (decision 2: colleagues are named, subjects are not) and the collision rides it. A client-side observer's clarifying questions in s9 are now attributed under a moderator who was not there.
What does not travel
CSV/XLSX carry participant quotes only; Miro cards carry codes only; clip filenames name participants only. The MCP roster does travel, in its own way (F7).
Who it harms
Mike, whose session is unattributed, and the researcher, whose deliverable states something false in a register the reader trusts because the subjects' names were visibly removed.

F7The agent answers with the first name it found

With Agent Access on, a researcher's own Claude Desktop asks the project who ran session 9. The MCP overview carries one name per code, chosen first-wins, and the model answers in the confident register the tool's data deserves.

Claude Desktop · bristlenose MCP · project_overviewshipped · read from code
You
Who moderated session 9 of the ward handover study, and how long did it run?
Claude
bristlenose · project_overview(project_id=1) → sessions: [{session_id: "s9", session_date: "2026-01-14", duration_seconds: 2530}, …] → speakers: {"m1": "researcher", "p1": "participant", …} → speaker_names: {"m1": "Martin Storey", "p1": "Sarah Chen", …}
Session 9 (14 January 2026, 42 minutes) was moderated by Martin Storey (m1), with one participant, p9 Priya Raman.
Commentary
Cause
resolve_speaker_names builds a code→name map with names.setdefault(sp.speaker_code, display) (grounding.py:449): first row wins. The overview's roster collapses codes across sessions the same way (mcp_server.py:451–458). Twelve rows, one name, chosen by iteration order; after F4 it is the other name from the one the grid shows.
Scope
The in-app chat lens is code-only by design (grounding.py:55–57), so it cannot name the wrong person; it can only decline to name. The MCP surface is the one that names, and it hands the name to a model that will not hedge.
Recorded where
TODO.md:583 names this as "a different defect from the anonymise one": "which of N moderators wins", unrecorded until 27 Aug.

F8The number is an accident of who spoke first

Rachel Okonjo apprenticed on session 5 and ran 6 and 7 herself. Within a session two moderators work. Across sessions the counter restarts, so the same human is m2, then m1, and in session 7, where she opened, Martin becomes m2. The names on the slots never move; the people under them do.

Sessions lens · rows 5–7shipped · read from code
#
Speakers
Start
Duration
m1Martin Storeym2Rachel Okonjop5Mary O.
9 Jan 2026
46:20
m1Martin Storeyp6Mary A.
12 Jan 2026
40:05
m1Martin Storeym2Rachel Okonjop7Mickael
12 Jan 2026
38:44
who the codes actually point at
s5   m1 = Martin   m2 = Rachel      as shown
s6   m1 = Rachel   (alone)          shown as Martin
s7   m1 = Rachel   m2 = Martin      she spoke first

label_role is built from first occurrence
(s05b_identify_speakers.py:443–447); the
mod_counter increments in that order (:454–456).

people.yaml  m1.full_name = Martin Storey
             m2.full_name = Rachel Okonjo
Both names are right about somebody. Neither
is right about session 7.
Commentary
Cause
Two things that are both correct on their own: codes are per-session ordinals, and names are per-code. decision 1 calls this the m/o namespace "over-colliding and over-fragmenting", because the number "comes from within-session ordering, which has nothing to do with identity".
Why §F S11 "already handled" is not a contradiction
Two moderators in one session works (test_two_researchers). The register row is about one session; this frame is about three, and the sidebar's MULTI_MODERATOR fixture (m1 in s1, m2 in s2) draws a shape the pipeline cannot produce across sessions, so the test is specifying a world that does not exist.
Consequence
Rachel's two sessions are credited to Martin; Martin's turns in session 7 are credited to Rachel, in the transcript, in the exported roles line, and in the coverage "% moderator" arithmetic, which is per code and still sums correctly.

F9Jane is o1, then o2; Tom is o1

Jane Smith (Meridian Health) observed session 1 alone and session 3 beside Tom Blake, who spoke first. Observers are never auto-named, so the researcher typed "Jane Smith" on o1 in session 1. The slot now names Tom's words in session 3, and Jane's own s3 turns sit under an empty o2.

Sessions lens rows 1 and 3 · transcript s3shipped · read from code
#
Speakers
Start
Duration
m1Martin Storeyp1Sarah Cheno1Jane Smith
6 Jan 2026
48:02
m1Martin Storeyp3Mary A.o1Jane Smitho2Observer
7 Jan 2026
44:51

Session 3 · p3Mary A.

Moderator Martin Storey, observers Jane Smith and o2
31:40o1Sorry, can I jump in: is that the paper form or the one on the trolley screen?
31:48p3The trolley one, but half the time it's not charged…
32:05o2That matches what we saw on Bay 4 last week.
the observer slots
s1   o1 = Jane
s3   o1 = Tom  (spoke first)   o2 = Jane

people.yaml
  o1: {computed: {session_id: s3}, editable: {full_name: Jane Smith}}
  o2: {computed: {session_id: s3}, editable: {full_name: ""}}

auto-fill never reaches observers:
  pipeline.py:2152–2162 maps PARTICIPANT and
  RESEARCHER infos only.
Tom's question at 31:40 is credited to Jane.
Jane's own s3 turns are an unnamed o2.
"observers Jane Smith and o2" is the roles line
(TranscriptPage.tsx:247–280, name falls back to code).
Commentary
Why observers matter more than the scale table suggests
design-people's own correction: the extra person in a session is an observer in ~95% of cases, so observer repair is the dominant correction, and the o namespace has both failures the m namespace has. On top of that, observers are the one class the pipeline never names at all, so every observer row begins as "Observer" and every name on one was typed into a slot that moves.
Disclosure
Under decision 2 observers are named in exports. A client-side observer's question attributed to another client-side observer is a small wrong fact in a client's own document.
Also systemic
Role identification samples the first five minutes (s05b, seg.start_time > 300), so an observer who first speaks at 31:40 is classified by the heuristic that scores question ratio, and both UNKNOWN and PARTICIPANT become a p code. Tom's question could as easily have minted p13. design-people J3.

F10The correction becomes the next error

Say the researcher won F3 and F4 and people.yaml now reads m1: Mike Alvarez, typed with session 9 in mind. A thirteenth interview arrives, Martin runs it, and the pipeline does exactly what it promises: it never overwrites a name a human set.

Sessions lens · after the next runshipped · read from code

Sessions

Moderated by Mike Alvarez
#
Speakers
Start
Duration
m1Mike Alvarezp9Priya Raman
14 Jan 2026
42:10
m1Mike Alvarezp12Nadia Haddad
16 Jan 2026
37:58
m1Mike Alvarezp13Grace Whitfield
22 Jan 2026
45:12
merge_people, people.py:244–275
existing  m1.editable.full_name = Mike Alvarez  (typed)
computed  m1 ← s13 stats (Martin's session)

merged    m1.computed  = s13          replaced
          m1.editable  = Mike Alvarez preserved by key

auto_populate_names: full_name non-empty → untouched
                     (people.py:389, "never overwrites user edits")

The researcher corrected one session. Thirteen
sessions now say Mike, and the one that was wrong
before is the only one that is right.
Commentary
Cause
The rule that protects typed names (merge_people preserves editable fields by key; auto_populate_names fills only empties) is correct and settled: design-people §B8, "never second-guess a name the researcher set". It is correct about a code that names one person. On a code that names two, it preserves the mislabel with the same loyalty.
No way to say "this one session"
The sentence "session 9's moderator is Mike" has nowhere to live in a per-code file. decision 1 is exactly the observation that the only representable version of that sentence is "Mike is a different code".
Roadmap §11c called this
"a name the researcher typed for session 1's moderator stays attached to an entry whose stats are now session 2's — mislabelling, not just loss."

F11The platform knew, and the slot cannot take it

The finding under review. Teams labels every cue with the account that spoke. Twelve transcripts carry two moderator names between them, each on the right session. There is one place to put them.

cloud-import transcripts plan · §3 1d, §8 Q4plan · three outcomes of one rule

What arrives: 2026-01-14 0934 — P09 Interview.vtt

WEBVTT

NOTE bristlenose-cloud-transcript 1
source: teams
speakers: named
media: 2026-01-14 0934 — P09 Interview.mp4

00:00:00.400 --> 00:00:13.900
<v Mike Alvarez>Thanks for making the time…</v>

… and eleven siblings

<v Martin Storey>… </v>   s1, s2 … s8, s10, s11, s12

What the slot can hold

① "every named speaker gets a name" (the draft rule)
m1:
  editable: {full_name: <whichever session is processed last>}

② 1d as reviewed: name participants only
m1:
  editable: {full_name: ""}   "Moderator" ×12, the names discarded

③ Q4: study-wide codes from the platform
m1:
  editable: {full_name: Martin Storey}   s1–s8, s10–s12
m2:
  editable: {full_name: Mike Alvarez}    s9
Commentary
The finding, restated
Outcome ① is the sentence under review: a per-session fact written through a per-code slot makes the last writer win, and with twelve correct inputs produces one correct output and eleven wrong ones, or one wrong and eleven right, depending on order. The review was right to strike the draft rule.
What ② costs
Honesty at the price of twelve "Moderator" placeholders in a study where the platform stated the name on every turn. It is the right first slice because it is the only outcome that is wrong nowhere; it is also the outcome F11 exists to make visibly unsatisfying.
What ③ needs
decision 1's mechanism, entered from the platform instead of from a rename: a second moderator is a second code, assigned at assign_speaker_codes time by an identity the platform asserts. The plan's §0 says key by participant id; its §3 says by display name. Only the first is compatible with decision 1's "never auto-merge on name equality", and it is the one to keep (challenge 5).
Also in play
"Platform name beats LLM person_name" is agreed for platform labels (plan §0b), but test_llm_priority_over_label pins the opposite for generic labels; the plan already says re-home that contract, not invert it.

F12The sealed static report shows eleven sessions with no moderator

Every bristlenose run still writes the deprecated HTML byproduct. Its sessions table groups codes by the computed.session_id on each people.yaml entry, so the one m1 entry belongs to session 12 and no other.

bristlenose-ward-handover-report.html · sessions table (Jinja2, sealed)shipped · deprecated surface · read from code
Moderated by m1Martin Storey
#
Speakers
Start
Duration
p1Sarah Chen
6 Jan 2026
48:02
p3Mary A.o1Jane Smitho2
7 Jan 2026
44:51
p9Priya Raman
14 Jan 2026
42:10
p12Nadia Haddad
16 Jan 2026
37:58
Commentary
Cause
s12_render/dashboard.py:61–73 groups by entry.computed.session_id; line 89 drops moderators from every row when there is exactly one moderator code, so the header is the only place a moderator appears at all. With the collision there is always exactly one code.
Status
The React SPA is the product and the Jinja2 renderer is a sealed byproduct (CLAUDE.md), so this frame is a record, not a work item. It is here because the same researcher sees both files in the same output folder and the two disagree about how many moderators the study had.
Part 5 What the settled design does about it — decision 1 (24 Aug 2026) and decision 2 (25 Aug 2026), drawn. Nothing in this part is built. The chrome says "proposed" on every frame.

Designing the failure out, not warning about it

Read with Part 5b. Decided 1 Oct 2026, after this part was drawn: route C replaces the renumber-on-rename mechanism (D1, D2) with identity-minted codes and a per-session map whose value may be null. D1's grouped picker survives unchanged; D3 survives as the platform-evidence path; D4's italic interim survives for migrated studies only (E7). The frames below are kept as the record of the argument, per the mockup register's rule that nothing is deleted.

The stance in design-people.md governs the shape of the fix: pick the typical case confidently, render a wrinkle so it is obvious to someone who was in the room, and make the fix cost one action. One moderator per study is the typical case, so m1 everywhere stays the default. The wrinkle is a second moderator, and the one action is naming them in the session where they appear. Everything below follows from "a code has one name everywhere": the only way for session 9's moderator to be Mike is for Mike to be a different code.

D1Name the moderator in session 9, and he becomes m2

The sessions-grid speaker entry offers m1 Is ▸, a grouped cast list with the bank of colleagues under it. Picking Mike asserts an identity, the app notices m1 is Martin elsewhere, and renumbers. One sentence, one Undo, no dialog.

Sessions lens · row #9 · m1 Is ▸ (design-people §B9, §B4; bench 9 of person-actions-everywhere)proposed · not built
#
Speakers
Start
Duration
m1Martin Storeyp8Femi
13 Jan 2026
51:17
m1Martin Storeyp9Priya Raman
14 Jan 2026
42:10

Mike is now m2 in session 9. Martin stays m1 in sessions 1–8 and 10–12.Undo

Commentary
What the researcher did
Said who the moderator in front of them is. They did not think "separate m1 into two people" and no verb asks them to. decision 1: "renumbering is a consequence of naming, not a separate verb"; the Separate… sheet remains for the aggregate-level case and is secondary.
What the app did
Noticed m1 carries Martin's name in eleven other sessions, concluded this is a different person, minted m2 for session 9, and said so in one sentence with the scope in the words (§B3: wide-reaching items name their scope). The sweep line's Undo is top-of-stack ⌘Z and vanishes when superseded (§B10). Edit ▸ Undo reads Undo Rename Moderator, never bare Undo.
Why the bank, not a text field
§B4: naming a moderator is "a pick, not a keystroke", and picking is a link, not a copy, so Mike's row joins the Mike who already exists across studies (J4, J12 as a side effect). The bank holds moderators and observers only, never participants, never pre-selects, shows a study count, and keeps Someone New… as the escape. It has no store today: Person rows do not span projects (one SQLite per project), so the bank needs instance.db first (§B4 measured blocker, §J2).
Lexical rule
m1 Is ▸ names a speaker, so nothing moves but labels and numbers. All of m1's Turns Are ▸ would name speech and move words. §B2 keeps them in different menu zones so the 41-turn word-move and the relabel cannot be one slip apart.

D2What the three stores hold after the renumber

The same act, seen from the files. people.yaml keeps its shape and gains a key; the DB rows for session 9 change code; the bracket tokens in transcripts-raw/s9.txt are the open question.

people.yaml · bristlenose.db · transcripts-raw/s9.txt · after "Mike is now m2"proposed · §J4 open on the third store

people.yaml — no reshape, one new key

participants:
  m1:
    computed: {session_id: s12, …}
    editable:
      full_name: Martin Storey
  m2:
    computed: {session_id: s9, words_spoken: 1 711, …}
    editable:
      full_name: Mike Alvarez
      # origin: you set this (step 2 field)
  p1: …

bristlenose.db

session_speakers
  (s9, m1) → (s9, m2)   person_id → Mike's row
transcript_segments   s9 rows: speaker_code m2
quotes                s9 rows: participant_id unchanged (p9)
UNIQUE(session_id, speaker_code): free m1 before taking m2,
or the constraint fires mid-transaction (§J1).

transcripts-raw/s9.txt — two honest answers

# Session: s9 · Source: 2026-01-14 0934 — P09 Interview.mp4
[00:00] [m1] (Mike Alvarez) Thanks for making the time…
[00:14] [p9] (Priya Raman) We're a thirty-bed surgical ward…

(a) render-time derivation: the token stays as written,
    provenance of what the pipeline said; every reader maps
    (s9, m1) → m2 through session_speakers.
(b) recode: rewrite the file so the token reads [m2]; the
    reader regex is \d{1,2} and the file is the one
    researchers hand-edit and re-import.

what no longer needs to exist

no (session, code) rekey of people.yaml   (rejected 25 Aug)
no migration of files in the field
no "Separate m1" primary verb
no name-equality merge, at any altitude
Commentary
Why the file keeps its shape
Roadmap §11c's migration argument became part of the case against the rekey: under renumbering "there is no reshape and no migration". A file written on 14 Jul by a TestFlight build stays valid input; it simply gains an m2 when someone names one.
The seven things a recode touches
§J1 counts them: the unique constraint ordering, N segment rows, quote attribution (cheap, §C4), the quote stable key (which carries participant_id and breaks on re-import), the people.yaml key (the write-through has no rekey path today), the bracket tokens on disk, and any export already distributed (unreachable). For a moderator renumber the quote half is moot, because quotes are participant-only; that is what makes m/o the cheap end of step 4.
The open question
§J4: render-time derivation keeps transcripts-raw/ as provenance and makes the renumber a DB-and-YAML operation; a recode makes every file agree and costs a rewrite path plus a round-trip with the importer. The answer decides whether step 4 is medium or large, and it is not yet settled. This report does not settle it; it notes that the moderator case needs only the cheaper half of either answer.
Undo
Rename-and-renumber is one act in the user's mind and must undo as one (§B10). The undo-state bridge is dead on the frontend half (bridge.ts hard-codes canUndo: false), which is why §D prices the undo bridge as a step-1 gate.

D3Platform-named moderators get study-wide codes without anyone typing

The transcripts plan's Q4, keyed the way its §0 says and not the way its §3 says: by the platform's participant identity. The same outcome as D1, reached at assign_speaker_codes time instead of from a rename.

Sessions lens · after importing twelve Teams pairsproposed · plan Q4, owner call open

Sessions

Moderated by Martin Storey and Mike Alvarez
#
Speakers
Start
Duration
m1Martin StoreyFrom the transcriptp8Femi
13 Jan 2026
51:17
m2Mike AlvarezFrom the transcriptp9Priya Raman
14 Jan 2026
42:10
m1Martin StoreyFrom the transcriptp10Owen Daly
15 Jan 2026
44:36
assign_speaker_codes, with a study-wide moderator map
study_moderators: dict[platform_id, code]
  "8f1c…@tenant"  → m1   Martin, 11 sessions
  "c07a…@tenant"  → m2   Mike, 1 session

per session:
  RESEARCHER label with a platform id
    → code = study_moderators.setdefault(id, next_m)
  RESEARCHER label without one (Whisper)
    → per-session counter, as today

Key by the account, not the string: two
"M. Alvarez" accounts stay two codes, one
account renamed in Teams stays one.
Commentary
Why it is decision 1 and not an exception to it
decision 1 forbids merging on name equality because a name is not evidence of identity: "p3 Mary and p5 Mary are routinely two different people". A platform participant id is not a name. It is the platform asserting that the same account spoke, which is the same class of evidence as the researcher picking Mike from the bank. The plan's §0 wording ("by platform participant id, not display name") is the one that survives contact with decision 1; the §3 wording ("by display name") does not, and Q4 should be answered in §0's terms.
What it buys
F11 outcome ③ with zero typing, the "Moderated by" header correct by code, the sidebar able to say two moderators, and the export right in the only case that travels. It also makes the renumber path (D1/D2) a second entrance to a mechanism that already exists, rather than the only one.
What it does not do
Name anyone the platform did not name (a Whisper session keeps the per-session counter), join the Teams Martin to the Meet Martin (different ids; the bank or a rename does that), or decide who is "me" (design-cloud-import.md: the signed-in account is never auto-made m1).
Origin mark
"From the transcript" is the provenance label the people-provenance mockup settled (bench 8: a confirmed name is a recorded state). It is drawn here because the sentence is only true once origin is stored per field (§J3, step 2).

D4Until then: a guess looks like a guess, and the loss is said where it happened

The interim render that needs no renumber: names the pipeline heard are italic, their origin is one small word, and one absent-when-zero line under the heading names the wrinkle. Zero geometry, one translated string.

Sessions lens · heading + sweep line (design-people §B6, provenance bench 8)proposed · needs name origin per field (step 2)

Sessions

Moderated by Martin Storey

1 moderator name was heard in one session and applied to 12. If a different colleague ran any of them, name them there.

#
Speakers
Start
Duration
m1Martin StoreyHeard in session 12p8Femi
13 Jan 2026
51:17
m1Martin StoreyHeard in session 12p9Priya Raman
14 Jan 2026
42:10
m1Martin StoreyHeard in this sessionp12Nadia Haddad
16 Jan 2026
37:58
Commentary
What this is and is not
It is the stance's second half: being wrong is acceptable, being wrong invisibly is not. It does not fix the slot. It makes the eleven inherited names look like what they are (a guess copied from one window) and puts the sentence the researcher needs next to the rows it is about, instead of in a log under a hidden directory (F2). The sweep line is the house pattern (§B6 "one absent-when-zero line", §B10's Undo-Send precedent); it is not a toast, which the house rules ban.
What it depends on
Name origin stored per field with the two sentinels (never recorded; cleared), which is step 2 of §D and §J3. Without origin, italic cannot be rendered truthfully: every people.yaml in the field today has no record of which names were typed. The copy "Heard in the session" / "From the transcript" / "You set this" is already adjudicated (people-provenance-paths.html; TODO.md:583).
Why it is still worth drawing
It is the cheapest change that ends the invisible case, and it is independent of the instance database, the bank, the menu host and the undo bridge, which gate D1. If step 1 slips, D4 is what the researcher should get in the meantime.
Part 5b Route C, decided 1 Oct 2026 — unknown stored as unknown, identities minted on evidence or a pick, drawn with the shipped proposed-badge vocabulary (atoms/badge.css .badge-proposed + .badge-action-pill). Supersedes D1/D2's renumber-on-rename within this report; D1's picker survives, D4 survives for migrated studies only.
Superseded for pixels, same day. The E-frames below approximate the type and draw two things the product would never say — the .bn-name-source chips ("heard …", "inherited") and a hand-rolled menu. moderator-identity-iteration-2.html redraws route C with the shipped stylesheet inlined verbatim and only shipped strings and idioms, frame by frame as slot state × surface, and found one consequence these frames could not (the pill overhanging the name in the grid). Read that file for what ships; read this part for the decisions and the argument, which stand.

The moderator state engine

Eight calls taken in conversation on 1 Oct 2026, recorded here so the frames below can be read against them:

#DecidedConsequence in the frames
P1A platform-proposed moderator is tentative in the app, plain in the export and MCPDashed, pulsing split badge with the accept/deny pill on hover; the exported roles line states the platform's name as fact
P2Name-only platform labels mint one identity per distinct name within a study"Martin B Storey" in eight files is one m1 proposed on eight sessions, origin "from the transcript"; a recorded carve-out from decision 1 for this evidence class
P3Nothing asks. No "looks like you" line, no confirm promptThe badge reads m1 Martin B Storey; right-click changes it or nothing happens. "That's Me" is a menu item, never an offer. The heading line states a count and asks for nothing
P4Observers use the same engine; the count line counts moderators onlyo? rows exist and rest silently
I1Wait for the transcripts plan's 0b; key the per-session map by its stable session identityNo assignment can move to the wrong interview when an older recording is added; 0b is the first package
I2Studies in the field migrate to "proposed on every session, origin inherited"Frame E7: the old name stays everywhere it was shown, drawn tentative
I3Two picker implementations: a web popover in the browser, a native NSMenu on the Mac; visual parity tuned later in a Diagnostics side-by-side mockup; wiring firstFrames draw the web popover; the native twin is named, not drawn
I4Me comes from the account name; no settings UI in v1"That's Me (Martin Storey)" is seeded from NSFullUserName on the Mac and the GECOS field on the CLI
I5people.yaml is retired as a store; the database is the only home for names, identities, the per-session map and originThe state panels below show rows, not a file. A legacy people.yaml is read once on first import after upgrade, then ignored, never deleted. Computed stats move to an intermediate JSON
I6The per-session map is session_speakers.person_id, made nullable, plus state and evidence; persons gain a per-project code, a uuid and an originOne Alembic revision on tables that exist; one Person per identity instead of one per session; NULL is m?
I7The pipeline runs the importer at the end of every runThe database always exists after a run, whoever ran it; serve becomes a reader; the importer proposes from evidence and never overwrites a confirmed row
I8The markdown report and the sealed static HTML get no investmentWith no people.yaml to read they fall back to codes, as their existing missing-file path already does. The exported HTML is the report to share

Defaults taken without asking, change them if wrong: unidentified slots export as "Moderator not identified"; identities take numbers in identification order and Me gets no reserved number; a moderator's short name defaults the way a participant's does; a heard name pre-fills the Someone New… field.

E1The vocabulary: four states of one badge

The split person badge in each state the engine can hold. The tentative treatment is the AutoCode proposed badge's, unchanged: dashed border, the slow pulse, and the ✗ | ✓ pill that appears on hover. Nothing new is drawn.

Sessions lens · speaker entries · one per stateproposed · reuses .badge-proposed and .badge-action-pill verbatim
null
m?Moderator
null, with a hint
m?Moderatorheard “Kerri”
proposed (platform label)
m1Martin B Storey×✓
proposed, hovered
m1Martin B Storey×✓
confirmed
m1Martin B Storey
confirmed, linked to Me
m1Martin B Storey (Me)
proposed, inherited (migration)
m1Martin Storey×✓inherited
Commentary
What each means
null: nobody knows; the pipeline's default for a Whisper session. proposed: the platform said so (a Teams or Meet account label, a .docx speaker line, a Zoom Name: that passes the 1c rule), or the name was inherited by migration. confirmed: a person said so, by pick, ✓, That's Me or Someone New…. A hint is not a state: a heard name annotates a null slot and pre-fills the new-person field, and never mints anything.
What ✓ and ✗ do
✓ moves the slot to confirmed with no other change (keyboard a, as on tags). ✗ returns the slot to null and leaves the identity in the cast (d). Neither is required: a proposed badge that is never touched reads correctly for ever (P3).
Why the pill is not needy UX
It is hidden until hover or focus, exactly as it ships on proposed tags, and the pulse is the only resting signal. The researcher's attention is invited, not demanded, and the export does not carry either (P1).

E2The study after two weeks: three Teams imports, three hand-dropped recordings

Sessions 1–3 arrived from Teams with labels (Martin, Martin, Kerri). Sessions 4–6 were dragged in as video. The researcher has so far picked one of them. Nothing was inferred for the other two.

Sessions lens · route Cproposed · not built

Sessions

Moderated by Martin B Storey and Kerri Ng · 2 not identified
#
Speakers
Start
Duration
m1Martin B Storey×✓p1Sarah Chen
6 Jan 2026
48:02
m1Martin B Storey×✓p2Amara Nwosu
6 Jan 2026
39:51
m2Kerri Ng×✓p3Mary A.
8 Jan 2026
44:51
m1Martin B Storeyp4Marrian Boateng
10 Jan 2026
42:10
m?Moderatorheard “Kerri”p5Mary O.
14 Jan 2026
46:20
m?Moderatorp6Mickael
15 Jan 2026
38:44
bristlenose.db — session_speakers is the map; persons are the identities
session_speakers   (session key is 0b's stable identity)
 session  tag   person_id  state       evidence      hint
 s1       m1    → 11       proposed    teams-label
 s2       m1    → 11       proposed    teams-label
 s3       m1    → 12       proposed    teams-label
 s4       m1    → 11       confirmed   pick
 s5       m1    NULL                               heard "Kerri"
 s6       m1    NULL

persons
 id  code  uuid    full_name        origin           me
 11  m1    7c1e…   Martin B Storey  from-transcript  no
 12  m2    a90d…   Kerri Ng         from-transcript  no

tag = the per-session [m1] token in transcripts-raw/,
a within-session speaker tag, not an identity.
people.yaml: read once if present, then ignored.
Commentary
What the pipeline did
Minted m1 and m2 from the Teams labels (P2), proposed them on their sessions, and wrote null for the three Whisper sessions. The heard name on session 5 is a hint on the row, not an assignment.
What the researcher did
One pick, on session 4, after looking at it the day it landed. Sessions 1–3 were never touched and read correctly. Sessions 5 and 6 are honest gaps.
The heading line
States two facts and asks nothing: who moderated, and how many sessions have no moderator identified. It is absent when the count is zero and it counts moderators only (P4). It is the same sentence the export will carry.
Why the database, not the file
Names and identities are researcher state, like stars and tags, which have never had a file twin. One store ends the dual truth, the best-effort write-through, the downgrade hazard of every new YAML block, and the one-name-per-code key this report is about. The pipeline carries evidence forward in its intermediates (labels, heard names, speaker tags); the importer, now run at the end of every pipeline run, turns evidence into proposals and never touches a confirmed row.

E3Picking: session 5 was Kerri's

The researcher right-clicks the m? badge, or clicks it. The cast of this study opens, most likely first. The hint promoted Kerri to the top. Return.

Sessions lens · row #5 · the web popover (the Mac raises the same list as an NSMenu)proposed · not built
#
Speakers
Start
Duration
m?Moderatorheard “Kerri”p5Mary O.
14 Jan 2026
46:20
↓
m2Kerri Ngp5Mary O.
14 Jan 2026
46:20
Commentary
Ordering is the ergonomics
Top item highlighted, never pre-selected (§B4): the hinted identity, else the most frequent in this study, else Me. Two gestures, zero decisions in the common case. Keyboard: Return opens on a focused badge, arrows move, Return picks.
What the pick writes
One row: session_speakers(s5, m1).person_id = 12, state = confirmed, evidence = pick. No renumber, no rename, no cascade. ⌘Z puts NULL back. Edit ▸ Undo reads Undo Pick Moderator.
One trap, and its mitigation
With m1 minted from Teams as "Martin B Storey" and Me seeded as "Martin Storey", the list holds both. Picking "That's Me" on a null slot mints a second Martin. Ordering keeps the Teams identity above Me when it already holds sessions, so the natural one click is the right one; "That's Me" on the existing m1 badge links it to Me instead (E5), and a duplicate is mergeable later from the People lens.
The Mac twin
The same list raised as a native NSMenu from the bridge (§B10), so it escapes the web view and speaks to VoiceOver. Visual parity between the web badge and the native item is tuned later in a Diagnostics side-by-side mockup (I3). The storyboard draws the web popover only.

E4Someone new: session 6 was Mike's

Nobody in the cast ran session 6. The researcher picks Someone New…, the list closes, the row's name field opens, they type, Return. Create and name is one act.

Sessions lens · row #6 · after Someone New…proposed · not built
m?Mike Alvp6Mickael
15 Jan 2026
38:44
↓
m3Mike Alvarezp6Mickael
15 Jan 2026
38:44

Sessions

Moderated by Martin B Storey, Kerri Ng and Mike Alvarez
Commentary
What it writes
One persons row: code m3, a fresh UUID, name "Mike Alvarez", origin typed; then the slot's person_id and state = confirmed. The number is the next free one in this project and is never reused.
The field
The shipped editing tint (molecules/editable-text.css idiom 3). A heard name would have pre-filled it. This is where "Kerri" becomes "Kerry" before an identity exists, which is why a hearing never mints on its own.
The count line
Gone, because the count is zero. Nothing else changed.

E5Changing your mind, and renaming a person

Two edits that look alike and do different things. Re-picking session 3 moves one slot. Renaming Kerri moves nothing and changes her everywhere.

Sessions lens · ① right-click on #3's badge · ② rename on #5proposed · not built
m3Mike Alvarezp3Mary A.
8 Jan 2026
44:51
m2Kerry Ngp5Mary O.
14 Jan 2026
46:20
three different writes
① re-pick: one slot moves
session_speakers(s3, m1).person_id: 12 → 13, state confirmed
persons[12] unchanged (still Kerri, still holds s5)

② rename: the person changes, no slot moves
persons[12].full_name: Kerri Ng → Kerry Ng, origin typed
  shown on s5 now, and on s3 had it still been hers;
  the Teams label stays recorded as evidence on the rows

③ "That's Me" on an existing badge: a link, not a slot
persons[11].me: no → yes
  renders "Martin B Storey (Me)"; name unchanged unless edited
Commentary
The menu's zones
Per §B3 and §I: state picks within a zone. On a proposed badge the first zone offers Confirm; on a confirmed one it does not. The identity zone names its scope ("Moderator of session 3 is ▸"), because a slot pick reaches one session and a rename reaches a person, and the researcher cannot see the anchor.
Why rename does not ask about scope
A name belongs to a person and a person has one name (§0, §F S8). "Kerri" to "Kerry" is a spelling fact about her, so every session she holds follows. If the researcher meant "this session was someone else", that is the identity zone, one item down, and it moves words nowhere either.
Change of mind is free
Any slot can be re-picked any number of times. Each is one undo entry. Nothing is ever locked by having been confirmed.

E6What leaves the machine while a slot is still null

Exported the evening of E2, before sessions 5 and 6 were picked. Proposed names are plain, unidentified slots say so, and no colleague is credited with a session they did not run.

Exported report · session 5 page · and the MCP overviewproposed · not built

Session 5 · p5

Moderator not identified
00:00m?Thanks for making the time. Before we start, can you tell me a bit about your ward?
00:14p5Night charge nurse, Bay 3 and 4, mostly post-op…

Session 1 · p1

Moderator Martin B Storey
MCP · project_overview
speakers: {"m1": "researcher", "m2": "researcher", "p1": …}
speaker_names: {"m1": "Martin B Storey", "m2": "Kerri Ng"}
sessions:
  s1 {moderator: "m1"}  s2 {moderator: "m1"}
  s3 {moderator: "m2"}  s4 {moderator: "m1"}
  s5 {moderator: null}  s6 {moderator: null}

INVARIANTS gains one line: a null moderator means
"not identified", never "no moderator".
Commentary
Plain in the export (P1)
The platform asserted "Martin B Storey"; the export states it. The dashed treatment and the pill are app chrome and never travel, as the proposed-tag pill already never travels (templates/export.css hides it).
"Not identified" is unfinished, not anonymised
Decision 2 names moderators. The wording must read as a gap in the researcher's work, not as a privacy choice; "Moderator" alone would read as the latter.
F6 and F7, closed by construction
No slot can carry a name it was not given, so nothing wrong can be exported, and the agent is handed null rather than a first-wins name.

E7A study from the field, the morning after upgrading

Twelve sessions, one m1 name typed in August. Every slot becomes proposed with origin "inherited" (I2). Nothing moved, nothing was lost, and every badge now says how it got there.

Sessions lens · migrated studyproposed · not built

Sessions

Moderated by Martin Storey
#
Speakers
Start
Duration
m1Martin Storey×✓inheritedp1Sarah Chen
6 Jan 2026
48:02
m1Martin Storey×✓inheritedp9Priya Raman
14 Jan 2026
42:10
m1Martin Storey×✓inheritedp12Nadia Haddad
16 Jan 2026
37:58
Commentary
Why proposed and not confirmed
The August name was typed once and spread by the slot (F10). Confirming it on twelve sessions would launder an inference into an assertion. Proposed keeps the name everywhere it was shown and tells the truth about session 9, where one re-pick makes Mike m2.
Why not m?
Twelve question marks on a finished study is the migration asking the researcher to redo work the old model never asked for. The inherited name reads correctly in eleven places; the treatment says "check if you care" and the pill makes checking one keystroke.
Origin as a word
"inherited" joins "from the transcript" and "heard" in the provenance vocabulary the people-provenance mockup settled. It is the one origin that exists only because of the upgrade and it ages out as slots are confirmed.
How it happens
On the first import after upgrade the importer reads the legacy people.yaml once: each named m/o entry becomes one persons row with origin inherited, proposed on every session that carried the code; participant names come across the same way. The file is then ignored and never deleted; it is the researcher's.

E8The two machines, drawn

A session's slot and a person's identity are separate objects with separate states. The slot machine is three states and never infers; the identity machine is where linking and renaming live.

A session's moderator slot null · m?pipeline default proposedplatform label · inherited confirmeda person said so evidence arrives ✓ · pick · That's Me pick · Someone New… ✗ · Not Identified re-pick (change of mind) Absent on purpose: null → anything by inference · proposed by a hearing · any transition from the signed-in account · a re-run touching a non-null slot. An identity (minted when a slot first gets one) ownnumber · name · uuid linked (Me · bank)That's Me · a bank pick retiredmerged into another Same Person As ▸ Rename changes the identity's name in place, in any state; number is never reused.
Commentary
Why two machines
"This session's moderator is identity X" and "identity X is the same human as Me" are different claims with different evidence. Teams can establish the first; only a person can establish the second. Collapsing them is how a label match would become an identity assertion.
What v1 holds
Both machines, project-scoped, in the project database: persons rows with codes and UUIDs as identities, session_speakers rows as slots, Me from the account as a link target, the bank absent. v2 lengthens the identity list with colleagues from the instance store; it changes no transition and no table.
Part 6 The gap register — measured against design-people.md at 1446e4d

What is missing, and what kind of missing it is

Five kinds. open a defect or absence nothing has decided about yet. decided · not built the design settles it and the tree does not carry it. blocked cannot move until something else lands, and that something is named. free win independent of every decision and small. suspected read from code, not reproduced.

#GapEvidenceFrameKind
G1Moderator and observer codes restart per sessions05b_identify_speakers.py:451–456; the right default for the one-moderator study (§0) and the whole of the collisionF1 F8decided · not built decision 1 keeps the default and adds the escape
G2people.yaml holds one entry per code; the last session's stats win and a typed name stays with the keypeople.py:194–225, :244–275; roadmap §11c; pinned by test_multi_session_moderator_codes_collide_and_are_warnedF1 F10decided · not built the renumber (§D step 4), never a rekey
G3The auto-filled name is whichever session was iterated last, including an empty guess over eleven non-empty onespipeline.py:2152–2162; people.py:389–394F2open needs a call: an empty slot is the only honest state until origin is stored (G11); preferring any non-empty guess makes outcome A/B more likely, not less
G4Observers are never auto-namedpipeline.py:2152–2162 maps participant and researcher infos onlyF9blocked on study-wide o codes: auto-naming into a slot that restarts per session would manufacture F9 without a researcher typing anything
G5GET /people has no ordering; the last row returned winsroutes/data.py:317–329F4free win order deterministically (by session number), and document which row speaks for a code
G6PUT /people writes one Person row of N for the coderoutes/data.py:350–366 (.first()); the importer seeds all N from one entry (importer.py:885–890)F4free win update every Person row the code points at; this restores §0's invariant in the one store that breaks it, and changes nothing the renumber will later undo
G7The editor state is keyed by code, so one pencil opens N editors and the edit collapsesSessionsTable.tsx:131, :443; measured in Chromium and WebKit (plan §0b)F3free win key editingCode by (session_id, code); pure UI
G8Duplicate data-testid="bn-name-m1" and twelve identical pencil labelsSessionsTable.tsx:466, :501; TODO.md:583F3free win with G7: include the session in the id and the label ("Edit name for m1 in session 9")
G9MCP and chat-lens name resolution is first-wins by codegrounding.py:449; mcp_server.py:451–458; TODO.md:583F7open correct by construction once a second moderator is m2; until then only consistency with G5/G6 is available
G10"Moderated by" dedupes by name; the sidebar detects multiple moderators by distinct namesroutes/sessions.py:152–158; SessionsSidebar.tsx:34–37F5blocked on the renumber (TODO.md:583: removing the dedup renders collided data faithfully as "Sarah, Sarah, Sarah")
G11No name origin per field; no cleared sentinel; a confirmed guess and an unchecked guess are the same bytesdesign-people §C1, §C5, §J3; people.py:373–400 refills absenceF2 D4decided · not built step 2; the sentinels must be chosen before the field ships
G12No role endpoint; a role change is a recode of seven thingsdesign-people §J1; routes/data.py exposes GET/PUT people only—decided · not built role as a recorded override in step 1, the recode with step 4
G13The bank has no store: Person rows live in one SQLite per projectserver/db.py:27; design-people §B4 measured blocker; §J2 names instance.dbD1blocked on the instance database of design-multi-project.md §2
G14The undo bridge is dead on the web half; compound verbs (rename + renumber) cannot undo as one actbridge.ts hard-codes canUndo: false; design-people §B10, §D step-1 gateD1 D2blocked a step-1 gate by the spec's own pricing
G15The quote stable key carries participant_id, so a recode of a p code does not survive re-importdesign-people §B10, §J4; _pinned_quote_ids has no re-attribution armD2open moot for a moderator renumber (no quotes), live for the observer mis-filing repair (p → o)
G16Whether codes are derived at render time or rewritten into transcripts-raw/design-people §J4, "not yet settled"D2open decides the size of step 4
G17The mockup register says the people lens and person actions shipdocs/mockups/STATUS.md rows for person-actions-everywhere.html and people-lens-scopes.html; the router has no People route and frontend/src has zero onContextMenu handlers—free win re-register both as PROPOSED (the lens) and PROPOSED · partly superseded by design-people §B (the actions); the check script cannot judge state, only presence
G18Docs and comments that still name the rejected rekey or the retired linking phasepeople.py:136–145 docstring and :218–224 WARNING text ("the fix is to give people.yaml the person↔session split"); stages/CLAUDE.md:60, :215 (signature with a session_id parameter the function does not take); design-html-report.md:76 Phase 2 same_as; README.md:313; ROADMAP.md:38; design-decisions.md:382–384; design-mcp-server.md:855—free win a truing pass; the WARNING text is user-visible in the log and points researchers at a rejected design. Done: people.py in cf18cb8, 1 Oct 2026; the rest on 2 Oct 2026, as dated route C notes with the history kept. design-mcp-server.md:855 turned out to be still true under route C; the stale text was its "currently anticipates a suggester" item at :452
G19The Mac Re-analyse sheet counts every Person row with a non-empty full_name as a researcher's renameCurationCountsReader.swift:73–77; the pipeline fills full_name from LLM and label names, and there are N rows per code—suspected not run; if true the sheet overstates "renamed speakers" by the session count and counts guesses as edits
G20No fixture can hold two m1 names, so no failing test for the header, the sidebar or scope-at-rename can be written today; the sidebar's MULTI_MODERATOR fixture draws a shape the pipeline cannot produceTODO.md:583; SessionsSidebar.test.tsx:146–186F5 F8blocked the renumber lands first; re-home the pinned-limitation test then, never delete it
G21The transcripts plan keys study-wide moderator codes two ways (§0 participant id; §3 display name) and Q4 asks the owner whichdesign-cloud-import-transcripts.md lines 121, 148, 454–455, 753–754F11 D3open owner call; this report recommends §0's key (challenge 5)
G22The §4 VTT contract carries display names (<v Name>) and no participant id, so D3 cannot be keyed by id without the writer emitting onedesign-cloud-import-transcripts.md §4 items 3–4; Meet's participants.list has ids (§1), Zoom's Name: prefix has noneD3open a contract addition (a NOTE map or an attribute per cue) decided with G21
G23The collision is announced only as a WARNING in the per-project logpeople.py:213–225; TODO.md:269 ("the weakest available surface")F2 D4open a MessageKind call under design-pipeline-diagnostic-popover.md; D4's sweep line is the SPA-side answer once origin exists
G25people.yaml is a second store for researcher state, with a best-effort write-through and a downgrade hazard on every new blockserver/CLAUDE.md § Names architecture; routes/data.py:223–299; design-people §J2E2decided · not built retired as a store on 1 Oct 2026 (Part 5b, I5); read once on upgrade, then ignored
G24Role identification samples the first five minutes and maps UNKNOWN to a p codes05b_identify_speakers.py (seg.start_time > 300); design-people J3F9decided · not built the repair (observer mis-filing, §B7a) is the product, not a better classifier
Part 7 What we need, in the order the dependencies allow

Four tranches

Decided 1 Oct 2026 — the route C package (see Part 5b)

Now — no decision needed, nothing the renumber will undo

Next — step 1 of §D, the prerequisites the spec already prices

Then — step 4, the moderator half first

With the transcripts plan — Q4, answered in §0's terms

Part 8 The challenges — what makes this harder than it looks from the grid

Twelve things to design around

  1. Files in the field. Dissolved 1 Oct 2026 by retiring the file as a store (Part 5b, I5); the record stands. people.yaml is the one file researchers hand-edit, with copies in the field since the 14 Jul TestFlight. Any reshape needs a migration; any new block carries a downgrade hazard, because write_people_file does a model_dump() and an older binary silently drops what it does not know (§J2). The renumber is the design that needs neither, which is a large part of why it won.
  2. Three stores, one invariant, no transaction across them. Reduced to two on 1 Oct 2026: the database and the raw transcripts, whose bracket tokens are now within-session tags rather than identities (Part 5b). YAML, SQLite and transcripts-raw/ each hold the code, and a renumber must change all three or leave a reader that disagrees (D2). The DB half has an ordering constraint (free m1 before taking m2); the YAML half has no create path in the write-through; the file half is the open J4 question.
  3. The fixture that cannot exist. Until a second moderator can be m2, no test can express "two moderators across sessions", so the header, the sidebar and scope-at-rename have no red test to write first. The sequencing inverts the obvious order (TODO.md:583): the renumber lands before any of the surfaces it fixes can be gated.
  4. Tests that pin the limitation. test_multi_session_moderator_codes_collide_and_are_warned is a contract that the loss is announced; it must be re-homed, not deleted, when the loss stops happening. The sidebar's MULTI_MODERATOR fixture is the opposite problem: a passing test about a world the pipeline cannot produce.
  5. What counts as identity evidence. decision 1 forbids merging on name equality at every altitude, for good reason: two Marys are two people. A platform participant id is not a name; it is the platform asserting that one account spoke. Q4 must be answered with that distinction stated, or the first implementation will merge on the display string because that is what the VTT contract carries (G21, G22). Two "M. Alvarez" accounts in one tenant is rare and must still be two codes.
  6. The bank's store does not exist. "A pick, not a keystroke" needs rows that span projects, and every Person row lives in a per-project SQLite. The instance database is a prerequisite of the keystone, and it is a new database with its own migration story.
  7. Compound undo across a dead bridge. Rename-and-renumber, name-from-the-bank-and-link, re-attribute-and-fall-out-of-the-lens: each is one act to the researcher and two or three writes to the system, and the channel that would let ⌘Z span them is wired on the Swift side and not on the web side.
  8. Observers are the common case and the unnamed one. The extra person is an observer ~95% of the time; observers are never auto-named; their codes restart per session and fragment when someone else speaks first (F9). Auto-naming them before study-wide codes exist would spread the mislabel without a human in the loop (G4).
  9. The warning has no good home yet. A log line is read by nobody; a toast is banned; the popover vocabulary is a five-kind taxonomy with a flowchart for new messages. Until origin exists, the sentence can only be about sessions and codes, which is less than the researcher wants to hear and all the system can truthfully say.
  10. Localisation of a menu that does not exist. §I4 counts 141 seedable keys for the person menu across 21 full locales, with the label-plus-chooser contract (§B2) because Korean and Japanese cannot close an English cloze sentence; the fr moderator word is itself an open owner call. The sweep line in D1 is one more plural-bearing string in 21 files.
  11. The sealed renderer disagrees and will keep disagreeing. The static HTML byproduct groups by computed.session_id and drops moderators from rows; it is frozen by decision, so the two files in one output folder will describe the study's moderators differently until the byproduct is repurposed.
  12. The register that says it shipped. Two mockups are listed IMPLEMENTED for a lens that does not exist. The gate behind the register checks presence, not truth, by design; the truth has to be put there by hand, and a reader who trusts the register will skip this whole problem as solved.
Part 9 Method, and what could not be read

How this was produced