---
name: numericalos-build-oci
description: Build a NumericalOS OCI container image runnable by Docker, Podman, containerd, or nerdctl. Use when numericalos-target-classifier routed here, or when the user asks for a NumericalOS container/Docker/Podman image. Covers the OCI, rootless-Podman, and LXC/system-container differences honestly rather than flattening them, and states plainly what a container can and cannot prove about a boot.
---

# Build a NumericalOS OCI image

Fastest honest feedback loop for NumericalOS: no kernel needed, no privileged
operations, runs on any OCI runtime.

**Prerequisite:** `numericalos-target-classifier` has run and routed here. If it
has not, run it first - it hands you `ARCH`, `REPO`, `STATE`, and the probe
results.

## What a container proves, and what it does not

Be precise about this with the user, because the difference is the entire
honesty boundary of this project.

**A container run DOES prove:** `numinit` parses `numos.state`, resolves the unit
DAG, walks boot phases in ordinal order, applies `on_failure` policy correctly,
and runs units as PID 1 of its namespace. That is the majority of the boot logic,
genuinely exercised.

**A container run does NOT prove:** the machine boots. There is no kernel
handoff, no initramfs, no `switch_root`, no real device or filesystem bring-up.
The container shares the host kernel. `bootstrap.sh`'s arch detection runs, but
its artifact-fetch and exec path is not the initramfs path.

Say both halves. "It runs in Docker" is true and useful; "it boots" is not
established by this skill.

## Step 1 - produce a state file

If the user has no `numos.state`, export one from the repo:

```sh
cd "$REPO"
python3 -m numos.export_state --out dist        # POSIX
# py -m numos.export_state --out dist           # Windows
```

Without a reachable IntrikataTopology instance, `--ops-url` will fail. Then build
from seed data only, which is a legitimate minimal state:

```sh
python3 -c "from numos.export_state import export; export([], 'dist')"
```

Tell the user which one they got. A seed-only state has the infrastructure units
(mount, net, clock, identity, join) and no op units.

## Step 2 - a Containerfile that does not lie

```dockerfile
# NumericalOS - graph-driven init, container target.
# The graph IS the configuration: no unit files, one numos.state.
FROM busybox:musl

# The boot chain. numinit.sh is the shell PID-1 path.
COPY boot/ /boot/
COPY dist/numos.state /etc/numos.state

# numinit reads exactly one artifact and refuses a state whose
# content hash does not match its C record.
ENV NUMOS_STATE=/etc/numos.state

ENTRYPOINT ["/bin/sh", "/boot/numinit.sh"]
```

Three details that matter and are easy to get wrong:

- **`busybox:musl`, not `scratch`.** `numinit.sh` is a POSIX shell script; it
  needs a shell, plus `grep`, `cut`, `head`, `sort`, `sed`, `tr`. `FROM scratch`
  produces an image that cannot start and a confusing `exec format error`.
- **`NUMOS_STATE` must be set.** `numos_main` halts with
  `numos: HALT: NUMOS_STATE is unset` otherwise. That is correct fail-closed
  behavior, not a bug to work around.
- **Do not `COPY` the repo wholesale.** Ship `boot/` and the state. The Python
  side is a build-time tool, not a runtime dependency.

## Step 3 - build

```sh
cd "$REPO"
docker build -t numericalos:dev -f Containerfile .
# podman build -t numericalos:dev -f Containerfile .
```

For a foreign architecture, be explicit rather than hoping:

```sh
docker buildx build --platform linux/arm64 -t numericalos:arm64 -f Containerfile .
```

Cross-arch builds need `binfmt_misc`/QEMU registered on the host. If the user has
not done that, `docker run --privileged --rm tonistiigi/binfmt --install all`
registers it. Say that this modifies host state before running it.

## Step 4 - run, and read the output properly

```sh
docker run --rm numericalos:dev
```

Expected output from a healthy seed-only state is the phase walk followed by:

```
numos: boot complete degraded=0
```

Interpret honestly:

| Output | Means |
|---|---|
| `boot complete degraded=0` | every phase succeeded |
| `boot complete degraded=1` | a `degrade`-policy phase failed; node would advertise itself degraded |
| `numos: HALT: phase <n> failed and is on_failure=halt` | correct fail-closed halt, nonzero exit |
| `numos: HALT: state hash mismatch` | the state file does not match its `C` record |
| `numos: HALT: NUMOS_STATE is unset` | you forgot `ENV NUMOS_STATE` |
| hangs | a `longrun` unit is blocking - see below |

**On the seed state, expect failures.** The seeded units run `mount -t proc`,
`ip link set eth0 up`, `ntpd`, and `numctl` - a container has `/proc` already
mounted, no `eth0` to configure that way, and `numctl` does not exist yet. Phases
with `on_failure=degrade` or `continue` will report and continue; the `mount` and
`identity` phases are `halt`. This is the system working correctly on inputs that
do not match its environment. Do not "fix" it by weakening the policy - if the
user wants a container-appropriate state, change the state, which is the point of
a graph-driven init.

## Step 5 - a container-appropriate state, if the user wants a clean run

Build one from the exporter rather than hand-editing the state file - hand-editing
breaks the `C` content hash and `numinit` will correctly refuse it.

```python
from numos.export_state import export
from numos import seed

seed.INFRA_UNITS = [
    {"name": "hello", "kind": "oneshot", "restart": "never",
     "backoff_ms": 0, "backoff_max_ms": 0, "arch_mask": [],
     "health_probe": None, "requires": [], "after": [],
     "exec": "echo numericalos: container unit ok"},
]
seed.BOOT_PHASES = [
    {"ordinal": 10, "name": "hello", "on_failure": "halt",
     "required_units": ["hello"]},
]
export([], "dist")
```

Then rebuild. This demonstrates the actual claim of the project: changing what
the machine does is a data change, not a code change.

## Runtime differences - named, not flattened

| Runtime | What differs | What to tell the user |
|---|---|---|
| **Docker / containerd / nerdctl** | Daemon runs as root; your process is PID 1 of its namespace | Baseline. Everything above applies unchanged. |
| **Podman rootless** | User namespace; UID 0 inside maps to your UID outside | Closest to a real unprivileged init. Any unit needing real `mount` will fail - correctly. Use `--userns=keep-id` if the user cares about file ownership. |
| **LXC / systemd-nspawn** | A *system* container: full userspace, real init slot | Highest fidelity short of a VM. `numinit` runs as actual PID 1 with its own PID namespace and can genuinely supervise. Needs a Linux host and more setup. Recommend when the user wants to exercise supervision rather than just the phase walk. |

**The `longrun` caveat, which applies to all three.** `numinit` backgrounds
`longrun` units and returns success once started - it does not wait. A container
whose only long-running unit is backgrounded will run its phase walk and then
**exit immediately**, because PID 1 finished. That is correct for the phase walk
and surprising if you expected a persistent container. If the user wants the
container to stay up, they need a foreground supervisor - which `numinit` does
not yet invoke (`numos_supervise` exists and is tested but has no caller). Say
that rather than papering over it with a `sleep infinity`.

## Reporting

State: image tag, base image, arch, whether the run completed or halted, the
final `numos:` line verbatim, and which units failed if any. Then state the
boundary: "this exercised the phase walk and DAG resolution under a shared host
kernel; it did not boot a machine."

## Related skills

- `numericalos-target-classifier` - run first
- `numericalos-build-initramfs` - real initramfs, needs Linux
- `numericalos-build-iso` - bootable media
- `numericalos-verify-boot` - QEMU; the only skill that may claim a boot
