M2-07: Recover a route the app was killed during #49

Merged
wk merged 1 commit from m2-07-track-recovery into main 2026-08-09 22:32:27 -04:00
Member

Closes #34. Second of two changes; #48 landed the format, the writer and assembly at stop, and said in as many words that a route a crash interrupted was not swept yet.

The sweep

CaptureRecovery — the renamed TranscriptRecovery — runs once per process and finishes both halves of a capture, transcripts and then routes. Each clears the parts it owns and leaves the other's, so the shared folder is emptied by whichever runs second. The route half needs its own predicate: the transcript's keys on a missing transcript, so a capture whose transcript published cleanly and whose track did not looks finished to it.

A route is published only while the opt-in still grants — asked per capture, and again on the line before the link(2) that publishes, because the sweep is detached and reading the parts takes time. The active capture is asked for per candidate rather than snapshotted: a recording can begin while the transcript pass runs, and a stale snapshot would let this publish a partial track from a live folder and delete the parts out from under the writer.

It replaces the withdrawal marker that shipped in #48

The ordering rule does not hold, in either direction, and that is worth stating plainly because it reads as clever. Deleting parts oldest first and inferring an interrupted withdrawal from a run that does not begin at 001:

  • A part that simply failed to write leaves exactly that shape — TrackPartWriter carries on after a failed write — so a legitimate route would be deleted on the next launch as though consent had been withdrawn.
  • An unlink that fails on 001 while later ones succeed leaves a run that does begin at one, so a withdrawn route would be published.

The shape of a folder cannot say what happened to it. <base>_track.withdrawn says the one thing it cannot, it outranks the toggle being back on, and it is taken away at stop once the process has plainly survived the deletion.

It is written synchronously at the instant consent goes, not when the deletion runs. The deletion has to wait for the points already on their way to the writer to drain, or one of them recreates a file just removed — and an app that exits inside that wait would otherwise leave route parts with nothing to say they were refused. That was the security review's one high finding, and it is the same class of hole as the ordering rule itself.

This supersedes the no-tombstone decision. The reason it does is that the premise that decision rested on — that the ordering covered the case — turned out not to hold.

Also

CaptureParts is TranscriptAssembly.clearParts and its ownership predicates, moved to their own file. Three callers own different things in that folder now and one of them is a route being deleted because consent went; it is not the transcript's rule any more. The type-length lint is what prompted it, and it was right to.

A refused route that will not delete is reported and not counted as removed — saying both "removed one route" and "part of it could not be removed" answers the one question that line exists for with two contradictory halves. A folder that cannot be listed is a failure rather than an empty folder. An assembly that produced no file is not a recovery.

What Codex caught

Four passes. Beyond the two above:

  • The consent check was documented as immediately before publication and was not — the document was still rendered and a temporary written after it. Moved to the line before the link.
  • Nothing tested the combined sweep. The route pass could have been replaced with "nothing to do" and every test stayed green while launches recovered transcripts and left every route in its folder. The sweep's body is now CaptureRecovery.sweepOnce, separate from the Task that is the single-flight, with a suite over it.
  • The consent-during-assembly test counted calls rather than timing; it now answers "no" only once the temporary track exists. activeBase was asked once of one candidate; there are two captures now. Refused-route cleanup was only exercised with canonical part names, so the .sb- temporary and its AppleDouble sidecar are asserted too.

Full dispositions in .agent/m2-07-track-recovery/SUMMARY.md.

Validated, and not

534 tests pass, make lint clean, make run launches.

Not tested on a device, which is what the needs-hardware label is for. Steps are in SUMMARY.md; note that step 4 turns location off in iOS Settings rather than in the app, because the in-app toggle cannot be reached while Stash is force-quit and reopening is what starts recovery.

The marker's ordering is held by the code, not by a test. Whether it is written before the unlinks cannot be observed from outside the actor without interposing on the filesystem. What is tested is that a marker which cannot be written does not stop the deletion.

Closes #34. Second of two changes; #48 landed the format, the writer and assembly at stop, and said in as many words that a route a crash interrupted was not swept yet. ## The sweep `CaptureRecovery` — the renamed `TranscriptRecovery` — runs once per process and finishes both halves of a capture, transcripts and then routes. Each clears the parts it owns and leaves the other's, so the shared folder is emptied by whichever runs second. The route half needs its own predicate: the transcript's keys on a *missing transcript*, so a capture whose transcript published cleanly and whose track did not looks finished to it. A route is published only while the opt-in still grants — asked per capture, and again on the line before the `link(2)` that publishes, because the sweep is detached and reading the parts takes time. The active capture is asked for per candidate rather than snapshotted: a recording can begin while the transcript pass runs, and a stale snapshot would let this publish a partial track from a live folder and delete the parts out from under the writer. ## It replaces the withdrawal marker that shipped in #48 **The ordering rule does not hold, in either direction**, and that is worth stating plainly because it reads as clever. Deleting parts oldest first and inferring an interrupted withdrawal from a run that does not begin at 001: - A part that simply failed to write leaves exactly that shape — `TrackPartWriter` carries on after a failed write — so a **legitimate route would be deleted** on the next launch as though consent had been withdrawn. - An unlink that fails on 001 while later ones succeed leaves a run that **does** begin at one, so a **withdrawn route would be published**. The shape of a folder cannot say what happened to it. `<base>_track.withdrawn` says the one thing it cannot, it outranks the toggle being back on, and it is taken away at stop once the process has plainly survived the deletion. **It is written synchronously at the instant consent goes**, not when the deletion runs. The deletion has to wait for the points already on their way to the writer to drain, or one of them recreates a file just removed — and an app that exits inside that wait would otherwise leave route parts with nothing to say they were refused. That was the security review's one high finding, and it is the same class of hole as the ordering rule itself. This supersedes the no-tombstone decision. The reason it does is that the premise that decision rested on — that the ordering covered the case — turned out not to hold. ## Also `CaptureParts` is `TranscriptAssembly.clearParts` and its ownership predicates, moved to their own file. Three callers own different things in that folder now and one of them is a route being deleted because consent went; it is not the transcript's rule any more. The type-length lint is what prompted it, and it was right to. A refused route that will not delete is reported and **not** counted as removed — saying both "removed one route" and "part of it could not be removed" answers the one question that line exists for with two contradictory halves. A folder that cannot be listed is a failure rather than an empty folder. An assembly that produced no file is not a recovery. ## What Codex caught Four passes. Beyond the two above: - The consent check was documented as immediately before publication and was not — the document was still rendered and a temporary written after it. Moved to the line before the link. - **Nothing tested the combined sweep.** The route pass could have been replaced with "nothing to do" and every test stayed green while launches recovered transcripts and left every route in its folder. The sweep's body is now `CaptureRecovery.sweepOnce`, separate from the `Task` that is the single-flight, with a suite over it. - The consent-during-assembly test counted calls rather than timing; it now answers "no" only once the temporary track exists. `activeBase` was asked once of one candidate; there are two captures now. Refused-route cleanup was only exercised with canonical part names, so the `.sb-` temporary and its AppleDouble sidecar are asserted too. Full dispositions in `.agent/m2-07-track-recovery/SUMMARY.md`. ## Validated, and not 534 tests pass, `make lint` clean, `make run` launches. **Not tested on a device**, which is what the `needs-hardware` label is for. Steps are in `SUMMARY.md`; note that step 4 turns location off in **iOS Settings** rather than in the app, because the in-app toggle cannot be reached while Stash is force-quit and reopening is what starts recovery. **The marker's ordering is held by the code, not by a test.** Whether it is written before the unlinks cannot be observed from outside the actor without interposing on the filesystem. What is tested is that a marker which cannot be written does not stop the deletion.
One sweep now finishes both halves of a capture, transcripts then routes,
so the shared parts folder is emptied by whichever runs second.

A route is published only while the opt-in still grants, checked per
capture and again on the line before the link that publishes it — the
sweep is detached and reading the parts takes time.

Replaces the withdrawal marker that shipped with the writer. Deleting
parts oldest first and inferring an interrupted withdrawal from a run
that does not begin at 001 does not hold in either direction: a part that
simply failed to write leaves the same shape, so a real route would be
deleted, and an unlink that fails on 001 while later ones succeed leaves
a run that does begin at one, so a refused route would be published. A
`<base>_track.withdrawn` file says the one thing the shape of a folder
cannot, and it is written synchronously at the instant consent goes
rather than after the points in flight have drained.

A refused route that will not delete is reported and not counted as
removed. A folder that cannot be listed is a failure rather than an empty
folder. An assembly that produced no file is not a recovery.

CaptureParts is TranscriptAssembly.clearParts and its ownership
predicates, moved out: three callers own different things in that folder
now and one of them is a route being deleted because consent went.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
wk merged commit a2f3576813 into main 2026-08-09 22:32:27 -04:00
wk deleted branch m2-07-track-recovery 2026-08-09 22:32:27 -04:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Stash/stash-ios!49
No description provided.