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::Discard — one 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
- 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.
- No ride-kind purge. "Do not add FORMAT and do not purge all rides."
- 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.
- No catalog-rebase for a counter-exhausted store. Deferred by the owner; it surfaces here only as the
Unrepairable terminal state.
- No timeout, no timer, no wake, no automatic replay. Owner ruling 5468099058. The bound this slice adds removes an automatic retry; it adds none.
- No
obc-storage change. The removal semantics, the atomic gate, the reserve release and settle_ride are already exactly what the policy names.
- 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.
- No
Caps change, no escape-set change, no RenderKeyKind change, no new Screen variant, no CardScheduler family.
- 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.
- No persisted recovery state. The latch is RAM, per boot, in
RecorderMachine.
- No simulator injection and no new snapshot frames (see Snapshots).
- 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 RideRecovery → input: 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.
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/develop1d35ea17(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
RECORDINGobject 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 exactMutation::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
RECORDINGentry. 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
RecorderEffectmust deliver exactly one matching terminalRecorderOutcomebefore the next app pass; explicitly abandoned work returnsCancelled. 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 throughdraw_guarded_rows) is the established confirmation grammar. This slice adds modes to that card; it adds no screen and noCapsrow.6. The card's terminal modes exit by a labelled row, not by
Back.Caps::blocks_escapeonRideRecovery(screen/mod.rs:1083) and its named set inscreen/mod.rs:1803-1807are unchanged, andBackstays 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-Backexit is the alternative and would need the escape set re-pinned.)7. The proposed copy. New
[ride_recovery]keys,encolumn: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 existingdiscard/backvocabulary.)Premises this slice corrects
Re-measured at
1d35ea17. Every claim below is code as it stands today.P1 —
State::Faultedcollapses 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 samerecovery_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 setwarning_pendingwhere the first two set it — an unintended inconsistency.P2 — a failed repair retries every pass, for ever.
apply_outcomeleaves the close pending onFailedby design (recorder.rs:634-643), andnext_effectre-admits it whilerecovered_heldholds (recorder.rs:571-582). Each attempt is a full catalog commit and each raisesREC_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::Homeimmediately (ride_recovery.rs:75), before any outcome exists;offer_recoveryis one-shot per boot (recorder.rs:489-496);Backis 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,
Startopens a session that records nothing, silently.advanceopens unconditionally (recorder.rs:520-527); the board'sopen()hits_ => {}forState::Faulted(flat_ride.rs:269) and raises no warning;object_owedtherefore staysSome(id)and the loop retries the no-op every pass (ride.rs:2162-2177); everyAppendis refused (flat_ride.rs:335) andfinalize()answersRideClose::Failed(flat_ride.rs:299). A riding view that records nothing is exactly the failureRecorderAdvance::Refusedexists 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-1244setsMode::CatalogUnreadableand clearsride/recoveredwhenrepair_rolloverfails at mount;flat_store.rs:313-320maps that toCard::Damaged, and boot ends onBootFault::StorageFaultbefore anAppexists. 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.
RECORDINGentries are filtered out of the ride listing (flat_store.rs:1811), so the removal changes no visible list. "Restores recording immediately" is entirelyflat_ridereturning toState::Idleplus the store's ownsettle_ride.Target files
firmware/obc-app/src/recorder.rspub enum RideDamage { Payload, Metadata, Catalog }; newpub enum RideRecoveryState(None,Resumable,Repairable(RideDamage),Repairing(RideDamage),RepairFailed(RideDamage),Unrepairable(RideDamage)) replacing therecovered_held: boolfield (:301);RecorderErrorgainsReadOnly;RecorderAdvancegainsRecoveryOwed;RecorderVerdictgainsRecoveryLatched;offer_recoverytakes the state it is offering;next_effect's object gate (:571) reads the new state;apply_outcome(:610-662) classifies a repair outcome and clearspendingon it;advance(:508-528) refuses to open a session against a damaged objectfirmware/obc-app/src/app.rsoffer_damaged_ride(:2253) takes aRideDamage; both offers and the new re-raise share one privateraise_ride_recovery()(the existingmap_dirty/cancel_holds/hold_cancel_pendingtrio, :2244-2247)firmware/obc-app/src/device_core/pass.rsadvance_recorder_session(:769-781) gains theRecoveryOwedarm; the outcome match (:412-422) gains theRecoveryLatchedarm, which re-raises the card so aStatic-keyed screen repaints its new modefirmware/obc-app/src/screen/ride_recovery.rscan_continue: boolbecomes aRecoveryMode(four modes: resumable / damaged / repair-failed / unrepairable); the row tables andselection_is_guardedfollow it;RetryandBackrows; new + amended testsfirmware/obc-app/i18n/{en,de,fr,es}.toml[ride_recovery]keys (locked decision 7)firmware/obc-fw-nrf54l/src/flat_ride.rsState::Faultedcarriesdamage: obc_app::RideDamage, set literally at the three existing sites (:126-133Catalog, :172-179Metadata, :197-203Payload);recovery_faulted()becomesrecovery_damage() -> Option<RideDamage>; the two boot sites stop settingwarning_pending(the card reports the damage);service_terminalreturnsResult<(), StoreError>; itsDiscardingarm drops the redundantwarning_pending = true(:615) and treatsStoreError::NotFoundas success;discard()returns the typed resultfirmware/obc-fw-nrf54l/src/ride.rsDiscardexecutor arm (:1525-1528) mapsStoreError::ReadOnlytoRecorderError::ReadOnly; onedefmtline naming the classified damage at boot and one per removal attempt/outcomefirmware/obc-app/src/harness/screens.rsoffer_damaged_ride()call sites (:1333, :1342) take aRideDamagefirmware/obc-fw-nrf54l/src/flat_ride.rs,ride.rs,firmware/obc-platform/src/debug_link.rsdebug-uartonly: three injection commands + a census command for the acceptance run (below)No
obc-storagechange. Noobc-portschange. Noobc-render/obc-displaychange. No host-executor change (host/obc-host-core/src/dispatch.rs:771-817keeps answeringRecorderError::Write; the new variant is board-only). NoCapsrow, noRenderKeyKind, no settings surface, no snapshot recipe.Behaviour specification
Classification, at boot, once
flat_ride::Recorder::newnames the damage at each of its three existing refusal sites:RideDamage:126-133)Catalog:172-179)Metadata:197-203)PayloadThere 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 inobc-app.The board reports it once:
app.offer_damaged_ride(damage). Recorder latchesRepairable(d)for the two logical causes andUnrepairable(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 postsRecorderIntent::Discard— one intent.flat_ridereturns toState::Idle; the store'ssettle_rideclearsride/recoveredand invalidates the 16 slot headers; the reserve is free. Recorder'sclose()clears the recovery state. The card returned Home on the hold. Recording is available at once, in the same boot: the nextStartallocates a fresh object with no reboot and no re-scan.Write). Nothing was written — the commit's refusal touches the card not at all (store.rs:1658-1666). Recorder latchesRepairFailed(d), clears the pending close, and returnsRecoveryLatched; 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. NoREC_ERROR— the typed card is the one explanation, andREC_ERRORmeans "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.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 latchesUnrepairable(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 toRepairable(d)and clearspending; 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 aDiscardeffect in this state at all — the object gate innext_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 inState::Discarding, so the same exact commit is re-attempted, and one attempt is all it gets.A
Startagainst a standing damaged object.advancedoes not open a session (P4). It keeps the request — the same rule a refused start already follows — reportsRecoveryOwedonce per ask (reusing the existingrefusal_toldlatch,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 clearsrefusal_told(:457), so asking again is answered again; and a subsequentDiscardreplaces the pendingStart(:458), so nothing auto-starts behind the rider's back after a successful repair.Every terminal state, named
ResumableRepairable(Payload|Metadata)Repairing(d)RepairFailed(d)Unrepairable(d)What does not change
The zero ledger on a damaged card stays (
ride_recovery.rs:95-96states 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.
a_failed_repair_costs_one_attempt_and_waits_for_the_ridersrc/recorder.rsRepairable, requestDiscard, take exactly oneRecorderEffect::Discard, answerFailed{Write}: the state isRepairFailed, and the next five passes mint no effect at all. Then one morerequest(Discard)mints exactly one more. Mutant (reviewer may request): restore thepending-preserving arm atrecorder.rs:641for the repair case — the middle assertion failsa_repaired_recording_lets_the_next_ride_open_in_the_same_bootsrc/recorder.rsDiscardedclears the recovery state; the immediately followingStartreturnsRecorderAdvance::Opened(Fresh)with no intervening bootan_unrepairable_recording_is_never_offered_to_the_storesrc/recorder.rsUnrepairable(Catalog):request(Discard)and twenty passes mint nothing. The "never risk the map" rule, expressed as an absencea_start_against_a_damaged_recording_re_raises_the_decision_once_per_asksrc/recorder.rsStartreturnsRecoveryOwed, opens no session, is reported once, and is reported again after a freshrequesteach_recovery_mode_offers_exactly_its_own_actionssrc/screen/ride_recovery.rsDiscard; the Back row returns Home and posts nothingevery_recovery_card_line_fits_in_every_languagesrc/screen/ride_recovery.rsFont::Label/Font::Bodyacross en/de/fr/es against the 240 px panel budget. A longer translation fails here rather than on glassthe_failed_repair_card_retries_without_a_reboottests/ride_recovery.rsapply_gesture+quiet_pass: damaged offer → hold → one effect →Failed→ the card is back with Retry → Back leaves to Home and non-recording screens work →Startre-raises the card → Retry →Discarded→ a ride opens. Also asserts noREC_ERRORcard was raised on the failureAmended, not added:
damaged_recording_can_only_be_discarded(ride_recovery.rs:206-224) — re-expressed againstRecoveryMode; 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.rsowns it), or theCancelledarm beyond the one assertion inside test 1.Resource limits
compile_time_allocations.appfirmware/tools/resource_baseline.json(51368). Nothing resident is added:recovered_held: boolis replaced by the recovery enum (two bytes where one was, expected in existing padding —RecorderMachinealready carries sixbools atrecorder.rs:282-301), andRideRecoveryScreen'scan_continue: boolis replaced by a one-byte mode. A movement is a finding and must be itemizedsize_of::<Screen>()screen/mod.rs:1835).RideRecoveryScreenis 16 B and is not the widest variantsize_of::<RenderKey>()Staticscreenflat_ride_deltaRESIDENT_BYTESisDELTA_BYTES + RIDE_RESUME_LEN(flat_ride.rs:726);Stateis a loop local, not a staticmeasured_resident,.bss+.data,.uninit,poll_frame_measured,residual_stack_measuredmeasured_flashdebug-uart-only and theirdebug_link::Msgarms dead-strip from the release image; expect ≈ 0 ondefault. Report itVERSIONMIN_SUPPORTED16. No settings surface is touchedSnapshots
The manifest does not move: 312 in, 312 out, byte-identical.
RideRecoveryis 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 runupdate.Seeding the sweep for
RideRecoveryis 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
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.store.rs:1237-1244.Unrepairableterminal state.obc-storagechange. The removal semantics, the atomic gate, the reserve release andsettle_rideare already exactly what the policy names.RecorderEffectvariant. 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.Capschange, no escape-set change, noRenderKeyKindchange, no newScreenvariant, noCardSchedulerfamily.RecorderMachine.PlatformSupport/DeviceFactswidening. Store writability is learned from the executor's typed answer, not a new capability axis (shared.rs:209-219explains why the fact cannot answer it).Host verification
Per CLAUDE.md's budget. The change spans packages, so let the registry choose:
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-nrf54lDeliberately 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-linksunless 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
debug-uartimage, 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):cu.usbmodem*133CDC port, pyserial —stty+printfdoes not work on macOS (README:874-884).K s d/K s ufor Select,K b d/K b ufor Back,K t <n>for a selection step (obc-platform/src/debug_link.rs:24-29). A hold isK s d, sleep 1.2 s (DEFAULT_HOLD_MSis 500,input.rs:29),K s u.input: <gesture> on <Screen>(ride.rs:2316-2318).K t 0is a free position read — it moves no selection on a one-row card and still printsinput: Step 0 on <Screen>.Four
debug-uart-only injection commands this slice addsPrecedent:
dfu-installis 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.ride-damage payloadRECORDINGobject with no session (flat_ride::start), then oneRequest::Journalwhose append is 7 bytes with the matching CRC and a valid resume image. Printsflat ride: fabricated a damaged RECORDING <id> (payload)Store::journalaccepts any append ≤PROGRAM_PAGEwith a matching CRC (store.rs:1918-1942). On the next mounttotal = 7 < FOOTER_LEN(84)skips the footer branch and7 % 20 != 0lands onflat_ride.rs:197-203. No card surgery, no block editing, no card removalride-damage metadataseam.rs:240-259);decode_resumerefuses on the magic (flat_ride.rs:669), landing onflat_ride.rs:172-179. The 20-byte append keeps the sample boundary valid, which is what separates this cause from the firstride-repair-failErr(StoreError::Media)without issuing the commitstore-censusdefmt::info!per catalog entry:id, revision, kind, flags, payload_len, payload_crc, name, plusentry_countandfree_extentsEntryMetaalready carries the per-object CRC, so "byte-identical" is a comparison, not a claimDo not self-reset after
ride-damage: the reset would race the confirmation line out of the UART. The operator issuesprobe-rs reset.Scenario A — happy path (payload damage)
store-censusfree_extents.ride-damage payload, thenprobe-rs resetflat ride: fabricated a damaged RECORDING <id> (payload)flat ride: recovered payload is not a v3 sample/footer boundaryand the newflat ride: damaged RECORDING <id> rev <r> — payload, repairable=true. NoREC_ERRORlineK t 0input: Step 0 on RideRecoveryK s d, 1.2 s,K s u)input: Hold on RideRecovery, then exactly oneflat ride: rider-confirmed exact removal of <id>followed byflat ride: exact removal committedK t 0input: Step 0 on Homestore-censusRECORDINGentry. Every remaining row identical inid, revision, kind, flags, payload_len, payload_crc, name.entry_countis C0's minus one.free_extentsis back to C0's (the 32 MiB reserve is released)flat: Rides menu loaded N finished ride(s)andflat: Route menu loaded N route(s)match their pre-damage valuesFfixes; Finish & Saveflat ride:start line), checkpoints land, andflat ride: finished <n> B with footer + one commit. No reboot between A5 and A9 — that is the "restores recording immediately" claimScenario B — the second logical cause (metadata damage)
Repeat A1–A7 with
ride-damage metadata. Pass criteria that differ:flat ride: recovered samples have no valid continuation metadataand— metadata, repairable=true.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
store-censusride-damage payload,probe-rs resetride-repair-failflat ride:line)flat ride: exact removal refused: Media. No commit reaches the cardexact removalline, 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 ratestore-censusRECORDINGentry included: the failed repair wrote nothingK t 0input: Step 0 on RideRecovery. On glass the card reads Repair failed with Retry and Back (owner's optional eyeball, or the curated #1515 list)input: Press on RideRecovery→input: Step 0 on Home; the Map renders;flat: Route menu loaded N route(s)is unchanged. Non-recording functions are usableinput: Step 0 on RideRecovery) and no ride object is created — noflat ride:start line. This is P4 closedexact removal committed. Retry without a rebootstore-census, then start and finish a rideRECORDINGentry; recording works, same bootNot covered on device, and why
RideDamage::Catalogneeds 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 byan_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 separatedocs:commit, andpython3 docs/build_docs.py --check-linksruns then. Otherwise state in the PR that no public documentation changed. The injection commands are documented infirmware/obc-fw-nrf54l/README.mdbesidedfu-install, which is a README change, not a public doc.Follow-ups this slice deliberately leaves open
BootFault::StorageFaultbefore the app; exposing itsRECORDINGentry for exact removal is astore.rsmode change. Owner-deferred.Unrepairable.RideRecovery, closing the one documented snapshot gap (ui-snapshots.sh:34-39) — more valuable now that the card has four modes.