# The build-on-miss boundary

What happens when the installer finds no wheel it can use, and how a host may
turn that into a build without the runtime ever deciding to.

Nothing described here is deployed. `src/runtime/python/build-service.ts`
defines the shape and ships a local fake; there is no client, no endpoint, and
no configuration that would produce one.

## The rule

The browser runtime is an installer. It does not compile, and it does not ask
anyone else to compile on its behalf.

A resolver that reached out to have a wheel built would be performing a remote
mutation the caller never asked for — spending someone else's compute,
publishing an artifact — at exactly the moment a user is least able to notice
it, during a routine `pip install`. So the runtime does one thing: it reports
the miss precisely enough to act on. `test/python-runtime/build-service.test.ts`
checks structurally that `resolver.ts`, `install.ts` and `pip-command.ts` do not
import the build service at all.

## The seam

1. Resolution fails and raises a `ResolutionError` carrying a
   `ResolutionFailure` — machine-readable, distinguishing
   `no-compatible-distribution` (building might help) from
   `conflicting-requirements` (it cannot).
2. The **host** — application code, not the runtime — may call
   `buildRequestFor(failure, { recipeRevision, … })`. It is a pure function:
   it contacts nothing and queues nothing, and returns `null` when building
   could not help or when no source exists to build from.
3. The host decides whether to submit that request anywhere.

## Requests converge

A build request promises that its artifact is interchangeable with one built
anywhere else. That holds only if everything which can change the artifact is
named in the request, so `idempotencyKey` is *derived* from the requirement,
the target ABI, the wheel tag and the recipe revision — never invented by a
client, because a random key makes every retry a new build.

What is deliberately excluded is evidence about *where* the requester saw the
source: two clients looking at different mirrors of the same release should
converge on one build. What is deliberately included is the ABI id, so an ABI
rollover does not serve a wheel the new runtime cannot load, and the recipe
revision, since the same source built under different rules is a different
artifact.

## States

`queued`, `resolving`, `building-host-tools`,
`building-target-dependencies`, `building-wheel`, `testing`, then one of
`published`, `unsupported`, `failed`, `cancelled`.

The intermediate states are the ones someone waiting would ask about, and they
fail differently: `building-target-dependencies` failing means a native library
did not cross compile, which is a different report from the package's own build
failing.

`unsupported` is terminal and separate from `failed` on purpose. "This package
cannot work on this platform" is worth caching forever and showing to the user
as a fact; "this build did not succeed" may be worth retrying. Collapsing them
makes a permanent answer look transient, and every client retries it forever.

Cancelling something already terminal does not rewrite its outcome: a published
wheel does not become uncancelled work because someone asked late.

## Security requirements

These are requirements on any implementation, stated here because the request
carries the policy and an audit should be answerable from the request alone. A
client-supplied policy is a statement of intent, never a grant of permission —
a service must enforce its own.

- **Source allowlist.** Fetch only from named hosts; the default is PyPI.
- **No network during the build.** A build that can reach the network can fetch
  an unpinned dependency, and then the artifact depends on the day it was
  built. Everything needed is pinned before the build starts.
- **Resource limits.** Wall-clock and memory ceilings, so a pathological build
  cannot occupy a worker indefinitely.
- **Nothing published before it runs.** A wheel that compiled is not a wheel
  that works; the `testing` state exists so that publication follows an actual
  import and exercise in the owned runtime, as `package-cohorts.test.ts` does
  locally.
- **Digest verification.** Immutable artifact locations and recorded hashes. A
  wheel that could be replaced under a URL makes every recorded digest a lie.
- **Auditability.** The provenance record the wheel already carries — source
  digest, build tools, native dependencies, ABI, recipe revision, patches — is
  echoed in the result for clients that will not open the archive.

## Building here: `buildFromSource`

The seam above describes talking to a service. The same boundary also has a
local implementation, so a host with the toolchain does not need one.

```js
configurePython({ buildFromSource: true });                       // build here
configurePython({ buildFromSource: "http://localhost:4180/build" }); // ask a machine
```

`true` is the default **only** where building can possibly work: a Node
process, running from a checkout that contains the build pipeline. A browser
has no compiler and will not get one; a published package has no pipeline. In
both, attempting a build would replace a clear "no wheel for this package" with
a hang or a confusing failure, so both keep reporting instead.

When `pip` hits a `no-compatible-distribution` failure for a package that has a
source distribution, it asks the builder, then retries the install once against
the index that now contains the wheel. The loop is bounded, because each pass
can only make progress by adding one package — without a bound, a graph whose
every member is unbuildable would rebuild forever rather than report.

Two failure classes are deliberately *not* built: conflicting requirements,
which compiling cannot fix, and packages with no source distribution, which
have nothing to build from.

### Where the recipe comes from

`python-runtime/scripts/auto_recipe.py` writes one. A recipe states which
package, where its verified source is, and which backend drives it — and PyPI
publishes the first two while the source declares the third, so requiring a
person to transcribe them is what made every new package a small project.

Nothing that affects the artifact is inferred. Compiler flags still come from
`abi/extension-abi.json`, and a package needing a patch, a native library or a
build-environment switch still needs those declared by hand. A build backend
with no adapter is refused **by name** rather than attempted — attempting it
fails deep inside someone else's build system, where the message is about a
missing CMake rather than about this pipeline not supporting CMake.

Build requirements a package declares and the lock has never seen are pinned
at that moment rather than dropped. Dropping them is what made `ujson` fail
with `ModuleNotFoundError: setuptools_scm` raised from inside its own
`setup.py` — a message about the package, caused by the generator quietly
omitting a requirement the package had stated plainly.

## Serving builds to a browser

```bash
npx sandboxedjs-build-wheels 4180
```

It serves the wheel index over HTTP and builds on request, binding loopback
only — a build runs a package's own build system, which is arbitrary code
execution by design, so exposing it to a network hands that to whoever can
reach it.

The exchange is a package name in, a verdict out. The wheel itself comes back
the ordinary way, fetched from the index and checked against its digest like
every other wheel; a service that returned bytes directly would bypass that.

It is submit-and-poll, not one long request. A build takes minutes, and a
connection held open that long is dropped somewhere in between — which reads
to the caller as the service being unreachable while it is in fact working.

## What is not decided here

Whether to run a *hosted* service, where, and who pays for it. That is the
user's decision, and this project does not own cloud accounts, deployment,
billing or signing infrastructure.

## What "no wheel" actually means, and what it does not

A package with no compiled extension needs no wheel of ours: `requests`, `rich`
and everything else pure-Python installs from PyPI unchanged. A package with a
compiled extension needs one built for `cp313-cp313-emscripten_5_0_6_wasm32`,
because no such wheel is published anywhere.

There is no compiler inside the container and there is not going to be one. A
C/C++ toolchain targeting WebAssembly is clang, lld and a sysroot -- hundreds of
megabytes before a single package is built -- and it would still not cover the
Fortran in SciPy or the Rust in pydantic-core and cryptography. Shipping that to
a browser tab to install one package is not a trade worth making, and calling it
"free" ignores what serving it costs. So the boundary stays where it is: a host
with the toolchain builds locally, a host without one points at a build service,
and a host with neither is told plainly that no wheel exists rather than being
left to wait on a compiler that cannot run.

A local build that fails because the *build machine* lacks a package now says
so and names what to install, rather than printing the backend's traceback:
`pip install scipy` reported a `ModuleNotFoundError` for `mesonpy` where it
meant "install meson-python into the interpreter running this pipeline".

## Standard library coverage

276 of the 290 modules in `sys.stdlib_module_names` import. Of the rest:

- `msvcrt`, `nt`, `winreg`, `winsound` are Windows-only. CPython on Linux or
  macOS does not have them either; a program importing `msvcrt` unguarded is
  already broken everywhere but Windows.
- `tkinter`, `turtle`, `turtledemo`, `idlelib`, `curses`, `readline` need a GUI
  or a terminal device that a container in a page does not have.
- `antigravity`, `pydoc_data`, `webbrowser` are absent for no good reason and
  are cheap to add; they live in the interpreter's data image, so adding them
  means rebuilding it.
- `ctypes` is the one real gap. It needs libffi cross-compiled and linked into
  the image, and packages that import it unconditionally will fail until then.
