# Step-Up Phase Implementation Plan (v2 — supersedes 2026-06-07) > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Implement Aliro v1.0 §8.4 Step-Up Phase end-to-end against stock X-CUBE-ALIRO firmware so the j3r452 returns `DOOR OPERATION SUCCEEDED` on a real NFC reader, then incrementally extend to full Access Document retrieval over the same code path. **Architecture:** Step-Up handlers live on `StepUpApplet` (`A000000909ACCE5502`). The `StepUpSK` 32-byte session secret is already derived during AUTH1 by `AliroApplet` and parked in the shared `SessionContext` (`armStepUp()`); `StepUpApplet` reads it via `copyStepUpSK()` after the vendor firmware issues the spec-mandated step-up AID SELECT. The same `AliroGcm` instance gets re-keyed for the Step-Up phase (one shared object across both phases — saves ~368 B transient). CBOR parsing is structural-only (major-type + length boundaries). `IssuerAuth` COSE_Sign1 verification is cached at personalization. The plan ships in **two demoable milestones**: Milestone 1 = minimum to make X-CUBE-ALIRO say "DOOR SUCCEEDED" (no real document retrieval, just spec-shaped acks), Milestone 2 = real Access Document retrieval. **Tech Stack:** Java Card 3.0.5 (target J3R452), userland AES-256-GCM with 4-bit GHASH (existing), native SHA-256 + ECDSA-P256 verify, structural CBOR codec (RFC 8949 §4.2.1 deterministic encoding for emit), COSE_Sign1 (RFC 9052), mdoc DeviceRequest/DeviceResponse shapes (ISO/IEC 18013-5 §8 with Aliro-remapped integer keys per spec Tables 8-21/8-22). **What today's Path X session locked in (no longer in scope to investigate):** 1. **Crypto baseline is interop-correct.** Three bugs found and fixed: - `salt_volatile` had an extra 32-byte `x(credential_long_term_pub)` (commit pending; line 720-723 of `AliroApplet.buildSaltVolatile`) - `Kdh` derived via HKDF instead of X9.63 KDF per §8.3.1.4 (commit pending; `AliroCrypto.deriveKdh`) - `salt_volatile` used AUTH0's `command_parameters` instead of AUTH1's (commit pending; same `buildSaltVolatile`) Vendor library's `ACWG_processAUTH1ResponsePayload` now returns `ACWG_OK` on a freshly personalized j3r452 — confirmed in `/tmp/nucleo-three-fixes.log` against UID `04565E4A0B2190`. 2. **X-CUBE-ALIRO firmware DOES honor §10.2 step-up AID SELECT.** Once `processAUTH1ResponsePayload` succeeds and `signaling_bitmap` bit 2 is set, the firmware correctly issues `00 A4 04 00 09 A0 00 00 09 09 AC CE 55 02 00`. The "vendor SHALL violation" I was planning to file with ST was actually downstream of the GCM decrypt failure — one less vendor bug to file. Our existing `StepUpApplet.sendStepUpFci` already responds 9000 with a minimal FCI. 3. **Vendor firmware sends TWO post-AUTH1 commands on Step-Up:** - `00 C3 00 00 ENVELOPE` — encrypted mdoc DeviceRequest (Table 8-21 keys) - `80 C9 00 00 EXCHANGE` — encrypted Reader Status sub-event (§8.3.3.5.x) Both arrive after the SELECT to `ACCE5502`. Both need to land on `StepUpApplet` (not `AliroApplet`). ENVELOPE's CLA is `0x00` — unusual for a proprietary command but matches the spec table. EXCHANGE's CLA is `0x80`. 4. **StepUpSK derivation is wired.** `AliroApplet.processAuth1` extracts bytes 64..96 of `derived_keys_volatile` and calls `SessionContext.armStepUp(stepUpSK, 0)`. The consumer side (`StepUpApplet`) just hasn't been written yet. **Out of scope for this plan:** - BLE Step-Up (separate transport) - mdoc field-level interpretation beyond locating IssuerAuth + extracting nameSpaces (we transport bytes; the reader interprets) - Revocation Document handling (same shape as Access Document; just a different blob — covered implicitly by the same emit path) **Reference material:** - Aliro 1.0 spec §8.3.1.5 (HKDF), §8.3.1.13 (key material), §8.4 (Step-up phase), §10.2 (Transaction), Tables 8-14 / 8-21 / 8-22 - RFC 8949 (CBOR core deterministic encoding) - RFC 9052 (COSE_Sign1) - ISO/IEC 18013-5 §8 (mdoc DeviceRequest/DeviceResponse; Aliro Tables 8-21/8-22 remap the integer keys) - Existing code: `AliroApplet.java`, `AliroGcm.java`, `AliroCrypto.java`, `SessionContext.java`, `StepUpApplet.java` (scaffold with TODOs that name each optimization) - Today's interop trace: `/tmp/nucleo-three-fixes.log` (shows the exact ENVELOPE + EXCHANGE bytes the firmware sends) - Original Step-Up plan: `docs/plans/2026-06-07-step-up-implementation.md` (this plan supersedes it; reuses the four optimization names + spec citations) --- ## Milestone 1 — "DOOR OPERATION SUCCEEDED" on X-CUBE-ALIRO (~400 LOC, 4-6 hours) **Result:** The Nucleo prints `DOOR OPERATION SUCCEEDED` against our card. No real Access Document is served — we acknowledge the ENVELOPE and EXCHANGE with spec-shape empty responses. Enough for fundraising demo, leaves the real protocol body for Milestone 2. ### Phase M1A — Wire `StepUpSKDevice`/`StepUpSKReader` into `StepUpApplet` Per §8.4.3, the Step-Up phase uses fresh GCM session keys derived from `StepUpSK`. Spec text: > Compute `StepUpSKDevice = HKDF(IKM=StepUpSK, salt=∅, info="SKDevice", L=32)` > Compute `StepUpSKReader = HKDF(IKM=StepUpSK, salt=∅, info="SKReader", L=32)` The HKDF helpers already exist on `AliroCrypto` (Extract/Expand are public-package-visible). The info strings are 8 ASCII bytes each. ### Task M1A.1: Add a `deriveStepUpSessionKeys` helper to `AliroCrypto` **Files:** - Modify: `applet/src/main/java/com/dangerousthings/aliro/AliroCrypto.java` - Modify: `applet/src/test/java/com/dangerousthings/aliro/AliroCryptoTest.java` **Step 1: Write a failing test for the device/reader derivations.** ```java @Test void deriveStepUpSessionKeysMatchesRfc5869WithSpecInfoStrings() throws Exception { AliroCrypto crypto = new AliroCrypto(); byte[] stepUpSK = new byte[32]; for (byte i = 0; i < 32; i++) stepUpSK[i] = (byte) (0x10 + i); byte[] skDevice = new byte[32]; byte[] skReader = new byte[32]; crypto.deriveStepUpSessionKeys(stepUpSK, (short) 0, skDevice, (short) 0, skReader, (short) 0); // Reference: RFC 5869 HKDF-SHA-256, salt=∅, info=ASCII("SKDevice")/("SKReader") javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA256"); mac.init(new javax.crypto.spec.SecretKeySpec(new byte[32], "HmacSHA256")); // empty salt byte[] prk = mac.doFinal(stepUpSK); byte[] expectedDevice = hkdfExpandOnce(prk, "SKDevice".getBytes("ASCII"), 32); byte[] expectedReader = hkdfExpandOnce(prk, "SKReader".getBytes("ASCII"), 32); assertArrayEquals(expectedDevice, skDevice); assertArrayEquals(expectedReader, skReader); } ``` (`hkdfExpandOnce` is a tiny BouncyCastle-free helper at the bottom of the test class.) **Step 2: Run, expect FAIL with "method not defined."** ```bash ./scripts/dt-mvn.sh test -Dtest=AliroCryptoTest#deriveStepUpSessionKeysMatchesRfc5869WithSpecInfoStrings ``` **Step 3: Implement `AliroCrypto.deriveStepUpSessionKeys`.** Uses existing `hkdfExtract` + `hkdfExpand`. Info strings are 8-byte static `byte[]` fields. The "empty salt" passes a zero-length range to `hkdfExtract`, which already falls back to `emptySaltZeros`. ```java private static final byte[] STEP_UP_INFO_SK_DEVICE = { 'S','K','D','e','v','i','c','e' }; private static final byte[] STEP_UP_INFO_SK_READER = { 'S','K','R','e','a','d','e','r' }; short deriveStepUpSessionKeys( byte[] stepUpSK, short stepUpSKOff, byte[] skDeviceOut, short skDeviceOff, byte[] skReaderOut, short skReaderOff) { // PRK = HKDF-Extract(salt=∅, IKM=StepUpSK) hkdfExtract( emptySaltZeros, (short) 0, (short) emptySaltZeros.length, stepUpSK, stepUpSKOff, HASH_LEN, hkdfPrevT, (short) 0); // SKDevice = HKDF-Expand(PRK, info="SKDevice", L=32) hkdfExpand( hkdfPrevT, (short) 0, HASH_LEN, STEP_UP_INFO_SK_DEVICE, (short) 0, (short) STEP_UP_INFO_SK_DEVICE.length, HASH_LEN, skDeviceOut, skDeviceOff); // SKReader = HKDF-Expand(PRK, info="SKReader", L=32) hkdfExpand( hkdfPrevT, (short) 0, HASH_LEN, STEP_UP_INFO_SK_READER, (short) 0, (short) STEP_UP_INFO_SK_READER.length, HASH_LEN, skReaderOut, skReaderOff); return HASH_LEN; } ``` **Step 4: Run test, expect PASS.** **Step 5: Run full suite, expect 81/81 PASS (one new test).** **Step 6: Commit.** ```bash git add applet/src/main/java/com/dangerousthings/aliro/AliroCrypto.java \ applet/src/test/java/com/dangerousthings/aliro/AliroCryptoTest.java git commit -m "feat: AliroCrypto.deriveStepUpSessionKeys per §8.4.3" ``` ### Task M1A.2: Have `StepUpApplet` derive its session keys on demand **Files:** - Modify: `applet/src/main/java/com/dangerousthings/aliro/StepUpApplet.java` - Modify: `applet/src/main/java/com/dangerousthings/aliro/SessionContext.java` (expose `armStepUpAlreadyArmed()` if needed; current `isStepUpArmed()` works) - Test: `applet/src/test/java/com/dangerousthings/aliro/StepUpAppletTest.java` **Step 1: Write failing test — StepUpApplet, freshly selected after an armed SessionContext, can derive its SKDevice/SKReader.** Re-uses existing test plumbing in `AliroAppletAuth1Test.auth1SuccessArmsStepUpContextWithExpectedStepUpSK`. **Step 2-5:** Add private fields `stepUpSKDevice[32]`, `stepUpSKReader[32]` to `StepUpApplet` (transient, CLEAR_ON_DESELECT). On `select()`: if `SessionContext.isStepUpArmed()`, copy `StepUpSK` via `copyStepUpSK()` then call `AliroCrypto.deriveStepUpSessionKeys(...)`. Re-use the singleton `AliroCrypto` already used by `AliroApplet` (move it to a package-private static getter on a `CryptoSingletons` class so both applets share the same instance — opt 1 prelude). **Step 6: Commit.** ```bash git commit -m "feat: StepUpApplet derives SKDevice/SKReader on select when StepUpSK armed" ``` ### Phase M1B — Decrypt-and-discard `EXCHANGE` (INS 0xC9) on `StepUpApplet` X-CUBE-ALIRO sends a "Reader Status sub-event" via EXCHANGE after the Step-Up SELECT. From today's trace: 20 bytes of payload (4-byte plaintext after AES-GCM strip-tag-16). Per §8.3.3.5 the response is a Reader Status response sub-event — for our purposes an empty acknowledgment works. ### Task M1B.1: Decrypt the inbound EXCHANGE payload with `StepUpSKReader` **Files:** - Modify: `applet/src/main/java/com/dangerousthings/aliro/StepUpApplet.java` - Test: `applet/src/test/java/com/dangerousthings/aliro/StepUpAppletTest.java` **Step 1: Write failing test that drives a fake X-CUBE-ALIRO-shape EXCHANGE.** Set up: AUTH0+AUTH1 via existing test harness. Use `ReaderSide` to derive `StepUpSKReader`, encrypt a 4-byte payload with `expedited_reader_counter=1` per §8.3.1.8, frame as `80 C9 00 00 00`, send to StepUpApplet via JCardSim. Assert SW=9000. **Step 2-5:** Implement INS_EXCHANGE handler that: - Reads encrypted_payload via `apdu.setIncomingAndReceive()` - Builds IV = `0x0000000000000000 || expedited_reader_counter` (4B BE) per §8.3.1.8 - Calls `gcm.decrypt(StepUpSKReader, IV, ct, tag, plaintext_scratch)` - Discards plaintext (we don't care what the Reader Status said — log/ack only) - Increments `expedited_reader_counter` - Returns `apdu.setOutgoingAndSend((short) 0, (short) 0)` — 9000 with no payload But wait: spec says Reader needs the EXCHANGE response in same secure channel. Vendor may or may not accept empty. **Test first whether 9000-empty is enough; if not, encrypt-and-return an empty CBOR map (`0xA0`).** Note `apdu.setOutgoingAndSend` returns after sending — extend with a one-shot encrypt that produces 17 bytes (1B `0xA0` + 16B tag) if needed. **Step 6: Commit.** ```bash git commit -m "feat: StepUpApplet INS_EXCHANGE decrypt/discard/ack (minimal Reader Status)" ``` ### Phase M1C — Decrypt-and-discard `ENVELOPE` (INS 0xC3) on `StepUpApplet` X-CUBE-ALIRO sends the mdoc DeviceRequest via ENVELOPE with CLA=0x00, INS=0xC3. From today's trace: 75 bytes of payload (the encrypted DeviceRequest CBOR with `expedited_device_counter`-derived IV). ### Task M1C.1: Accept CLA=0x00 on `StepUpApplet.process` Current code rejects anything not `CLA_PROPRIETARY` (0x80). ENVELOPE is `0x00 C3 ...` per spec Table 10-? (see `app_aliro.c` ENVELOPE_POS for the byte values we just observed). **Step 1-5:** Add INS dispatch handling for `CLA=0x00 INS=0xC3`. Same decrypt-and-discard pattern as EXCHANGE but with the device-side key + the device counter starting from 1 + §8.3.1.6 IV format (`0x0000000000000001 || expedited_device_counter`). Return empty-encrypted DeviceResponse (`0xA0` CBOR empty map encrypted with `StepUpSKDevice`). **Step 6: Commit.** ```bash git commit -m "feat: StepUpApplet INS_ENVELOPE (CLA=00) decrypt/discard, return empty CBOR map ack" ``` ### Phase M1D — Retire the `AliroApplet.INS_EXCHANGE` stub Now that `StepUpApplet` handles EXCHANGE on its own AID, the bitmap=0x0005 + `AliroApplet` EXCHANGE stub from June 7 is obsolete. The vendor firmware will route post-AUTH1 commands to `ACCE5502` correctly. ### Task M1D.1: Remove the stub **Files:** - Modify: `applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java` (delete the `case INS_EXCHANGE:` block) - Modify: `applet/src/test/java/com/dangerousthings/aliro/AliroAppletTest.java` (assert `AliroApplet` now returns `SW_INS_NOT_SUPPORTED` for INS 0xC9) **Step 1-5:** TDD as usual. Spec-conformant readers route EXCHANGE to `ACCE5502` after step-up SELECT; the stub on `AliroApplet` was vendor-firmware-bug-specific compat and is now strictly wrong (would let stale `expedited_device_counter` confuse the vendor). **Step 6: Commit.** ```bash git commit -m "refactor: retire AliroApplet INS_EXCHANGE stub now that StepUpApplet handles it" ``` ### Phase M1E — End-to-end Nucleo retest ### Task M1E.1: Rebuild, reinstall, retest on real hardware 1. Rebuild j3r452 CAP: `./scripts/dt-mvn.sh -Pj3r452 clean package -DskipTests` 2. Verify the card on PC/SC: `aliro-bench-test --trust-dir ~/aliro-trust` (AUTH1=9000, ~3.2s) 3. Move to Nucleo + capture via `python3 -c "import serial,time; ..."` (pyserial — reliable; bash `cat` is flaky after USB re-enumeration) 4. **Expected verdict:** ``` NFCA Passive ISO-DEP device found. UID: 04565E4A0B2190 SELECT (ACCE5501) → 9000 AUTH0 → 9000 AUTH1 → 9000 SELECT STEPUP (ACCE5502) → 9000 ENVELOPE (00 C3 ...) → 9000 + encrypted empty CBOR map EXCHANGE (80 C9 ...) → 9000 + (empty or encrypted-empty) DOOR OPERATION SUCCEEDED ← THE MILESTONE ``` 5. **If different failure mode:** capture full UART, identify the next vendor error class (`retval=ACWG_Error_*`), iterate. ### Task M1E.2: Update memory + commit milestone - Update `nucleo_nfc10a1_bringup_state.md` with the Milestone 1 success and note: real Access Document retrieval is deferred to Milestone 2. - Commit any docs changes. ```bash git commit -m "feat: Step-Up Milestone 1 - DOOR OPERATION SUCCEEDED on X-CUBE-ALIRO" ``` --- ## Milestone 2 — Real Access Document retrieval (~1500 LOC, 1-2 weeks) **Result:** Spec-conformant Step-Up phase. The Reader receives our actual provisioned Access Document via the mdoc DeviceResponse over ENVELOPE/GET RESPONSE chaining. IssuerAuth verification is cached at personalization time so per-transaction latency stays at ~3.2 s AUTH1 + ~500ms-1s Step-Up. Bitmap goes back to fully honest about capability. ### Phase M2A — Foundation: cache IssuerAuth verification at personalization (opt 4a + 4b) (Tasks A1-A3 from the original 2026-06-07 plan, mostly unchanged.) - **Task M2A.1:** Add `accessDocumentVerified` persistent boolean to `CredentialStore` + getter (~30 lines + test) - **Task M2A.2:** Add `CoseVerifier.verifyCoseSign1` (RFC 9052, ECDSA-P256) (~150 lines + test vectors) - **Task M2A.3:** Wire IssuerAuth verification into `PersonalizationApplet.finalizeAccessDocument` inside `JCSystem.beginTransaction()` (~50 lines + tests for valid/tampered) ### Phase M2B — Structural CBOR codec (opt 2) (Tasks B1-B4 from the original plan, unchanged.) - **Task M2B.1:** `StructuralCbor.decodeHeader` — RFC 8949 §3 major-type + argument decoder (~80 lines + 8 tests) - **Task M2B.2:** `StructuralCbor.elementSpan` — recursive span walker (~50 lines + 5 tests) - **Task M2B.3:** `StructuralCbor.locateIssuerAuth` — walk mdoc IssuerSigned wrapper (~30 lines + test) - **Task M2B.4:** Canonical CBOR encoder for uint/bstr/tstr only (the primitives we emit) (~50 lines + tests) ### Phase M2C — GCM session reuse for Step-Up (opt 1) - **Task M2C.1:** Add `AliroGcm.setKeyAndIv` rekey method (~30 lines) - **Task M2C.2:** `StepUpSession` IV builder for both directions (~50 lines) (Note: deriving keys + decrypting the inbound `EXCHANGE` and `ENVELOPE` payloads is already done at Milestone 1; M2C just consolidates the GCM instance to share with `AliroApplet`.) ### Phase M2D — Real ENVELOPE handler with DeviceResponse + chaining (opt 3) - **Task M2D.1:** mdoc DeviceRequest structural parse (don't interpret fields, just confirm shape + extract docRequest namespace IDs) (~80 lines + tests) - **Task M2D.2:** Build DeviceResponse with cached-verified Access Document bytes (~120 lines + tests) - Top-level CBOR map per spec Table 8-22 - Embed cached `accessDocument` bytes verbatim (already CBOR-encoded by issuer) - **Task M2D.3:** Stream-encrypt during ENVELOPE emit (opt 3) (~80 lines + tests) - **Task M2D.4:** GET RESPONSE chaining when DeviceResponse exceeds APDU size (~60 lines + tests) ### Phase M2E — Real EXCHANGE Reader Status response - **Task M2E.1:** Decrypt Reader Status sub-event request (~30 lines + tests) - **Task M2E.2:** Build Reader Status response sub-event (status=OK, no payload) per §8.3.3.5 Table 8-20 (~50 lines) ### Phase M2F — Bitmap honesty + cleanup - **Task M2F.1:** Switch bitmap from hardcoded 0x0005 back to a computed value reflecting actual capability (bit 0 set iff Access Document provisioned, bit 2 set per spec since we use split AID) (~5 lines + test) - **Task M2F.2:** Delete the Milestone 1 stub responses, replace with real ones (~50 lines net) - **Task M2F.3:** Strip diagnostic INSes (`INS_DIAG_*`) — flip `DIAGNOSTICS_ENABLED=false`, run prod build, verify CAP shrinks ~3 KB (M2F.3 is a quick win — Phase 6 of the older RAM-diet/cleanup todo list) ### Phase M2G — Cross-reader interop - **Task M2G.1:** Run end-to-end on stock X-CUBE-ALIRO: DOOR SUCCEEDED + Access Document bytes round-tripped (verified by adding a print hook to the firmware that dumps the received DeviceResponse, comparing to what we provisioned) - **Task M2G.2:** Run end-to-end on `aliro-bench-test` Python reader (after extending the harness with `aliro-bench-stepup` CLI that drives ENVELOPE/GET RESPONSE) - **Task M2G.3:** File the WTX-cap bug + one or both remaining vendor issues with ST and/or CSA (see "vendor bug reports" backlog item below) ### Phase M2H — Documentation + memory - Update `MEMORY.md` with Milestone 2 completion - Mark this plan complete; add `step_up_implementation_notes.md` memory capturing the four optimizations' final form + spec citations - README pass on the Step-Up architecture decisions --- ## Risk register (revised based on today's learnings) | Risk | Likelihood | Impact | Mitigation | |---|---|---|---| | j3r452 transient RAM doesn't fit additional Step-Up buffers | Low (revised down) | High | Today's measurements show ~1.7 KB used / 3.1 KB available; Milestone 1 adds ~150 B for SK keys; opt 3 streaming caps Milestone 2 at +400 B max. Comfortable headroom. | | CBOR canonical encoding subtleties | Low | Medium | We only emit uint/bstr/tstr — fully unambiguous per RFC 8949 §4.2.1. No floats, no special cases. | | Vendor library rejects "empty" Reader Status response | Medium | Low | If 9000-empty doesn't work in Milestone 1, fall back to a 17-byte encrypted-empty-CBOR-map. Adds ~30 lines if needed. | | Vendor library rejects "empty" DeviceResponse map | Medium | Low | Same fallback: encrypt `0xA0` (empty map) instead of returning empty payload. | | ECDSA-P256 verify on j3r452 quirks | Low | High | We already use it for AUTH0 reader-sig verification (works). No new risk. | | Spec ambiguity on Reader Status sub-event response shape | Medium | Medium | Try empty first; if vendor errors, capture the new error class + iterate. Same Path-X-style debugging we just demonstrated end-to-end. | --- ## Acceptance criteria **Milestone 1:** - Stock X-CUBE-ALIRO firmware (with only the documented `RFAL_ISODEP_MAX_WTX_RETRYS` cap bump) prints `DOOR OPERATION SUCCEEDED` against our card - PC/SC `aliro-bench-test` still passes (≥ regression check) - All existing 80+ unit tests pass + new StepUpApplet tests - AliroApplet INS_EXCHANGE stub retired - Transient RAM usage on j3r452 remains ≤ 2,200 B `CLEAR_ON_DESELECT` (≥ 900 B headroom on the 3,120 B pool) **Milestone 2 (additional):** - Real Access Document bytes round-trip through ENVELOPE/GET RESPONSE - All four optimizations land as documented (reuse AliroGcm, structural CBOR only, stream-encrypt, IssuerAuth verify cached at personalization) - `signaling_bitmap` honestly reflects capability - Diagnostic INSes stripped for production CAP - Code-reviewer subagent pass before merge --- ## Vendor bug reports backlog (separate from this plan) These were genuinely identified during the AUTH1 bring-up; they remain real even after today's progress: 1. **WTX retry cap default of 20 is too aggressive.** Card running spec-compliant userland GCM on the most permissive ISO 7816 secure element (j3r452) needs ~11 WTX cycles for AUTH1. ST's 20-cap is conservative; a 50-cap default would cover ecosystem. File with ST as middleware enhancement request. 2. **(Possible — verify during Milestone 1) X-CUBE-ALIRO may still reject empty-payload Reader Status responses.** If observed, file with ST as spec-clarification (RFC the Reader Status response shape with CSA). Item 1 is real; item 2 we'll know during M1E. --- Plan complete and saved to `docs/plans/2026-06-11-step-up-implementation-v2.md`. Two execution options: **1. Subagent-Driven (this session)** — I dispatch a fresh subagent per task, review between tasks, fast iteration. **2. Parallel Session (separate)** — Open new session with executing-plans, batch execution with checkpoints. Which approach?