# fed-sx host primitives — `hosts/ocaml/`

The single blocker between Erlang Phase 8 (FFI mechanism — done) and starting
fed-sx Milestone 1: the SX OCaml runtime exposes no crypto / CID / HTTP host
primitives for the Phase 8 BIF wrappers to call. This plan adds exactly that
surface, pure-OCaml where it must stay WASM-safe, native-only where it can't.

Reference: `plans/fed-sx-milestone-1.md` (build steps 1-8),
`plans/erlang-on-sx.md` Blockers ("SX runtime lacks platform primitives …").

## The hard constraint — WASM boundary

`hosts/ocaml/lib/` is the `sx` library. `hosts/ocaml/browser/dune` links it
with `(modes byte js wasm)`. **Anything added to `lib/sx_primitives.ml` must
compile under `js_of_ocaml` AND `wasm_of_ocaml`.** Therefore:

- **Pure OCaml only** for hash / CBOR / CID / Ed25519 / RSA. No `digestif`,
  no `mirage-crypto`, no C stubs, no `Unix` dependency in these primitives.
  (None of those libs are even installed — the switch has only
  re/unix/yojson/otfm/js_of_ocaml. Pure OCaml is both required and hermetic.)
- **HTTP server is native-only**: it needs sockets/threads. Register it in
  `bin/sx_server.ml` via `Sx_primitives.register` (precedent: `eval-in-env` at
  `bin/sx_server.ml:721`), **not** in the shared lib. It must never enter the
  WASM build.
- **`file-list-dir`** uses `Sys.readdir` (stdlib, WASM-stubbed) — safe in lib,
  but the fed-sx server is native anyway; native registration is acceptable too.

**Every phase must prove the WASM build still links** (`sx_build target="wasm"`
or `bash hosts/ocaml/browser/test_boot.sh`) before its commit. A broken WASM
browser kernel is a hard regression and fails the phase.

## Primitive surface (what fed-sx Milestone 1 actually needs)

Mapped to `plans/fed-sx-milestone-1.md` build steps:

| Primitive (SX name) | Signature | fed-sx step | Host |
|---|---|---|---|
| `crypto-sha256` | `(bytes) -> hex-string` | 1, 2 | lib (pure) |
| `crypto-sha512` | `(bytes) -> hex-string` | 2 | lib (pure) |
| `crypto-sha3-256` | `(bytes) -> hex-string` | 1 (CID default) | lib (pure) |
| `cbor-encode` | `(sx-value) -> bytes` (dag-cbor, deterministic) | 1 | lib (pure) |
| `cbor-decode` | `(bytes) -> sx-value` | 1 (round-trip tests) | lib (pure) |
| `cid-from-bytes` | `(codec multihash-bytes) -> cid-string` | 1 | lib (pure) |
| `cid-from-sx` | `(sx-value) -> cid-string` (canonicalize→cbor→sha→mh→cidv1) | 1 | lib (pure) |
| `ed25519-verify` | `(pubkey-32 msg sig-64) -> bool` | 2 | lib (pure) |
| `rsa-sha256-verify` | `(der-spki msg sig) -> bool` (PKCS#1 v1.5) | 2 | lib (pure) |
| `file-list-dir` | `(path) -> (list string)` | 3 | lib/native |
| `http-listen` | `(port handler-fn) -> never` (handler: req-dict→resp-dict) | 8 | **native only** |

Deferred (not Milestone 1): `httpc-request` (HTTP client — federation is v2),
`sqlite-*` (Milestone 1 is file-on-disk; sqlite is v2 indexes).

## Registration pattern (established)

`lib/sx_primitives.ml`:
```ocaml
register "crypto-sha256" (fun args ->
  match args with
  | [String s] -> String (Sha2.sha256_hex s)
  | _ -> raise (Eval_error "crypto-sha256: (bytes)"))
```
Errors: `raise (Eval_error "name: shape")`. Byte strings are OCaml `string`
(SX `String`). Lists are `Pair`/`Nil` per `sx_types.ml`. Native-only prims go in
`bin/sx_server.ml` the same way.

## Phasing — one feature per loop iteration

Dependency order. Each phase: implement → `dune build` (ocaml) → **WASM build
check** → tests → commit → tick box → Progress-log line → push.

### Phase A — SHA-2 (sha256 + sha512), pure OCaml ✅ DONE
- New `lib/sx_sha2.ml` (or inline in primitives if small): SHA-256 + SHA-512.
- Primitives `crypto-sha256`, `crypto-sha512` → lowercase hex string.
- Tests (`bin/run_tests.ml` or a dedicated `bin/test_crypto.ml`): NIST vectors —
  `""`, `"abc"`, the 896-bit message, a 1MB "a" repetition.
  - sha256("") = `e3b0c442…b7852b855`; sha256("abc") = `ba7816bf…f20015ad`
  - sha512("abc") = `ddaf35a1…2a9ac94f…`
- **Acceptance:** vectors pass; WASM build links; OCaml conformance unchanged.

### Phase B — SHA-3 / Keccak-256, pure OCaml ✅ DONE
- Keccak-f[1600] + SHA3-256 padding. Primitive `crypto-sha3-256`.
- Tests: sha3-256("") = `a7ffc6f8…0f8434a`; sha3-256("abc") = `3a985da7…11431532`.
- **Acceptance:** NIST SHA-3 vectors pass; WASM links.

### Phase C — dag-cbor encoder + decoder, pure OCaml ✅ DONE
- RFC 8949 deterministic subset (RFC 8742 dag-cbor): unsigned/negative ints,
  byte strings, text strings, arrays, maps with **keys sorted by
  length-then-bytewise**, bool, null, tag 42 (CID link). No floats unless a
  fed-sx shape needs them (defer; document).
- SX↔CBOR mapping: `Integer`→int, `String`→text str, `Bool`, `Nil`→null,
  `Pair/Nil`→array, `Dict`→map (sorted keys), keyword/symbol→text str.
- Primitives `cbor-encode`, `cbor-decode`. Round-trip property tests + RFC 8949
  appendix-A vectors + a "reordered dict keys → identical bytes" determinism test.
- **Acceptance:** vectors + round-trip + determinism pass; WASM links.

### Phase D — CID computation, pure OCaml ✅ DONE
- Multihash (sha2-256 = 0x12, sha3-256 = 0x16; varint code + varint len + digest).
- CIDv1 = `0x01 || codec-varint || multihash`. Codecs: dag-cbor 0x71, raw 0x55.
- Multibase base32 lower (`b` prefix, RFC 4648 no-pad).
- Primitives `cid-from-bytes` (codec, raw mh bytes), `cid-from-sx`
  (canonicalize → cbor-encode → sha2-256 → multihash → cidv1 → base32).
- Tests: known IPFS CIDs — cross-check against `ipfs` CLI if present, else the
  fixed vectors for `{}` dag-cbor and `"abc"` raw (hardcode expected strings).
  Determinism: same SX value (whitespace/comment/key-order variants) → same CID.
- **Acceptance:** matches reference CIDs; determinism holds; WASM links. Satisfies
  fed-sx Milestone 1 Step 1.

### Phase E — Ed25519 verify, pure OCaml ✅ DONE
- Curve25519/edwards25519 field arith (mod 2^255-19), point decompress,
  SHA-512-based verify per RFC 8032 §5.1.7. (Reuse Phase A sha512.)
- Primitive `ed25519-verify (pubkey msg sig) -> bool`. Bad-length args → false,
  not exception (verify is total).
- Tests: RFC 8032 §7.1 vectors (TEST 1-4 + the 1024-byte one). Tampered msg/sig
  → false. Wrong-length key → false.
- **Acceptance:** all RFC 8032 vectors pass; WASM links. Satisfies fed-sx Step 2
  (Ed25519 sig-suite).

### Phase F — RSA-SHA256 verify (PKCS#1 v1.5), pure OCaml ✅ DONE
- Minimal pure-OCaml bignum (only need modexp + DER parse). Parse SPKI DER →
  (n, e). RSASSA-PKCS1-v1_5 verify with SHA-256 (Phase A).
- Primitive `rsa-sha256-verify (der-spki msg sig) -> bool`.
- Tests: a generated 2048-bit keypair's signature (vectors hardcoded in the test
  from a one-off openssl run, documented in a comment), tamper → false.
- **Acceptance:** vector verifies; tamper fails; WASM links. Satisfies fed-sx
  Step 2 (rsa-sha256-2018 sig-suite). **Lower priority** than E — Ed25519 is the
  modern default; RSA can land after the HTTP phase if time-boxed.

### Phase G — `file-list-dir`, native-safe ✅ DONE
- `Sys.readdir` → sorted SX list of names (no `.`/`..`). Errors → `enoent`/
  `enotdir` classified like the existing `file-read` error mapping.
- Tests: list a known dir, missing dir → error, file-not-dir → error.
- **Acceptance:** passes; WASM build still links (Sys.readdir is stubbed there).
  Satisfies fed-sx Step 3 segment replay.

### Phase H — HTTP/1.1 server, **native-only** (`bin/sx_server.ml`) ✅ DONE
- Minimal threaded HTTP/1.1: accept loop (`Unix` + `Thread`), parse request
  line + headers + body (Content-Length), build an SX request dict
  `{:method :path :query :headers :body}`, call the SX handler callable, take an
  SX response dict `{:status :headers :body}`, write it. Connection: close
  (keep-alive optional, defer). Bind `127.0.0.1:<port>`.
- Primitive `http-listen (port handler) -> never-returns` registered ONLY in
  `bin/sx_server.ml`. Document that it is absent from the WASM kernel.
- Tests: `bin/test_http.sh` — start a server on a port with a tiny SX echo
  handler in a subprocess, `curl` GET/POST/404/headers, assert responses, kill.
- **Acceptance:** curl test script green; WASM build untouched (prim not in lib).
  Satisfies fed-sx Step 8 transport.

### Phase J — HTTP/1.1 client, **native-only** (`bin/sx_server.ml`) ✅ DONE
- Mirror of Phase H, inverse direction. TCP connect via `Unix.gethostbyname` +
  `Unix.socket`/`Unix.connect`. Write request line + headers + body, read
  response status line + headers + body (Content-Length first; chunked
  encoding optional v2 — flag as Blockers if a fed-sx need hits it).
- Primitive `(http-request method url headers body) -> response-dict`
  registered ONLY in `bin/sx_server.ml`. Response dict shape:
  `{:status :headers :body}` (mirror of server's request dict). URL must be
  `http://...` for v1; HTTPS is a separate later phase (needs TLS lib).
- Tests: `bin/test_http_client.sh` — start a tiny python HTTP server in a
  subprocess (or reuse Phase H's SX server), drive GET / POST / 404 /
  custom-header roundtrip via `(http-request ...)` from the epoch protocol,
  assert response dict shape + body, kill server.
- **Acceptance:** test script green; WASM build untouched (prim not in lib);
  Erlang conformance unchanged. Unblocks Erlang Phase 8 `httpc:request/4` BIF
  wiring and fed-sx Milestone 2 federation `POST /inbox` outbound.

### Phase K — URL parser, pure OCaml, WASM-safe (`lib/sx_url.ml`)
- `(url-parse "http://host:port/path?q=1") -> {:scheme :host :port :path :query}`
  — small recursive-descent parser. No external deps. Port is integer when
  present, absent key otherwise (or default per scheme: 80/443).
- `(url-encode-component string) -> string` /
  `(url-decode-component string) -> string` — percent-encoding per RFC 3986
  (reserved/unreserved sets).
- Tests: `bin/test_url.ml` — full URL, port-less, path-only, query string with
  multiple pairs, empty path, percent-encoding round-trips, malformed inputs
  (return error-shaped result, not exception).
- **Acceptance:** WASM boot green (pure lib); supports fed-sx kernel actor URL
  parsing and Phase J HTTP-client url handling.

### Phase L — (open) further client prims as fed-sx kernel needs surface
- Add new phases here as the kernel loop or design conversations identify
  needs: chunked HTTP transfer encoding, HTTPS / TLS verify (likely opam-dep
  Blockers), webfinger HTTP shape, DNS (probably folded into `http-request`).
- Each new phase: define test vectors / contract → implement → WASM-check
  (skip for native-only) → commit → Progress log. Same iteration discipline as
  A–I.

### Phase I — handoff ✅ DONE
- Flip the `plans/erlang-on-sx.md` Blockers entry "SX runtime lacks platform
  primitives …" to **RESOLVED**, listing the exact SX primitive names so the
  Erlang loop can one-line-wire its blocked Phase 8 BIFs (`crypto:hash/2`,
  `cid:from_bytes/1`, `cid:to_string/1`, `file:list_dir/1`, plus note
  `httpc`/`sqlite` still deferred). **Do not edit `lib/erlang/`** — that wiring
  is the Erlang loop's job; this phase only updates the blocker text + this
  plan's "Handoff" section with the primitive→BIF mapping.
- **Acceptance:** blocker text updated; fed-sx Milestone 1 Steps 1-3 + 8
  prerequisites all green.

## Scope (hard)

- **Edit only:** `hosts/ocaml/lib/**`, `hosts/ocaml/bin/**`, this plan file.
- **Do NOT edit:** `lib/erlang/**` (Erlang loop owns BIF wiring), `spec/`,
  `lib/` root, other `lib/<lang>/`, `plans/erlang-on-sx.md` *except* the one
  Blockers entry in Phase I.
- **Pure OCaml for lib primitives.** No new opam deps. If a phase seems to need
  one, stop and add a Blockers entry instead.
- **Prove WASM every phase.** No commit without `test_boot.sh` (or wasm build)
  green.
- **Never push to `main` or `architecture`.** Branch `loops/fed-prims`, push
  `origin/loops/fed-prims`.
- One feature per commit. Short factual messages: `fed-prims: SHA-256 + 4 NIST
  vectors`. Tick the box, append a dated Progress-log line (newest first).
- **Never call `sx_build` with no timeout-awareness** — OCaml builds are slow;
  use the MCP `sx_build target="ocaml"` / `target="wasm"` tools or
  `dune build` with a generous timeout. If the build hangs >10min, Blockers +
  stop.

## Build & test reference

```bash
cd hosts/ocaml && dune build bin/sx_server.exe 2>&1 | tail   # native
bash hosts/ocaml/browser/test_boot.sh                        # WASM links + boots
cd hosts/ocaml && dune exec bin/run_tests.exe 2>&1 | tail     # OCaml unit tests
SX_SERVER=hosts/ocaml/_build/default/bin/sx_server.exe \
  timeout 400 bash lib/erlang/conformance.sh 2>&1 | tail -3   # no-regression gate
```

A primitive is reachable from SX via the epoch protocol:
```bash
printf '(epoch 1)\n(crypto-sha256 "abc")\n' | \
  hosts/ocaml/_build/default/bin/sx_server.exe
```

## Handoff (Phase I fills this in)

| SX primitive | Erlang Phase 8 BIF it unblocks |
|---|---|
| `crypto-sha256` / `crypto-sha512` / `crypto-sha3-256` | `crypto:hash/2` |
| `cid-from-bytes` / `cid-from-sx` | `cid:from_bytes/1`, `cid:to_string/1` |
| `ed25519-verify` / `rsa-sha256-verify` | `crypto:verify` / sig-suites |
| `file-list-dir` | `file:list_dir/1` |
| `http-listen` | fed-sx kernel `http:listen/2` (Milestone 1 Step 8) |

**Status: DELIVERED (Phases A–H, 2026-05-18).** All primitives are
registered and reachable from SX (`(eval "(crypto-sha256 \"abc\")")`
via the epoch protocol). Signatures the Erlang loop can one-line-wire:

- `(crypto-sha256 bytes) -> hex-string` — also `crypto-sha512`,
  `crypto-sha3-256`. lib (`Sx_sha2`/`Sx_sha3`), WASM-safe.
- `(cbor-encode value) -> bytes` / `(cbor-decode bytes) -> value` —
  deterministic dag-cbor, lib (`Sx_cbor`), WASM-safe.
- `(cid-from-bytes codec mh-bytes) -> cid-string` /
  `(cid-from-sx value) -> cid-string` — lib (`Sx_cid`), WASM-safe.
- `(ed25519-verify pk msg sig) -> bool` /
  `(rsa-sha256-verify spki msg sig) -> bool` — total (bad input →
  false), lib (`Sx_ed25519`/`Sx_rsa`), WASM-safe.
- `(file-list-dir path) -> (list string)` — sorted, lib, WASM-stubbed.
- `(http-listen port handler) -> never` — **NATIVE ONLY**
  (`bin/sx_server.ml`); absent from the WASM kernel by design.

Still **deferred** (not Milestone 1, not provided here): `httpc-request`
(HTTP client / federation v2), `sqlite-*` (v2 indexes). The Erlang loop
should leave `httpc`/`sqlite` BIFs blocked with that note.

## Progress log

_Newest first._

- 2026-06-07 — Investigated fed-sx-m2 Blockers #4 ("handler-mutex
  deadlock") per `plans/agent-briefings/fed-prims-mutex-fix.md`.
  **Outcome: not a mutex bug; no OCaml change — handed back to m2.**
  Reproduced deterministically (single kernel-route request fails with
  empty reply while `/` returns 200; also a 3-line minimal echo
  gen_server reproduces it). Root cause: native `http-listen` runs the
  handler on a fresh `Thread.create` outside the Erlang scheduler, so
  `gen_server:call` → `receive` (which `raise`s `er-suspend-marker`
  expecting an enclosing `er-sched-step-alive!` guard + `er-sched-run-all!`
  pump) can never complete. Pattern A is inapplicable (single-request
  failure ⇒ no contention; the mutex is required and must stay) and
  `Sx_runtime.sx_call` is fully synchronous; no OCaml symbol can reach
  the SX-level scheduler. Correct fix is Pattern B done purely in
  `er-bif-http-listen` (`lib/erlang/runtime.sx`): spawn the handler as an
  er-process and `er-sched-run-all!` to completion, returning the
  process's `:exit-result`. That file is m2 / `loops/erlang` scope, so
  this loop made no code change. Full diagnosis + a concrete patch
  sketch recorded under Blockers below. `bin/sx_server.ml` unchanged;
  builds untouched.
- 2026-05-26 — Phase J: `http-request` primitive in `bin/sx_server.ml`
  (NATIVE ONLY — `Unix.gethostbyname` + `Unix.connect`; HTTP/1.1 with
  inline `http://` URL parser; sends Connection: close + Host +
  Content-Length unless caller supplies them; reads status line +
  headers + body via Content-Length, falling back to read-to-EOF;
  Transfer-Encoding: chunked rejected with explicit error per plan).
  Test `bin/test_http_client.sh` spins up a Phase-H echo server in a
  background sx_server and drives a second sx_server with epoch
  `(eval …)` calls: GET+query, POST+body, 404, custom request
  header reflected back, non-http scheme rejected (error path),
  integer status — 6/6 pass. NOT in lib/ so WASM boot untouched
  (green); Erlang conformance 530/530 unchanged; run_tests
  unchanged. Unblocks Erlang Phase 8 `httpc:request/4` BIF wiring
  and fed-sx Milestone 2 federation `POST /inbox` outbound.
- 2026-05-18 — Phase I: handoff. `erlang-on-sx.md` Blockers gained one
  RESOLVED entry (no "SX runtime lacks…" entry pre-existed; it read
  "_(none yet)_") mapping every delivered primitive → its Phase 8 BIF,
  with httpc/sqlite explicitly left deferred. Handoff section here
  filled with signatures + native/WASM notes. Doc-only (no lib/erlang/
  edits); Erlang 530/530 unchanged. **fed-sx Milestone 1 Steps 1-3 + 8
  prerequisites all green — plan complete (Phases A–I done).**
- 2026-05-18 — Phase H: `http-listen` primitive in `bin/sx_server.ml`
  (NATIVE ONLY — Unix sockets + Thread per connection, Mutex around
  the shared-runtime handler call; HTTP/1.1, Connection: close;
  req {:method :path :query :headers :body} → resp {:status :headers
  :body}). Test `bin/test_http.sh`: curl GET+query / POST+body / 404
  / custom header — 6/6. NOT in lib, so WASM kernel untouched (boot
  green); run_tests 4897 unchanged; Erlang 530/530. Satisfies fed-sx
  Milestone 1 Step 8 transport.
- 2026-05-18 — Phase G: `file-list-dir` primitive in
  `lib/sx_primitives.ml` (Sys.readdir → sorted names, no "."/"..";
  Sys_error prefixed like file-read, msg carries enoent/enotdir).
  4 tests: sorted listing, missing dir, not-a-dir, arity. WASM boot
  green (Sys.readdir stubbed there); Erlang 530/530; run_tests +4.
  Satisfies fed-sx Step 3 segment replay.
- 2026-05-18 — Phase F: pure-OCaml `lib/sx_rsa.ml` (self-contained
  bignum modexp, minimal DER SPKI reader, RFC 8017 §8.2.2 PKCS#1
  v1.5 verify with SHA-256 DigestInfo prefix). Primitive
  `rsa-sha256-verify` total. 5 tests on a fixed RSA-2048 vector
  (one-off python-cryptography keygen, hardcoded): valid, tampered
  msg/sig, garbage SPKI, non-string. WASM boot green with new lib
  module; Erlang 530/530; run_tests +5. Satisfies fed-sx Step 2
  (rsa-sha256-2018 sig-suite).
- 2026-05-18 — Phase E: pure-OCaml `lib/sx_ed25519.ml` (minimal
  base-2^26 bignum, edwards25519 extended-coord points, RFC 8032
  §5.1.7 cofactorless verify reusing Phase-A sha512). Primitive
  `ed25519-verify` is total (bad/short/non-string args → false).
  8 tests: RFC 8032 §7.1 TEST 1-3 (re-derived independently via
  python-cryptography), tampered msg/sig, wrong-length, non-string.
  WASM boot green with new lib module; Erlang 530/530; run_tests +8.
  Satisfies fed-sx Milestone 1 Step 2 (Ed25519 sig-suite).
- 2026-05-18 — Phase D: pure-OCaml `lib/sx_cid.ml` (unsigned-varint,
  multihash, CIDv1, multibase base32-lower), primitives `cid-from-bytes`
  / `cid-from-sx` (cbor→sha2-256→mh→cidv1, dag-cbor codec 0x71). 5 tests:
  raw "abc"=bafkreif2pall7d…, raw ""=bafkreihdwdcefg…, dag-cbor {}=
  bafyreigbtj4x7i… (all match canonical IPFS CIDs; no `ipfs` CLI so
  vectors independently derived in Python), key-order determinism. WASM
  boot green with new lib module; Erlang 530/530; run_tests +5.
- 2026-05-18 — Phase C: pure-OCaml `lib/sx_cbor.ml` (dag-cbor encode/
  decode), primitives `cbor-encode`/`cbor-decode`. RFC 8949 Appendix-A
  vectors, length-then-bytewise key sort + order-invariance determinism,
  decode∘encode round-trip (30 tests). Floats unsupported (raise, no
  fed-sx shape needs them); tag-42 decode = inner-item passthrough.
  WASM boot green with new lib module; Erlang 530/530; run_tests +30.
- 2026-05-18 — Phase B: pure-OCaml `lib/sx_sha3.ml` (Keccak-f[1600] +
  SHA-3 pad, domain 0x06), primitive `crypto-sha3-256`. 4 NIST FIPS 202
  vectors pass (empty/abc/896-bit + 1600-bit 0xa3 multi-block). WASM boot
  green with new lib module; Erlang conformance 530/530; run_tests +4.
- 2026-05-18 — Phase A: pure-OCaml `lib/sx_sha2.ml` (SHA-256 + SHA-512),
  primitives `crypto-sha256`/`crypto-sha512`. 7 NIST FIPS 180-4 vectors pass
  (empty/abc/896-bit/1M-'a' for sha256; empty/abc/896-bit for sha512). WASM
  boot green with new lib module; Erlang conformance 530/530 unchanged.

## Blockers

- 2026-06-07 — **fed-sx-m2 Blockers #4 (handler-mutex deadlock) is NOT a
  mutex bug — root cause is in the Erlang substrate, so the fix is m2
  scope, not OCaml.** Investigated per `plans/agent-briefings/
  fed-prims-mutex-fix.md`. Reproduced deterministically (m2 worktree
  binary + `next/kernel/*.erl`, port 51920): a **single** request — no
  concurrency, no prior request — to `/actors/alice/outbox` returns an
  empty reply (curl exit 52) while the non-kernel control route `/`
  returns 200 `fed-sx kernel m1`. Also reproduced with a 3-line minimal
  echo gen_server + a handler that does `gen_server:call(echo, ping)`
  (no kernel needed; boots in ~20s vs ~7min for the full kernel here).

  Diagnosis: native `http-listen` (`bin/sx_server.ml:743-840`) runs each
  connection's handler on a fresh `Thread.create` **outside any Erlang
  scheduler step**. The handler closure (`er-bif-http-listen`'s
  `sx-handler`, `lib/erlang/runtime.sx`) calls `er-apply-fun handler`
  directly, so when the route reaches `gen_server:call` →
  `receive` (`lib/erlang/transpile.sx:1132`), the `receive` captures a
  `call/cc` and `raise`s `er-suspend-marker` expecting an enclosing
  `er-sched-step-alive!` guard **and** a scheduler pump
  (`er-sched-run-all!`). On the native handler thread neither is on the
  stack: with no guard the suspend either propagates out (→ empty reply,
  minimal case) or is caught by an Erlang `try`/guard in the route and
  the request stalls (→ "hang" the m2 loop observed). The kernel
  gen_server can never be stepped because the only scheduler driver
  (the boot thread that ran `erlang-eval-ast`) is parked forever in the
  native `Unix.accept` loop.

  Why Pattern A (release/rescope the runtime mutex) does NOT apply: the
  failure reproduces on a **single request with zero contention**, so it
  is not a mutex-contention deadlock. Releasing the mutex cannot help and
  would be actively harmful — the mutex is *required* to serialise the
  shared single-threaded SX runtime / scheduler across handler threads.
  `Sx_runtime.sx_call` (`lib/sx_runtime.ml:102`) is fully synchronous
  (it just dispatches into the CEK evaluator), which is exactly the
  briefing's stated condition for falling back from Pattern A to
  Pattern B. There is also no OCaml-only fix: `grep` confirms nothing in
  `hosts/ocaml/{lib,bin}` references `er-sched*`/the Erlang scheduler —
  `er-sched-run-all!` is a pure-SX symbol in `lib/erlang/runtime.sx`, so
  OCaml cannot pump it. Running the handler synchronously on the accept
  thread (no `Thread.create`) does not help either: the `er-suspend-marker`
  `raise` would unwind the native `handle` frame that writes the HTTP
  response, losing the response across the suspension.

  Recommended fix (Pattern B, **m2 / `loops/erlang` scope — entirely in
  `er-bif-http-listen`, no OCaml change**): have `sx-handler` run the
  handler as a scheduled er-process and pump the scheduler to completion,
  e.g.

  ```
  (sx-handler
    (fn (req-dict)
      (let ((req-pl (er-request-dict-to-proplist req-dict)))
        (let ((pid (er-spawn-fun
                     (fn () (er-apply-fun handler (list req-pl))))))
          (er-sched-run-all!)                       ; drains: handler →
                                                    ; kernel reply → handler
          (er-proplist-to-dict
            (er-proc-field pid :exit-result))))))   ; handler's return value
  ```

  This keeps every suspend/resume inside the SX scheduler; the native
  side only ever sees the final response dict. The existing native
  per-connection `Thread.create` + `Mutex` stay as-is and remain correct
  (they serialise the single pump across concurrent connections — the
  mutex must NOT be removed). Verified by reasoning through the full
  step trace (handler suspends on `receive` → kernel `handle_call`
  replies → handler resumes → dies with `:exit-result`); the m2 loop
  should implement + run `next/tests/http_server_tcp.sh` plus a
  kernel-route smoke. No OCaml or `bin/sx_server.ml` change was made or
  is needed.
