Skip to content

feat: Device recovery via ROM bootloader mode - #207

Draft
Yona-Appletree wants to merge 50 commits into
mainfrom
claude/xenodochial-darwin-b6680f
Draft

feat: Device recovery via ROM bootloader mode#207
Yona-Appletree wants to merge 50 commits into
mainfrom
claude/xenodochial-darwin-b6680f

Conversation

@Yona-Appletree

Copy link
Copy Markdown
Member

Plan: lp2025/2026-07-30-device-recovery-bootloader

Recovery for a device whose own project is what stops it from running — the
originating case being a project bright enough to brown the board out, which
loops forever because it never stays up long enough for Studio to connect.

Draft: opened at the first commit so CI signals early. M1–M2 of 9.

Landed

  • M1 — spike (no repo changes). Confirmed esptool-js 0.6.0 exposes
    readFlash(offset, size, onPacketReceived) and that main() already runs
    the stub. Measured on real C6 silicon: 960 KB in 3.2 s, 4 MB in 13.6 s;
    write → read-back → restore byte-identical. Two planning assumptions were
    wrong and are corrected in the plan: raising the baud is slower (USB
    CDC — the parameter is meaningless), and ESP_READ_FLASH is not stub-only.
  • M2 — boot-control sector. New lp-base/lp-bootctl crate, a bootctl
    partition carved from the unused nvs (no other offset moves, so existing
    lpfs images stay valid), and a firmware read-and-consume path ahead of
    project auto-load.

Still to come

M3 "boot safe next time" end to end · M4 bootloader-mode detection ·
M5 recovery UX (visual gate) · M6/M7 filesystem backup+restore as ZIP ·
M8 whole-flash backup/restore · M9 flap detector + cleanup.

Validation so far

  • cargo test -p lp-bootctl — 34 passed
  • just check — green
  • just test — 29 suites, 0 failures
  • just fw-esp32c6-size-check — 278 800 B headroom vs the 65 536 B margin

ADR: docs/adr/2026-07-30-boot-control-sector.md

🤖 Generated with Claude Code

Yona-Appletree and others added 13 commits July 30, 2026 17:33
A project can make its own device unrecoverable: one bright enough to
brown the board out comes up, kills the rail, resets, auto-loads itself,
and loops. lp-recovery's boot-loop ladder does not catch it, and its
breadcrumb region lives in RTC RAM that a power cycle wipes — so the
first thing a user does (unplug it) erases the evidence.

Add a 4 KB `bootctl` flash partition carrying an instruction the firmware
reads before it auto-loads a project. Flash-resident so it survives the
power cycle; shared format so the two writers cannot disagree — the host
over esptool while the device sits in ROM download mode (the only channel
that works on a board that cannot boot), and later the firmware itself.

The 4 KB comes out of `nvs`, which nothing reads: no LightPlayer code
touches NVS, and esp-radio's `NVS` is a RAM array, not the partition.
That moves no other offset, so `lpfs` stays put and existing devices'
filesystem images remain valid.

Blank is safe: an erased sector, bad magic, bad CRC, future version,
short read, and torn write all decode to "boot normally". A corrupt
sector can never cause a degraded boot, only fail to prevent one. NOR
flash cannot flip one word to publish a record, so the payload is written
first and the magic last — `encode_write_order()` makes that ordering the
API rather than writer discipline. The firmware consumes the record on
read, before acting on it, so a crash during the recovery boot cannot
make the instruction sticky.

Layout guards live in lp-bootctl's tests, not fw-esp32c6: that crate is
RV32-only and excluded from host builds, so tests there would never run.

Validated: cargo test -p lp-bootctl (34), just check, just test (29 suites,
0 failures), just fw-esp32c6-size-check — 278 800 B headroom vs the
65 536 B margin.

ADR: docs/adr/2026-07-30-boot-control-sector.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rwin-b6680f

# Conflicts:
#	docs/adr/README.md
The firmware is not the only thing that will ever encode this record —
host writers over esptool produce the same 16 bytes, and without a fixture
the two implementations can drift into disagreeing about a format neither
side can renegotiate at runtime.

These exact bytes were written to a C6's bootctl partition with esptool
and honored by fw-esp32c6 on silicon:

  [BOOTCTL] record found: flags=0x00000001
  [BOOTCTL] record consumed
  [BOOTCTL] SAFE BOOT: boot-control record — skipping project auto-load

The sector read back erased afterwards, confirming the one-shot consume.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the link-layer vocabulary for writing the boot-control sector, plus
the espflash implementation behind it. `LinkOperation::WriteBootControl`
joins the capability set; `LinkManagementRequest::SetBootControl` carries
`lp_bootctl::BootFlags` bits as a plain u32 so the wire type stays
independent of the on-flash format crate, with `boot_safe_once()` as the
constructor callers should reach for instead of assembling bits.

The host writer erases the sector, then writes payload-first/magic-last
via `encode_write_order` rather than reassembling the record — that
ordering is the whole reason an interrupted write is safe, and duplicating
it at a call site is how it stops being true.

The fake provider implements the request too, so dispatch, capability
gating, and the UX arm are all testable without hardware; the encoding
itself is covered by lp-bootctl's golden vector rather than re-asserted
there.

Browser provider and the Studio action are still to come.

Validated: cargo test -p lpa-link --all-features (74 passed), cargo check
-p lpa-link --all-features.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adding SetBootControl broke the build in lpa-studio-core: four
near-identical match arms over LinkManagementResult, one per operation,
so every new operation is a build break that gets a fifth copy free to
drift from the other four.

Collapse it. `logs()`/`progress()` were already uniform accessors inside
lpa-link, just private — make them public and read through them instead of
matching. ResetRuntime stays special-cased because it genuinely carries no
logs of its own.

Also moves the fake provider's LinkBootControlResult import into the
`fake-device`-gated function that uses it, alongside the other result
types, so the import is not unused when the feature is off.

Found by CI, not locally: I ran a targeted `cargo check -p lpa-link`
rather than `just check`, which is the actual parity gate.

Validated: just check, cargo test -p lpa-link --all-features (74),
cargo test -p lpa-studio-core (776).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The payload-then-magic ordering could not work. Every flash-write API that
can reach this sector issues the ESP ROM/stub FLASH_BEGIN, which ERASES the
sectors it is about to write — true of espflash's write_bin_to_flash and of
esptool-js's writeFlash alike. So the second write, meant to publish the
first, would erase it: a valid magic over an erased payload, failing its own
CRC. Safe, but the feature would silently never work.

The hardware walk missed this because it wrote the record as a single
16-byte file with esptool, which is the path that works.

Integrity never needed the ordering — the CRC covers the magic, so every
prefix of an interrupted write fails one of the two checks.
`no_partial_write_is_ever_honored` now asserts exactly that, at all 16
truncation points, which is a stronger claim than the ordering test it
replaces.

Drops WriteOrder/encode_write_order for a public encode_record, and updates
the ADR and README, which both documented the ordering as the discipline.

Validated: cargo test -p lp-bootctl (35), cargo check -p lpa-link
--all-features.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`story-apply-refresh.mjs` parsed argv and called process.exit(2) at module
scope, so `story-pull.mjs` — which imports applyRefresh from it — exited 2
before doing anything. The documented manual fallback for story drift has
been dead since the scripts were split, and CI's own failure message sends
you into it.

Argument parsing moves inside the `import.meta.url === argv[1]` guard, with
a comment saying why it has to stay there. CLI usage and the import path are
both verified.

Found while diagnosing a capture crash on this branch. Also appends the
incident to the debt register, including a note that the guard step reports
"drift" even when the crash sentinel has positively identified a crash —
the step conclusions, not the step name, are the reliable signal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes SetBootControl across both real providers. The record is encoded
in Rust and handed to JS as bytes — the firmware reading it cannot
renegotiate the format at runtime, so lp-bootctl stays its single
implementation.

One writeFlash call, no explicit erase: FLASH_BEGIN already erases the
sectors it writes, which is also why the record must not be split across
two writes.

This was also the CI break. The browser provider's dispatch is feature-gated
to wasm, so the non-exhaustive match the new variant created was invisible
to `just check` AND to `cargo check -p lpa-link --all-features` on the host
— it only failed under `dx build [wasm32-unknown-unknown]`, surfacing as a
red "Validate story baselines" that looks exactly like a capture wedge. The
debt register entry is corrected accordingly, with the step conclusions that
tell the three cases apart.

Validated: cargo check --target wasm32-unknown-unknown for both -p lpa-link
--features browser-serial-esp32 and -p lpa-studio-web; just check;
cargo test -p lpa-link --all-features (74).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Surfaces SetBootControl as a DeviceOp on the not-responding card's
troubleshoot sheet, next to the recovery flash.

Deliberately not destructive and severs_lens: false — unlike ResetToBlank
this erases nothing, so the user keeps their project and the editor's lens
stays valid. The device consumes the record as it boots, so it affects
exactly one restart, which the completion notice says out loud.

Adds the missing rung to the sheet's advice: the existing bullets assume a
cable or firmware problem, and had nothing for "it worked until I changed
the project", which is the case this whole plan exists for.

Minimal placement on purpose — M5 owns making the recovery flow
presentable.

Validated: just check, cargo test -p lpa-studio-core (776), cargo check
-p lpa-studio-web --target wasm32-unknown-unknown.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drives a real board the way Studio does — connect → SetBootControl →
device reboots reachable with nothing loaded → reboot again → the record is
gone and the project loads normally.

The second reboot is the point, and the step most easily skipped by hand:
the record is one-shot, so "safe once" must not silently become "safe
forever". Asserting it here means the earlier manual esptool walk is no
longer the only thing standing behind that claim.

Companion to manage_smoke, which covers erase/flash/reset and IS
destructive; this one erases nothing and the device's project survives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
App / Bootloader / Unknown, exposed on the device snapshot.

The tempting implementation cannot work and is worth naming so it is not
re-proposed: VID/PID tells you nothing here. C6 and S3 use the chip's native
USB-Serial-JTAG peripheral and the ROM bootloader uses THAT SAME peripheral,
so the device enumerates as 303A:1001 whether it is running our firmware or
sitting in download mode. Bridge boards always report the bridge. The mode
has to be established by talking to the device.

This lands the passive half: a hello means App, ROM download-mode boot lines
mean Bootloader, everything else is Unknown. Boot lines stay corroboration —
strong when present, meaningless when absent, because a board already in
download mode printed its banner before Studio attached. Readiness is
untouched: a proto-matching ServerHello remains the only thing that grants
it, and this module classifies rather than promotes.

`probe_would_help()` encodes when escalating is worth it. It is false for
App on purpose: the SYNC probe resets the device and drops USB enumeration,
so probing a healthy board costs a reboot and buys nothing.

Validated: cargo test -p lpa-link --all-features (83, 9 new); just check;
cargo check -p lpa-studio-web --target wasm32-unknown-unknown.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes the three-way classifier with its authoritative tier:
`DeviceSession::probe_link_mode` runs the esptool SYNC handshake, which only
a ROM/stub bootloader answers. That handshake is the only way to separate
"in download mode" from "not responding" — USB-Serial-JTAG parts present
identical enumeration data in both states.

It takes DeviceMode::Management and rebuilds the link afterwards, the same
discipline as manage(), because the handshake REBOOTS the device: it drives
DTR/RTS to enter download mode, and on USB-Serial-JTAG that reset drops USB
enumeration. probe_would_help() is false for App so callers do not pay a
reboot for an answer they already have.

An unanswered probe is not reported as Unknown. A device running the app
ignores SYNC too, so on probe failure the session reports the rebuilt link's
PASSIVE classification — the honest answer rather than a guess.

Providers without a bootloader concept report the probe unsupported instead
of fabricating an answer. The fake provider implements it, so the escalation,
its exclusivity, and the App-mode shortcut are all covered without hardware.

ADR: docs/adr/2026-07-30-bootloader-mode-detection.md — including VID/PID
matching recorded as REJECTED-because-impossible, since it is the thing most
likely to be re-proposed by someone who has not hit the USB-Serial-JTAG
detail.

Validated: cargo test -p lpa-link --all-features (86, 3 new); just check;
cargo check -p lpa-studio-web --target wasm32-unknown-unknown.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

CI refreshed the story baselines on this branch

The validate-stories job detected drift and committed the fresh set: 43 changed
in a138fa1.

Review every PNG in the PR's Files changed view (swipe / onion-skin).
Your local branch is now behind — git pull before pushing again.

Story Before After
exploration__node-ui__status-indicators__sm.png exploration__node-ui__status-indicators__sm.png@223c0946b5 exploration__node-ui__status-indicators__sm.png@a138fa1939
studio__roster__roster-card__bootloader-entry-confirmed__lg.png studio__roster__roster-card__bootloader-entry-confirmed__lg.png@223c0946b5 studio__roster__roster-card__bootloader-entry-confirmed__lg.png@a138fa1939
studio__roster__roster-card__bootloader-entry-confirmed__md.png studio__roster__roster-card__bootloader-entry-confirmed__md.png@223c0946b5 studio__roster__roster-card__bootloader-entry-confirmed__md.png@a138fa1939
studio__roster__roster-card__bootloader-entry-confirmed__sm.png studio__roster__roster-card__bootloader-entry-confirmed__sm.png@223c0946b5 studio__roster__roster-card__bootloader-entry-confirmed__sm.png@a138fa1939
studio__roster__roster-card__bootloader-entry-generic__lg.png studio__roster__roster-card__bootloader-entry-generic__lg.png@223c0946b5 studio__roster__roster-card__bootloader-entry-generic__lg.png@a138fa1939
studio__roster__roster-card__bootloader-entry-generic__md.png studio__roster__roster-card__bootloader-entry-generic__md.png@223c0946b5 studio__roster__roster-card__bootloader-entry-generic__md.png@a138fa1939
studio__roster__roster-card__bootloader-entry-generic__sm.png studio__roster__roster-card__bootloader-entry-generic__sm.png@223c0946b5 studio__roster__roster-card__bootloader-entry-generic__sm.png@a138fa1939
studio__roster__roster-card__bootloader-entry-instructing__lg.png studio__roster__roster-card__bootloader-entry-instructing__lg.png@223c0946b5 studio__roster__roster-card__bootloader-entry-instructing__lg.png@a138fa1939
35 more file(s)
  • studio__roster__roster-card__bootloader-entry-instructing__md.png (changed)
  • studio__roster__roster-card__bootloader-entry-instructing__sm.png (changed)
  • studio__roster__roster-card__bootloader-entry-not-yet__lg.png (changed)
  • studio__roster__roster-card__bootloader-entry-not-yet__md.png (changed)
  • studio__roster__roster-card__bootloader-entry-not-yet__sm.png (changed)
  • studio__roster__roster-card__bootloader-entry-waiting__lg.png (changed)
  • studio__roster__roster-card__bootloader-entry-waiting__md.png (changed)
  • studio__roster__roster-card__bootloader-entry-waiting__sm.png (changed)
  • studio__roster__roster-card__erase-sheet-open__lg.png (changed)
  • studio__roster__roster-card__erase-sheet-open__md.png (changed)
  • studio__roster__roster-card__erase-sheet-open__sm.png (changed)
  • studio__roster__roster-card__name-sheet-open__lg.png (changed)
  • studio__roster__roster-card__name-sheet-open__md.png (changed)
  • studio__roster__roster-card__name-sheet-open__sm.png (changed)
  • studio__roster__roster-card__op-overlay-awaiting-device__lg.png (changed)
  • studio__roster__roster-card__op-overlay-awaiting-device__md.png (changed)
  • studio__roster__roster-card__op-overlay-awaiting-device__sm.png (changed)
  • studio__roster__roster-card__op-overlay-determinate__lg.png (changed)
  • studio__roster__roster-card__op-overlay-determinate__md.png (changed)
  • studio__roster__roster-card__op-overlay-determinate__sm.png (changed)
  • studio__roster__roster-card__op-overlay-failed__lg.png (changed)
  • studio__roster__roster-card__op-overlay-failed__md.png (changed)
  • studio__roster__roster-card__op-overlay-failed__sm.png (changed)
  • studio__roster__roster-card__op-overlay-indeterminate__lg.png (changed)
  • studio__roster__roster-card__op-overlay-indeterminate__md.png (changed)
  • studio__roster__roster-card__op-overlay-indeterminate__sm.png (changed)
  • studio__roster__roster-card__recovery-mode__lg.png (changed)
  • studio__roster__roster-card__recovery-mode__md.png (changed)
  • studio__roster__roster-card__recovery-mode__sm.png (changed)
  • studio__roster__roster-card__stop-sim-sheet-open__lg.png (changed)
  • studio__roster__roster-card__stop-sim-sheet-open__md.png (changed)
  • studio__roster__roster-card__stop-sim-sheet-open__sm.png (changed)
  • studio__roster__roster-card__troubleshoot-sheet-open__lg.png (changed)
  • studio__roster__roster-card__troubleshoot-sheet-open__md.png (changed)
  • studio__roster__roster-card__troubleshoot-sheet-open__sm.png (changed)

Yona-Appletree and others added 16 commits July 30, 2026 18:52
M5's model half. Corrects a planning assumption that did not survive
contact: the plan said board-specific recovery instructions should come from
board manifest data. They cannot. HwManifest is DEVICE-side — read from the
device's own /hardware.json or compiled into its firmware — and a device that
will not boot cannot tell Studio what board it is, which is exactly the
situation these instructions exist for. There is no Studio-side board
catalog.

So instructions key on chip family, which Studio can honestly know two ways:
a hello from a device it reached earlier, or the SYNC probe's chip_name from
M4. When it knows neither — the normal state before the user reaches
bootloader mode — it renders generic guidance and SAYS it is generic. An
unhedged "hold BOOT" on a board whose button is labelled IO0 does not read as
wrong instructions; it reads as a dead device.

Chip matching is substring-based because esptool reports
"ESP32-C6FH4 (QFN32) (revision v0.2)" — an exact match would fall through to
generic for every real board. An unrecognized chip falls back to generic
rather than inheriting another family's button sequence.

Every sequence starts by unplugging, and a test enforces it: the strap is
sampled at reset, so holding BOOT on a running board does nothing. Getting
that order wrong is the most common reason the ritual fails.

Validated: cargo test -p lpa-studio-core (783, 7 new); just check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The confirmation is the point of this milestone. Without it a failed
attempt at the BOOT-button ritual and a genuinely dead board look identical,
so people repeat the wrong motion and conclude the device is bricked.

Edge-triggered, not level-triggered. The authoritative test for bootloader
mode is the SYNC handshake, and that handshake REBOOTS the device — polling
it would reboot a healthy board repeatedly and could knock a device out of
the very state the user just achieved. So the flow waits for the device to
re-enumerate (the physical replug is already part of the ritual, so an
arrival is exactly the moment worth probing) and probes once, on that
arrival. `should_probe_on_arrival()` is the guard, true only while Waiting,
and a test pins it false in all three other states.

An unanswered probe is `NotYet`, not failure: an app-mode device ignores
SYNC too, so the honest reading is "that attempt did not land" and the flow
returns the user to the steps — with the SAME instructions, so an unknown
chip's guidance does not silently change mid-ritual.

Also fixes an unused-parameter warning on the connector probe: `events` is
consumed only by the host arm, and which arms exist is feature-dependent.

Validated: cargo test -p lpa-studio-core (790, 7 new).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…sheet

Replaces the single hardcoded "Hold BOOT while plugging in" bullet with the
chip-keyed sequence from RecoveryInstructions, as numbered steps.

The chip comes from the card's firmware provenance: `fw.package` is
"fw-esp32c6", which names the chip it was built for. That is the only chip
source available BEFORE the user reaches bootloader mode — a device that
will not boot cannot tell us its board — so a device Studio reached earlier
gets specific steps even while it is unreachable now. Without it, generic
steps that say they are generic.

The old bullet also had the order wrong by omission: it said "hold BOOT
while plugging in" without saying to unplug first. The strap is sampled at
reset, so on an already-running board there is nothing to hold BOOT for.

Validated: cargo test -p lpa-studio-core (791); just check; cargo check
-p lpa-studio-web --target wasm32-unknown-unknown.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Renders the ritual and — the whole point of M5 — tells the user the moment
it worked. Without that, a failed attempt and a dead board look identical,
so people repeat the wrong motion and conclude the device is bricked.

The sheet carries the whole BootloaderEntryFlow as its view-state value, so
advancing is re-opening with the next state and the renderer holds nothing
of its own. Core ownership matters more here than for any other sheet: the
ritual REQUIRES physically unplugging the device, which tears down the
session and re-renders the card — component-local state would lose the
user's place at exactly the moment the confirmation is due.

The not-yet copy says the attempt did not land, not that the device is
broken: an app-mode device ignores the SYNC handshake too, so "no answer"
genuinely does not mean "no device".

Five stories, one per state, including the generic-instructions case where
Studio cannot name the chip and says so.

Validated: just check; cargo test -p lpa-studio-core (791) and
-p lpa-studio-web (214); cargo check -p lpa-studio-web --target
wasm32-unknown-unknown.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Caught by looking at the captured baselines rather than trusting them: the
waiting story was BYTE-IDENTICAL to the instructing one at all three sizes.
Not a rendering-logic bug — the card's per-sheet min-height had no arm for
this sheet, so it fell to the 210px catch-all and clipped right after step
4. Every state differs only BELOW that cut, so four of five states rendered
as the same picture, and the confirmation this whole milestone exists for
was invisible.

Gives the sheet its own floor, state-dependent: 430px for the step states
(a two-line title, four wrapping steps, plus a conditional warn or hedge
line, plus buttons) and 210px for Confirmed, whose payoff is one line and a
button and was left stranded above a void by the tall floor.

Also fixes `tw:text-warn` on the not-yet line — not a real class, so it
silently did nothing and the "that attempt didn't land" copy rendered as
ordinary text. The token is `tw:text-status-attention-foreground`.

Local captures are non-authoritative (macOS ≠ CI) and are NOT committed;
they were used only to confirm the clipping is gone. CI recaptures the
baselines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two bench reports, both correct.

**The probe was never wired.** M4 built `probe_link_mode`, M5 built the flow
that consumes probe results — and NOTHING connected them; `rg` for callers
of either found only doc comments. The button moved the sheet to Waiting and
then no probe ever ran, so the confirmation this milestone exists for could
not fire. The passive classifier cannot cover for it: a board already in
download mode printed its ROM banner before Studio attached, which is the
"silence proves nothing" case the ADR describes.

Adds `DeviceOp::ProbeBootloaderMode { card_key, flow }`: shows Waiting
immediately (the probe takes seconds; a frozen sheet reads as a hang), runs
the SYNC handshake, and folds the answer back as Confirmed or NotYet.
Reaching the device but finding it NOT in bootloader mode is treated like a
failed probe — both mean "that attempt did not land", since an app-mode
device ignores the handshake too. User-triggered only: the handshake reboots
the device, and the button press is itself the signal that a replug just
happened.

**Boards label the button "B".** Many shorten the silkscreen because four
letters do not fit. The deeper point is that the label is a BOARD property,
not a chip one — knowing the chip buys the sequence, not the legend — so
both the generic and chip-specific steps now hedge it.
`no_sequence_asserts_an_unhedged_button_label` keeps a future chip family
from quietly reintroducing the assumption.

I had each piece green in isolation and took my remaining-work list from the
plan instead of asking whether a user could finish the flow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bench report: a board in ROM download mode showed "Ready to set up" with an
Install-firmware button, and the recovery flow was unreachable.

Both halves were real. `DeviceState::Bootloader` has existed all along and
was collapsed with BlankFlash into one card state — so the session detected
download mode and the card threw the fact away. And Troubleshoot hung off
NotResponding alone, which is the wrong gate: the states where a user most
needs recovery are the ones where the ladder ended somewhere ELSE.

Splits `RosterCardState::RecoveryMode` out of ReadyToSetUp, labelled
"Recovery mode" — bootloader is our word, not the user's. Users arrive three
ways (a new board that will not talk, firmware that interferes with
flashing, a device being rescued) but the verbs are the same for all three,
so this is one flow, not a branch.

Its affordance is Troubleshoot, NOT SetUp. This is not a normal
provisioning: a device here may hold data the user came to rescue, so the
headline verb must not be the one that erases it.

Troubleshoot now leads the danger zone in every state that HAS one. Working
states still have none — troubleshooting mid-operation would want the wire
the operation is holding — and an empty list still means no section rather
than a section with one row.

Validated: just check; cargo test -p lpa-studio-core (792) and
-p lpa-studio-web (214); wasm32 build. Four expectations updated where the
danger list legitimately changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…mode explains itself

Two bench reports.

**The confirm dialog was a real bug.** The Troubleshoot row's action is
meta-only — it supplies the row's label/summary/icon and is never dispatched,
because the sheet is the effect. But it borrowed `DeviceOp::ProvisionFirmware`
purely as a carrier and inherited its DESTRUCTIVE confirmation with it, so
opening a read-only help sheet asked "This will write LightPlayer firmware to
the selected ESP32. Continue?". Latent since M6; exposed the moment
Troubleshoot became reachable from every state. Its sibling rows
(ChooseProject, NameDevice) use inert HomeOp carriers — only this one reached
for a destructive op. Now stripped, with the reason written down: a meta-only
carrier must never inherit a gate for an effect it does not have.

**Recovery mode now says what it is.** The label alone left the user
guessing why a board they just plugged in was not simply working. The
sub-line names the situation, the two common ways people arrive at it, and
the part that actually surprises them: anything installed from here needs a
physical replug before it will run. A test pins that last sentence, because
it is the difference between a successful flash and what looks like a dead
board.

Validated: just check; cargo test -p lpa-studio-core (793); wasm32 build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The flow's shape was wrong for the one path a user in recovery takes. A
board flashed while sitting in ROM download mode does not boot the image it
was just given until it is physically replugged — so the post-flash reattach
CANNOT succeed, and the flow rendered the card's Failed op. A successful
flash reported as a failure.

The shape now follows the device's actual state rather than which button was
pressed, because the state is what determines whether waiting for a reattach
is sensible: `awaits_manual_replug` is set when the session is in
DeviceState::Bootloader, and it changes both the narration ("Unplug the
board and plug it back in to start it") and the ending.

`reattach_failure_op` makes the distinction explicit and testable: two
different situations wear the same error — a device that FAILED to come back
and a device that was never going to. The first keeps its Failed render and
single exit (tolerating it would hide real breakage); the second stays in
AwaitingDevice with the instruction as the narration, and does not mark the
session failed for doing exactly what it must do.

Tested as a pure decision rather than through the flow: the fake device's
connect-error injector lives on the provider, which the e2e helper moves
into the registry and does not return, and changing that helper's signature
would touch 31 call sites.

Validated: just check; cargo test -p lpa-studio-core (795, 2 new); wasm32.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three bench findings from the Recovery-mode walk, one commit each half.

**The face now carries the exits.** A user in Recovery mode has already
done the hard part; routing them through Troubleshoot — a sheet that mostly
explains how to get INTO this state — was backwards. The face offers both
exit verbs directly, each with a one-line summary that lets the user
self-select their case, because Studio cannot tell the cases apart (a board
in download mode sends no hello, so it cannot be linked to a registered
device). Install first (the common new-board arrival), the rescue verb
second; neither endangers the other's case.

**Sheets are now grid-stacked, not absolute.** The card's below-the-header
wrapper stacks tab content, sheet, and op overlay in ONE grid cell, so the
region is as tall as the tallest of them and a sheet GROWS the card. The
old absolute overlays could not contribute height, which forced a
hand-maintained per-sheet min-height table — a recurring clipping-bug class
(Name at 210px, the bootloader sheet, then the troubleshoot sheet leaving
the user stuck with no way to scroll). The table is deleted; height is
content-driven everywhere, including the op overlay.

**Boot-safe from recovery ends on the replug.** The manually-entered
download mode latches until power-on reset (bench-confirmed), so writing
the boot-control record from Recovery mode cannot end on an auto-reconnect
— same physics as the recovery flash, now the same ending.

Validated: just check; cargo test -p lpa-studio-core (795) and
-p lpa-studio-web (214); wasm32 build; local captures confirm the stuck
sheet now renders in full and the face reads as designed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…edence

Bench feedback: "start without the project" meant nothing to users. What
they want is safe mode — the board starts, the LEDs are dim, they connect
and fix the project.

The record format already reserved bits 8..16 for exactly this, so the
contract is definable NOW even though the firmware clamp is follow-up work:

- Bits 8..16 are assigned: a safe-mode output clamp level, 0 = none, else a
  brightness ceiling out of 255 (default 26 ≈ 10% — visible, far below
  brownout territory).
- Precedence, defined in the format and testable: a firmware that
  implements the clamp loads the project DIMMED and ignores the skip bit —
  a dim visible board beats a dark one. Firmware that predates the clamp
  ignores the unknown bits and honors the skip.
- Studio's verb writes BOTH. The same record therefore means "nothing
  loaded" today and upgrades to "project running dim" the moment the clamp
  lands in firmware, with no format bump and no Studio change.

LinkManagementRequest::boot_safe_once() becomes start_safe_mode(); every
user-facing string moves to safe-mode language, hedged honestly ("dim, or
with nothing loaded on older firmware") until the clamp ships.

The Recovery-mode face also gains a wayfinding row — "More options — wipe,
erase, or troubleshoot…" — jumping to the danger zone, where those verbs
live and where M6/M7's backup/restore rows will land. A user on this face
should not have to know that.

ADR amended (clamp bits assigned, firmware consumption stays with the
follow-up plan, which shares the output path with the fixture mA limiter).

Validated: cargo test -p lp-bootctl (31+6) -p lpa-link --all-features (86)
-p lpa-studio-core (795) -p lpa-studio-web (214); just check; wasm32 build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A dotted underline through two wrapped lines at 12px renders as struck-out
text — and this row says "wipe, erase", so it read as deleted options.
Caught in the story capture. Hover underline + a trailing arrow carry the
clickability instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Yona-Appletree and others added 21 commits July 31, 2026 12:17
…rwin-b6680f

# Conflicts:
#	lp-fw/fw-esp32s3/partitions.csv
PR #208 (fixture power limiting) landed the application point this was
waiting for, and its power_limit module explicitly anticipated "a later
caller composing an additional outer ceiling (a device-level safe clamp,
say)". This is that caller.

The chain, each layer doing one thing:

- lp-bootctl: `BootAction` — THE home of the skip-vs-clamp precedence
  (clamp wins; a dim visible board beats a dark one). Consumers ask for
  the action instead of re-deriving it from flags, so the rule cannot
  drift between implementations. Every decode failure is BootAction::Normal.
- lpc-engine: `Engine::set_safe_output_clamp(Option<u8>)` — DEVICE state,
  deliberately not project data: the mechanism that saves a device must not
  be editable by the thing that broke it. Threaded through the resolve host
  into ControlRenderContext; the fixture render composes it into the power
  scale by `min`, and it applies to EVERY fixture, budgeted or not — the
  clamped project may predate the power feature entirely.
- lpa-server: `LpServer::set_safe_output_clamp` applies to every loaded
  engine and every future load.
- fw-esp32c6: matches on `boot_action()` — LoadClamped loads the project
  dimmed (the explicit user instruction also outranks the recovery ladder,
  since the user may be recovering exactly the loop the ladder saw);
  SkipAutoload keeps the old dark-but-reachable behavior for records
  written without a clamp.

Merged main first; the S3 partition conflict was real — main gave the S3
an 8 MB partition floor, so the boards' tables legitimately diverge above
0x10000. bootctl re-carved into the 8 MB layout at the same 0xe000; the
layout guards now assert the true invariant (identical LOW region,
per-board lpfs/flash bounds) instead of whole-table identity.

Not yet covered: a render-level test that a clamped fixture actually emits
scaled samples — the fixture test harness has no power case to extend, and
building one is delegated with M6 rather than done inline. The hardware
walk covers it end-to-end meanwhile.

Validated: cargo test -p lp-bootctl (33+6) -p lpc-engine (298+) -p
lpa-server; just check; just fw-esp32c6-size-check — 284 144 B headroom.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Engine::set_safe_output_clamp` landed without a render-level test, so the
property that matters — a clamped fixture EMITS smaller samples — was
unpinned. The setter storing a q16 number was covered; the composition
into the fixture render was not.

Extracts the direct-sampling harness into `direct_sampled_fixture_engine`
(a fixture with no power budget of its own, so any scaling can only have
come from the clamp) and asserts both directions: set, and cleared again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…won't boot

`LinkOperation::ReadRawFilesystem` has been declared and unimplemented since
the link layer was designed. This fills it in: a management request that
reads the device's `lpfs` partition back to the host verbatim, over the
bootloader, so it works on a board whose own project prevents it running.

The region is NOT a parameter and NOT a constant. It is per board (the C6's
lpfs is at 0x310000, the S3's at 0x610000), and which board this is only
becomes knowable when the esptool SYNC handshake answers — a device that will
not boot cannot be asked what it is, which is exactly the case this exists
for. So `LinkFlashRegion::lpfs_for_chip` resolves it from the detected chip,
guarded against both partitions.csv files by test, and an unrecognized chip is
refused rather than guessed at: a wrong region yields a plausible-looking
archive of the wrong bytes.

Capability advertisement is READ ONLY — `with_raw_filesystem_read()` split out
of the paired builder — because restore is M7 and advertising the write half
early would put a button in the UI that every provider answers `unsupported`.

Host: an in-memory ESP_READ_FLASH loop rather than espflash's file-writing
`read_flash`, so the bytes stay in RAM and every acked packet reports progress;
the device's trailing MD5 is verified. Browser: esptool-js `readFlash`, with
the region resolved mid-flow through a Rust callback so the per-board table is
not mirrored into JS. Default baud on both — raising it on USB-Serial-JTAG is
measurably SLOWER (M1 bench: 3.2 s vs 4.2 s for 960 KB).

The fake provider answers with a REAL littlefs image of its scripted files. A
fake returning arbitrary bytes would exercise dispatch and hide the only
failure that matters — an image that does not mount.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`app/device/filesystem_backup`: mount the raw lpfs image in wasm with the same
littlefs implementation the firmware uses, walk it, and emit a ZIP with the
device's paths mirrored verbatim under `files/` plus a `manifest.json`.

Support-facing, but shaped as if it were public — that is what keeps the
upgrade path free, since M7's restore and any future selective restore read
these archives. The layout and every manifest field are documented in the
module's README, which is normative.

The manifest records the captured device uid. `/.lp/device.json` is INSIDE
lpfs, so identity rides along in every backup and a naive restore would clone
it; recording the uid is what lets M7 detect that rather than silently do it.

An unmountable image fails loudly. On a board being rescued that is a real
possibility, and an empty archive presented as a backup is the worst available
outcome.

Tested against fixture images built in-process — no hardware, no device, no
provider.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`DeviceOp::BackUpFilesystem` runs the raw read through the same management
orchestration flash and erase use — release the wire, narrate, reattach — and
then builds the archive and publishes it for the shell to download.

Two details the flow turns on:

- It reads like the GENTLE ops, because it is one. Nothing is written, so the
  lens is not severed, and a device in recovery mode gets the replug
  instruction rather than being reported as a failure for doing what ROM
  download mode does.
- The download is seq-gated (`UiDeviceBackup`, the agent debug-dump pattern).
  The view is a full snapshot, so without the paint key every re-render would
  drop another megabyte into the user's Downloads folder.

The danger zone offers it above the destructive verbs in every state that has
a filesystem to read — including Recovery mode, the state this exists for —
and not on blank or foreign boards, where the row could only fail.

Also fixes copy that M6 makes untrue: the unreadable-content wipe gate said
the content "can't be backed up". A raw backup does not need Studio to
understand the content, only to read the bytes, so the gate now points at the
way out instead of asserting there is none.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Recovery-mode face gains "Download a backup" below "Start in safe mode":
a board in download mode is exactly where somebody is about to try something
drastic, and the copy says the thing that is not obvious — this works even
though the board will not start.

The danger zone carries the same verb as a plain row (no confirm gate — a
gate on "save a copy of your work" is theatre), and `HomeGallery` performs the
download when the backup's seq advances.

Plus an end-to-end test through the REAL provider path: dispatch → capability
gate → raw read → mount → walk → manifest → zip, asserting the archive holds
the device's own files at their own paths and records the captured uid.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
M6 shipped the backup as a README-level contract; M7 reading these archives
back is the moment the layout stops being an implementation detail. Ratified
by Yona at the bench walk. Records the posture (support-facing, shaped as if
public), the alpha versioning rule (version and refuse, never migrate — same
as share envelopes), the verbatim-paths layout selective restore depends on,
and why the manifest carries the chip/partition/uid fields (they are what
let M7/M8 refuse the dangerous restores).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dev server failed to build: 12 story fixtures construct UiHomeView as a
literal, and M6's new `backup` field never reached them. Neither gate caught
it — `just check` skips wasm32 entirely, and the M6 review's wasm build ran
DEFAULT features while the story fixtures sit behind the stories feature dx
enables. The wasm gap, one level deeper than the last time it bit.

The review gate for studio-view changes is therefore
`cargo build -p lpa-studio-web --target wasm32-unknown-unknown --all-features`
— build, all features — and this commit is what running it looks like.

Found live by Yona when the dev server refused to build for the hardware
walk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…get caps

Two bench reports from the hardware walk.

**The vanishing card.** "Start in safe mode" writes the record, the board
drops USB during the espflash reset, the anonymous recovery session lands
Gone, the state derives Offline — and `live_device_card` returns None for
Offline, so the card vanished at the exact moment its op overlay was
showing "unplug the board and plug it back in". The overlay machinery even
documents that an identity-less board's op "rides the live card" — there
was just no card left to ride. An op in flight now pins the card
(`HomeDeviceEvidence::op_in_flight`); a Gone anonymous session with no op
stays invisible as before. Regression test covers both directions.

**The comically long console.** The grid-stack change (which lets sheets
grow the card) also made the op overlay content-sized — and its terminal is
a `flex:1` + `overflow-y:auto` scroller, which only scrolls when its parent
is bounded. The parent no longer is, so a flash's log grew the card line by
line and nothing ever scrolled. Unbounded streams own their caps now:
`.ux-card-op-term` and `.ux-console-lines` get max-height + scroll.

Validated: cargo test -p lpa-studio-core (808, 1 new); just check;
cargo build -p lpa-studio-web --target wasm32-unknown-unknown
--all-features (the gate the previous story-fixture break taught us).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The bench found it as "Arming safe mode failed / Yet to handle Non
Compressed Writes": the browser boot-control writer passed compress: false,
and esptool-js 0.6.0 simply does not implement uncompressed writes — it
throws. The record was never written, which also reframes the walk's
ending: the board coming up "Connected — nothing loaded" was not safe mode,
it was a normal boot of a board that happened to be empty.

compress: true, like flashFirmware always used. The ROM/stub accepts
FLASH_DEFL_BEGIN in download mode, and deflate erases the target sectors
exactly as FLASH_BEGIN does, so the one-write rule for the record holds.

This is the path M1 flagged as untestable without a human port grant, and
this is what that flag was about: the host writer (espflash) was
hardware-verified, the browser writer (esptool-js) first ran against
silicon on Yona's bench today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bench: from app mode, "Start in safe mode" landed on a bare "Not seen yet"
offline card — no overlay, no guidance. The spec set awaits_manual_replug
only from recovery mode, on the theory that an app-mode board auto-returns;
when the post-write reattach misses (USB re-enumeration racing the
rebuild), the flow's ending was the worst of the options: silence.

The awaiting ending is now unconditional for this op, with per-mode copy —
recovery mode keeps the hard replug instruction; app mode says the board
should return by itself and what to do if it doesn't. A successful reattach
still clears the op, so the happy path pays nothing. The op rides the
registered offline card by uid (takes_card_op), so the instruction stays
visible even when the session dies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bench, bootloader-mode arm: still a bare "Not seen yet" after the previous
fix. The two halves contradicted each other — op_in_flight pins the
anonymous card (which renders Offline once the session dies), while
takes_card_op(None) refused to attach a uid-less op to ANY offline card. The
card survived; the instruction didn't.

The offline exclusion exists to keep uid'd REGISTRY cards of other devices
from adopting a stray anonymous op, and it still does. A uid-less op now
rides any uid-less card — there is at most one anonymous hardware session —
including the pinned one. Test pins the pair: the pinned card takes the op,
a registered offline card does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…zing

The technical-details box styles every line as a one-line console tail;
for log lines that is right, but the error line is the one thing the user
must read in full. On the bench the actionable half of an esptool warning
died behind the ellipsis: "Boot-control write failed: Flash…".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
reportFailure passed the raw Error object as console.error's second
argument; the console forwarder ships arguments to the Rust log bridge,
which expects strings — the object arrived as {} and the WHOLE entry died
with "invalid type: map, expected a string", exactly on the line that
carried the boot-control failure. The message string already contains the
error text and stack; the object added nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…oise

Bench, ESP32-C6 rev 2 over USB-Serial-JTAG: esptool-js's flash-ID probe
reads 0 and prints "Failed to communicate with the flash chip" on a board
where actual stub traffic demonstrably works — the 2.9 MB firmware write
and the filesystem backup both succeeded on the same plug session, and a
power cycle does not clear it. The ID probe drives per-chip SPI registers;
FLASH_DEFL_*/READ_FLASH are a different path. Gating the boot-control
write on that warning was a permanent false positive on this board.

The gate is now the guarantee we actually want: read the record back from
flash and compare byte-for-byte. Mismatch throws with both hex dumps; the
warning is left as log noise. The erase path keeps its warning gate — there
is nothing to read back after an erase.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ays so

Bench ask: after arming safe mode (or flashing firmware) from download
mode, the flow should END on "unplug the board and plug it back in" — the
board does not boot the result until then. The spec already encoded that
ending (awaits_manual_replug + copy), but only the reattach-MISS arm used
it. On a C6 over USB-Serial-JTAG the post-write RTS reset re-enters
download mode, so the reattach succeeds into a recovery session, the Ok
arm cleared the op, and the user landed on the recovery card advising
them to install the firmware they had just installed.

The Ok arm now checks: op wants a real boot + device still in recovery =
the replug instruction, same op as the miss arm. A wipe keeps its
clear-on-Ok (the recovery card IS its correct next step), and app-mode
ops that land running are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant