Skip to content

Terminal ride recovery: one rider-confirmed exact removal of a damaged RECORDING entry, one typed terminal state, and the end of the per-pass retry #1591

Description

@timohueser

Parent epic: #1398

Program tracker: #1448

Source policy: #1557 item 3 (owner comments 5468153801, 5468182382, 5468196894) and the verification ruling 5469819084.

Start gate met: R1 (#1552) and R2 (#1553 / PR #1567) are merged; #1557 items 2–3 have owner policy. Measured against origin/develop 1d35ea17 (the #1515 D4b–D5 batch, all merged 2026-08-30).

Process, and it differs from the last batch. The owner's 2026-08-30 ruling (comment 5469819084) does not extend the no-on-device waiver to this work. The merge gate is adversarial review + green CI plus one on-device acceptance run on the real board, run by the orchestrator over the DK VCOM injection harness. See On-device acceptance below; it is not optional and it is not prose.

Goal

A durable RECORDING object that cannot be attached to a ride session is a classified fault with a bounded repair. On a catalog this executor read completely, the repair is the exact Mutation::Remove { id, revision } the store already implements, issued once per rider-confirmed action — never once per pass. Success restores recording in the same boot with every other object byte-identical. Failure latches one typed terminal state, writes nothing more automatically, leaves every non-recording function usable, and lets the rider confirm again without a reboot.

No new store mechanism, no new effect, no new screen, no persisted state, no format change.

Locked decisions (quoted from the owner's ruling)

1. The repair is the existing exact removal, and nothing wider. From comment 5468196894: "The ordinary recovery is the existing exact removal of the one damaged RECORDING entry. Its atomic catalog commit removes only that object, frees its reserved ranges, clears recovered/journal state, and leaves the map, routes, trips, weather, updates, and every completed ride untouched. Do not add FORMAT and do not purge all rides." The code already does this (store.rs:1423-1437, :1526-1542, :2244-2281); this slice adds no store change at all.

2. One attempt per rider action, never one per pass. From the same comment: "issue one attempt per rider action, never one per pass; on success, restore recording immediately with every non-target object byte-identical; on failure, latch one typed terminal recovery result for that boot and make no further automatic writes." "No further automatic writes" means: Recorder mints no recovery effect of its own accord for the rest of the boot. A fresh rider confirmation is not automatic and buys exactly one more attempt.

3. Logical damage is classified apart from catalog / read-only / media failure. From the same comment: "classify logical ride corruption separately from catalog/read-only/media failures" and "If neither catalog copy validates, or the medium cannot complete the exact catalog commit, there is no safe automatic object-level repair: preserve the card and direct the rider to service. Never risk the map by rebuilding from untrusted catalog rows or erasing the whole card."

4. Recorder has no timeout and never replays a physically ambiguous write. From comment 5468099058: "Every executor that takes a RecorderEffect must deliver exactly one matching terminal RecorderOutcome before the next app pass; explicitly abandoned work returns Cancelled. Recorder must not time out and replay an operation whose physical completion is unknown… do not add retry state, timers, or wakes." Nothing in this slice adds a timer, a wake, or a replay. The bound added here is the opposite of a retry: it removes an automatic one.

5. The guarded card is the rider gesture, and it is the one that already ships. RideRecoveryScreen's hold-guarded action (screen/ride_recovery.rs:49-76, drawn through draw_guarded_rows) is the established confirmation grammar. This slice adds modes to that card; it adds no screen and no Caps row.

6. The card's terminal modes exit by a labelled row, not by Back. Caps::blocks_escape on RideRecovery (screen/mod.rs:1083) and its named set in screen/mod.rs:1803-1807 are unchanged, and Back stays inert. A rider who must be able to keep using non-recording functions gets a discoverable Back row in the two terminal modes instead of an invisible gesture. (Owner may veto: a plain-Back exit is the alternative and would need the escape set re-pinned.)

7. The proposed copy. New [ride_recovery] keys, en column: repair_failed = "Repair failed", unrepairable = "Card needs service", retry = "Retry", leave = "Back". de/fr/es columns land with them and are pinned by the new width test, which is the gate — a translation that does not fit fails the test rather than the panel. (Owner may veto the two body lines; the two row labels follow the existing discard / back vocabulary.)

Premises this slice corrects

Re-measured at 1d35ea17. Every claim below is code as it stands today.

P1 — State::Faulted collapses three different facts. flat_ride.rs:126-133 (the recovered key could not be reconciled with a complete catalog listing), :172-179 (the continuation image does not decode), :197-203 (the bytes are not a ride-v3 sample/footer boundary) all produce the same value, the same recovery_faulted() -> bool (:231-233) and the same card (ride.rs:883-884). The first is a catalog/media fact; the last two are logical ride corruption. The third also fails to set warning_pending where the first two set it — an unintended inconsistency.

P2 — a failed repair retries every pass, for ever. apply_outcome leaves the close pending on Failed by design (recorder.rs:634-643), and next_effect re-admits it while recovered_held holds (recorder.rs:571-582). Each attempt is a full catalog commit and each raises REC_ERROR (pass.rs:418-420). This is #1557 finding 3, unchanged.

P3 — there is no in-session escape and no retry. The hold returns Transition::Home immediately (ride_recovery.rs:75), before any outcome exists; offer_recovery is one-shot per boot (recorder.rs:489-496); Back is inert and the global escape is refused. Only a reboot re-offers the decision — the thing the owner forbade.

P4 — while a faulted object stands, Start opens a session that records nothing, silently. advance opens unconditionally (recorder.rs:520-527); the board's open() hits _ => {} for State::Faulted (flat_ride.rs:269) and raises no warning; object_owed therefore stays Some(id) and the loop retries the no-op every pass (ride.rs:2162-2177); every Append is refused (flat_ride.rs:335) and finalize() answers RideClose::Failed (flat_ride.rs:299). A riding view that records nothing is exactly the failure RecorderAdvance::Refused exists to prevent — and this path bypasses it.

P5 — the rollover-blocked case is not reachable here and must not be built here. store.rs:1237-1244 sets Mode::CatalogUnreadable and clears ride/recovered when repair_rollover fails at mount; flat_store.rs:313-320 maps that to Card::Damaged, and boot ends on BootFault::StorageFault before an App exists. Exposing that entry for exact removal is a storage-side mode change and is deferred, exactly as the owner directed.

P6 — nothing about the repair needs a catalog re-scan. RECORDING entries are filtered out of the ride listing (flat_store.rs:1811), so the removal changes no visible list. "Restores recording immediately" is entirely flat_ride returning to State::Idle plus the store's own settle_ride.

Target files

File What changes
firmware/obc-app/src/recorder.rs new pub enum RideDamage { Payload, Metadata, Catalog }; new pub enum RideRecoveryState (None, Resumable, Repairable(RideDamage), Repairing(RideDamage), RepairFailed(RideDamage), Unrepairable(RideDamage)) replacing the recovered_held: bool field (:301); RecorderError gains ReadOnly; RecorderAdvance gains RecoveryOwed; RecorderVerdict gains RecoveryLatched; offer_recovery takes the state it is offering; next_effect's object gate (:571) reads the new state; apply_outcome (:610-662) classifies a repair outcome and clears pending on it; advance (:508-528) refuses to open a session against a damaged object
firmware/obc-app/src/app.rs offer_damaged_ride (:2253) takes a RideDamage; both offers and the new re-raise share one private raise_ride_recovery() (the existing map_dirty / cancel_holds / hold_cancel_pending trio, :2244-2247)
firmware/obc-app/src/device_core/pass.rs advance_recorder_session (:769-781) gains the RecoveryOwed arm; the outcome match (:412-422) gains the RecoveryLatched arm, which re-raises the card so a Static-keyed screen repaints its new mode
firmware/obc-app/src/screen/ride_recovery.rs can_continue: bool becomes a RecoveryMode (four modes: resumable / damaged / repair-failed / unrepairable); the row tables and selection_is_guarded follow it; Retry and Back rows; new + amended tests
firmware/obc-app/i18n/{en,de,fr,es}.toml four new [ride_recovery] keys (locked decision 7)
firmware/obc-fw-nrf54l/src/flat_ride.rs State::Faulted carries damage: obc_app::RideDamage, set literally at the three existing sites (:126-133 Catalog, :172-179 Metadata, :197-203 Payload); recovery_faulted() becomes recovery_damage() -> Option<RideDamage>; the two boot sites stop setting warning_pending (the card reports the damage); service_terminal returns Result<(), StoreError>; its Discarding arm drops the redundant warning_pending = true (:615) and treats StoreError::NotFound as success; discard() returns the typed result
firmware/obc-fw-nrf54l/src/ride.rs the boot offer (:883-884) passes the damage; the Discard executor arm (:1525-1528) maps StoreError::ReadOnly to RecorderError::ReadOnly; one defmt line naming the classified damage at boot and one per removal attempt/outcome
firmware/obc-app/src/harness/screens.rs two offer_damaged_ride() call sites (:1333, :1342) take a RideDamage
firmware/obc-fw-nrf54l/src/flat_ride.rs, ride.rs, firmware/obc-platform/src/debug_link.rs debug-uart only: three injection commands + a census command for the acceptance run (below)

No obc-storage change. No obc-ports change. No obc-render / obc-display change. No host-executor change (host/obc-host-core/src/dispatch.rs:771-817 keeps answering RecorderError::Write; the new variant is board-only). No Caps row, no RenderKeyKind, no settings surface, no snapshot recipe.

Behaviour specification

Classification, at boot, once

flat_ride::Recorder::new names the damage at each of its three existing refusal sites:

Site RideDamage Repairable
catalog listing incomplete, or the recovered key absent from it (:126-133) Catalog no
continuation image does not decode (:172-179) Metadata yes
bytes are not a v3 sample/footer boundary (:197-203) Payload yes

There is deliberately no classifier abstraction: the three sites already are the classification, and the board crate has no test tier to unit-test a helper in (testing/suites.toml:94-103 — build only). The site→variant mapping is proven by the on-device run; the consequence of each variant is pinned in obc-app.

The board reports it once: app.offer_damaged_ride(damage). Recorder latches Repairable(d) for the two logical causes and Unrepairable(Catalog) for the third.

The rider-visible flow

Repairable. The card comes up rooted, exactly as today: title RIDE RECOVERED, body Recording damaged, one hold-guarded row Discard. A completed Select hold posts RecorderIntent::Discardone intent.

  • Success. The commit lands; flat_ride returns to State::Idle; the store's settle_ride clears ride/recovered and invalidates the 16 slot headers; the reserve is free. Recorder's close() clears the recovery state. The card returned Home on the hold. Recording is available at once, in the same boot: the next Start allocates a fresh object with no reboot and no re-scan.
  • Failure (Write). Nothing was written — the commit's refusal touches the card not at all (store.rs:1658-1666). Recorder latches RepairFailed(d), clears the pending close, and returns RecoveryLatched; the pass re-raises the card in its failed mode: body Repair failed, rows Retry (hold-guarded) and Back (plain press → Home). No further recovery effect is minted this boot until the rider confirms again. No REC_ERROR — the typed card is the one explanation, and REC_ERROR means "a ride-log write did not happen mid-ride, so the log is now incomplete or at risk" (screen/warning.rs:45-47), which is not this.
  • Failure (ReadOnly). A card whose revision or sequence space is exhausted (store.rs:192-224) will refuse this commit for the rest of the boot. Recorder latches Unrepairable(d): body Card needs service, one Back row, no Retry. A catalog-rebase design for that store is deferred (owner ruling).
  • Cancelled. No attempt was performed. Recorder returns to Repairable(d) and clears pending; the rider may confirm again. (No shipped executor produces this; it is the item-2 contract's honest arm.)

Unrepairable, from boot. RideDamage::Catalog: body Card needs service, one Back row. Recorder never mints a Discard effect in this state at all — the object gate in next_effect (recorder.rs:571) refuses it. That is the "no safe automatic object-level repair" rule, enforced by the one owner rather than by the executor.

Retry, without a reboot. Retry posts one more RecorderIntent::Discard; the executor is still in State::Discarding, so the same exact commit is re-attempted, and one attempt is all it gets.

A Start against a standing damaged object. advance does not open a session (P4). It keeps the request — the same rule a refused start already follows — reports RecoveryOwed once per ask (reusing the existing refusal_told latch, recorder.rs:288-290), and the pass re-raises the recovery card. A rider who presses START is shown the one thing standing between them and recording. request() already clears refusal_told (:457), so asking again is answered again; and a subsequent Discard replaces the pending Start (:458), so nothing auto-starts behind the rider's back after a successful repair.

Every terminal state, named

Recorder state Card body Rows Effects it may mint
Resumable Recording restored Continue ride / Discard (guarded) Discard
Repairable(Payload|Metadata) Recording damaged Discard (guarded) Discard, one per rider action
Repairing(d) — (sub-frame; never rendered as its own mode) none (in flight)
RepairFailed(d) Repair failed Retry (guarded) / Back Discard, one per rider action
Unrepairable(d) Card needs service Back none, ever

What does not change

The zero ledger on a damaged card stays (ride_recovery.rs:95-96 states the rule it follows: at a recovery boundary those figures render zero, never invented data). Caps, the escape set, Back's inertness, the render key, the checkpoint cadence, the append/checkpoint/close ranks, the DFU refusal while an object is open (ride.rs:1302), and the ordinary in-ride Save/Discard retry are all untouched — a live ride's failed close still re-offers, because the rider is still riding it.

Tests

Seven new, two amended. Each names what would fail without it.

Test Home What it pins
a_failed_repair_costs_one_attempt_and_waits_for_the_rider src/recorder.rs Offer Repairable, request Discard, take exactly one RecorderEffect::Discard, answer Failed{Write}: the state is RepairFailed, and the next five passes mint no effect at all. Then one more request(Discard) mints exactly one more. Mutant (reviewer may request): restore the pending-preserving arm at recorder.rs:641 for the repair case — the middle assertion fails
a_repaired_recording_lets_the_next_ride_open_in_the_same_boot src/recorder.rs Discarded clears the recovery state; the immediately following Start returns RecorderAdvance::Opened(Fresh) with no intervening boot
an_unrepairable_recording_is_never_offered_to_the_store src/recorder.rs Unrepairable(Catalog): request(Discard) and twenty passes mint nothing. The "never risk the map" rule, expressed as an absence
a_start_against_a_damaged_recording_re_raises_the_decision_once_per_ask src/recorder.rs P4: Start returns RecoveryOwed, opens no session, is reported once, and is reported again after a fresh request
each_recovery_mode_offers_exactly_its_own_actions src/screen/ride_recovery.rs The four modes' row tables and guards; a tap never repairs; the Retry hold posts exactly one Discard; the Back row returns Home and posts nothing
every_recovery_card_line_fits_in_every_language src/screen/ride_recovery.rs The card's two body lines and four row labels in Font::Label/Font::Body across en/de/fr/es against the 240 px panel budget. A longer translation fails here rather than on glass
the_failed_repair_card_retries_without_a_reboot tests/ride_recovery.rs The whole rider path over apply_gesture + quiet_pass: damaged offer → hold → one effect → Failed → the card is back with Retry → Back leaves to Home and non-recording screens work → Start re-raises the card → Retry → Discarded → a ride opens. Also asserts no REC_ERROR card was raised on the failure

Amended, not added:

  • damaged_recording_can_only_be_discarded (ride_recovery.rs:206-224) — re-expressed against RecoveryMode; it keeps its claim and gains the failed mode's row set.
  • a_card_the_rider_must_answer_refuses_the_escape (harness/screens.rs:1322-1345) — call sites only; its assertions do not move, which is the point: the escape set is unchanged.

No new test for: the store's removal semantics (obc-storage's crash suite owns them and no storage line changes), the checkpoint/append ranks (#1553 owns them), the boot-fault copy family (fault.rs owns it), or the Cancelled arm beyond the one assertion inside test 1.

Resource limits

Figure Expectation
compile_time_allocations.app Exact match against firmware/tools/resource_baseline.json (51368). Nothing resident is added: recovered_held: bool is replaced by the recovery enum (two bytes where one was, expected in existing padding — RecorderMachine already carries six bools at recorder.rs:282-301), and RideRecoveryScreen's can_continue: bool is replaced by a one-byte mode. A movement is a finding and must be itemized
size_of::<Screen>() 104, unchanged (screen/mod.rs:1835). RideRecoveryScreen is 16 B and is not the widest variant
size_of::<RenderKey>() ≤ 304, unchanged. The recovery mode is never a drawn key — the card is re-rooted, and a stack change is what repaints a Static screen
flat_ride_delta Unchanged. RESIDENT_BYTES is DELTA_BYTES + RIDE_RESUME_LEN (flat_ride.rs:726); State is a loop local, not a static
measured_resident, .bss + .data, .uninit, poll_frame_measured, residual_stack_measured Byte-identical expected; report the measured figures
measured_flash A record, not a gate. The injection commands are debug-uart-only and their debug_link::Msg arms dead-strip from the release image; expect ≈ 0 on default. Report it
Settings VERSION Stays 18, MIN_SUPPORTED 16. No settings surface is touched

Snapshots

The manifest does not move: 312 in, 312 out, byte-identical. RideRecovery is the one screen with no frame in the sweep (firmware/ui-snapshots.sh:34-39) and this slice touches no other screen's drawing. Run the sweep once on the final head as a check because screens and i18n are touched; a changed frame is a finding, not a re-pin. Do not run update.

Seeding the sweep for RideRecovery is out of scope and stays the documented gap: it needs a simulator injection this slice does not build, and the owner has bought an on-device read of these frames instead. It is a good follow-up candidate now that the card has four modes.

What this slice must not do

  1. No whole-card FORMAT, anywhere, on any surface. Rejected by owner correction 5468182382: "Removing the map is not an acceptable field-recovery path." No companion recovery surface either.
  2. No ride-kind purge. "Do not add FORMAT and do not purge all rides."
  3. No repair of the valid-catalog / rollover-blocked state. Deferred by the owner; and P5 shows it is not even reachable from the app today. Do not touch store.rs:1237-1244.
  4. No catalog-rebase for a counter-exhausted store. Deferred by the owner; it surfaces here only as the Unrepairable terminal state.
  5. No timeout, no timer, no wake, no automatic replay. Owner ruling 5468099058. The bound this slice adds removes an automatic retry; it adds none.
  6. No obc-storage change. The removal semantics, the atomic gate, the reserve release and settle_ride are already exactly what the policy names.
  7. No new RecorderEffect variant. The owner ruled "keep the guarded rider-confirmed exact Discard". Repair-vs-ordinary is knowable from Recorder's own state; the executor learns nothing new.
  8. No Caps change, no escape-set change, no RenderKeyKind change, no new Screen variant, no CardScheduler family.
  9. No change to the ordinary in-ride close. A live ride's failed Save/Discard still re-offers every pass. That rider is riding; this one is not.
  10. No persisted recovery state. The latch is RAM, per boot, in RecorderMachine.
  11. No simulator injection and no new snapshot frames (see Snapshots).
  12. No PlatformSupport / DeviceFacts widening. Store writability is learned from the executor's typed answer, not a new capability axis (shared.rs:209-219 explains why the fact cannot answer it).

Host verification

Per CLAUDE.md's budget. The change spans packages, so let the registry choose:

obc test affected --base origin/develop --dry-run   # read the plan
obc test affected --base origin/develop

obc test -p obc-app
cargo clippy -p obc-app --all-targets -- -D warnings
cargo fmt --all --check
(cd firmware/obc-fw-nrf54l && cargo fmt --check)

python3 tools/check_one_home.py
python3 tools/check_render_keys.py
python3 tools/check_screen_vocabulary.py

The sweep once, on the final head, as a check:

cargo build --release -p obc-sim
firmware/ui-snapshots.sh /tmp/ui-snapshots
python3 firmware/tools/ui_snapshot_manifest.py check firmware/ui-snapshots.sha256 /tmp/ui-snapshots
# 312/312 byte-identical. No `update` step in this slice.

One head board build against the recorded baseline — the base is not rebuilt:

cd firmware/obc-fw-nrf54l
cargo build --release --locked
python3 ../tools/resource_guard.py board  --profile default --elf target/thumbv8m.main-none-eabihf/release/obc-fw-nrf54l
cargo build --release --locked --features resource-report
python3 ../tools/resource_guard.py report --profile default --elf target/thumbv8m.main-none-eabihf/release/obc-fw-nrf54l

Deliberately omitted, and stated in the PR: obc test full (nothing cross-cutting changes), obc suites check (no suite, workflow or registry change — every test lands in an existing file), wake-profile isolations (no wake or scheduling term moves; this slice removes per-pass work), docs/build_docs.py --check-links unless a doc is touched, and the iOS / web / desktop surfaces. CI is the gate.

On-device acceptance

Required before merge (owner ruling 5469819084). Run by the orchestrator on the owner's plugged-in board over the DK VCOM injection harness. The board is reflashed with the normal release build afterwards.

The harness

  • Build and flash the debug-uart image, twice, keeping --verify — probe-rs's program path corrupts the first write after a code change often enough to matter on RRAM (firmware/obc-fw-nrf54l/README.md:323-334):
    cd firmware/obc-fw-nrf54l
    cargo build --release --features debug-uart
    probe-rs download --chip nRF54LM20A --verify target/thumbv8m.main-none-eabihf/release/obc-fw-nrf54l
    probe-rs download --chip nRF54LM20A --verify target/thumbv8m.main-none-eabihf/release/obc-fw-nrf54l
    probe-rs run --chip nRF54LM20A target/thumbv8m.main-none-eabihf/release/obc-fw-nrf54l   # RTT/defmt
  • VCOM HWFC off (README §Bring-up step 3), the cu.usbmodem*133 CDC port, pyserial — stty+printf does not work on macOS (README:874-884).
  • Button edges over the existing input injection: K s d / K s u for Select, K b d / K b u for Back, K t <n> for a selection step (obc-platform/src/debug_link.rs:24-29). A hold is K s d, sleep 1.2 s (DEFAULT_HOLD_MS is 500, input.rs:29), K s u.
  • The position oracle is the blind-walk log: input: <gesture> on <Screen> (ride.rs:2316-2318). K t 0 is a free position read — it moves no selection on a one-row card and still prints input: Step 0 on <Screen>.
  • The card must hold a map, ≥ 1 completed ride, and ≥ 1 route, so "every other object intact" is observable.

Four debug-uart-only injection commands this slice adds

Precedent: dfu-install is exactly this — a word-tag debug command whose whole purpose is to make an on-glass gate runnable (debug_link.rs:40-44). All are #[cfg(feature = "debug-uart")] on the board and dead-strip from the release image.

Command What it does Why it is the least invasive option
ride-damage payload Opens a RECORDING object with no session (flat_ride::start), then one Request::Journal whose append is 7 bytes with the matching CRC and a valid resume image. Prints flat ride: fabricated a damaged RECORDING <id> (payload) Uses only the production seam. Store::journal accepts any append ≤ PROGRAM_PAGE with a matching CRC (store.rs:1918-1942). On the next mount total = 7 < FOOTER_LEN(84) skips the footer branch and 7 % 20 != 0 lands on flat_ride.rs:197-203. No card surgery, no block editing, no card removal
ride-damage metadata The same, with a 20-byte zeroed sample and an all-zero resume The resume image is opaque to storage and carried verbatim (seam.rs:240-259); decode_resume refuses on the magic (flat_ride.rs:669), landing on flat_ride.rs:172-179. The 20-byte append keeps the sample boundary valid, which is what separates this cause from the first
ride-repair-fail Arms a one-shot in the board executor: the next removal answers Err(StoreError::Media) without issuing the commit The card is never harmed and the injection sits exactly where a real media failure surfaces — the executor's answer. A genuine media fault cannot be produced on the owner's board safely
store-census One defmt::info! per catalog entry: id, revision, kind, flags, payload_len, payload_crc, name, plus entry_count and free_extents The before/after proof that only the target entry moved. EntryMeta already carries the per-object CRC, so "byte-identical" is a comparison, not a claim

Do not self-reset after ride-damage: the reset would race the confirmation line out of the UART. The operator issues probe-rs reset.

Scenario A — happy path (payload damage)

# Step Observable pass criterion
A1 store-census Record census C0: N entries with their crcs, including the map's. Record free_extents.
A2 ride-damage payload, then probe-rs reset flat ride: fabricated a damaged RECORDING <id> (payload)
A3 Boot RTT shows flat ride: recovered payload is not a v3 sample/footer boundary and the new flat ride: damaged RECORDING <id> rev <r> — payload, repairable=true. No REC_ERROR line
A4 K t 0 input: Step 0 on RideRecovery
A5 Hold Select (K s d, 1.2 s, K s u) input: Hold on RideRecovery, then exactly one flat ride: rider-confirmed exact removal of <id> followed by flat ride: exact removal committed
A6 K t 0 input: Step 0 on Home
A7 store-census C1 = C0 minus the fabricated RECORDING entry. Every remaining row identical in id, revision, kind, flags, payload_len, payload_crc, name. entry_count is C0's minus one. free_extents is back to C0's (the 32 MiB reserve is released)
A8 Open the Map, then Rides, then Routes The map renders on glass. flat: Rides menu loaded N finished ride(s) and flat: Route menu loaded N route(s) match their pre-damage values
A9 Start a ride from the UI; feed F fixes; Finish & Save A fresh object is created (flat ride: start line), checkpoints land, and flat ride: finished <n> B with footer + one commit. No reboot between A5 and A9 — that is the "restores recording immediately" claim

Scenario B — the second logical cause (metadata damage)

Repeat A1–A7 with ride-damage metadata. Pass criteria that differ:

  • A3 must read flat ride: recovered samples have no valid continuation metadata and — metadata, repairable=true.
  • A7's census must show the same exact-removal result.

This is the classification proof: two different fabrications reach two different named causes and one identical repair.

Scenario C — failure path, latch, usable device, retry without reboot

# Step Observable pass criterion
C1 store-census Census C0
C2 ride-damage payload, probe-rs reset as A2/A3
C3 ride-repair-fail the one-shot is armed (one flat ride: line)
C4 Hold Select on the card Exactly one flat ride: exact removal refused: Media. No commit reaches the card
C5 Wait ≥ 60 s with the board running No second exact removal line, ever. This is the "one attempt per rider action, never per pass" proof and the "no more automatic writes that boot" proof. Against today's code this line would repeat at pass rate
C6 store-census C1 is identical to C0, damaged RECORDING entry included: the failed repair wrote nothing
C7 K t 0 input: Step 0 on RideRecovery. On glass the card reads Repair failed with Retry and Back (owner's optional eyeball, or the curated #1515 list)
C8 Press the Back row, then use the device input: Press on RideRecoveryinput: Step 0 on Home; the Map renders; flat: Route menu loaded N route(s) is unchanged. Non-recording functions are usable
C9 Press START The recovery card comes back (input: Step 0 on RideRecovery) and no ride object is created — no flat ride: start line. This is P4 closed
C10 Hold Select on Retry The one-shot is spent, so this attempt commits: exact removal committed. Retry without a reboot
C11 store-census, then start and finish a ride C2 = C0 minus the RECORDING entry; recording works, same boot

Not covered on device, and why

RideDamage::Catalog needs a real catalog read failure and cannot be fabricated on the owner's card without risking it. Its behaviour — no effect is ever minted — is pinned host-side by an_unrepairable_recording_is_never_offered_to_the_store, and the whole point of the state is that it performs no I/O to observe.

Documentation

Check whether docs/content/software/ describes the recovered-ride decision; if it does, the new terminal states belong in that paragraph in a separate docs: commit, and python3 docs/build_docs.py --check-links runs then. Otherwise state in the PR that no public documentation changed. The injection commands are documented in firmware/obc-fw-nrf54l/README.md beside dfu-install, which is a README change, not a public doc.

Follow-ups this slice deliberately leaves open

  • Typed storage recovery for a valid catalog whose ride-rollover repair failed (P5): today it is a BootFault::StorageFault before the app; exposing its RECORDING entry for exact removal is a store.rs mode change. Owner-deferred.
  • A catalog-rebase design for a readable counter-exhausted store. Owner-deferred; surfaces here only as Unrepairable.
  • A simulator seed for RideRecovery, closing the one documented snapshot gap (ui-snapshots.sh:34-39) — more valuable now that the card has four modes.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions