fix(notes): keep provisional meeting identities from screen and calendar evidence

GI
10d ago
+1567-340

Meeting notes can see a counterpart on a calendar card or call screenshot and still save them as Speaker 2, because tile-name and email-local-part gates drop the name before the notes model can keep it. This lets that model keep a name it read from a frame, a screen moment, or a calendar attendee, store a provisional person, and record its own speaker binding as inferred. A wrong name is reversible. A voiceprint is not.

What changes

  • Screen-frame tile names are no longer joined into the roster. Approved frames and the OCR digest still reach the notes call. A matched calendar title and attendees pass through unchanged. Privacy rejects are unchanged.
  • A non-owner name survives validation only when that exact name appears in the transcript, the supplied screen text, or this meeting's attendee roster. A frame or screen block existing is not enough. The email is kept only when it is on the attendee roster or in that same text. Released app clients still decode source as roster or transcript; a screen-backed name is stored as transcript on that wire.
  • On note save, a corroborated non-owner participant becomes a provisional person: name, email if supplied, short alias if the model asserts one. A transcript-only name stays in the note and is not stored as a person. No speech sample, embedding, or learning job. Exact email or exact stored alias can attach an existing person. First-name match cannot.
  • The model's own speaker binding is stored as inferred (notes_inferred, public source auto) only when some segment is already marked as the user, and never on that user's speaker. An existing non-notes match is not overwritten. A later notes call may rewrite an inferred label. Manual labels, unassignments, and rejections win and are not reapplied. Rejecting the label removes the name from the note and removes identifiers this note added.
  • If approved frames land after a note that had none, one notes refresh runs, unless the user set the title. The 20s client pass and 25s server wait are unchanged. The refresh does not run memory, task, embedding, or teaching work.
  • Recent contacts' names, aliases, and emails are included in the people block even when this meeting's roster is empty, inside the existing cap.

This supersedes the admission scanner in #20730. It keeps counterpart-first titles and notes-model bindings. It does not port the introduction scanner, the full-name creation gate, SUMMARY_SPEAKER_LABELS_ENABLED, or the roster-source allowlist.

Validation

backend/test.sh on the identity selection: 309 passed. backend/scripts/typecheck.sh: 0 errors. Includes a screen name with a freemail local part that does not spell the name, a hallucinated name dropped while a frame exists, no person from a transcript-only name, no binding when the owner is unmarked, no first-name merge, no teaching, manual rejection removed from the note, and late refresh skipped when the user set the title.

No deployment and no flag flip in this PR. The behavior is on the notes path that is already in production, so it takes effect on the next backend deploy. Do not deploy from this PR until review and CI are green.

Product-Invariants: INV-MEM-4, INV-TASK-2

INV-MEM-4: canonical memory promotion is unchanged. This stage writes transcript labels and provisional people after the note is saved.
INV-TASK-2: task capture admission is unchanged.

Failure-Class: none

The reviewed registry classes do not describe provisional contact identity or inferred transcript labels. The regression tests are the prevention surface.

Line-Count-Exception: backend/database/conversations.py | 3657 -> 3688 | Atomic notes, people, and binding persistence, plus the concurrent user-title veto. A broad persistence refactor is outside this change.
Line-Count-Exception: backend/utils/conversations/process_conversation.py | 3571 -> 3577 | Carry notes identity context into the existing persistence seam.
Line-Count-Exception: backend/utils/llm/conversation_processing.py | 1961 -> 1969 | Pass screen or calendar evidence into validation without waiving name corroboration.