# Rearming pending saves after a read outage Branch: `fix/rearm-read-retry`. Base: `95790ba8a176b3fdfeae07ee8b250e160aa67da2` (merged #426). Head: 34c910f7c4acffe982b6e8ffc1f4a0f4d2207d7a. Runtime: Obsidian 1.14.4 on Linux, isolated vault `podnotes-fix-surface-write-failures`, Node v22.23.2. No podcast/audio fixture was needed. The import fixture contains schemaVersion 2 and skipForwardLength 25. ## Reproduction on master The repeatable `probe.mjs` drives real PodNotes Settings controls. It wraps adapter reads only for `.obsidian/plugins/podnotes/data.json`, records reads/writes and Notice nodes, and reads the actual file directly from disk. Both routes restore adapter methods, file contents, and plugin state in `finally`. - Revert route: make reads fail, set Skip forward to 25, revert it to 15, and wait 11 seconds. The retry stops because nothing remains pending. Restore reads, set Skip backward to 33, and wait 11 seconds. - Import route: make reads fail, import the fixture through the file picker and confirmation button, let rollback restore the old settings, and wait 11 seconds. Restore reads, set Skip backward to 33, and wait 11 seconds. On both routes, master leaves Skip backward at 15 on disk while memory holds 33. The unreadable refusal and sticky Notice remain, with no timer and no write attempt. After a further 11 seconds idle, nothing changes. The before/after disk SHA256 is `fceab877035127efbabc2cba359b825f487a63d739f75bf28ef2bfed64610b81`. This is a data-loss path for later edits on quit, not merely a stale Notice. See `master-revert.json`, `master-import.json`, their logs, and the dormant/recovered screenshots. `master-doctor.log` records the correct vault, active API/URI handlers, and no captured runtime errors after cleanup. The injected failures use `Injected read outage` and are intentional. ## Final behavior The same real Settings sequences on the branch save Skip backward 33 to disk after either a revert or an import rollback made the retry dormant. Each route makes exactly one recovery write, clears the unreadable refusal and its sticky Notice, and leaves no timer. An additional 11 seconds idle adds no reads or writes. Each route shows one sticky read Notice for its failure streak; the failed import also shows its specific rollback-failure Notice, with no generic save Notice. The recovered data.json SHA256 is `526ac459a053ceb231236c07d8ce7b1fc4b08d824c3be311f4a1670d027587c7`. See `branch-revert.json`, `branch-import.json`, their logs and screenshots. The live probes ran at `457a276875fc7a7996683b73259596fc9c1a1d32`; the later commit removes only two comments. The final production bundle is byte-identical to the live-tested bundle, SHA256 `a4c29b1fd2560c09c4cbcfb40ef1b43167702d841660ea47977b72441fd7ff36`. ## Regression and review Tests were committed before the fix. Against master production at 95790ba, the targeted run reports `2 failed | 96 passed`: the revert regression expects disk Skip backward 33 but gets 15, and the import regression expects the Later feed but disk still contains only Before and Remote. Both assert persisted values before timer details. With the fix, all 99 targeted tests pass. The refused-background unload regression kills the surviving N1 mutation by reporting `expected "Notice" to not be called at all, but actually been called 1 times`, with the generic save Notice. Additional mutation checks kill routing to `request()`, retrying under every refusal, and unconditional scheduling. Removing the explicit loaded-base check alone survives because `hasUnsavedEdits()` also rejects null base and a reachable null-base failed write already has a timer; no artificial state was added to kill a redundant guard. Startup recovery remains blocked with a truly null writer base. Other refusals do not re-arm via saveSettings. The writer test shows no idle timer, one timer for repeated requests, and a 6000 ms remaining delay when the edit arrives 4000 ms after the last save. See `implementation-report.md`, `red-master.log`, `mutation-n1.log`, `green-fix.log`, and `mutations-fix.log`. The data shape remains the existing loaded base, optional failed write, and checkpoint timer. The new operation centralizes retry scheduling in the writer; only main's runtime unreadable refusal routes to it. Startup protection stays tied to a loaded base. The failed-read catch retains its existing predicate and gains its requested why-comment. No encode-boundary or schema changes. The explicit retry operation was chosen over request(), flush(), or checkpoint(() => request()): the first two bypass retry cadence, and the third lacks the loaded-document and pending-work guards without changing playback checkpoint semantics. Both design reviewers supported the writer-owned operation. See design-gpt.md and design-claude.md. ## Validation All CI-equivalent checks passed on the final head in one sequential gate run: - `npm run lint`: no errors or warnings. - `npm run format:check`: all matched files formatted. - `npm run typecheck`: passed. - `npm run build`: passed. - `npm run test`: Svelte checks passed; 99 files and 1740 application tests passed. - `npm run test:tooling`: 1 file and 10 real-process checks passed. - `npm run docs:build`: 18 pages, valid internal links, all 112 legacy fragments resolve. - `npm run size`: within both budgets. `npm run test:e2e` ran once on Obsidian 1.14.4/Linux: all 12 files and 77 tests passed in 254.86 seconds. This includes startup unreadability, external sync/merge, popout persistence, unload, and the 50 ms before quit save. The harness stopped the instance it launched. See `e2e.log`. Master count:writes records zero idle writes, two completed writes and 3966 bytes for the normal Settings edit/restore flow. See `master-counts.json`. The branch recorder matches every master field at every snapshot: zero idle writes, two attempted/completed writes, 3966 total bytes, no inflight writes, recorder intact and plugin unchanged. See `branch-counts.json` and `live-summary.json`. Master bundle: 285447 raw bytes, 90856 gzip bytes at level 9. Final bundle: 285622 raw bytes and 90884 gzip bytes at level 9, a delta of +175 raw and +28 gzip. Both pass the 330000/100000 byte budgets. Commands use `TMPDIR="$PWD/.tmp"` and `fnm exec --using 22 --`: ```sh npm run build npm run start:e2e-obsidian node .obsidian-e2e-artifacts/rearm-read-retry/probe.mjs master revert node .obsidian-e2e-artifacts/rearm-read-retry/probe.mjs master import node .obsidian-e2e-artifacts/rearm-read-retry/count-probe.mjs master ``` After building the branch and explicitly reloading it, repeat the same probes with `branch`. Stop the live instance before the full E2E suite so the suite owns its instance. Final gate commands and outputs are recorded in `gates.json` and `gate-*.log`; E2E is recorded in `e2e.log`. ## Deferred cases - [#429](https://github.com/chhoumann/PodNotes/issues/429): older/newer schema refusals cause read-only retries that cannot succeed. Newer-schema sync also stops saves without a Notice, pre-existing before #426. The issue includes V-426's P2/P6 results and its report link. No schema refusal behavior changes here. - [#430](https://github.com/chhoumann/PodNotes/issues/430): a strict import or API-key save is still rejected under a dormant runtime read refusal even after the disk recovers. A second import was reproduced live through the actual file picker after restoring reads: read count stays 2, writes stay 0, settings roll back to 15, no timer remains. The API-key route shares the same gate and is source-confirmed. See strict-probe.mjs, strict-retry.json/log and strict-retry-rejected.png. It is separate issue work. - [#431](https://github.com/chhoumann/PodNotes/issues/431): a runtime read refusal still skips the visibility flush after the disk recovers. Source-confirmed call chain; Android process termination was not reproduced. No lifecycle gate change in this PR. - [#432](https://github.com/chhoumann/PodNotes/issues/432): startup Notice omits the reload needed to adopt the previously unread file. Confirmed by source and the strengthened null-base test; no new live startup reproduction. ## Cleanup and decision record Both live routes restore adapter methods, original data.json and plugin state in finally blocks. The count recorder stopped before each live instance stopped. The full E2E suite restored its fixtures and stopped its own instance. An explicit final stop terminated 0 processes; dry-run reports pids: none. Owned .tmp scratch was removed. No custom fixture server or shared dev vault was used. Fix Root Causes put the recovery trigger at the refusal gate that drops new edits. Boundary Discipline keeps the loaded-document and pending-work checks inside the writer. Prove It Works required the same real-UI sequence on master and branch, disk hashes, red/green regressions and mutation checks. `decisions.tsv` records the scope and design choices. Investigation ran on claudeAgent/claude-opus-5-5; design ran on claudeAgent/claude-fable-5-1 and codex_pool/gpt-6-astra; implementation ran on claudeAgent/claude-opus-5-5. Final independent review found no introduced correctness blocker. GPT reported no findings. Claude identified pre-existing visibility-flush and startup-Notice cases, filed as #431 and #432; four optional cleanup nits were declined to keep this patch small. See `review-verdict.md` and the two raw reports. Dispatch ledger: | Step | Provider/model | | --- | --- | | Investigation | claudeAgent/claude-opus-5-5 | | Design | codex_pool/gpt-6-astra and claudeAgent/claude-fable-5-1 | | Implementation | claudeAgent/claude-opus-5-5 | | Comment review | claudeAgent/claude-opus-5-5 | | Final independent review | codex_pool/gpt-6-astra and claudeAgent/claude-fable-5-1 | | Live reproduction, gates, final artifact verification | In-thread, parent owns real-app evidence and final acceptance | Evidence-capture deviation: two early logs omitted stderr because of redirect order; both were regenerated with complete failure diffs. A delegate wrote one note outside the worktree; the parent removed that exact line and kept the note locally. See `evidence-capture-note.md`.