One row per person — not per speaker code, not per session. The same lens answers a different question at each of three altitudes, and the change of question is what the scope control is really switching.
Fictional corpus used throughout, continuous with
people-provenance-paths.html: client folder NHS Trust holds
Ward handover app — round 1 (8 sessions, Sep 2025), round 2 (12 sessions,
Jan 2026) and Discharge letters — diary study (on an external drive, currently
unplugged). Client folder Kestrel Bank holds Mobile onboarding (6 sessions).
At the root, in no folder: Oral history — early free software (10 sessions).
section-heading atom, the real speaker badge and editable-name rules, the real
subgrid construction. No labels that would not ship.
| Scope | The question it answers | What the researcher does here | What the app may propose |
|---|---|---|---|
| This project | Are these the right people, with the right names and roles? | Confirm a guess, type a name, fix a role, separate one code that holds two humans. | Names, roles — it already guessed them during the run. |
| This client (a folder) |
Have I met any of these people before? | Say "same person" or "different people" — once, and it holds. | Matches, within this folder only. |
| Everyone | Who do I actually work with? | Read. Occasionally assert a link the app is not allowed to suggest. | Nothing. Cross-folder matching is refused by design, not unbuilt. |
A filter narrows one list. These three change the object. At project scope the identity anchor
is the speaker code — p3 is who this person is, it is what the quotes say,
it is what the export ships. One folder up, the same human is p3 in one project and
p6 in another, so the code stops identifying anything and the name becomes
the anchor. That inversion is why the People grid's degradation ladder drops the code column
first, where the sessions grid drops the name first and keeps the badge
(docs/design-sessions-grid ladder, rung 3). Same product, opposite priority, one
altitude apart.
It is also why the scope control cannot be a segmented control that quietly re-filters. Moving up a scope changes what a row is, what the columns mean, and — the part with teeth — what the app is willing to propose.
Round 1. One moderator, platform-sourced names, nothing to fix. The sweep line under the heading is absent because its count is zero — which is the only way a count line earns its place.
section-heading atom, the badge, the editable name and the subgrid construction are all shipped; one row per person is not..toolbar-btn + .toolbar-arrow — the same control as the tag filter and view switcher — sitting in the section-heading-action slot the Codebook Library button already occupies. No new control was invented for it.The job-title line under each name (PersonEditable.role) is worth pausing on: it
round-trips through people.yaml, it is filled by the same model pass that guesses
the name, and it is rendered nowhere in the app today — get_people builds a
three-key dict and drops it. So does persona, so do notes. This lens
is the first surface where a researcher could see what the pipeline wrote about a named person.
It is also, uncomfortably, already in the anonymised export (see bench 10).
Only the first count is knowable. 26 people for the client is the number of rows, not people — nothing is linked yet, so Rachel is three of those 26. And 58 silently excludes the diary study, which is on an external drive that is not plugged in. A menu is the wrong place to explain either. The lens has to, on arrival — bench 8.
Naming: the middle item carries the folder's real name rather than the word "folder", on the
All Sessions precedent in docs/glossary.md — name a scope by its content.
"Folder" is the app's word for the container; "NHS Trust" is the researcher's word for who
these people are.
Round 2. Rachel ran sessions 1–8, Priya ran 9–12. The moderator counter
resets every session, so both are m1, and people.yaml is keyed by
code alone — it is structurally incapable of holding two of them.
The two halves come from different places, which is what makes it undetectable by eye on the Sessions lens and detectable here. Editable fields are preserved by key across runs, so Rachel's typed name survives; computed stats are overwritten by key, so the last session in the run wins. The lens puts a name and a word count in the same row for the first time, and 4,210 words across twelve sessions is arithmetic nobody who moderates would believe.
This is the argument for the lens existing at all, and it is not a UI argument: the
serve-mode database already models this correctly — SessionSpeaker joins
person↔session per session, and its docstring says cross-session moderator linking is
why it exists. The database knows more than the file does. A person-shaped surface is
the only one that can show the difference.
One speaker code covers two names. Sort it out
The evidence for the contest already exists and is thrown away one step before it could be
used. Stage 5b extracts a name per speaker per session from self-introductions; the
pipeline then funnels it through a flat {code: info} dict, so session 9's "Priya"
overwrites session 1's "Rachel" and no record survives that they disagreed. Rekey that dict —
and people.yaml with it — by (session, code), and the contest becomes
a two-line query.
Two deliberate restraints in the pixels. No error colour: the row takes
--bn-colour-quote-bg, not a red or amber. Nothing has gone wrong for the
researcher — the app has a question. No auto-split: two extracted names is strong
evidence, not proof, and the same signal fires for one person who says "Rachel here, covering
for Priya today". The app states what it heard and where; a human decides.
The sweep line says one thing and links to a place, on the pattern four review agents converged on independently: a count line under the heading costs no column width, survives every rung of the degradation ladder, and is one translated string rather than a mark on every one of thirty rows.
Bristlenose numbers moderators within each session, so one code can cover more than one person. Sessions 1–8 and 9–12 introduce different names.
Quotes are not affected — moderators do not have quotes. The speaker code stays
m1 in both halves and in every transcript.
Three buttons, and the third one matters. "Keep as one person" is not a synonym for
Cancel — it records the decision, so the contest never re-appears on the next run. Without it
the researcher who genuinely has one moderator with two names is nagged forever, which is how
a correctness affordance gets switched off. It is the not_same half of the
linking vocabulary, one altitude down.
The sentence beginning "Bristlenose numbers moderators…" is doing the load-bearing work: it explains the consequence before asking the researcher to choose, and it names the mechanism in a way that makes the question answerable rather than mysterious. Cancel is not the default; the trailing button is.
The tail of round 2. Four participants, four different relationships between a row and a name: a real record, a guess, a researcher's own typing, and nothing at all. Only one of them is a name in the sense the researcher means.
A reuses the shipped placeholder and it stutters. speakerRolePlaceholder()
renders the role word — "Participant" — in the name slot, which reads perfectly on the Sessions
lens because no role column exists there. Move it into a table that has a Role column
and row p9 says "Participant · Participant". A string is only as good as the
surface around it; this is a straight import of one lens's solution into another lens's
geometry.
B leaves the cell empty and lets the count line carry it — the house move, and cheaper
in every language. But an empty cell is pixel-identical to a failed fetch: the Sessions lens
swallows a /people error in an empty catch and renders exactly this.
What saves B is the line above the table: it is absent when the count is zero, so its
presence is what distinguishes "one person is unnamed" from "the request failed" — and
the failure path has to say something of its own for that to hold.
Only one of the four rows is a plain fact. Hannah Wu was typed by the researcher. "Danny" is the model repeating a first name it heard, set in italic and nothing else — one treatment, no chip, no dot, no extra tab stop, and it survives every rung of the ladder. p9 is silence.
And p10 is the one to worry about. An email address in the name column has the highest confidence of the four — it is a verbatim record from the Teams file, not a guess — and it is also direct, unambiguous, contactable PII, sitting in a field that ships in exports and reads as machine output rather than as a name. The confidence axis and the sensitivity axis are orthogonal, and the design so far only draws one of them.
Oral history — early free software. Ten interviews, one interviewer. Stage 5b tagged the interviewer as a participant and each interviewee as a moderator: exactly the failure the FOSSDA stress test found, now with its downstream cost visible.
Both tells are arithmetic across sessions, so neither is visible on the Sessions lens, where each session is a row and Zach is simply "p1" ten times. This is the second argument for the lens after bench 2, and it is the same argument: some defects are only visible when the person is the row.
The Role cell is a popup button — the native control for a closed set of three. It is
borderless at cell density (text plus a muted chevron), which is what macOS does in a table;
the bordered .toolbar-btn form would put thirty boxes on the screen.
Roles changed for 11 people. Quotes were pulled out using the old roles. Analyse again…
The roles are now right and the Quotes column is unchanged — 47 against the interviewer, none against the interviewee. That is the truth and the screen says it. A role is a label on a person; quotes were produced by a stage that read the old label and has already finished.
The alternative — silently re-running extraction when a role changes — is worse in three ways: it spends the researcher's money on a cloud call they did not ask for, it discards every edit made to the affected quotes, and it makes a dropdown into a destructive action. So the line offers the existing verb instead. Analyse again is already built, already counts what it will replace in its confirmation sheet, and is already the researcher's decision. The lens surfaces the need; it does not take the decision.
Wording, deliberately: "Quotes were pulled out using the old roles", not "quote extraction used stale role assignments". Same fact, no machinery.
Round 2, sessions 3 and 4. The client's product manager sat in. He is a speaker, so he has a code, a name and a row — and a set of privileges nobody chose for him.
The row is right: Tom spoke 310 words across two sessions and contributed no quotes. What the row does not say is that the anonymise switch keeps his name. Anonymising an export blanks participant names and preserves both moderators and observers, on the reasoning that the research team is not the study's subject. That reasoning holds for Rachel. It does not hold for a client-side stakeholder who wandered into a session, whose name and employer then travel in a document the researcher hands to other people at the same client.
The checkbox hint the researcher actually reads says only "Moderator names are preserved" — which is not false, and is also not the whole list. Observers are a second category wearing the first one's rationale.
Note also that "Observer" is the role and Product manager, Meridian Health is the job title, and they sit two columns apart because they are different things. Four senses of "role" already coexist in the codebase — the stored enum, a translated display label nothing reads, the UI word "Moderator", and this job-title field — and the glossary adjudicates none of them. A lens named after this vocabulary has to settle it before it ships, not after.
Client scope, NHS Trust. Nothing is linked, so a person who appears in two projects is two rows — because the importer creates a fresh person row for every speaker in every session, and no step has ever joined them.
4 people may appear twice. Go through them
p1 in round 1 and p4 in round 2 are the same nurse; showing both codes shows the machinery. This is why the People grid's degradation ladder drops the code first — at this altitude it has already stopped identifying anyone.Two answers, both recorded. "Different people" is not a dismissal — it writes a negative link, so the pair is never proposed again. Without it, every re-run re-offers the same wrong match and the researcher learns to ignore the whole affordance. Same shape as "Keep as one person" in bench 2, one altitude up.
Both suggestions here rest on exact name equality plus platform provenance — the sentence says so in the researcher's words rather than showing a score. The design doc allows near-matches (Levenshtein ≤ 2) as well; a fuzzy match should not wear the same sentence as an exact one, because the researcher's answer depends entirely on which it is.
Sarah Chen: 2 sessions, 16 quotes, across two rounds. That row is the reason a researcher would ever visit this scope voluntarily — it is a longitudinal finding, and until the link exists there is no object in the system it could hang on. Everything else in this lens is correction work; this one row is the payoff.
Two things the merged row must not do. It must not delete anything — a link changes how rows are presented, and un-linking has to put both back exactly as they were. And it must not leak: an export from round 2 carries round 2 only, link or no link, so the leave-behind Rachel hands the client never gains a second study's sessions because of a bookkeeping decision she made in a different window.
The job title shown is round 2's, because that project has more of her. Auto-choosing the fuller record is right; it also silently picks a winner between "Ward sister, acute medicine" and a shorter title from round 1, so the choice has to be visible and reversible in one click.
Two participants the model named "Danny" from a first name it heard once each. Same folder, so a match is proposed. Neither name was ever a record.
A link suggestion is a claim about two names. If the app guessed both names, the suggestion is a guess about a guess, and it is offered in a control whose two buttons look exactly like the well-founded case in bench 6. Confidence does not compose, and a design that draws the same row for "two platform records agree" and "the model heard a first name twice" is inviting a wrong merge that then travels — it will pool quotes, and pooled quotes reshape agreement counts in the analysis.
Three consequences drawn here. The sentence names the weakness in plain words rather than showing a percentage. Neither button is the default — the primary fill is removed, so returning is as easy as deciding. And the sentence sends the researcher to the evidence ("open the sessions"), because for this class of match the transcript is the only thing that can settle it.
The sharper option is to not suggest at all when either side is a guess, and let the pair sit unmerged. Suggestions are cheap to add and expensive to trust; a lens that only proposes matches it can stand behind is the one whose proposals get read.
Fifty-eight person rows exist. Fifty-two of them are someone who took part in one session of one study and will never be looked at again. Showing all fifty-eight is showing the database.
People you have worked with more than once. Show everyone (58)
Discharge letters — diary study is on Samsung T7, which is not plugged in. Nobody from it is counted here.
Matching is folder-scoped by design, and this is the row where that rule earns its keep. Across clients, name equality carries almost no evidence — the two Sarah Chens would be proposed as one person by any algorithm that looked, and a wrong merge here pools quotes from two organisations into one person's record. The rule also gives a clean answer for projects in no folder at all: the oral history study sits at the root, so it receives and offers nothing. Rachel and Priya are one folder each; the folder is not decoration, it is the consent boundary for matching.
The default view is the roster, not the table. The question at this altitude is "who do I work with", and its answer is the six people who recur — moderators, contractors, and the occasional longitudinal participant. The other fifty-two are one link away and behind a plain count, because a lens that opens on fifty-eight rows of noise is a lens that gets opened once.
The last line is the one nobody would think to write. Half a freelancer's projects are on drives and shares that come and go, so a total at this scope is always a total of what is currently readable. Saying which drive and naming it is the difference between a count the researcher can trust and a count they cannot audit. Same principle as the greyed-out project cards on the home screen: unavailable is normal, not an error — but it has to be said out loud wherever a number depends on it.
The researcher selects both Priya rows and says they are the same person. Round 1 and round 2 are already tied together, so this link is not the only thing that changes.
Bristlenose never suggests a match across clients, because two people can share a name. You are telling it these are one person.
Round 1 is already linked to round 2, so it joins as well — three projects, two clients.
Nothing moves between the projects. An export always carries one project, so a report you send Kestrel Bank cannot gain anything from the NHS work. What changes is that a name or job title you fix in one shows in the other.
The chain is drawn, not described. Linking A to B when B is already linked to C links A to C, and a researcher who is told that in a sentence will not picture it. Three nodes and two relations fit on one line and make the consequence a thing you look at rather than parse. Un-linking needs the same picture, showing what breaks.
The cross-client crossing is drawn twice — once as the dashed node, once in the sentence — because it is the only part that is irreversible in the social sense. Bristlenose can un-link at any time; the researcher who realises they have wired two clients' records together cannot un-see it.
The third paragraph exists because the researcher's actual fear is not modelled by the data model: "will Kestrel see the NHS material?" The answer is no, structurally — export is project-scoped whatever the links say — and the sheet says so before asking, rather than leaving it to be discovered. Naming the real limit (a shared name and title) buys the credibility for the reassurance.
Cancel is not the default and is not destructive-styled; the trailing button is the default. Nothing here is destructive — which is exactly why the sheet must not borrow destructive styling to signal seriousness.
Round 2, anonymise ticked, sent to the client. The shipped checkbox and its shipped hint, beside what actually leaves the machine.
Creates a standalone report that anyone can open in a browser. Recipients can view and search but cannot edit.
role, which the model extracted from the same speaker pass. It renders nowhere
in the app, so it is invisible in the UI and present in View Source — and job-title-plus-employer
is the product's own documented example of indirect identification. Stripping it was decided
in the export design doc and never implemented; this lens is the first surface that would
show a researcher the field exists.And the lens itself does not ship in the export. Not because export mode is read-only —
that is a mechanism — but because everything in it is a correction affordance for someone who
can correct, and one part of it is actively unsafe to send: telling a recipient that
p7's name was heard in a session tells them a real name exists, that it is
in the transcript body they already have, and that the pipeline located it. That converts a
latent leak into a targeted one, precisely for the participants whose names were deliberately
removed. Every other lens ships; this one is the exception, and the exception needs the
one-line reason written next to the code that enforces it.
people.yaml and the pipeline's speaker-info dict are rekeyed by
(session, code). That reshape has a home already — the speaker-editing roadmap, §11c,
with its eight-reader list. The lens should extend it, not restate it.Not settled here, deliberately. Whether the placeholder for an unnamed person is the role word or nothing (bench 3). Whether "Everyone" belongs in a report lens at all or in the app's own window, given that every other tab means "in this project". Whether the job title gets the same italic-when-guessed treatment as the name, since it is filled by the same model pass and is just as inventable. And what a person-shaped API owes a participant who withdraws consent after analysis — the erasure verb does not exist, and this is the design where it would be cheap to allow for.