> ## Documentation Index
> Fetch the complete documentation index at: https://recipe.uselettuce.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# RFC 002 compilation toolchain

# RFC-002 — Compilation toolchain for self-hosted distribution

**Status:** Draft (LET-16 v0)
**Author:** LET-16
**Decision owner:** Founder
**Related issues:** [LET-15](https://linear.app/we-build-autonomously/issue/LET-15),
[LET-16](https://linear.app/we-build-autonomously/issue/LET-16)
**Supersedes:** RFC-001 §2 (the recommendation there was Option A — license
key gated source; the founder has since chosen Option B — compiled binaries —
and this RFC documents how we deliver it).

## TL;DR

We compile the Python services to standalone native binaries with **Nuitka**
and ship a runtime image that contains **only** those binaries plus the
non-Python shared libraries they link against. There is no `src/` directory,
no `*.py`, no `*.pyc`, and no readable bytecode in the final image. This RFC
records why Nuitka, the multi-stage Dockerfile shape, the known-fragile
patterns we are watching, and the follow-up work that has to happen before
this can be the single source of truth for self-hosted releases.

This is a v0. The first PR ships a working compiled image for the **api**
service, a smoke script, and a tag-gated CI workflow. `worker` and `web`
follow in LET-16.1 / LET-16.2 — see §6.

## 1. Why compile at all

The founder picked Option B over RFC-001 §2's Option A. The customer-facing
requirement is "our IP is not visible inside the container." That rules out
shipping `.py` files (Option A's approach) and it also rules out
**PyInstaller**, which bundles `.pyc` — `.pyc` round-trips to source with
`uncompyle6` / `decompyle3` in seconds and is a near-zero deterrent.

Option B does **not** promise DRM-grade protection. A determined attacker
can still reverse a Nuitka binary with enough time and a disassembler. What
it does deliver:

* No casual `cat src/codewaze/service/billing.py` inside the container.
* No `python -c "import codewaze; print(inspect.getsource(...))"`.
* A meaningful deterrent + an enforceable contract clause ("the customer
  shall not attempt to reverse-engineer the binary").

That's the bar the user signed up for, and it's the bar this RFC commits to.

## 2. Toolchain candidates

| Tool                                  | Output                                     | Reverses to source?                                       | Native deps (numpy, psycopg)   | Effort to retrofit                                                             |
| ------------------------------------- | ------------------------------------------ | --------------------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------ |
| **Nuitka** (`--standalone --onefile`) | Real C → native binary                     | No (needs a disassembler)                                 | Yes, ships `.so` automatically | Low                                                                            |
| **Cython**                            | `.so` per module + tiny `__main__.py` stub | No (same as Nuitka)                                       | Yes                            | High — each module needs a `.pyx` or `# cython: ...` header. Painful retrofit. |
| **PyInstaller**                       | `.pyc` inside a self-extracting archive    | **Yes — trivially.** Use `pyinstxtractor` + `uncompyle6`. | Yes                            | Low                                                                            |
| **Rewrite hot paths in Go**           | Static binary                              | No                                                        | n/a                            | Months. Not v1.                                                                |

**Decision: Nuitka.** It hits the IP bar, ships native deps without extra
plumbing, and is a one-line change per entry-point binary. Cython is the
fallback if Nuitka turns out to break on a specific dependency we can't
patch around — we'll cross that bridge in §5 if it ever happens.

## 3. Image shape

Two-stage Dockerfile, `self-hosted/Dockerfile.compiled`:

```
┌─ stage 1: build ───────────────────────────────────────────────┐
│ FROM python:3.12-bookworm  (full toolchain — gcc, patchelf,    │
│                              libpq-dev, libssl-dev, …)         │
│ uv sync --frozen --no-dev   (install deps)                     │
│ uv pip install nuitka                                          │
│ uv run python -m nuitka --standalone --onefile                 │
│       --include-package=codewaze                               │
│       --output-filename=codewaze-cloud                         │
│       src/codewaze/service/__entry_api__.py                    │
└────────────────────────────────────────────────────────────────┘
                              │
                              ▼ copy a single file
┌─ stage 2: runtime ─────────────────────────────────────────────┐
│ FROM python:3.12-slim-bookworm                                 │
│ apt-get install libpq5 libssl3 ca-certificates git             │
│ COPY --from=build /artifacts/codewaze-cloud /usr/local/bin/    │
│ USER 10001                                                     │
│ EXPOSE 8000                                                    │
│ CMD ["/usr/local/bin/codewaze-cloud"]                          │
└────────────────────────────────────────────────────────────────┘
```

Verifications a reviewer can run on the runtime image:

```bash theme={null}
docker run --rm --entrypoint sh lettuce-api-compiled \
  -c 'find / -name "*.py" 2>/dev/null | grep codewaze || echo "no source"'
docker run --rm --entrypoint sh lettuce-api-compiled \
  -c 'find / -name "*.pyc" 2>/dev/null | grep codewaze || echo "no bytecode"'
```

Both must print `no source` / `no bytecode`. If either prints a `codewaze`
path, the build is broken and the smoke job fails.

## 4. Entry-points

Today `pyproject.toml` declares four console scripts:

| Script            | Module                            | Compiled?        | Notes                                                                                                                                                                                                                                                                                                                                                 |
| ----------------- | --------------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `codewaze-cloud`  | `codewaze.service.app:run`        | **Yes (v0)**     | The api binary this RFC ships.                                                                                                                                                                                                                                                                                                                        |
| `codewaze-worker` | `codewaze.service.worker:run`     | **Yes (LET-34)** | Same recipe as api, different entry shim (`__entry_worker__.py`). Smoke asserts the worker actually claims a queued job under compilation, not just that it starts.                                                                                                                                                                                   |
| `codewaze-mcp`    | `codewaze.server.mcp_server:main` | **Yes (LET-35)** | Stdio MCP server. Entry shim (`__entry_mcp__.py`) sets `sys.stdout.reconfigure(line_buffering=True)` BEFORE importing `mcp_server` — Nuitka can otherwise initialise the stdout pipe in block-buffered mode and the MCP framing (one JSON-RPC frame per line) appears to hang. Smoke asserts the binary actually round-trips an `initialize` request. |
| `codewaze` (CLI)  | `codewaze.cli:main`               | **Yes (LET-35)** | Local-dev CLI. Compiled into the same image as the MCP server (`Dockerfile.mcp.compiled`) — both run on the developer's machine, not on the Lettuce server, so they ship together and are NOT part of the self-hosted `docker-compose.yml` surface.                                                                                                   |

Nuitka wants a real file to compile, not a `console_scripts` entry-point.
We introduce a thin shim `src/codewaze/service/__entry_api__.py` whose only
job is `from codewaze.service.app import run; run()`. Same trick applies to
worker / mcp when we get to them.

## 5. Known-fragile patterns to watch

Things in this codebase or its deps that historically break under Nuitka.
Triage list — if smoke fails, look here first.

1. **`tree_sitter_*` language loaders.** Several extractors call
   `Language(tree_sitter_python.language())`. The grammars ship as
   compiled `.so` files inside the wheel. Nuitka has to copy these into
   the standalone bundle — usually it does automatically, but
   `--include-package-data=tree_sitter_python` (and one entry per
   language) is the safety net.
2. **`pydantic` v2.** Uses `pydantic-core` (Rust). Generally compiles
   fine; the runtime image needs `libssl3` because `pydantic-core`
   links it on Bookworm.
3. **`fastembed`.** Downloads ONNX models on first use. The model
   cache must live on a writable volume in the runtime image.
   Compilation doesn't affect this but it's easy to forget.
4. **`mcp.server.fastmcp`'s decorator-driven routing.** Routes get
   registered via decorators at module import. Nuitka preserves this
   provided every decorated module is included — the `--include-package=
   codewaze` flag handles it.
5. **`importlib.resources` / `__file__` introspection.** A spot-check
   (`grep -rn "__file__\|inspect.getsource" src/codewaze`) found
   nothing in our code, so this should be a non-issue. If a dep
   relies on it (some older libs do), Nuitka has `--include-data-dir`
   to ship the original tree as a data file.
6. **Stack-trace readability.** Nuitka stack traces show C source
   lines, not Python source. This is the support-cost RFC-001 §2
   warned about. Acceptable for v0 — we'll add an optional
   `LETTUCE_DEBUG_BUILD=1` switch later that re-includes `--debug`
   builds for incident response.

## 6. Out of scope for this PR (file as LET-16.x)

* **LET-16.1 — compile worker.** **Shipped (LET-34).** Same recipe,
  different entry shim (`__entry_worker__.py`). Smoke script asserts
  the worker actually claims a queued job under compilation, not just
  that the process starts. See `self-hosted/Dockerfile.worker.compiled`
  and `self-hosted/scripts/smoke-worker-compiled.sh`. The compiled
  image is opt-in in `docker-compose.yml` via `LETTUCE_COMPILED_WORKER=1`
  until the publish workflow proves out on a real tag push.
* **LET-16.2 — compile mcp server + cli.** **Shipped (LET-35).** Both
  binaries built from a single Dockerfile (`Dockerfile.mcp.compiled`)
  because they both run on the *developer's machine*, not the Lettuce
  server. The MCP entry shim sets line-buffered stdout/stderr before
  importing the server module — under Nuitka the stdout pipe can come
  up block-buffered, which breaks MCP's one-frame-per-line wire
  framing (Claude Desktop / Cursor would hang waiting for a flush).
  Smoke (`scripts/smoke-mcp-compiled.sh`) exercises an MCP
  `initialize` round-trip over stdio AND a `codewaze --help` boot of
  the CLI binary, in addition to the standard IP-leak guard. The
  image is intentionally NOT added to `docker-compose.yml` — the
  compose file is the self-hosted server surface.
* **LET-16.3 — pytest under the compiled artifact.** Today the test
  suite imports `codewaze.*` modules directly. Running pytest
  against the compiled binary means launching the binary as a
  subprocess and exercising it via HTTP. A handful of unit tests
  poke private internals and will not survive; they need to be
  re-cast as integration tests or marked `@pytest.mark.skip_compiled`.
* **LET-16.4 — image signing + SBOM.** Cosign + `syft` / `grype`.
  Customers in regulated industries will ask for both.
* **LET-16.5 — strip + UPX.** Final binary is \~80 MB unstripped.
  `strip` + UPX gets us to \~25 MB. Cosmetic for v0; meaningful
  for air-gapped customers who pull tarballs.

## 7. Open questions for the founder

1. **Single binary or per-service binary?** This RFC ships per-service
   (one binary per process). Alternative: one fat binary that picks a
   sub-command. Per-service is simpler today; revisit if image count
   becomes a maintenance burden.
2. **Re-introduce a license-key check?** Option B replaces Option A,
   but they aren't mutually exclusive — we can still gate the
   compiled binary on a license key (LET-15.13). RFC-001 still
   recommends this and nothing in this RFC changes that recommendation.
3. **Public-cloud release continues to ship source.** This RFC only
   covers self-hosted images. The Render-deployed SaaS at
   `app.uselettuce.dev` keeps using the existing `Dockerfile` (source-
   based) — there is no reason to pay the compile cost for the
   single-tenant cloud we operate.
