Theme:

Redact PII — Privacy tab & the dialogs its flows imply. Off by default. On the Mac the detection stack is delivered on demand (Apple-Hosted Background Assets) the first time you enable it — so enabling shows a brief one-time download, then it's a plain setting. Failures surface through the normal project-sidebar status line, not a bespoke dialog. Full spec: docs/design-redact-pii.md.

1 · Settings › Privacy (new tab)

Same 660-pt window and in-cell help-subtitle idiom as the Appearance / LLM / Transcription tabs. New tab item: Privacy, SF Symbol hand.raised.
🖌️Appearance
🧠AI
〰️Transcription
✋Privacy
Redact personal information
Automatically remove names, emails, phone numbers, and other personal details from transcripts before they are analysed. Redaction imperfect – do check your transcripts. Applies to the next analysis, onward. How redaction works →
◐ shown only when the app language is not English — Detection is best for English. Redaction in other languages less thorough.
Why one toggle only. pii_llm_pass and pii_custom_names exist in config.py but are inert (they emit runtime warnings and do nothing), so they are deliberately not surfaced. pii_score_threshold stays a tuned default — no threshold UI, per house rule.

1b · Turning it on — the one-time model download

The detection model is 425 MB and is not in the app bundle — it arrives on demand the first time the toggle is switched on, via the managed Background Assets API — AssetPackManager.ensureLocalAvailabilityOfAssetPack plus a ManagedDownloaderExtension of a few lines. That API is macOS 26+, which is why the feature is gated at macOS 26 (state F) rather than the app's 15.0 floor being raised: gating buys one code path instead of a 15.0 fallback beside a 26 fast path. Only weights download — the detection code ships inside the reviewed binary, which is what keeps this on the clean side of App Store §2.5.2. The size is disclosed before the flick, not after it.
A · Off — the default, and the first thing every user sees
Redact personal information
Automatically remove names, emails, phone numbers, and other personal details from transcripts before they are analysed. Redaction is imperfect — do check your transcripts. Applies to the next analysis, onward.
Turning this on downloads a 425 MB language model, once. How redaction works →
B · Downloading — the switch stays on
Redact personal information
Redaction starts with your next analysis.
Downloading language model…Cancel
182 of 425 MB
Why the switch goes on immediately rather than after the download. Per decision D2 redaction applies to the next analysis, onward — so the switch states an intention about future runs, and the download has until the next run to finish. A switch that snapped back, or sat disabled behind a spinner, would be worse and would also be wrong: nothing about the setting is untrue while the bytes are still arriving. The system owns the transfer — resume, background, storage accounting — so quitting the app does not lose it.
C · Paused — offline mid-download. Not an error, so not styled as one
Redact personal information
Redaction starts with your next analysis.
Paused — waiting for a network connection.Cancel
182 of 425 MB
D · Failed — the switch returns to off, because leaving it on would be a lie
Redact personal information
Automatically remove names, emails, phone numbers, and other personal details from transcripts before they are analysed. How redaction works →
Couldn’t download the language model. Check your connection and try again.Try Again
E · On and ready — a plain setting, no residue of the download
Redact personal information
Applies to the next analysis. Existing reports are unchanged.
The model stays on your Mac. macOS may reclaim the space if the disk runs low, and it will be fetched again next time it is needed. How redaction works →
Turning it back off does not delete anything, and we offer no “remove model” button. Purging is the system’s call: Background Assets accounts the storage to the app and reclaims it under pressure — exactly what a Mac user already expects from on-demand dictation and translation languages. Our own delete button would duplicate a system affordance and invite the user to manage storage we do not own.
What a run does if it starts before the download finishes. Open decision, and the only one this screen does not settle. Two candidates: wait (the run blocks on the pack, showing the existing progress subtitle) or fail cleanly (the normal .failed row with MISSING_DEP, Retry). Failing is nearly free — §2 below already builds that path — but waiting is kinder and matches “the appliance copes”. Decide before the Swift lands, not after.
F · Below macOS 26 — visible and disabled, and honest about why
Redact personal information
Requires macOS 26 Tahoe or later.
Visible and disabled, not hidden. A researcher who has read that Bristlenose can redact needs to find out why they cannot, and a control that is simply absent answers nothing — they conclude the feature was dropped, or that they are looking in the wrong place. The row states the requirement in the position where the setting will appear once they upgrade.

Why gate the feature rather than raise the floor. The managed Background Assets API is macOS 26+, and it is the one whose downloader extension is a few lines rather than a hand-written one. Raising the app's floor to 26 would cost every user on 15–15.x the whole product to give some of them redaction; gating costs only the feature, only to them, and the gate deletes itself when the floor moves.

2 · Failure surfaces the normal way — no new UI

Turning the toggle on is just a setting; re-analysing is the user's existing choice, made the existing way. If a run's redaction fails, it uses the standard .failed project-row subtitle — nothing bespoke.
Projects
Coffee Machine Study
12 sessions · 2 Jul
Ikea Kitchen Study
✕ Redact PII — language model missing
Onboarding Diaries
8 sessions · 28 Jun

This is the existing failure family from ProjectRow.swift — red MessageKind.error glyph · one-line summary · the whole subtitle is a click target opening the normal diagnostic popover for detail. Retry is the standard right-click action. The title-bar pill carries the dominant category + count exactly as for any other stage failure.

Summary text is chosen by the typed Cause: missing bundled model → MISSING_DEP; CLI download failure → NETWORK. Same precedence chain, same tooltip, same Retry. There is nothing here to design — only wiring to add.

⚠ For this to work, PII must be wired into that mechanism — today it isn't. The stage-7 block in pipeline.py has no try/except and never calls mark_stage_failed, unlike transcribe / topics / quotes / clusters. A remove_pii() raise today propagates as an unclassified crash that can't reach the .failed subtitle at all. The fix is to wrap the stage and route to CauseCategoryEnum.MISSING_DEP / NETWORK like every other stage — no new UI, just the missing wiring. See the apparatus review below.

3 · CLI equivalent (pip install)

The one surface where a fetch does happen — first --redact-pii run. Single inline status line (under the 50 MB banner cutoff), then cached.
$ bristlenose run interviews/ --redact-pii
  ✓ Merged transcript          [2.1s]
    Downloading language model… ✓ [6s]
  ✓ Redacted PII (47 entities)  [3.4s]
  ✓ Topic segmentation          [11s]

# on failure — same fail-stop, classified:
  ✗ Redact PII — language model not installed
    Run python -m spacy download en_core_web_sm or reinstall.

4 · Apparatus review — what PII has vs. lacks

You were right to suspect gaps: PII is wired for the happy path but is missing the failure & progress machinery every other heavy stage has.
✅ Manifest tracking have
mark_stage_running / _complete(STAGE_PII_REMOVAL) — resume & provenance know it ran.
✅ Config provenance have
Topic stage hashes pii_enabled, so toggling correctly invalidates the downstream cache.
✅ Doctor check & outputs have
check_pii() reports health; transcripts-cooked/ + pii_summary.txt (re-identification key, kept hidden) are written.
🔴 No failure classification build
Stage-7 block has no try/except and no mark_stage_failed. A raise becomes an unclassified crash that can't reach the .failed status line.
🔴 Model-name mismatch Phase 0
Code loads en_core_web_sm; Presidio default + the bundle ship en_core_web_lg. In the frozen sidecar the sm probe → FrozenSidecarError. Works in dev only because both are installed. Reconcile first.
🟡 No live-progress event decide
PII is deliberately folded into its neighbours (RunProgressSubtitle.swift) and absent from timing.py stages — fine for the ring, but a stall/failure has no labelled surface.
🟡 No resume/cache guard minor
Unlike topics/quotes, PII isn’t wrapped in _is_stage_verified — it re-redacts on every resume. Cheap, but inconsistent.
Sequence. Phase 0 (reconcile the model name across stage guard · Presidio NlpEngine · spec, prove it in a real bundle) → wrap stage 7 in try/except routing to typed Cause + mark_stage_failed (this is what makes the .failed status line reachable) → native Privacy tab + piiEnabled UserDefaults key + one env line in BristlenoseShared.swift → copy & i18n. The toggle is the small part; the failure apparatus is the real work.