diff --git a/FABRIC-3.md b/FABRIC-3.md
index a26f129..1f277c1 100644
--- a/FABRIC-3.md
+++ b/FABRIC-3.md
@@ -121,7 +121,13 @@ decisions get added here, not to `FABRIC-2.md`. Follow the same discipline `FABR
read-only today specifically because of this).
- [ ] Implement basic error/stall recovery (CSW failure status, endpoint stall clear) — at
- minimum enough to not wedge the controller on a single bad transfer.
+ minimum enough to not wedge the controller on a single bad transfer. **SCOPED 2026-08-27
+ (`FABRIC-3.md` §F.14)**: full BOT-spec recovery designed — new `STALL_ERROR` completion code,
+ new xHCI Reset Endpoint + Set TR Dequeue Pointer commands, `CLEAR_FEATURE(ENDPOINT_HALT)` via
+ existing control-transfer plumbing, escalating to a full Bulk-Only Mass Storage Reset on a
+ second stall, bounded via a new retry counter mirroring `bot_tur_retries`'s own precedent.
+ Real gap closed today: CSW status handling (PASS/FAILED/PHASE ERROR) already existed; nothing
+ distinguished a STALL specifically, and no recovery of any kind existed before this pass.
### From FABRIC-2.md §X, Milestone 3 — Block subsystem extensions
@@ -1435,7 +1441,7 @@ punch list hides that; a graph doesn't.
graph TD
W10["❌ WRITE(10) SCSI support
(Milestone 2 — biggest single blocker)"]
HOTPLUG["✅ Hotplug event surfacing
CLOSED 2026-08-27, M2 (§F.3)"]
- STALL["❌ USB error/stall recovery (M2)"]
+ STALL["❌ USB error/stall recovery (M2)
SCOPED 2026-08-27 (§F.14) — full BOT-spec recovery designed"]
M6["✅ Milestone 6 — capsule PKI
DONE 2026-08-26"]
PH8["✅ Phase 8 — Zuse identity
+ block-fence, DONE 2026-08-26"]
@@ -1573,6 +1579,10 @@ finished). Dashed arrows = softer "gates/informs" relationships.
noticing, because each only traced `homeblocks_sig_check()` itself, never the ordinary attach
path running alongside it. Fixed now (relocate to devblock 1) rather than left to surface
later as a real, confusing runtime failure.
+- **`STALL` closes out Milestone 2 entirely** (§F.14) — the last item on that punch list.
+ Unlike most nodes this session, this one designs genuinely new protocol machinery rather than
+ finding existing infrastructure already covers it: no completion-code distinction and no
+ recovery of any kind existed before this pass, only a bounded-timeout safety net.
**Not yet done:** an ordered plan (which node to attack first, given the graph). Per Captain
Bob's own framing, that's the next pass — "start asking and answering questions iteratively
@@ -2153,6 +2163,66 @@ power is lost between the two writes) — real question, not addressed here; the
updating from their currently-recorded devblock-0-relative assumption to devblock-1, a
mechanical follow-up once any of this is actually coded, not re-litigated here.
+### F.14 — `STALL` (the last M2 item) — full BOT-spec recovery designed
+
+Traced the actual completion-code handling before designing anything. **Real finding: every
+transfer completion, control or bulk, shares one gate** — `if (code != XHCI_COMPLETION_CODE_SUCCESS)
+{ console_println("xhci: control transfer failed"); break; }` (`xhci.c:1267-1270`). A STALL
+isn't distinguished from any other failure today, and there is no recovery of any kind — no
+xHCI Reset Endpoint command, no USB `CLEAR_FEATURE(ENDPOINT_HALT)`. There is a real safety net
+though: `xhci_bot_wait_for_idle()` (`xhci.c:831-839`) has a bounded iteration count and returns
+`BOT_STATUS_TIMEOUT` rather than hanging forever, since the generic bail-out never resets
+`bot_cmd_kind`. So today's failure mode is "clean timeout, endpoint left permanently wedged for
+everything after it" — not a crash, but not recoverable either.
+
+Also confirmed by inspection: none of the pieces real recovery needs exist yet —
+`XHCI_COMPLETION_CODE_STALL_ERROR` isn't defined (only `_SUCCESS`), nor are the `RESET_ENDPOINT`
+(xHCI spec value 14) or `SET_TR_DEQUEUE_POINTER` (value 16) command TRB types. Per direct
+instruction, full recovery is designed now rather than deferred to Milestone 8.
+
+**Decisions made 2026-08-27 — the recovery sequence, mirroring the existing bounded-retry
+precedent `bot_tur_retries`/`XHCI_BOT_TUR_MAX_RETRIES` already establishes for TUR:**
+
+1. **Distinguish the completion code.** Add `XHCI_COMPLETION_CODE_STALL_ERROR` (6, per xHCI
+ spec) alongside the existing `_SUCCESS` (1). The shared completion-code gate branches: a
+ plain non-success/non-stall code keeps today's behavior (log and bail); a stall specifically
+ enters the new recovery path below instead of falling straight to the generic message.
+2. **xHCI-level reset (two new command TRB types + two new command functions, mirroring the
+ existing `xhci_cmd_disable_slot()`/`xhci_cmd_configure_endpoint()` shape exactly):**
+ - `xhci_cmd_reset_endpoint(dev, slot_id, ep_id)` — new `XHCI_TRB_TYPE_RESET_ENDPOINT_CMD`
+ (14), transitions the stalled endpoint from Halted back to Stopped in the xHC's internal
+ context. Which endpoint is "the stalled one" is already known — it's whichever of
+ `bulk_in_ep_addr`/`bulk_out_ep_addr` the in-flight transfer purpose
+ (`BOT_DATA_IN`/`BOT_DATA_OUT`/`CBW_SENT`/`CSW_RECEIVE`) was using, both already tracked
+ fields.
+ - `xhci_cmd_set_tr_dequeue_pointer(dev, slot_id, ep_id, new_dequeue, dcs)` — new
+ `XHCI_TRB_TYPE_SET_TR_DEQUEUE_POINTER_CMD` (16), repositions the ring's dequeue pointer
+ past the failed TRB so the next enqueued transfer resumes cleanly.
+3. **USB-level clear (reuses the existing control-transfer infrastructure already built for
+ `GET_DESCRIPTOR`/`SET_CONFIGURATION` — no new transfer-stage machinery, just a new request
+ payload):** send `CLEAR_FEATURE(ENDPOINT_HALT)` (standard request, `wValue=0`, `wIndex=`
+ the stalled endpoint address) to clear the device's own halt condition and reset its data
+ toggle.
+4. **Escalation on a second stall (or if step 3 itself stalls):** issue Bulk-Only Mass Storage
+ Reset (BOT class request, `bmRequestType=0x21`, `bRequest=0xFF`, no data stage — again the
+ existing control-transfer machinery, a different request payload) followed by
+ `CLEAR_FEATURE(ENDPOINT_HALT)` on *both* bulk endpoints (BOT spec 5.3.4's full procedure),
+ then retry the original command from scratch — the same "fresh target" restart shape the
+ existing TUR-chain retry already uses.
+5. **Bounded, not infinite:** a new `bot_stall_recoveries` counter capped at a new
+ `XHCI_BOT_STALL_MAX_RECOVERIES`, exact value TBD but same shape as `XHCI_BOT_TUR_MAX_RETRIES`.
+ Exhausting it sets `bot_last_status = BOT_STATUS_FAILED` and `bot_cmd_kind = BOT_CMD_NONE`
+ explicitly — a clean terminal failure signaled to `xhci_bot_wait_for_idle()`'s caller,
+ rather than relying purely on the outer timeout the way today's un-recovered stall does.
+
+**Not yet scoped (deferred within this node):** the exact `XHCI_BOT_STALL_MAX_RECOVERIES` value;
+whether a `WRITE(10)`-specific residual-data concern exists on a stalled Data-Out stage (a
+partially-written SCSI command's recovery semantics may differ subtly from a stalled Data-In
+read — not analyzed here, `WRITE(10)` itself still doesn't exist); real-hardware validation is
+explicitly out of reach until Milestone 8, same caveat `WRITE(10)`'s own CSW-residue question
+carries (§F.1) — this design is believed spec-correct but has only QEMU (which rarely if ever
+issues real stalls) to test against for now.
+
### D.5 — Scope expansion (2026-08-27): identity is common to every VM, not just users
Surfaced while scoping `ACLKEY`, stated directly: *"the whole object is to deliver a