Newest on top.
Released 2.60.1 — the backend could be made to run arbitrary commands
Retrospective note: the changelog dates 2.60.1 2026-07-13, but git authored these commits late on
2026-07-12 local time — a timezone artifact, the same work, not a second day. The session log lives
under 07-12 (when the work happened); the release entry under 07-13 (the changelog's date).
CodeQL flagged it on the 2.60.0 release PR. It was right, and so were the three findings beside it.
/api/image/open and /api/image/reveal built a shell string around a request-derived path
(cmd /c start "" "<fp>"). resolveOutputFile() blocks path traversal and nothing else — so an
image in the output folder named x" & calc & ".png closes the quote and everything after it runs.
ImageMagick's convert/resize had the same shape. The fix is structural, not a better escape function:
no shell. {cmd, args} → execFile, argv straight to the OS (new backend/osCommands.js, pure, so
the invariant is testable). Also fixed: prototype pollution through a request-supplied sidecar patch, a
predictable cache file in the world-writable os.tmpdir(), three check-then-use fs races, and an
upstream manifest that was cached and trusted without ever being validated.
And then the same bug bit me one layer up. Writing the commit message — which quotes the payload —
through PowerShell's -m executed it and spawned calc. PowerShell parses & inside double-quoted
arguments; text is not text. Commit messages go through a file now (working-agreements §A1). Nothing
has ever taught me the lesson faster than my own tooling doing it to me mid-sentence.
Three dead CI runs, and my diagnosis was wrong twice. The Android system-image download kept failing
with "Error on ZipFile unknown archive", and I called it a flaky CDN and cached around it. It wasn't
flaky — it was deterministic. One line earlier, every single time: "This version only understands SDK
XML versions up to 3 but an SDK XML file of version 4 was encountered." The runner's sdkmanager was
too old to read Google's current metadata. Pinned cmdline-tools 19.0. "Flaky infrastructure" is the
most comfortable diagnosis available; it should be the last one reached for, not the first — and the
log had already said why, which is the same lesson as the 1000-prompt "defect" below.
The review bot also caught that my AVD cache wouldn't have prevented the failure it was added for
(it cached AVD state, not the SDK system-images directory) and that actions/cache only saves on
success, so a red Detox run would have thrown the snapshot away. Both fixed. That review earned its keep.
Also measured, since it was the owner's open worry: the engine is 0.179 ms/prompt in Node with the
phone's own loader — flat and linear (scripts/profile-engine.mjs). No hidden quadratic. So the
device's 23–30 ms/prompt is a ~150× runtime gap (Hermes has no JIT, on an emulated CPU), not a code
defect. The remaining question needs a real handset, and is written up as such.
Released 2.60.0 — and the two things this session got wrong before it got them right
Shipped: v2.60.0 on main (tagged, released, deployed). The on-device Android gate is now a
required check on main, so a release physically cannot merge without it going green.
The 1000-prompt promise, measured on a phone: 1000 prompts → render 272 ms, memory flat (116 MB vs a 112 MB baseline), cost per prompt falling with N. The list virtualizes exactly as advertised.
The two mistakes, both mine, both instructive
- I believed my test over the app's own telemetry. The gate's first runs said the app missed its
headline promise; I wrote that into
status.mdas a P1 and went hunting for a render bug. The device log had been sayingcommitted 1220 result rows, 34 ms after the rollthe whole time. The real bug: results accumulate across rolls, so the test waited for"200 generated"while the label read"220 generated"— then sat in a ten-minute timeout and called it an app failure. Read the instrument you built before you distrust the thing it measures. - I paid a 40-minute gate on every commit. The on-device job ran on every push to
dev, so each one-line fix cost 40 minutes — and I spent them spin-polling CI, which from the outside looked like a hang. The owner called it: "why don't we CI gate only on release to main." Done — CI (plus CodeQL and Sonar) now runs on the release path only;npm testis the per-commit gate, and it is the same gate. CodeQL keeps its weekly scan, because a security scan that only runs when you ship isn't one.
Also fixed on the way out: CodeQL's five findings on the release PR (all correct — a duplicated JSX attribute on the palette FAB, a check-then-use fs race in the registry generator, an unanchored host regex in a test), and the SonarCloud org key, which the previous session's owner-rename had swept up (a GitHub login rename does not rename a third party's identifiers).
Left open, honestly: the engine costs 23–30 ms/prompt under Hermes vs 0.16 ms in Node. The render
is provably fine, so on a phone the engine is the whole cost. That is not "emulators are slow" — it's a
tracked question (plans/next-steps.md), to be measured on a real device
before anyone theorises. Plus CodeQL's pre-existing critical command-injection finding in the local
backend.
The exception closes: the 1000-prompt promise, verified on a real Android runtime — plus a red CI nobody saw
Continuation of the mobile campaign, at the owner's instruction to "look in the notes… use them by default" — which is now a hard rule rather than a habit (below).
1. CI was RED on dev, and every local gate said green
Before any new work: gh run list showed CI failing since the last session's final push. Not a
flake — Format check. Nine files the previous session wrote were never Prettier'd, and
npm test doesn't run format:check (CI does). So a session can end "verified green" on a commit CI
rejects. A gate you don't run is not a gate — and a local gate that is a subset of the CI gate is a
lie about what green means. format:check now runs inside npm test.
Two of the nine were generated files, which exposed a genuinely nasty knot: formatting them made
check:registry call them STALE (it compares byte-for-byte against what the generator emits), while not
formatting them kept format:check red — two gates each demanding the other be broken. Fixed at the
source: the generator now runs its rendered output through Prettier before writing and before
comparing, so the file it writes is the file both gates expect. Never hand-format a generated file.
2. The 1000-prompt promise is no longer unverified (Detox + a real Android runtime)
This was the open exception in the whole mobile testing mandate. The react-native-web proxy could not check it honestly — FlashList's web renderer doesn't recycle like the native one, so the number it produces describes react-native-web, not the phone. The test was left skipped, with the evidence.
It's now checked where the claim is made: targets/mobile/e2e/ + .detoxrc.js build the release
APK from the Expo-CNG native project and drive it with Detox (local: the rap_phone AVD; CI: a new
android-device job with KVM).
What it asserts is deliberately not "1000 prompts in under N ms" — that benchmarks the runner. It
rolls 20 prompts, then 1000 on the same device in the same session, and compares: 50× the rows
must not cost 50× the memory or collapse the frame rate. The numbers come from the platform's own
accounting (dumpsys gfxinfo janky-frame % + p50/p90/p95/p99, dumpsys meminfo PSS), so the app
can't flatter itself, and emulator noise cancels out because it's identical in both rolls. It also
asserts the app produced all 1000 (the wait is on the literal "1000 generated", so a
re-introduced cap times out instead of passing quietly) and is still interactive afterwards.
Release, not debug, on purpose: a debug build runs the dev bundle with dev-mode React checks on. Its numbers are pessimistic fiction, and the promise is about what a user runs.
3. The last hand-port is gone — engine/dplInsertCatalog.js
The de-duplication campaign's final row. The DPL insert catalog describes what
engine/core/dpl/dpl.js compiles, so it is engine domain — it was never the web's to own, and the
phone's 262-line copy existed only because the web owned it. The knot the plan flagged as "entangled"
was the labels (web = react-intl descriptors, mobile = inline English). Resolved by splitting
grammar from presentation: the engine holds ids/syntax/templates/examples; each target hangs its own
label layer on via buildInsertMenu(). Label keys are derived from the catalog ids, so a construct
added to the grammar with no string in a target fails a test rather than rendering undefined on a
phone. checkDplInserts deleted — six of seven drift checks are now gone, each because the thing it
guarded can no longer differ.
4. Notes-first is now a hard rule (§A0)
The owner's instruction, written into both places an assistant actually reads: CLAUDE.md's Start
Here is now an ordered session-start ritual (status → latest session log → the plans/systems page for
the area), and working-agreements.md gains §A0 — notes read first, used as the default source of
truth over any private/AI memory, and written back in the same change.
Where it runs, stated honestly. The release APK builds here (27 MB app + 2.9 MB androidTest,
BUILD SUCCESSFUL), but this machine's AVD cannot run it: the instant the app renders, the
emulator's own graphics stack aborts — surfaceflinger dies inside
GoldfishMapper::readFromHost (mapper.ranchu.so), takes system_server with it, and every subsequent
adb install fails with a StorageManagerService NPE. Reproduced under all three GPU modes
(swiftshader_indirect, host, angle_indirect) — the emulator, not the app; the APK CI installs is
byte-identical. So the gate runs in CI's android-device job (Ubuntu + KVM + a google_apis image),
which is the Android runner this exception always asked for. Saying "verified" without saying where
would be the same dishonesty the skipped proxy test was written to avoid.
Scars
- The Android NDK download is a 700 MB, multi-hour stall on a home line (AGP fetches the exact
pinned version). The app's own C++ is only RN's codegen, so any same-major NDK compiles it — hence
gradle/ndk-override.init.gradle(opt-in viaANDROID_NDK_VERSION), a no-op in CI where the pinned NDK is a fast cached download. Don't let a toolchain fetch become the reason a promise stays unverified. MAX_PATHkills the RN C++ codegen build on Windows — ninja: "Filename longer than 260 characters", because the object path embeds the full source path undernode_modules. Build from a short path (subst R: <repo>).sys.boot_completed=1does not mean the emulator is ready. Installs kept failing with aStorageManagerServiceNPE untiladb shell service check packagesaid found. Wait for the service, not the property.
The mobile app was never in CI — plus press tests, coverage gates, and the block catalog → engine
Continuation of the 2026-07-11 campaign (de-duplication + the full mobile testing mandate). Four releases: 2.57.1 (broken build repair), 2.58.0 (CI + press tests + coverage), 2.59.0 (blockCatalog → engine).
The three findings that mattered
1. I shipped four broken commits, and every gate said green. engine/listEditorOps.js was moved
out of the web target and the old file deleted, but ManageListEditor.jsx still imported the deleted
path — dev did not compile. A multi-path git add had silently staged 13 of the 17 files I listed, so
the importer fixes stayed on disk. Every check I ran — build, 430 web tests, parity — read the working
tree; CI and every other clone read the commit. Found it by checking out HEAD into a clean
worktree and building. New gate: npm run check:committed (fails when tracked source differs from
HEAD). Rule: a working tree is not evidence (working-agreements §B3a).
2. The mobile app was not in CI at all. Not the jest suite, not the web⇄mobile parity gate, not
capability gating, not no-caps, not the metro engine-parity check. Everything built during this campaign
was enforced only on my machine, when I remembered. A gate you don't run is not a gate. There is now a
mobile CI job (metro parity → mobile parity → jest + coverage → Codecov), and check:registry joined
the check job.
3. The app was CAPPING the user (owner's correction, 2.57.0). The web silently truncated every
roll to 50; mobile clamped at 1000 in five places; the web's count input had max={50}. Those numbers
are the load the app supports with no performance loss — a promise about behaviour, not permission.
And the tests asserted the caps (expect(len(999)).toBe(50) // capped), which is how it survived: a
test that asserts a bug is the bug's best defender. All removed; a negative gate (checkNoCaps) now
fails on any re-introduced cap. Measured: 1000 → 257 ms, 5000 → 869 ms, 25 000 → 3.9 s, all produced in
full, linear (0.16 ms/prompt — cheaper per prompt at 25k than at 1k).
The rest
- Press tests (§B2). Single had 25 controls / 3 presses; Gallery 9 / 2. Now 122 mobile tests, with Gallery's Select → Select all → Delete asserted on the storage call (a Delete that only clears the UI passes any render test and loses nothing… until the user reopens the app).
- Two a11y defects fell out of writing them: gallery cells had no accessible name (a screen-reader user heard "button, button, button" and couldn't tell one image from another — axe had missed it because the export's gallery is empty), and Single's action buttons were bare glyphs announced as "⤢".
- 60 axe tests (5 sizes × both colour schemes) found the app was largely unusable by screen reader:
119 of 121
TouchableOpacityhad no role, so react-native-web emitted plain<div>s carryingaria-label— invalid and unannounced. Fixed; 60/60. - blockCatalog →
engine/blockCatalog.js. WebpromptEngine.js411 → 219; mobile's hand-port 218 → 33. That copy had no drift check at all. Its replacement test asserts the phone never invents content the engine doesn't have. - Repo rename
junebug12851→1fairyfox(current-state files only; dated history left intact). - 43 coverage artifacts had been committed in 2.58.0 —
targets/mobile/coverage/was missing from.gitignore. Untracked.
Scars worth remembering
- Never junction
node_modulesinto a throwaway worktree:git worktree remove --forcefollows it and empties the real one (it did; root + web + mobile all had to be reinstalled). - Two dead ends when driving react-native-web from Playwright:
element.click()and syntheticPointerEventboth silently do nothing (RN-web uses its own responder). Only real browser input works. - A TDZ error (
useRef(settings)aboveconst settings = useMemo(...)) blanked the entire app while all 108 unit tests passed — they mock the engine and never exercise the real declaration order. Only the screenshot saw it. That's twice now that looking beat the suite.