LIVE https://todo-nim.bfj.oncarlos.com 290dbb4

## Where it stands

- Deployed `290dbb4` (the second review round's fix) to `todo-nim` on
  account `bfj`, channel `edge`. `carlos deploy` confirmed it through the
  `/api/version` build probe. Earlier deploys: `df803f7`, then `6aa1f35`.
- Shared behaviour suite: **67 passed, 3 skipped** both locally
  (`dist/app --listen`) and against
  `https://todo-nim.bfj.oncarlos.com`. The skips are the suite's own: blur
  and Escape editing with JavaScript off, and the screenshot test with
  JavaScript off.
- `make test`: 17 unit tests pass (store scoping, filters, toggle-all,
  Unicode and 10,000-character titles, Unicode-whitespace trimming, migrations, cookies, 303s, 403 on
  a foreign Origin, 404 on a foreign id, the edit view, fragments, static
  files).
- `scripts/boot-check.sh dist/app-linux-arm64`: `/healthz` and
  `/api/version` answer 200 on a unix socket under qemu, and SIGTERM
  exits 0.
- The binary is a 1.7 MB static aarch64 ELF (zig cc, musl). SQLite 3.54.1
  is compiled in.

## Worth knowing

- `src/httpserver.nim` is a copy of Nim 2.2.12's `std/asynchttpserver`
  with a single change, marked "todo-nim": chunked bodies are limited.
  When upgrading Nim, re-copy it and re-apply that change.
- `asynchttpserver` has no unix-socket mode. `unixServer` in `src/app.nim`
  binds the socket and gives it to the server through
  `std/importutils.privateAccess`, and it has to set `maxFDs` too:
  without it `shouldAcceptRequest` is always false and the server
  silently never accepts. That's the one place this app reaches into the
  standard library's internals. A `listen(domain = AF_UNIX)` option
  upstream would remove it.
- The README and AGENTS.md prose didn't go through a copy review. This
  session ran unattended, and that review needs a person.

## Review round 2

The one finding is fixed in `290dbb4`:

1. **Invalid UTF-8 in titles (security).** `title=%80` made
   `unicode.strip` index out of bounds. The `IndexDefect` isn't a
   `CatchableError`, so it got past the handler and stopped the server.
   Now `action` runs `unicode.validateUtf8` on the decoded title before
   anything else touches it, and answers **400** if it isn't valid. That
   check is bounds-safe. Browsers always send UTF-8, so a real visitor
   never hits this. A unit test covers truncated 2-, 3- and 4-byte
   sequences and stray bytes for both add and edit, and the list is
   unchanged afterwards. Before the fix, `title=%80` killed the local
   binary. After it, the binary and the live host both answer 400 and
   keep serving.

After the fix the shared suite gave 67 passed and 3 skipped both locally
and against the live host, and the boot check passed.

## Review round 1

All four findings are fixed in `6aa1f35`:

1. **Chunked bodies (security).** The parser lives inside the standard
   library, so the server is now vendored as `src/httpserver.nim`. Each
   chunk's declared size is checked against what's left of the body limit
   before `recv` allocates anything. Oversized, overflowing and
   many-small-chunk bodies all get a 413 and the connection closes. The
   limit is 1 MiB for both chunked and `Content-Length` bodies (it was
   8 MiB); a 10,000-character title needs at most about 120 KB. Checked
   with raw-socket requests against the binary: it stayed at 7 MB RSS.
2. **Unicode trim.** Add and edit now strip the whitespace JavaScript's
   `String.prototype.trim` removes (U+00A0, U+2000–U+200A, U+3000, U+FEFF
   and the rest). A title of only non-breaking spaces adds nothing, and
   editing to that deletes the item. There's a unit test for both.
3. **Edits across re-renders.** `render()` keeps the item being edited
   (text, cursor, focus) and restores it on the same item in the new
   list, and the blur that removing the old field may fire doesn't
   count as a save. I checked it with Playwright: toggle one item with
   the response held for 800 ms, double-click another, type, and wait.
   The edit is still open, focused and holding the text, and Enter saves
   it.
4. **Zig's caches.** The Makefile exports `ZIG_GLOBAL_CACHE_DIR` and
   `ZIG_LOCAL_CACHE_DIR` under `.cache/`, and `clean-deps` removes them.
   After `clean-deps`, `make build-arm64` rebuilt musl and compiler-rt
   (61 MB of cache) from scratch.

After the fixes the shared suite gave 67 passed and 3 skipped both
locally and against the live host, and the boot check passed.

## Review findings not fixed

None.
