Privacy controls (voiceprints and inferred gender)
OpenTranscribe can recognize the same person across recordings by their voice, and it can infer a speaker's gender from their voice. Both work on biometric-style data, so each has a switch. This page explains the layers of that policy, what turning a switch off does, and the separate, irreversible action that deletes the data a switch only hides.
This page also seeds the data inventory for issue #1132, the voiceprint privacy lifecycle (consent records, per-profile expiry, export), which moved to v0.9.0.
What the two switches control
| Switch | When on (the default) | When off |
|---|---|---|
| Voiceprint matching (cross-file speaker recognition) | Each speaker in a recording gets a voiceprint. New speakers are matched against saved voiceprints, name suggestions come from those matches, and speakers are grouped into clusters across recordings. | No voiceprint is created, stored, matched, suggested from or clustered. Speakers are still told apart within a recording (SPEAKER_00, SPEAKER_01), because that uses temporary data that is never stored. |
| Inferred gender | A model infers male or female for each speaker from the voice (and the speaker-name step may compare that with a first name). | Nothing is inferred, shown or used. A gender a person confirmed by hand is theirs and is kept. |
Gender inference is on by default. Wherever gender is shown or configured, the interface says that it is inferred automatically from voice and can be turned off, and links to the switch.
The four layers
The two questions (may voiceprints exist, may gender be inferred) are answered for one person in one organization (or their personal workspace) by combining four layers. The policy is restrict-only: every layer can switch a feature off, and no layer can switch on something a layer above it turned off. Any layer that is off turns the feature off.
| Layer | Who sets it | Where | Default |
|---|---|---|---|
| Platform | The operator of a managed edition, through an extension | Not a screen. A derived deployment registers a resolver at an extension seam | No opinion |
| Instance | A super admin | Settings, Privacy & Redaction, Redaction Policy tab, card "Speaker recognition and inferred attributes" | Both on |
| Organization | An organization administrator | Settings, organization group, Privacy policy (managed editions only; a community install has no organizations) | Both on; an organization with no saved choice inherits |
| User | Each person | Voiceprint switch: Settings, Speaker Identification, "Recognize speakers across my recordings". Gender switch: the same page, Voice Attributes, "Predict gender from voice" | Both on |
The first layer that is off is reported as locked_by (platform, instance, organization,
user, consent or file), so the interface can say who to ask. A user whose organization turned a
feature off sees their switch disabled with the reason, and the API refuses to change it
(409 policy_locked).
Fail closed. Every error path reads as "off". A platform resolver that raises, a policy read that
fails, a recording that cannot be found, or a consent hook that raises or returns anything but
True denies the feature rather than allowing it.
Consent. Core ships no consent record. An extension can register a consent hook that must
approve creating a voiceprint, and approve letting someone other than the owner match against a
saved profile. With no hook registered, consent allows. A denial by a hook is reported as
locked_by: "consent" for voiceprints. Consent records and their user interface are part of #1132.
Per-recording voice identification (a fifth, narrower control)
Each recording carries its own voice identification state, set when it is uploaded and changeable by
its owner later. It is restrict-only like the layers above: it can turn voiceprints off for
one recording and can never turn on what the platform, the deployment, the organization or the
person has turned off. It is checked after those layers and before any consent hook, and is
reported as locked_by: "file".
| State | Meaning |
|---|---|
unspecified | Nothing was stated. This is every existing recording, and changes nothing |
granted | The uploader stated that the speakers agreed. It is a recorded claim: OpenTranscribe stores the statement, when it was made and who made it, and does not verify it. It enables nothing by itself |
declined | No voiceprints are created for this recording |
At upload. The wizard's Speakers step has an unchecked box, "I state that the speakers in this
recording agreed to voice identification". Checking it records granted; leaving it unchecked
records nothing. It is not shown while voiceprints are off for you. A batch upload applies the one
choice to every file. Uploads that skip the presigned flow (URL imports, watch sources, the in-browser
recorder) record unspecified.
Opting a recording out later. The owner (nobody else, not even an organization administrator)
can turn voice identification off for a recording from its speaker panel. That first records
declined, which stops new voiceprints at once (the OpenSearch write guard re-reads the state on
every write), and then queues the file-scoped voiceprint purge with the reason opt_out. The purge
deletes the recording's voiceprint documents, recomputes each profile and cluster average from the
voices that remain, and is measured like every other purge. Names and links a person set themselves
are kept. Opting back in records granted but restores nothing: reprocess the speakers to create new
voiceprints.
Averages. A profile or cluster centroid never includes a voice from a declined recording, even
while that recording's document is still readable (an opt-out whose purge has not run, or failed).
Requiring a statement. The system setting privacy.voiceprint_upload_consent_required
(Settings, Privacy & Redaction, Redaction Policy tab, "Require a statement before voiceprints are
created") is off by default. When on, a recording is treated as declined unless it is granted.
Every existing recording is unspecified, so turning it on hides the voiceprints of all existing
recordings. It deletes nothing, and there is deliberately no automatic purge behind a settings
toggle.
Rolling back. The migration refuses to downgrade while any recording is declined, because an
older build ignores the column and would create voiceprints for opted-out recordings. List them with
the partial index ix_media_file_voiceprint_declined, or set OT_ALLOW_CONSENT_DOWNGRADE=1 knowingly.
What "off" does
Turning a switch off hides data and stops new data. It never deletes anything. Switching the feature back on makes existing data visible again, and new recordings get voiceprints again. Deleting is the separate purge.
Each decision is taken for the owner and organization of the record in question (the recording or profile), not for whoever is looking at it or whoever started a job.
| Where | What happens while voiceprints are off | What happens while gender is off |
|---|---|---|
| Pipeline plan (at upload and reprocess) | The speaker-recognition step is not scheduled. The pipeline view shows an informational notice: speaker recognition across recordings is off for this recording, speakers are still told apart within it. The diarizer is told not to return voiceprint vectors, so they never leave the worker. | The diarization sidecar is not asked for gender. |
| Entry gates (every stage, fresh read, no cache) | Each stage that produces or uses a voiceprint checks again when it starts, so a recording already in flight when a switch flipped is stopped too. A stopped stage reports "skipped by policy", not an error. | The same check in the gender attribute task, the sidecar gender write and the speaker-name step. |
| Write backstop (every OpenSearch write) | Every write of a speaker, profile or cluster document is checked again. A refused write is logged once per scope and returns without writing. The answer is cached for 10 seconds per process, so a switch change reaches every process within 10 seconds. A failed check denies and is not cached. | Not applicable (gender is stored in the database, not in a voiceprint document). |
| Reads and serialization | Voiceprint-based name suggestions are not made. A stored suggestion that a voiceprint match wrote is left out of responses, with its confidence and detail, whoever verified the speaker (the name a person gave stays). Cluster lists are empty for that scope. A name that only a voiceprint match supplied is also left out of every speaker label (see Where a voice-matched name is withheld). A transcript-derived AI suggestion is still offered. | Inferred gender is returned as null wherever a response embeds a speaker (see Where inferred gender is withheld). A profile's gender is voted on by confirmed genders only. |
| Actions that would create or recompute voiceprints | The cluster actions re-cluster, analyze outliers, promote, merge and split answer 409 voiceprints_disabled with locked_by. The admin profile-embedding repair answers the same when every profile is off, and otherwise skips the profiles that are off. | Not applicable. |
| Batch jobs | The admin v4 embedding migration, the consistency repair, the speaker merge task's voiceprint averaging, cluster tasks, profile rebuilds, retroactive matching and batch_speaker_matching skip scopes that are off and report a skipped_by_policy count. | The admin gender migration and the combined speaker analysis skip files whose owner or organization forbids it, and a forced reset leaves those files' values alone. |
| Clusters | A speaker is not placed in a cluster, and the cluster reads return nothing for the scope. | Gender no longer influences cluster joins or evictions unless a person confirmed it. |
| Suggestions and the speaker-name LLM | Profile and voice suggestions are not computed. | The voice-versus-name gender comparison is not added to the speaker-name prompt. |
| Sharing | Another user's profile that you can reach through a shared collection is dropped from your matching candidates when its owner's scope has voiceprints off, or when a consent hook denies sharing. | Not applicable. |
Plan previews follow the same rule as real recordings, with one difference in whose organization
counts. A request that names no recording, such as GET /user-settings/pipeline or the preview that
runs before you switch transcription providers, is judged in the organization the request is
working in, so a settings page never shows a speaker-recognition step as enabled while that
organization forbids it. A real recording is judged in the organization stored on its own row,
whichever organization the person who started the job has active.
Two behaviors are independent of the switches. First, the gender comparison is never added to the
speaker-name prompt for a remote LLM provider, even when gender inference is on; a local model
(a loopback, private-network or compose-hostname endpoint) still receives it. Second, the
DIAR_NATIVE_GENDER environment switch of the diarization sidecar remains an additional hard off.
What stays visible. Named speaker profiles, avatars, speaker assignments that a person verified and genders that a person confirmed are user assertions, not inferred data. Switching a layer off does not hide them, and a purge does not delete them.
An admin migration run while a scope is off. The v4 embedding migration skips files whose scope
has voiceprints off and reports skipped_by_policy when it finishes. After the new index goes live
it holds no voiceprints for those scopes. Their old documents stay in the preserved backup index and
are not restored. Rebuild them later, after switching the layer back on, with the existing
re-run speaker identification reprocess stage.
Where a voice-matched name is withheld
A speaker's displayed name is a name a person typed or verified, or else a name a voiceprint match
suggested with high confidence. While voiceprints are off for a recording's owner and
organization, the second source is ignored everywhere a name is printed, and the speaker shows
under the diarizer's label (SPEAKER_00) until a person names it. Every place goes through one
function, canonical_speaker_label_for_row in backend/app/utils/speaker_labels.py, which asks
the policy only when the voiceprint suggestion is what would decide the name:
- the file detail and the paginated segments, including
resolved_display_name,status_text(the "High confidence match" line) andresolved_speaker_name; the status is now computed after the redaction, not before, - the speaker list and detail,
- every transcript export (
txt,json,csv,md,srt,vtt), the Word and PDF documents, the player captions and the subtitle downloads, - the search index (chunks, and the digest and facts built from the same label, whenever they are rebuilt) and the speaker roster that chat resolves "who said ..." against.
A name a person typed or verified is not a voiceprint's and stays; a voice suggestion recorded on a speaker a person verified is not part of that name and is withheld too. Switching a voiceprint layer on or off, for a person, an organization or the deployment, queues the existing debounced re-index for the files whose name only a voiceprint supplied (and regenerates their facts), so the search index follows the switch in both directions without waiting for another edit to the file. Only a switch that actually changed value does this.
Names and links a voiceprint made
A voiceprint can name a speaker on its own: an automatic match at upload, a retroactive match
after you name another speaker, or a cluster join. Such a row looks exactly like a name a person
confirmed, so every speaker records who wrote its name and profile link (identity_source):
| Recorded as | Meaning | While voiceprints are off | A purge |
|---|---|---|---|
voiceprint | A voiceprint wrote the name and the profile link | The name, its "verified" mark and the link are hidden; the speaker shows its diarizer label | Reverted to unnamed, unlinked and unverified |
voiceprint_link | A voiceprint added only the link, to a name that was already there | The link, and the voice suggestion and match confidence that came with it, are hidden; the name stays | The link is removed together with that suggestion and confidence; the name and its verified mark stay |
human | A person typed, accepted, assigned or confirmed it, or promoted a cluster to a profile | The name, mark and link are never hidden; a voice suggestion left on the speaker is | The identity is not touched; a voice suggestion left on the speaker is cleared |
| none | Written before 0.6.0, or never named | The same as human (treated as a person's) | The same as human |
What a person does overrides what a machine did. Typing a name, accepting a suggestion, assigning
or creating a profile, verifying, batch-verifying and promoting a cluster make the identity
human. Skipping a voice suggestion in the Inbox only marks it reviewed: the suggestion stays a
voiceprint's, so it is hidden while voiceprints are off and a purge clears it (the speaker returns
to the Inbox), unlike accepting it. Renaming a profile renames only the speakers whose link to it
is visible, so a link a voiceprint made does not receive the new name while it is hidden.
Clearing a name keeps a machine link as a link (voiceprint_link). Unassigning, rejecting
or deleting a profile removes the link and leaves whatever name remains as unknown. Merging two
speakers keeps the survivor's own name, link and origin: a merge does not confirm a machine's
name.
Hidden means hidden in what is shown, not deleted: the speaker list and the speaker name directory (the gallery and chat speaker pickers; a hidden name is listed under the diarizer label only when unlabeled speakers are requested), a profile's occurrences (a recording whose only link is a machine's is not an occurrence while voiceprints are off), the "other recordings" list of a speaker and its administrator debug twins (each recording is judged by its own recording's switch, and the match confidence is withheld), the speaker preview, the speaker-merge notification, topic-suggestion prompts, exports, captions, the search index and every other place a name is printed. Turning voiceprints back on shows them again.
What else is recorded about an identity and a suggestion
The database keeps the full provenance of what is on a speaker, not only whether a person or a voiceprint wrote it. Each speaker records, for its current identity:
| Recorded | Meaning |
|---|---|
identity_source_at | When the current source was recorded |
identity_source_user_id | The person who made or confirmed it. Empty when a task wrote it (a voiceprint match, a cluster join) or when the account has since been deleted |
identity_source_detail | Where it came from. A person: how (typed, accepted_suggestion, profile_assigned, profile_created, batch_assign, cluster_promoted, cluster_merged, created, confirmed) and, when they accepted a pending suggestion, which one (its source and confidence, and for an AI guess the provider and model). A voiceprint: voice_match, profile_match, profile_propagation or cluster_join with the confidence, the threshold it had to clear, the matched profile or speaker (ids), the embedding model and the task |
and, for the recommendation in suggested_name / suggestion_source, suggestion_detail:
a voice or profile match (the same match record) or an AI guess (provider, model, confidence, the
task). An AI guess is only ever stored as a suggestion; it is never applied by itself.
These hold ids and identifiers only: no name and no transcript text, and they are never written to
the search index or sent to a language model. The speaker API returns them read-only as
identity (source, at, by, detail) and suggestion (source, confidence, detail).
by names the person only to the file's owner, to an administrator of the file's organization and
to a platform administrator whose reach covers the file: in a single-tenant deployment any
administrator, in a multi-tenant deployment only under a support-access grant for the file's
tenant (the use is recorded like any other platform access). A platform administrator who reaches
a file through an ordinary share gets source and at only, like anyone else who can see the file.
When the person's account is being erased (the account row can outlive the request while a legal
hold lasts), by is {"erased": true} with no id, name or email.
They follow the identity they describe. While a file's voiceprints are off, the record of a
voiceprint or voiceprint_link identity (it names the profile or speaker the voice matched) and
the detail of a voice suggestion are hidden with the name and link: identity is empty and the
suggestion is gone, also when the suggestion sits on a speaker a person verified (new voice
suggestions are not recorded on such a speaker; older ones are hidden and purged all the same).
A person's own identity record is never hidden. A purge clears them in the same statement
that reverts the identity or removes the voice suggestion, counts them
(identity_provenance_cleared, suggestion_details_cleared) and checks that none are left
(speaker_identity_provenance, voiceprint_suggestion_details); a person's identity record, and the
detail of an AI suggestion, stay. The record of an identity a person made keeps no voice
comparison either: when they accept a voice suggestion it notes only that the suggestion was a
voice match, not its similarity score. Reverting an identity to unknown (unlinking, rejecting, deleting
a profile) forgets the record with it.
This is the current state, not a history. Only the latest source, time, person and origin are
kept; what was there before is overwritten. A complete, append-only history of who changed which
name and when is tracked in issue #1336 (v0.7.0), and a review of the queries these columns need
at scale in #1337. Identities that existed before 0.6.0 have no time or person; the identities
the upgrade recognised as voiceprint-made carry {"origin": "v449_backfill"}.
Not covered. Summaries, topic suggestions and other text a language model wrote earlier can contain a name that a voiceprint supplied. That text is model output, not a label, so switching voiceprints off does not rewrite it; regenerate the summary while voiceprints are off to get one without the name. Names on recordings that existed before 0.6.0 follow the upgrade rule in Upgrading to 0.6.0.
What off hides, and what it keeps
When voiceprints are off for a recording, a speaker name that a voiceprint match wrote is replaced by
the generic speaker label (for example SPEAKER_00) everywhere the name would be shown or searched:
- the transcript, exports and captions,
- the speaker list and the gallery speaker chips, and filtering by speaker,
- chat's "who said" lookup,
- the speaker labels and summary text in the search index,
- the speaker-detection prompts,
- profile appearance counts.
A profile link that a voiceprint added is hidden and not counted. A name a person typed, accepted or confirmed is never hidden, with or without a machine-added link. Nothing is deleted by hiding: turning the switch back on shows everything again.
Hiding is immediate in responses. The search index is rebuilt in the background (usually within
minutes, longer for a very large library); if the rebuild cannot run, the periodic search
maintenance finishes it. The rebuild is recorded as the privacy.labels.resync audit event (see
Audit events).
Summaries already written
Summaries and topics that a language model generated earlier are stored as written and may still name a person whom a voiceprint identified. Their copy in the search index and in chat is masked while voiceprints are off, but the stored text is not rewritten. To remove the name from the stored text, regenerate the summary after turning voiceprints off. Only the full name recorded on the speaker is masked, not a first name on its own.
Where inferred gender is withheld
While gender is off for a recording's owner and organization, a response never carries the
inferred value, wherever it embeds a speaker. A segment embeds its full speaker, so the speaker
list and each segment's speaker are redacted together, by one function
(redact_response_speakers in backend/app/api/endpoints/files/crud.py). It covers:
- the file detail (
GET /files/{file_uuid}), GET /files/{file_uuid}/segments,- the response of a segment text edit (
PUT /files/{file_uuid}/transcript/segments/{segment_uuid}), - the response of a segment speaker reassignment (
PUT /transcripts/segments/{segment_uuid}/speaker).
The stored attribute data keeps the inference under three keys of attribute_confidence: gender
(the model's confidence), alignment and alignment_hint (the voice-versus-first-name comparison,
which is itself derived from the inferred gender). All three are withheld together, and the
alignment fields the API serves (gender_alignment, gender_alignment_hint) disappear with them.
The purge drops the same three keys.
Purging stored data
A purge deletes what a switch only hides. It is irreversible.
Targets. Choose voiceprints, inferred_gender or both.
| Target | Deleted | Kept |
|---|---|---|
voiceprints | Names and links a voiceprint made revert to unnamed and unlinked (see Names and links a voiceprint made), and those speakers leave promoted clusters (a promoted cluster left empty is deleted, except that a person's own purge keeps an emptied cluster another member owns and deletes only its centroid document). Speaker, profile and cluster-centroid documents in every speaker index (the legacy backup index included), selected by the speakers, profiles and clusters of the purged scope; an organization purge also deletes the documents carrying that organization's own stamp. Speaker match rows with either side in the scope. Unpromoted clusters whose members are all in the scope. Voiceprint-based name suggestions, with their confidence and detail, on any speaker, also one a person verified; that speaker's name, verified mark, profile link and identity record are left as they are (the affected speakers' status and label are refreshed and their recordings are queued for a search reindex). Each profile's embedding count and last-update time are reset. | Profiles, names a person typed or confirmed, avatars, speaker assignments a person verified, and the records of promoted clusters (their centroid documents are deleted). Another tenant's rows and documents |
inferred_gender | On speakers whose gender no person confirmed: the predicted gender, the three attribute_confidence keys gender, alignment and alignment_hint, and the time the attributes were predicted | Genders a person confirmed, and the gender stored on a speaker profile |
A gender purge does not clear a profile's gender. speaker_profile.predicted_gender is set only
when a person confirms a gender, so it is a person's statement and not an inference. The
inferred_gender target leaves it in place, and it is not deleted by any purge. Only the values the
model inferred on individual speakers are removed.
Confirmation. The request must carry the literal "confirm": "PURGE". The interface makes the
person tick a box and type PURGE. The API answers 422 to anything else.
Switch first. A target is refused with 409 policy_still_enabled unless its switch is off
at the purging layer or above, because otherwise new data would grow straight back. A purge counts
the layers from the instance down to the purging scope: an organization administrator can purge
once the organization or the instance has the feature off, and a person can purge their own data
once their own switch, their organization or the instance has it off. A person whose organization
turned a feature off can therefore still delete their own data, though they can no longer change
their own switch. The check fails closed, and the background task checks it again when it starts.
Two passes. The task deletes once, then repeats the same deletion about 60 seconds later. The
second pass catches a write that raced the switch (the write backstop caches for up to 10 seconds).
The task stays in_progress until the second pass ends. A cluster centroid document that the
first pass could not delete is handed to the second pass (its cluster is already gone from the
database, so only the first pass knows its id), which deletes and measures it again. If a store
could not be fully purged, the task ends failed: run the purge again.
Backups and snapshots. A purge deletes from the live database and search indexes only. Database backups and OpenSearch snapshots keep their copies until they rotate out, so a restore can bring purged voiceprints back. After restoring a backup, run the purge again.
Who may purge what.
| Scope | Who | Route | What it covers |
|---|---|---|---|
| Your organization | An organization administrator | POST /api/org-admin/policy/purge | Every member's data stamped with the organization |
| Yourself | Any signed-in user | POST /api/privacy/me/purge | Your own data in the active scope (the organization you are working in, or your personal workspace) |
| Personal workspaces | A super admin | POST /api/admin/privacy-policy/purge with "scope": "personal" | Every user's personal-workspace data, never an organization's |
| Everything | A super admin, single-tenant deployments only | The same route with "scope": "all" | Literally all speaker data on the instance. In a multi-tenant deployment (any active organization, or TENANCY_MODE=multi) this answers 403 purge_scope_not_allowed: a platform administrator must not destroy tenant data, and each organization purges its own |
Destroying the voiceprints of specific files
There is one more scope, files, with no route of its own: it is what a retention schedule uses
to destroy the voiceprints of named recordings, whoever owns them. It runs through the same two
passes, residual check and audit event as every other purge, with three differences:
- It never deletes a profile. Each profile one of those speakers was linked to keeps its row, its name and its links. Its embedding is recomputed from the speakers it still has, once the purged speakers' voiceprint documents are confirmed gone; a profile left with no voiceprint has its embedding document erased (it is rebuilt when a voiceprint links to it again). If the deletion could not be confirmed, the embedding is erased rather than recomputed, so it can never average a deleted voice back in.
- Every voiceprint of those files goes, including one on a speaker a person linked to a profile; the speaker row, its name and its link stay. Those speakers leave the promoted clusters whose centroid averaged them in, and the centroids are rebuilt.
- It does not depend on a privacy switch. It is a retention destruction, recorded in the
audit event as
purge_reason: "retention"withpurge_scope.kind: "files"and apurge_scope.file_count(never file names). Without that reason afilespurge is refused.
What a purge selects, and what it never touches. Speakers belong to their recording's organization (or personal workspace), profiles to the organization they are stamped with, and a cluster to its members. A purge deletes voiceprint documents by the ids of those rows, because a document written before the tenant backfill has no stamp: stamp-based deletion alone would miss it in an organization purge and could reach it from a member's personal purge. An organization purge additionally deletes (and measures) every document that carries the organization's own stamp, which reaches documents the database has no row for (orphans, and documents keyed by the old numeric speaker id). A personal, member or whole-instance-personal purge never deletes by stamp, so a document with no stamp is out of its reach. A document stamped with the organization is the organization's even if its speaker row says otherwise. Two edge cases follow from the same rule:
- A speaker match that joins a speaker in the purged scope to the same person's speaker in another
scope is deleted (it is voice-derived data of the purged scope) and counted separately as
cross_scope_matches. The other scope's speaker row is not modified. - A cluster that holds speakers of two scopes (possible only for clusters created before the tenant
gates) is not deleted: it loses the purged scope's members and its centroid is rebuilt from the
rest (
cross_scope_clusters_trimmed).
Another tenant's rows, profiles and documents are never touched.
Measured and verified. Each pass returns the exact count removed from each store (what
OpenSearch reports as deleted, never the number of ids asked for), then looks again, in a fresh
database session and after an index refresh, for what it was supposed to remove in the scope:
voiceprint-made identities, voiceprint suggestions, speaker matches, unpromoted cluster members,
profile embedding counters, and the speaker, profile and cluster documents of the scope's ids (for
inferred_gender: speakers that still carry an inferred gender). Every value must be 0. A
non-zero value after the final pass makes the purge fail (status failed, audit outcome
FAILURE, reason residual); after the first pass it is recorded and the second pass gets to
finish it. A measure that could not be taken, for example because OpenSearch was unreachable, is
reported as unverified, never as zero, and also fails the purge.
Read a purge back from the audit log. Every purge writes one event per target
(privacy.voiceprints.purge, privacy.gender.purge) for each phase: requested, each pass
completed, aborted or failed. Open the events of the purge's task id (shown in the dialog) and read
the details of the completed passes:
| Field | Meaning |
|---|---|
task_id, phase, targets | Which purge and pass, and what it was asked to delete |
scope | kind (organization, user, instance_personal, instance_all) and the organization_id and user_id it covered |
counts.postgres | speaker_identities_reverted, speaker_links_removed, suggestions_cleared, identity_provenance_cleared, suggestion_details_cleared, speaker_matches_deleted, cross_scope_matches, speakers_unclustered, cluster_members_deleted, clusters_deleted, cross_scope_clusters_trimmed, promoted_cluster_members_removed, promoted_clusters_deleted, profiles_reset, statuses_refreshed, gender_speakers_cleared |
counts.opensearch | speaker_docs_deleted, profile_docs_deleted, cluster_docs_deleted |
residual, residual_ok, residual_unverified | What the check found (all zeros when residual_ok is true) and which measures could not be taken |
reason | residual when anything was left, store_errors when a store reported a failed delete |
The event's own fields carry the actor (user_id and the username), the organization stamp and, when
an administrator purged someone else's scope, target_user_id. The request event also records the
source IP and user agent. Events hold counts and ids only, never a name.
Every purge writes audit events (see below) and records counts only, never a name.
Voiceprint retention (opt-in)
An operator can have voiceprints destroyed once nobody has used them for a set time. It is off by
default: the system setting privacy.voiceprint_retention_months is 0. A value such as 24
turns on a daily task (privacy.voiceprint_retention_sweep, 05:25).
A voiceprint is used when a person links or unlinks a speaker to a profile, edits the profile,
accepts or rejects a suggestion for it, or when an automatic voiceprint match writes a link. Viewing
a profile, computing a suggestion and maintenance (re-embedding, cluster rebuilds, migrations,
purges) do not count. The time is speaker_profile.last_interaction_at. Because automatic matches
count, a voice that keeps being recognised stays in use.
A file is due when it was uploaded before the cutoff, every profile linked to one of its speakers was last used before the cutoff (an unlinked speaker counts as due), nobody holds the file (legal or organization hold), and it still has voiceprints. Due files go to the file-scoped voiceprint purge (at most 200 per day). That purge removes the files' speaker voiceprints and recomputes each affected profile from its remaining speakers; it never deletes a profile. A stale profile left with no speakers is listed in the task result for review, not deleted: deleting a named person record is a human decision.
The file-scoped purge ships separately; until it is installed the task logs that nothing was
destroyed and returns unavailable.
API reference
All routes are under /api. Request and response shapes are in backend/openapi.json.
Error bodies that carry a code look like {"detail": {"code": "...", ...}}.
Effective policy
GET /privacy/effective (any signed-in user). Optional query file_uuid: the answer is for that
recording's owner and organization. A recording you cannot see, or one that does not exist, is a
404 exactly like the file page. Without it, the answer is for you in your active organization.
{
"organization_uuid": "5b1f0c7e-...",
"voiceprint_matching": { "enabled": false, "locked_by": "organization" },
"gender_inference": { "enabled": true, "locked_by": null }
}
organization_uuid is null for personal scope. locked_by is null exactly when enabled is true.
A recording's voice identification
PATCH /files/{file_uuid}/voiceprint-consent with {"state": "granted" | "declined"} (the owner only;
anyone else, or an unknown file, is a 404; refused under a support grant). Answer:
{
"state": "declined",
"previous_state": "granted",
"changed": true,
"purge_task_id": "0b2c...",
"reprocess_to_create_voiceprints": false
}
declined always queues the purge (a repeated declined is how a lost purge is retried; poll
GET /privacy/purge/{purge_task_id}). If the purge cannot be queued the state is saved and the answer
is 503. POST /files/prepare accepts voiceprint_consent (granted or declined; 422 otherwise) and
POST /files/complete accepts the same field but only restricts: declined is applied, granted
never widens what prepare recorded and is ignored, not refused, because clients retry complete. The file
detail response carries voiceprint_consent and voiceprint_consent_at. GET|PUT /admin/privacy-policy
gain voiceprint_upload_consent_required.
Your own voiceprint switch
GET /privacy/me and PUT /privacy/me. Refused under a support-access grant.
{ "voiceprint_matching_enabled": true, "locked_by": null }
PUT takes {"voiceprint_matching_enabled": false} (a real boolean; "no" or 0 is a 422) and
returns the same shape. locked_by is platform, instance or organization when a layer above
you has the feature off; PUT then answers 409 {"code": "policy_locked", "locked_by": "..."}.
Your own gender switch
The gender switch is the existing per-user speaker attribute setting, GET /user-settings/speaker-attributes
and PUT /user-settings/speaker-attributes. Both resolve for the organization the request is working
in (the personal workspace when there is none).
{
"detection_enabled": true,
"gender_detection_enabled": false,
"gender_locked_by": "organization",
"age_detection_enabled": true,
"show_attributes_on_cards": true,
"auto_llm_speaker_identification": true
}
gender_detection_enabled is the effective value: it is false when a layer above you forbids
gender inference, whatever you chose, and also false when you turned it off yourself. Your stored
choice is untouched, so it applies again once the restriction is lifted. gender_locked_by is
platform, instance, organization or null. Your own switch is never reported as a lock, so it
is null when only you turned the feature off. It is read-only: a PUT ignores it, and a PUT
stores your choice even while a layer above has the feature off (unlike the voiceprint switch, it
does not answer 409).
Instance layer (super admin)
GET /admin/privacy-policy and PUT /admin/privacy-policy.
{ "voiceprint_matching_enabled": true, "gender_inference_enabled": true }
PUT is partial (send either or both; at least one is required, and values must be booleans). Each
changed switch is audited as admin.settings.change.
Organization layer (organization administrator)
GET /org-admin/policy and PATCH /org-admin/policy. The organization is always the caller's own;
there is no organization parameter. Gated by the org.defaults capability: when it is off the route
answers 404, even for a platform superuser. Refused under a support-access grant. A community
install has no organizations, and the capability is off, so these routes answer 404 there.
{
"voiceprint_matching_enabled": true,
"gender_inference_enabled": true,
"instance": { "voiceprint_matching_enabled": true, "gender_inference_enabled": true },
"effective": {
"voiceprint_matching": { "enabled": true, "locked_by": null },
"gender_inference": { "enabled": true, "locked_by": null }
},
"redaction_floor": {
"forced_categories": [],
"forced_pii_entities": [],
"forced_custom_words": [],
"force_toxicity_threshold": null,
"force_export_redacted": false,
"force_redact_before_llm": false
},
"updated_at": null
}
voiceprint_matching_enabled and gender_inference_enabled are the organization's own switches;
instance shows what the deployment allows (a switch the operator turned off cannot be turned on
here); effective is what applies once the layers above are counted. updated_at is null until an
administrator saves a policy. redaction_floor is null while the redaction.policy capability is
off for the request.
The organization redaction floor has an API but no screen in this release: the organization privacy panel shows the two switches only. A floor screen is part of #1279, planned for v0.7.0. Until then an administrator sets a floor through the API, and the stored floor is combined with the deployment's floor (strictest wins).
PATCH is partial and needs at least one field: voiceprint_matching_enabled,
gender_inference_enabled and/or redaction_floor. Sending redaction_floor while
redaction.policy is off answers 404. A floor can only add to the deployment's floor (see
Content redaction). Validation errors are 422: categories must be
pii, toxicity or profanity; PII entities must be supported entities; custom words are at most
200 words of at most 100 characters; the toxicity threshold is between 0 and 1 (an explicit null
clears it); other floor fields do not accept null. Each PATCH writes an org.policy.change
audit event listing field names only, never values, because a custom word can be sensitive.
Admin migration and consistency checks
The admin migration and consistency routes report what the policy makes a batch job skip. The policy adds fields to existing routes and creates no new route.
GET /embeddings/migration/status (super admin) carries policy_skipped, so the admin sees what
a migration would skip before starting it:
{
"files_total": 240,
"files_skipped": 31,
"scopes_total": 12,
"scopes_skipped": 2,
"locked_by": ["organization"]
}
files_total counts completed recordings, and files_skipped those whose owner and organization
have voiceprints off. locked_by lists the layers responsible.
POST /embeddings/migration/start answers 409 {"code": "voiceprints_disabled", "locked_by": "<layer>"} only when every recording is in a forbidden scope, because then nothing could be
migrated. Otherwise it starts, and its response carries the same policy_skipped object (only when
something is skipped) naming what the run will leave out.
The embedding consistency check reports skipped_by_policy in the orchestrator's return value and
its stored last run, in the progress payload of a repair, and in the dry-run counts
(GET /admin/embedding-consistency/counts). A speaker with no document in a scope whose voiceprints
are off is the policy working, not damage, so it is not counted in main_missing or v4_missing
and no repair is queued for it.
Purge routes and status
Request bodies for the three purge routes:
{ "targets": ["voiceprints", "inferred_gender"], "confirm": "PURGE" }
POST /admin/privacy-policy/purge also needs "scope": "personal" or "all". Each answers
202:
{ "task_id": "3f6c1c0e-..." }
Errors: 409 {"code": "policy_still_enabled", "targets": ["voiceprints"]} (the listed targets are
still on); 403 {"code": "purge_scope_not_allowed"} (scope all in a multi-tenant deployment);
422 (confirm is not PURGE, or no target); 503 when the task could not be queued (nothing was
deleted).
GET /privacy/purge/{task_id} returns the status of a purge you started. Anyone else's, or an
unknown id, is a 404.
{
"task_id": "3f6c1c0e-...",
"status": "completed",
"counts": {
"postgres": { "speaker_identities_reverted": 12, "speaker_matches_deleted": 40, "clusters_deleted": 17 },
"opensearch": { "speaker_docs_deleted": 412, "profile_docs_deleted": 9, "cluster_docs_deleted": 17 }
},
"residual": { "speaker_identities_machine": 0, "speaker_docs": 0, "profile_docs": 0 },
"residual_ok": true,
"residual_unverified": [],
"audit": [{ "event_type": "privacy.voiceprints.purge", "task_id": "3f6c1c0e-..." }]
}
status is pending, in_progress, completed, failed or skipped. counts is null until a
pass has finished, is grouped per store (postgres, opensearch) and sums both passes; the keys
are listed under Read a purge back from the audit log. residual is what
the latest pass measured afterwards; residual_ok is true only when everything was measured and
is zero, and anything left makes status failed. audit lists the audit events (one per
target) that record the purge.
Audit events
An organization administrator sees the organization's events in Settings, organization
group, Audit log (titled "Organization audit log"; managed editions with the org.audit_logs
capability, route GET /org-admin/audit-logs). The three events below are stamped with the organization, so they
appear there and in its event filter, labelled "Changed the privacy policy", "Deleted stored
voiceprints" and "Deleted inferred gender data".
| Event | When |
|---|---|
admin.settings.change | An instance switch changed (setting name, old and new value) |
org.policy.change | An organization policy PATCH (field names only; stamped with the organization) |
privacy.voiceprints.purge, privacy.gender.purge | One event per target for each phase of a purge: requested, each pass completed, aborted or failed. The completed passes carry the purge_scope, the exact counts per store and the residual check (see Read a purge back from the audit log). Counts and ids only; stamped with the organization when there is one |
privacy.file_consent.change | A recording's voice identification state changed (file id, old and new state, via). Never a file name |
privacy.labels.resync | A voiceprint switch changed and the speaker labels in the search index are being rebuilt. Written when the rebuild is requested and again when it finishes. details hold the switch, its previous and new value, the scope, and the counts of files selected, queued, already pending and failed. An unfinished rebuild is marked incomplete and retried by the periodic search maintenance. Never names a person or a file |
Data inventory
What the two features store, and what each purge target removes. This is the starting inventory for #1132.
| Data | Store | Created when | Hidden by | Deleted by |
|---|---|---|---|---|
| Speaker voiceprint (one per speaker in a recording) | OpenSearch speaker index (speakers alias, speakers_v3 / speakers_v4) | Speaker identification after transcription | Voiceprint switch | voiceprints |
| Profile embedding (average of a profile's voiceprints) | OpenSearch speaker index; speaker_profile.embedding_count, last_embedding_update | A speaker is linked to a profile | Voiceprint switch | voiceprints |
| Cluster centroid | OpenSearch speaker index; speaker_cluster, speaker_cluster_member, speaker.cluster_id | Cross-file clustering | Voiceprint switch | voiceprints (unpromoted clusters) |
| Speaker match | speaker_match | Matching a speaker against others | Voiceprint switch | voiceprints |
| Voiceprint-based name suggestion | speaker.suggested_name, suggestion_source (voice_match, profile_match), confidence | Matching | Voiceprint switch (when unverified) | voiceprints (when unverified) |
| Inferred gender and confidence | speaker.predicted_gender, attribute_confidence, attributes_predicted_at | The gender step, or the diarizer sidecar | Gender switch | inferred_gender (when not confirmed) |
| Confirmed gender, named profiles, avatars, verified assignments | Database | A person's action | Never | Never by a purge |
For operators
- Remote LLM speaker-ID prompts no longer receive the gender cross-reference. The speaker-name step used to add a voice-versus-first-name comparison ("voice: female 92%") to its prompt. It now adds it only when the configured LLM is local (a loopback, private-network or compose-hostname endpoint); a remote provider gets the prompt without it, even with gender inference on. The check fails closed: any ambiguity reads as remote. Sending an inferred biometric attribute to a third party is a data-egress event, and this follows the same local-versus-remote rule as masked transcript text.
- A community install has no organization layer.
org.defaultsis off there, so/api/org-admin/policyand its purge route answer 404, and only the instance and user layers apply. - After upgrading an install that uses organizations, run the tenant backfill described in Upgrading to 0.6.0. It also re-stamps the cluster documents of single-organization clusters that an earlier release rewrote without their organization. Clusters whose members span scopes stay unstamped until the next re-cluster.
Related
- Upgrading to 0.6.0 for what changes on an existing deployment.
- Speaker diarization for how speakers are detected.
- Content redaction for the redaction floor an organization can add to.
- Support access for
TENANCY_MODEand support-access grants.
Self-service account erasure
Separate from the voiceprint and gender switches, a super administrator can let people erase their own account (Settings > Privacy & Redaction > policy tab). It is off by default, because on an employer-run deployment erasure is the controller's process.
| Setting | Key | Default | Range |
|---|---|---|---|
| Self-service account erasure | gdpr.self_erasure_enabled | off | on/off |
| Waiting period (days) | gdpr.self_erasure_grace_days | 14 | 0 to 21 |
The cap exists because the one-month deadline for handling the request (GDPR Art. 12(3)) starts at
the request, not at the end of the waiting period. A request is an entry in the erasure ledger, so it
has one deadline clock and survives a database restore (the on-disk journal re-opens it as a request,
or as cancelled, never as an immediate erasure). The person gets a request receipt (ledger ID plus a
sha256 digest); the erasure receipt is the separate, measured record that follows. Both settings are
audited as admin.settings.change. User-facing steps: Erase my account.