# PRIV2 follow-up to V-428 Head `9fd33c35784a659823e9468f87cd06e63740c8c0`. Branch `fix/private-feed-devices`, PR https://github.com/chhoumann/PodNotes/pull/428. Base for this delta: `2898cc1bde4a8bb8fa087be5bc615c6cad3c51a2`, including master `95790ba8a176b3fdfeae07ee8b250e160aa67da2`. ## Changes 1. Settings export passes this device's private feed addresses through `serializeSettings` to `encodePodNotesData`. A quoted address in a favorite's description is replaced; independent signed media URLs are preserved. The new sentinel unit test fails before this fix. 2. `FeedAddressModal` cancels the Enter default before saving. The input uses its visible label through `aria-labelledby`. Recovery rejects a public address before the network request, with `Enter a private feed address.` 3. The Button mock now installs one listener, replaces its callback, and ignores disabled clicks, matching Obsidian. Deleted `Button.test.ts`. `Button.svelte` remains cleanup only, with no claim that it fixed a production duplicate listener. 4. A raw address that cannot move to SecretStorage gets a session-only reference and a persisted placeholder. Other changes save normally. The repository warns once. After restart the same reference has no secret, so the saved row asks for Set feed address. Startup, sync and settings import share this behavior. API-key import failures retain their existing handling. 5. Scrubbing skips string replacement without any known or discovered private feed address. It checks only strings containing a URL scheme, searches for all possible private hosts in one regex, caches URL lookups for each pass, and copies only changed containers. Canonical host forms, punctuation, object-key collision refusals, and independent signed resources retain their previous behavior. 6. OPML reports `Saved 1 podcast` and pluralizes other counts. Replied to Macroscope at https://github.com/chhoumann/PodNotes/pull/428#discussion_r4253894769 explaining why its fallthrough strips a significant dot from an independent resource URL. No schema or ID migration. The hand-edited raw URL plus existing ID case was left alone. The accepted product trade-offs stay under Docs at release in the PR. ## Runtime proof Real Obsidian 1.14.4 on Linux x64, under Xvfb, in this worktree's isolated vault and profile. One instance at a time. Node v22.23.2. `podnotes.test` resolves to loopback through the runner's resolver flag. Fixture: `http://podnotes.test:41627/query.xml?auth=V428_FIX_SENTINEL`, with a 180-second WAV enclosure. The reproducible driver is `live.py`; `runtime-commands.log` records exact CLI, UI and eval calls. The setup deliberately seeds data and intercepts SecretStorage writes; export, playback and recovery use the real app. No changes to any shared dev vault. - Export before: `before-export-result.json` reports the sentinel present. After: `after-export-result.json` reports it absent, with `KEEP_MEDIA` still present. These assertions inspect the exported file written into the vault. - Enter: `enter-before-corrected-driver.log` fails with `expected 1 to be +0` after running the old modal from `2898cc1` against the current surrounding code. The final E2E test sends the same trusted keydown/keyup sequence as the project's UI driver, and checks invalid, public and valid private input. An early test driver sent an independent char event after a cancelled keydown; that driver was corrected rather than changing the product to accommodate it. - Failed storage: `after-failure-seed.json` seeds a raw private address and volume 0.73. `after-failure.json` records a successful save, volume 0.37 and a placeholder. The real SecretStorage has no value at its new reference, while this session resolves the URL and plays `PRIV2 Episode`. `after-session-playing.json` records advancing playback. Exactly one storage-failure Notice was captured. - Full process restart: `after-restart.json` keeps that reference and reports a null resolution. `after-restart.png` shows Set feed address. The driver closes the Electron window, waits for process exit, preserves the profile, and starts it again. ## Verification and performance Final gate results are in `gates.json`, with one log per command. The first unit run exposed an old import test that asserted the superseded save-refusal policy; it was updated to assert the approved session fallback. A separate source-copy baseline bundle failed to start and was not used as behavioral evidence. The Enter negative control instead rebuilt only the old modal in the normal production build, then restored the fix. `bench-build.mjs` bundles master, `2898cc1`, and the final codec with production minification. `bench.mjs` verifies equal normal outputs, absence of the planted sentinel in the final output, preserved media, and completed operations. `bench-live.py` runs those codecs in Obsidian's renderer using the library generated by `npm run size:data`. Nine alternating rounds follow two warmup rounds. Each number is the median of per-round averages, with ranges and individual runs retained in `bench-live.json`. The write probe includes serialization and an actual synchronous file write/readback to a scratch file, using the warm filesystem cache; it is not the plugin's complete three-way merge cycle. The CPU profile is a separate, unreported-timing run. It identifies URL candidate matching and object traversal as the remaining scrub cost. Schema decoding and snapshot construction are master's floor. Every string that might quote a feed address still needs inspection; large descriptions account for most bytes. `canonical-probe-before.json` and `canonical-probe-after.json` record the fast filter's numeric/IPv6 correction before final verification. ## Final results All gates passed on the committed source. Unit/component tests: **1,773 / 99 files**. Svelte checks: zero errors and warnings. Tooling tests: **10**. E2E: **80 / 13 files**, including the three Enter cases. Docs: 18 pages and 112 legacy anchors. `gates.json` lists all exact commands. Master moved to `edb944776811b7171823aad56061058114ee2fba` (#433) and was merged normally without conflicts. All gates and the full E2E suite passed again after that merge. Export, failed SecretStorage, playback, and restart were also repeated on the merged build. `merge-edb9447-proof.json` confirms the merge adds exactly master's changes; `fixes.patch` contains only this follow-up's changes, while `delta.patch` includes master. Bundle: **290,887 raw bytes / 92,571 gzip9 bytes**. Delta from the previously verified branch head `2898cc1`: **+976 raw / +378 gzip9**, including master's +175 raw / +25 gzip9. The fixes alone add +801 raw / +353 gzip9. Both size gates pass. No package-lock change. Renderer performance, Obsidian 1.14.4, on the same machine and production-minified codecs. Timings were measured at the fix commit before the merge; rebuilt master and candidate codecs are byte-identical after the merge, as recorded in `codec-merge-proof.json`: | Timed work | Master median (range), ms | Prior head median (range), ms | New head median (range), ms | | --- | --- | --- | --- | | Encode, no address on device | 1.80 (1.62-2.98) | 3.55 (3.35-3.73) | 2.62 (2.23-3.33) | | Encode, one private feed | 2.18 (1.67-2.52) | 31.65 (29.93-35.90) | 5.82 (5.38-6.48) | | Encode + final write guard | 1.85 (1.65-2.12) | 61.02 (60.33-63.00) | 9.83 (9.52-10.58) | | Encode + guard + JSON + cached file write/readback | 40.30 (39.58-42.68) | 102.38 (100.93-106.88) | 49.23 (48.27-51.83) | The fixture occupies 5,593,631 compact bytes after adding one private subscription. Nine measured rounds per variant, four operations per round, alternating order, after two warmup rounds. 528 renderer operations completed, with zero validation errors. No playback, gate suite, CPU profiler, or competing benchmark ran during the final measurement. The host was shared: load averages were 5.53 / 5.01 / 5.49 on 12 cores. Master and both candidate versions saw the same interleaved load. One-private-feed encode fell from 31.65 ms to 5.83 ms (82% lower), with master at 2.18 ms. The remaining roughly 3.65 ms pays for discovering raw feed addresses and inspecting arbitrary string values and keys. The profile's largest added cost is the combined candidate regex over long descriptions. Without a private address, the string-replacement pass is skipped and encode is 2.63 ms versus master's 1.80 ms. The 5.6 MB library already costs about 40 ms to encode, serialize and write/read in the inclusive scratch-file probe on master; this fix adds about 9 ms there, down from about 62 ms. The persistence format itself, including duplicated episode descriptions, sets that separate floor. `runtime-errors-final.log` records the final app error check (no errors captured after attaching the debugger on the restarted process). Evidence scripts and snapshots are synthetic. Neither an Obsidian instance nor the fixture server is left running at handoff.