# Flags and a doctor for the host — 2026-09-19 A Mojo host application was configured by environment alone. That was decided, not overlooked: D29 recorded "no CLI flags and no `--doctor`" with the retiring condition "a flag its operator cannot set in the environment", and the developer product (host plan, Phase 6) is that condition — a CLI whose `doctor` delegates to the binary's own needs the binary to have one. This round gives the host m0serve's command-line contract: flag over variable over default, strict parsing, and a doctor that exits with the server's own code. It is SPEC E30 and E31. ## What was built `m0_host/flags.mojo` is the parser, pure over a list of strings. `serve[H, P](config)` now begins by laying the command line over the config it was handed, so `serve(AppConfig())` is still an application's whole `main`. The flags are what `AppConfig` already carried — host, port, workers, threads, blocking threads, access log, heartbeat, tick, keep-alive cap, QoS, `--spawn-workers` (read, then refused exactly as its variable is) — plus `--doctor` and `--help`. `Report`, the pure half of m0serve's doctor, moved from m0-wsgi to `m0_http.doctor` with its first key made a parameter, so the two doctors render one shape: `ok`, `exit`, grouped facts, and `checks` whose failures each carry a `fix` and the exit they cause. `m0_wsgi.doctor` re-exports it. ## Question 1: where does the command line get applied? In `serve`, because that keeps the one-line `main`. But every host app in the tree prints its own address BEFORE it calls `serve`, from the config it just built — so under `--port 9000` the banner would name 8080 and the server would bind 9000. `AppConfig()` could not simply start reading argv: m0serve builds one too (`AppConfig(default_port=DEFAULT_PORT)`), and would have its own arguments parsed twice by two grammars. So there is `host_config()` — `AppConfig()` with the command line applied, the same exits — for an application that prints an address, and **the overlay is idempotent**, which is what lets `serve` apply it again without asking whether someone already did. The six host apps with a banner take `host_config()`; a scaffold with no banner keeps `AppConfig()`. The two-argument `serve(config, server_config)` has the same problem one level down: `apps/sim_loop` builds its `ServerConfig` and sets its own tick before the flags are read. `HostFlags.apply_to` lays over it only the flags that were GIVEN — the operator's explicit word outranks the application's code; a flag nobody typed leaves the application's value alone. ## Question 2: is `--threads 0` a usage error or a refusal? m0serve answers its `--workers 0` with 2. The host already refused `M0_THREADS=0` with 78, and a host that answered the flag with 2 and the variable with 78 would have two descriptions of one rule. So the parser refuses only what it cannot READ (not a number, a port out of range, an unknown flag, a positional), and a count that cannot be SERVED goes down the one path whichever way it arrived. That path is `host_checks`: every rule the host refuses by, in the order it applies them, each evaluated. `host_refusal` — what `serve` exits 78 on — is its first failure, and the doctor lists them all and exits on the same first failure. m0serve's doctor mirrors `main`'s check order by hand and says so in a comment; here the server and the doctor read one list, so they agree by construction. The gate runs both anyway, for the reason a by-construction argument is worth one sabotage: reversing the loop in `host_refusal` is caught only by a configuration that trips two rules at once, which the first version of the probe did not have. Each refusal now prints its fix, and the fix names both spellings (`set --threads (M0_THREADS) to 1 or more`), because the operator may have used either. ## Question 3: what does the doctor print when the application prints too? One JSON object, as the **last line** of stdout. An application's banner comes first, and its OWN refusal (`apps/blobs` checks its cadence in `main` and exits 78 before `serve`) produces no report at all. That second fact is what settles the first: a caller already has to handle "exit 78 and no JSON", so "take the last line, try JSON" is the contract, and the application is not asked to know whether it is being doctored. That is also how the contract holds for application-owned configuration without a hook: those checks run before `serve`, so they fire identically under the doctor. What the doctor does not do is RENDER the application's settings; that needs a hook, and no second application has asked. The report's first key is `"m0_host":"1"` — a format number. The release version lives in `pyproject.toml` and `cli.mojo` and nowhere else (docs/RELEASING.md), and a third copy compiled into every app was not worth what it would say. The `m0` wheel can add its own version from outside. ## What the doctor does not cover The bind, and the application's `make`. Whether the address is free and whether the handler can be built are found out by doing them; a doctor that built the handler would open the application's database to report on a port. Both fail at run time with a named line (`make` with 78, D30), and E31 says so rather than claiming the whole contract. ## What is gated `smoke-host-doctor` (every PR) is `smoke-doctor`'s shape: twenty configurations of `apps/host_check`, each run as `--doctor ARGS` and as `ARGS`, the two exit codes required to agree with each other and with the table. A served row is really served — `/health` on the port the FLAG names while `M0_PORT` names another, which nothing may then answer on. **The sabotage found a hole in the gate before anything else did.** Two of the first twelve rules were MISSED: serving the environment under a command line that had been read, and withholding a given flag from the `ServerConfig`. Both passed because the gate app takes `host_config()`, which applies the command line before `serve` sees it — the banner fix had hidden whether `serve` does its own job. The probe now runs the flag rows in both application shapes (`M0_HOSTCHECK_ENV_CONFIG=1` hands `serve` a bare `AppConfig()`), and both rules are caught. Twelve rules in `sabotage-host`, seven on the wire and five against the parser's unit tests. The spec sheet's closed-set rule covers the new flag set too: every flag `flags.mojo` accepts must be named by a row, and a row may name a flag either command line accepts. ## Not built - The application's own settings in the report (a hook; wait for the second application that wants it). - Flags an application defines for itself. The parser is strict, so an unknown flag is exit 2; an application's settings are its variables. - `--version`. There is no version to print (above).