# PRIV2 verification Branch: `fix/private-feed-devices`. Candidate: `5fd3edf6ef0abd1e3c0e1ba3b97b565173753c5e`. Base: `origin/master` at `48566ab`. Real Obsidian 1.14.4 (installer 1.14.4), Linux x64 under Xvfb, PodNotes 2.21.1, Node v22.23.2. Only worktree-owned isolated vaults were used, one running process at a time. Device-local SecretStorage profiles stayed separate. Sync was simulated by copying only `.obsidian/plugins/podnotes/data.json` between stopped vaults. ## Fixtures and commands A: `.obsidian-e2e-vaults/podnotes-fix-private-feed-devices`. B: `.obsidian-e2e-vaults/priv2-b-fix-private-feed-devices`. All feed credentials are synthetic sentinels. `fixture-server.py` serves `http://podnotes.test:41627`, mapped to loopback by the worktree's resolver rule. The private feed used for device recovery is `http://podnotes.test:41627/PRIV2_PATH_SENTINEL/feed.xml?auth=PRIV2_QUERY_SENTINEL`. The public control feed is `/public.xml`; the first-write fixture is `/other/feed.xml?auth=PRIV2_SUBSCRIBE_SENTINEL`. Media is a generated 180-second WAV. The drivers call the repository's isolated Obsidian runner and `.claude/skills/verify/ui.mjs`; OPML uses the native file input through CDP. They operate on real UI controls, URIs, player state, SecretStorage and exact persisted bytes. `runtime.py` supplies the Node 22 environment, per-device configuration and graceful Electron shutdown. Each shutdown waits for the actual process to exit before switching devices, preserving SecretStorage writes. Early abrupt-stop experiments were rejected as invalid evidence; the corrected before and after cycles below use graceful shutdown. ```sh python3 .obsidian-e2e-artifacts/priv2/fixture-server.py python3 .obsidian-e2e-artifacts/priv2/devices-after.py python3 .obsidian-e2e-artifacts/priv2/devices-opml-after.py python3 .obsidian-e2e-artifacts/priv2/persistence-after.py python3 .obsidian-e2e-artifacts/priv2/renamed-after.py python3 .obsidian-e2e-artifacts/priv2/compatibility-after.py ``` Build with `fnm exec --using 22 -- npm run build` and reload PodNotes before each candidate's live proof. Start the server separately, with its output retained. Restore the helpers to the named artifact directory when replaying the archive. The scripts reset their own synthetic library and require the normal isolated-vault provisioning. ## Before and after | Behavior | Reproduced before | Verified after | | --- | --- | --- | | Second device setup | B's remove/re-add minted a new ID; after B-to-A sync A could not resolve it and lost its former secret in the sweep | Settings **Set feed address** retains one ID on A and B through recovery, both sync directions, and graceful restarts of each | | OPML revival | Same-title private import changed ID; a public namesake could replace the private subscription | Credential-bearing import restores the existing ID, followed by the complete two-vault cycle; public namesake leaves the private subscription untouched | | Publisher title change | Pasting the existing URL after the RSS title changed offered Add and created a duplicate | Canonical URL match offers Remove; no second entry or ID; Remove deletes the original saved key and its unused secret | | First disk write | The raw URI episode's feed address reached the first data.json write | The raw input is selected, but every captured saveData payload and the exact first and final disk bytes omit the sentinel; media remains loaded and playback works | | Missing device address | Actual released notice described the missing address but advised remove/re-add; the brief's claimed wording was inaccurate | `The private feed address for "PRIV2 Both" is not available on this device.` | | Released data | Random references were released in #295 / 2.20.0 | Actual 2.20.0 and 2.21.1 encoder fixtures load with identical disk bytes, preserve the reference and other saved data, and play successfully | Settings stable ID: `podnotes-feed-url-6a1bcd4c-cbd3-4182-98a4-ec87bc0dacae`. OPML stable ID: `podnotes-feed-url-5ae80065-81f6-43d6-807d-29cb2098835a`. Both summaries record seven stages: A creates, A control restart, B recovers, A after B sync, B after A sync, B final restart, A final restart. Each stage asserts the same reference, a resolving local secret, private-feed playback after clearing the feed cache and playing the public control, and private-address absence on disk. All stages report `No errors captured.` The first OPML attempt was interrupted by fixture-server exit 143; the complete successful rerun is the authoritative summary. The first-write probe wraps the real saveData, reads data.json immediately after the original write returns, and captures those exact bytes before another write can replace the evidence. - Before SHA-256: `5ce5f57053596c36c5ed1b40ad3118ed59078517c619490ed01980818fa93059`. - After SHA-256: `8f0eda38a8767de3be413da3f2f61b34355bfba89290de5dc5629a09851012a9`. - `rg --quiet PRIV2_SUBSCRIBE_SENTINEL .obsidian-e2e-artifacts/priv2/after-first-write.json` exits 1 (no match). The same sequence also passed after exporting settings on A and importing that envelope on B before recovery. The valid reference remains `podnotes-feed-url-2c658d7c-d710-4ad2-93e0-2effac4202b3` through all seven stages. Before this adjacent fix, real Settings export and import both removed the UUID. `settings-transfer-live.py` reproduces that loss; `devices-transfer-after.py` proves the corrected full sequence. Foreign SecretStorage namespaces remain excluded. ## Tests and review Before implementation, the expanded regression set produced 9 intended failures and 137 passes. It covers each bug, both OPML cases, released compatibility, storage failures and orphan retention. The one-pass adversarial review used one Claude and one GPT reviewer. GPT reproduced three gaps: a private-address prefix corrupted an independent signed media URL; a private address in an unknown object key survived encoding; an ID in an unknown object key lost its secret in the sweep. Four additional regression tests failed before the fixes and passed afterwards. Redaction now matches complete URL tokens, protects object keys and refuses key collisions; the sweep scans keys and values. The shared Button wrapper also needed an adjacent fix because repeated updates added duplicate click handlers and opened multiple recovery dialogs; a regression test covers one click after repeated updates. Claude also reproduced punctuation, Markdown-link and fragment misses in the revised redactor. Five additional cases failed before and pass after the fix. Whole independent resource URLs retain their exact bytes, including a distinct token ending in punctuation. The settings-transfer issue above is the only added production file outside the original file list. The final plain recovery controls sit below the card title so they do not squeeze it out of view. Screenshots were captured in the real instance and visually inspected. Final command results and review closure are recorded in the PR. Unit/component suite: 100 files, 1,762 tests. Tooling suite: 10 tests. Docs build: 18 pages and all 112 legacy anchors. The final full real-Obsidian E2E suite passed all 77 tests in 12 files; the result is in `full-e2e-final.log`. ## Persistence scope and follow-up The guarantee is **no private feed address in data.json**. The encoder identifies private feed addresses through credential-bearing `feedUrl` snapshots and locally known saved private feeds, then protects aliases throughout the document, including preserved unknown fields and keys. Independent signed media, artwork, chapters and episode links stay intact. The privacy detector itself remains unchanged: path-only private feeds require the separate proposal. Escaped or encoded text representations are not a new decoder feature here. No schema or ID migration. Startup leaves already released placeholder/UUID records unchanged. A SecretStorage protection failure refuses writes and preserves existing bytes. The sweep retains references in both raw data and decoded settings. Key redaction refuses a collision instead of silently overwriting saved data. Path-format research and cited recommendation: `PRIV2-path-secrets.md`. It recommends verified host patterns plus an explicit privacy action, research only. Reviewed host documentation did not establish reuse of the same durable feed credential in enclosure URLs. Subscriber-specific media exists; token equality remains unproven. Re-resolving media on restore stays a separate unit for signed URL persistence and expiration. Final bundle, measured with Node 22 `node:zlib` gzipSync level 9, matching `npm run size`: 289,802 raw bytes and 92,157 gzip bytes. Against current origin/master: +4,464 raw and +1,341 gzip. The earlier Python gzip comparison used a different compressor and is superseded by this like-for-like result. Budgets: 330,000 raw and 100,000 gzip bytes. Both isolated instances and the fixture server were stopped after verification; see `cleanup.json`.