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.
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.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.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.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.1446e4d, drawn from the cited source; proposed frames draw the settled design and say so in their chrome.people.yaml, the persons and session_speakers rows, the request on the wire. Red is the wrong value.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."
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.
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.
| Document | What it says about moderators, codes and names | Decided 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.md | Four 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.md | Why 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 identification | The 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 §2 | The 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.md | Line 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.md | 3c "preserve platform speaker names" and 5c, both unchecked. | Open. |
| design-export-html.md · design-export-sharing.md · SECURITY.md | The 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.md | Rows 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. |
| Mockups | person-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. |
| Where | What it records | State |
|---|---|---|
| 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 tests | tests/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). |
| Date | Commit / release | Change |
|---|---|---|
| 31 Jan 2026 | 83746ae9 · 0.4.1 | The people file and display names. |
| 1 Feb 2026 | 0.6.2 | Names editable; auto name/role extraction. "LLM results take priority over metadata, human edits never overwritten." |
| 2–3 Feb 2026 | 202136fc · 0.6.8 | Moderator identification Phase 1 with per-session codes. The counters that restart date from here. |
| 11 Feb 2026 | f2c42b51 · 0.9.1 | Moderator and observer names in project stats ("Moderated by …"). |
| 14 Feb 2026 | 9dd2f42f | Person and SessionSpeaker schema; the docstring says the join "makes cross-session moderator linking possible". Nothing has ever written a shared person_id. |
| 23 Feb 2026 | 45380392 | Names architecture: YAML canonical, DB materialised, write-through. |
| 28 Feb 2026 | 02767c2a | Inline name editing in the sessions table, keyed by code. |
| 16 Apr 2026 | 3bb355ed · bec69b96 | Speaker detection rewrite and the four speaker design docs. |
| 17 Jul 2026 | ae522367 | Moderator detection fix: the stored role is never "moderator"; surfaces key on the m prefix. |
| 31 Jul 2026 | 6a84dca6 · 0.23.0 | MCP Anonymise switch; grounding.resolve_speaker_names (first-wins by code). |
| 19–20 Aug 2026 | 773707fb · 57777276 | The 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 2026 | c106f8a9 · 0.27.0 | "Not re-keyed by (session_id, code)"; Re-analyse counts renamed speakers (desktop). |
| 27 Aug 2026 | 52146963 · c188db76 · deb62808 · 5a43c829 | design-people.md lands; glossary vocabulary; [OBSERVER] in the extraction prompt and job titles blanked under anonymise; TODO.md:583. |
| 31 Aug 2026 | 0.29.0 | Exports not anonymised by default; speaker ID runs before PII redaction. |
| 21–22 Sep 2026 | 5bcd9c9b · 12c7c9b4 | design-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 closed | Phase 2 linking closed not-planned, superseded by design-people. |
| 30 Sep–1 Oct 2026 | 3a302803 · 1446e4d | The whole-transcript splitting experiment; the cloud-import transcripts plan with the finding this report answers. |
llm/prompts/speaker-identification.md (v0.1.1): reads about the first five minutes; roles researcher (the one who "drives the conversation"), participant, observer; extracts person_name and job_title; a prompt-injection guard on names. This is where a moderator's name comes from on a Whisper transcript, once per session, from one five-minute window.llm/prompts/speaker-splitting.md (v0.1.1): the common case is "interviewer + interviewee"; the sample window is the open question the 30 Sep labelling answers.llm/prompts/quote-extraction.md rule 1 names [RESEARCHER] and, since 27 Aug, [OBSERVER]: "an observer is never a data source".transcripts-raw/ bracket tokens as provenance, or must a recode rewrite them? Decides whether step 4 is medium or large.New Moderator… as an anchored popover with a text field over the bank, or a pure NSMenu submenu. "A text field is what an NSMenu cannot host."Not a Speaker semantics; Not the Same Person placement; Role in All N Sessions on a mixed row; three unprecedented chrome nouns (lens, client, study); common-word suppression.Someone New….MessageKind the collision warning earns on the pill or popover.~/.config/bristlenose/bristlenose.db is occupied, so it is instance.db.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.
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.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.[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.cleared state. Absence is the only signal, and the pipeline refills absence (people.py:373–400).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).
| Surface | Key | Rule at 1446e4d | Where |
|---|---|---|---|
| people.yaml | code | One entry; computed stats from the last session in order; a typed name stays with the key whatever the stats now describe | people.py:194–225, :244–275 |
| Auto-fill from the LLM | code | The last session iterated supplies m1's guess, even when that session's guess is empty; observers are never filled at all | pipeline.py:2152–2162, people.py:373–400 |
| GET /people | code | Iterates every speaker row with no ORDER BY; the last row returned wins | routes/data.py:307–332 |
| PUT /people | code | .first() speaker row for the code; writes one Person of N; then YAML write-through by code | routes/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 them | routes/sessions.py:139–150, routes/dashboard.py:613–622 |
| "Moderated by …" header | name | De-duplicated by name, so two moderators with one name read as one | routes/sessions.py:152–158 |
| Sessions sidebar | name | "Multiple moderators" means more than one distinct name; shows only moderators[0]'s code | SessionsSidebar.tsx:28–37, :302–304 |
| React sessions table | code | Prefers /people's short name over the per-session name; the editor state is keyed by code | SessionsTable.tsx:131, :439–443 |
| MCP overview, chat lens | code | names.setdefault: the first non-empty name wins; {} for everyone when Anonymise is on | grounding.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) | code | Rows grouped by computed.session_id, so the moderator appears under the last session only; dropped from rows entirely when there is exactly one moderator code | s12_render/dashboard.py:61–101 |
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.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.
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)
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.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.o1 is Jane in s1 and Tom in s3, and the s3 stats overwrote hers (F9). Session-level moderator stats for eleven sessions.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.
A · sessions iterate s1 … s12 — s12 is Martin's
m1:
editable:
full_name: Martin Storey
# correct for 11 sessions, wrong for s9B · Mike's session happened to be processed last
m1:
editable:
full_name: Mike Alvarez
# wrong for 11 sessions, right for oneC · the last session's sample named nobody
m1:
editable:
full_name: ""
# eleven sessions heard "Martin"; the slot is emptyD · 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.
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.guess state is the only one that invites action, and it cannot be rendered because it is not stored).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.
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.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.(session, code) is a pure UI fix that touches no store. It stops the collapse; it does not stop F4.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.
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 AlvarezGET /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".
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.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.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.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.
before any edit — one name, one slot
after F4 — two names, one slot
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).m2 the dedup is by code and correct for free.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.
/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.
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.
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.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.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.
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.
m/o namespace "over-colliding and over-fragmenting", because the number "comes from within-session ordering, which has nothing to do with identity".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.o1, then o2; Tom is o1Jane 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.
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).
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.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.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.
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.
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.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.
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
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).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.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.
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.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.
m2The 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.
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.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.Person rows do not span projects (one SQLite per project), so the bank needs instance.db first (§B4 measured blocker, §J2).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.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 — 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 altitudem2 when someone names one.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.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-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.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.
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.
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.m1).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.
1 moderator name was heard in one session and applied to 12. If a different colleague ran any of them, name them there.
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)..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..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.Eight calls taken in conversation on 1 Oct 2026, recorded here so the frames below can be read against them:
| # | Decided | Consequence in the frames |
|---|---|---|
| P1 | A platform-proposed moderator is tentative in the app, plain in the export and MCP | Dashed, pulsing split badge with the accept/deny pill on hover; the exported roles line states the platform's name as fact |
| P2 | Name-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 |
| P3 | Nothing asks. No "looks like you" line, no confirm prompt | The 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 |
| P4 | Observers use the same engine; the count line counts moderators only | o? rows exist and rest silently |
| I1 | Wait for the transcripts plan's 0b; key the per-session map by its stable session identity | No assignment can move to the wrong interview when an older recording is added; 0b is the first package |
| I2 | Studies in the field migrate to "proposed on every session, origin inherited" | Frame E7: the old name stays everywhere it was shown, drawn tentative |
| I3 | Two 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 first | Frames draw the web popover; the native twin is named, not drawn |
| I4 | Me 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 |
| I5 | people.yaml is retired as a store; the database is the only home for names, identities, the per-session map and origin | The 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 |
| I6 | The per-session map is session_speakers.person_id, made nullable, plus state and evidence; persons gain a per-project code, a uuid and an origin | One Alembic revision on tables that exist; one Person per identity instead of one per session; NULL is m? |
| I7 | The pipeline runs the importer at the end of every run | The database always exists after a run, whoever ran it; serve becomes a reader; the importer proposes from evidence and never overwrites a confirmed row |
| I8 | The markdown report and the sealed static HTML get no investment | With 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.
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.
.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.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.
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.
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.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.
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.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.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.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.
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.Two edits that look alike and do different things. Re-picking session 3 moves one slot. Renaming Kerri moves nothing and changes her everywhere.
① 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
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.
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".null rather than a first-wins name.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.
m2.m?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.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.
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.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.
| # | Gap | Evidence | Frame | Kind |
|---|---|---|---|---|
| G1 | Moderator and observer codes restart per session | s05b_identify_speakers.py:451–456; the right default for the one-moderator study (§0) and the whole of the collision | F1 F8 | decided · not built decision 1 keeps the default and adds the escape |
| G2 | people.yaml holds one entry per code; the last session's stats win and a typed name stays with the key | people.py:194–225, :244–275; roadmap §11c; pinned by test_multi_session_moderator_codes_collide_and_are_warned | F1 F10 | decided · not built the renumber (§D step 4), never a rekey |
| G3 | The auto-filled name is whichever session was iterated last, including an empty guess over eleven non-empty ones | pipeline.py:2152–2162; people.py:389–394 | F2 | open 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 |
| G4 | Observers are never auto-named | pipeline.py:2152–2162 maps participant and researcher infos only | F9 | blocked on study-wide o codes: auto-naming into a slot that restarts per session would manufacture F9 without a researcher typing anything |
| G5 | GET /people has no ordering; the last row returned wins | routes/data.py:317–329 | F4 | free win order deterministically (by session number), and document which row speaks for a code |
| G6 | PUT /people writes one Person row of N for the code | routes/data.py:350–366 (.first()); the importer seeds all N from one entry (importer.py:885–890) | F4 | free 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 |
| G7 | The editor state is keyed by code, so one pencil opens N editors and the edit collapses | SessionsTable.tsx:131, :443; measured in Chromium and WebKit (plan §0b) | F3 | free win key editingCode by (session_id, code); pure UI |
| G8 | Duplicate data-testid="bn-name-m1" and twelve identical pencil labels | SessionsTable.tsx:466, :501; TODO.md:583 | F3 | free win with G7: include the session in the id and the label ("Edit name for m1 in session 9") |
| G9 | MCP and chat-lens name resolution is first-wins by code | grounding.py:449; mcp_server.py:451–458; TODO.md:583 | F7 | open 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 names | routes/sessions.py:152–158; SessionsSidebar.tsx:34–37 | F5 | blocked on the renumber (TODO.md:583: removing the dedup renders collided data faithfully as "Sarah, Sarah, Sarah") |
| G11 | No name origin per field; no cleared sentinel; a confirmed guess and an unchecked guess are the same bytes | design-people §C1, §C5, §J3; people.py:373–400 refills absence | F2 D4 | decided · not built step 2; the sentinels must be chosen before the field ships |
| G12 | No role endpoint; a role change is a recode of seven things | design-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 |
| G13 | The bank has no store: Person rows live in one SQLite per project | server/db.py:27; design-people §B4 measured blocker; §J2 names instance.db | D1 | blocked on the instance database of design-multi-project.md §2 |
| G14 | The undo bridge is dead on the web half; compound verbs (rename + renumber) cannot undo as one act | bridge.ts hard-codes canUndo: false; design-people §B10, §D step-1 gate | D1 D2 | blocked a step-1 gate by the spec's own pricing |
| G15 | The quote stable key carries participant_id, so a recode of a p code does not survive re-import | design-people §B10, §J4; _pinned_quote_ids has no re-attribution arm | D2 | open moot for a moderator renumber (no quotes), live for the observer mis-filing repair (p → o) |
| G16 | Whether codes are derived at render time or rewritten into transcripts-raw/ | design-people §J4, "not yet settled" | D2 | open decides the size of step 4 |
| G17 | The mockup register says the people lens and person actions ship | docs/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 |
| G18 | Docs and comments that still name the rejected rekey or the retired linking phase | people.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 |
| G19 | The Mac Re-analyse sheet counts every Person row with a non-empty full_name as a researcher's rename | CurationCountsReader.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 |
| G20 | No 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 produce | TODO.md:583; SessionsSidebar.test.tsx:146–186 | F5 F8 | blocked the renumber lands first; re-home the pinned-limitation test then, never delete it |
| G21 | The transcripts plan keys study-wide moderator codes two ways (§0 participant id; §3 display name) and Q4 asks the owner which | design-cloud-import-transcripts.md lines 121, 148, 454–455, 753–754 | F11 D3 | open owner call; this report recommends §0's key (challenge 5) |
| G22 | The §4 VTT contract carries display names (<v Name>) and no participant id, so D3 cannot be keyed by id without the writer emitting one | design-cloud-import-transcripts.md §4 items 3–4; Meet's participants.list has ids (§1), Zoom's Name: prefix has none | D3 | open a contract addition (a NOTE map or an attribute per cue) decided with G21 |
| G23 | The collision is announced only as a WARNING in the per-project log | people.py:213–225; TODO.md:269 ("the weakest available surface") | F2 D4 | open a MessageKind call under design-pipeline-diagnostic-popover.md; D4's sweep line is the SPA-side answer once origin exists |
| G25 | people.yaml is a second store for researcher state, with a best-effort write-through and a downgrade hazard on every new block | server/CLAUDE.md § Names architecture; routes/data.py:223–299; design-people §J2 | E2 | decided · not built retired as a store on 1 Oct 2026 (Part 5b, I5); read once on upgrade, then ignored |
| G24 | Role identification samples the first five minutes and maps UNKNOWN to a p code | s05b_identify_speakers.py (seg.start_time > 300); design-people J3 | F9 | decided · not built the repair (observer mis-filing, §B7a) is the product, not a better classifier |
session_speakers.person_id nullable plus state and evidence, persons.code/uuid/origin/me, one revision and the head-pin bump; the importer run at the end of every pipeline run (I7), proposing from the intermediates and never overwriting a confirmed row; the pipeline stops writing people.yaml and writes its stats to an intermediate; the one-time legacy import (I5, E7); identities minted on platform evidence (per id, or per distinct name within a study, P2) or a pick; m? and the proposed treatment on the split badge (E1); the picker as a web popover and a native NSMenu twin, wiring first, visual parity in a Diagnostics side-by-side mockup later (I3); Me seeded from the account name (I4); "Moderator not identified" in the export and null in the MCP overview with one added INVARIANTS line (E6); migration to proposed-inherited (E7); the stored labels and per-session evidence column; the count line, moderators only (P4).server/CLAUDE.md's names architecture stop describing people.yaml as editable; the file's header comment is the last thing a researcher reads before editing it.editingCode, the test id and the aria-label by (session_id, code) (G7, G8). Ends the measured collapse in F3 without touching a store.put_people updates every Person row the code resolves to; get_people orders by session number (G5, G6). After this, F4's reload shows the name the researcher typed, everywhere, which is what the invariant promises and what the YAML already does. The MCP roster then agrees with the grid (G9) by consequence.MessageKind for "a speaker code was reused across sessions" (G23). Until origin exists the honest wording is about sessions, not names: "m1 appears in 12 sessions; one name covers all of them."PUT /people payload (str | None fields, assign only what was sent; §J2). Back-compatible, and the precondition for "confirm" changing no value.persons.uuid, one Alembic revision plus the head pin in tests/test_migrations.py.m/o code carrying a different name and mints the next free code for that session: session_speakers and transcript_segments updated under the unique constraint's ordering, people.yaml gaining a key through a write-through that can create (today it cannot; §J1), the sweep line with the scope in its words, one undo entry. Quotes are untouched for m/o, which is why this half is cheaper than the participant half.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.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.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.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.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.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.1446e4d. Nothing was executed; no pipeline was run on any data. Frames F3's collapse and the §2 "Pri" case are measured claims borrowed from the transcripts plan (30 Sep–1 Oct 2026), which ran code. F4's reload order is read from SQLite's default row order and is labelled a likely rendering, not a guaranteed one.assign_speaker_codes signature, the SessionSpeaker line number) the tree won.person-actions-everywhere.html bench 9 and people-provenance-paths.html without translation.