# quickchr Design

> Architecture and rationale. For the user-facing reference (every CLI
> command, every library API, provisioning, channels, networking, errors)
> see **[MANUAL.md](./MANUAL.md)**.

## Architecture

quickchr is a TypeScript/Bun CLI + importable library to manage MikroTik CHR virtual machines via QEMU.

### Layers

```text
CLI (src/cli/)          ← Arg parsing, wizard, formatting
    ↓
Library API (src/lib/quickchr.ts)  ← QuickCHR class, ChrInstance
    ↓
Modules (src/lib/)      ← qemu, images, versions, network, state, ...
```

- **CLI** — git-style subcommands + interactive wizard. Thin layer over the library.
- **Library** — `QuickCHR` class with static methods: `start()`, `add()`, `list()`, `get()`, `doctor()`. Returns `ChrInstance` handles with `stop()`, `remove()`, `rest()`, `exec()`, `monitor()`, etc.
- **Modules** — Pure functions for QEMU arg building, image download, port allocation, state persistence.

### Key Design Decisions

1. **JSON state, not SQLite** — Portable to Windows without native deps. Each machine gets a `machine.json` file in `~/.local/share/quickchr/machines/<name>/`.

2. **Port block allocation** — 10 ports per instance (base + 0-9). Default starts at 9100. Avoids conflicts by scanning existing machines and probe-binding.

3. **No shell scripts** — QEMU args built entirely in TypeScript. Enables Windows support and testability.

4. **Optional qcow2** — Default boot disk uses raw `.img` (MikroTik provides them). Users can opt into `qcow2` format for boot resize and QEMU snapshot/restore support. Requires `qemu-img` when enabled.

5. **ARM64 VirtIO rule** — Never use `if=virtio` on aarch64 `virt` machine. Always explicit `-device virtio-blk-pci,drive=drive0`.

6. **Class-based API** — `QuickCHR` is a class with static methods for clean namespacing. `ChrInstance` is an interface implemented as a plain object with closures.

7. **Running-only connection descriptors** — `ChrInstance.descriptor()`, `quickchr inspect`, and `quickchr env` are live connection handoff surfaces, not stale state readers. They intentionally fail with `MACHINE_STOPPED` when the VM is not running, because ports/auth/status are only safe to consume when the machine is active. Descriptor/env output includes auth material by design for subprocess handoff; callers must treat it as credential-bearing output.

8. **Boot respawn-once on hardware accel** — When `QuickCHR.start()` boots a fresh machine in background mode and `waitForBoot` exhausts its budget under `kvm`/`hvf`, it stops the QEMU process, clears its `server=on` socket files, and respawns **once** before raising `BOOT_TIMEOUT` (see `start()` in `src/lib/quickchr.ts`, gated on `accel`). This targets an observed CI flake: on GitHub's nested-KVM runner a single boot among many occasionally never reaches REST while siblings boot in ~30-45s — a *wedged* process that a longer timeout would not rescue, only a fresh one. Gated to hardware accel because TCG boots are legitimately long and doubling buys nothing.

   ⚠️ **Watch-item / scope caveat.** The root cause is **unconfirmed** — the respawn is a pragmatic mitigation that keeps CI green, not a proven diagnosis; the trigger could be something else (runner CPU-steal, a SLiRP stall, image-specific timing). Two deliberate limits to revisit if it recurs:
   - **Not applied to `_launchExisting`** (the restart-existing-machine path) — that path also spawns + `waitForBoot`s but has never been observed to flake. Extend the same respawn there only with evidence.
   - **Timeout factor left at 1.5×** (`accelTimeoutFactor`, `src/lib/platform.ts`) rather than inflated further — the respawn is the recovery mechanism, not a bigger ceiling.

   How to tell it's firing: grep CI `qemu.log`/run logs for `respawning QEMU once`. A frequent occurrence, or a `BOOT_TIMEOUT` that *survives* the respawn (or appears on the `_launchExisting` path), is the signal to stop treating it as a flake and find the real cause. Tracked as a watch-item in [#45](https://github.com/tikoci/quickchr/issues/45).

9. **Resilient downloads: normal `fetch` first, public-DNS IPv4 as a failback** — All fetches to MikroTik's `upgrade`/`download` hosts go through `fetchResilient()` (`src/lib/net.ts`), never bare `fetch()`. The intent is narrow: a **plain `fetch` is the path** — dual-stack (happy eyeballs), on par with curl/most tools, and honoring local DNS (`/etc/hosts` pins, VPN/split-horizon, mirror redirects, IPv6-only egress) — and we layer one **failback** beneath it to ride out *DNS misconfiguration somewhere in the environment*, wherever it comes from. We deliberately do **not** invert this (public-DNS+IPv4 first) — that would override local DNS on every machine and could introduce its own failures (e.g. an IPv4-only connect on an IPv6-only network). Normal first keeps us on par with most tools; the failback only adds smarts when the normal path actually breaks.

   The trigger is precise: only when the normal `fetch` throws a **connection-class error** (`isConnectionFailure()` — the `ConnectionRefused`/`FailedToOpenSocket`/`ECONNREFUSED`-family codes, or a `TypeError` carrying Bun's `errno: 0` connect-failure marker; *not* aborts or any HTTP response) does `fetchResilient` retry. It resolves the A record by querying public DNS **directly** (a `dns.Resolver` with `setServers([1.1.1.1, 8.8.8.8, 1.0.0.1])`, bounded by a 3 s timeout so a blocked resolver doesn't stall), then connects to the IPv4 literal preserving the `Host` header and TLS SNI so certificate validation still passes. If public DNS also has no answer it surfaces the original failure. HTTP responses (incl. 5xx) and aborts (`AbortError`, e.g. from `AbortSignal.timeout`) pass through unchanged, never retried; a `TypeError` retries only with Bun's `errno: 0` connect-failure marker, so an unrelated `TypeError` (a real bug) surfaces immediately.

   *Motivating incident (don't over-fit to it):* the failback was prompted by GitHub-hosted runners whose system resolver returned `ESERVFAIL` for `*.mikrotik.com` — slowly (2–26 s) — via *both* `getaddrinfo` and c-ares-over-`resolv.conf`, so a plain `fetch` either timed out resolving or (when the stub handed back only the unreachable AAAA) failed with Bun's `errno: 0` `ConnectionRefused` / `FailedToOpenSocket`; a direct public-resolver query answered in ~10 ms. That was one observed symptom of a broken resolver, and its deeper root cause is still unknown — so the design is framed as *generic resilience to a misconfigured/transient resolver*, not a CI-specific patch. The earlier "IPv6 happy-eyeballs" theory was a red herring (the symptom was DNS, not IPv6 egress). On-runner probe (2026-06-16): `lookup({family:4})` → `ESERVFAIL` ~9 s; `lookup({all})` → `ESERVFAIL`/`ETIMEOUT` 22–26 s; `resolve4` (resolv.conf) → `ESERVFAIL` 2–22 s; `Resolver([1.1.1.1,8.8.8.8]).resolve4` → OK ~10 ms.

   **Sister-project routing.** Because `fetchResilient` already absorbs a flaky resolver, a download/version-resolve failure should not be papered over downstream with `/etc/hosts` pins or IPv6 toggles in a consuming repo's workflow — fix it (or extend the failback) here in quickchr. The historical `getent ahostsv4` `/etc/hosts` workaround was both in the wrong layer and non-functional (it hit the same broken stub resolver, returning empty). The failback is best-effort, not a guarantee: it recovers connection-class DNS failures, not arbitrary network breakage. Covered by `test/unit/net.test.ts`.

10. **arm64 CHR is forced onto TCG on all Apple Silicon** — `detectAccel("arm64")` never returns `hvf` on macOS; the launch path surfaces `accelNote()` explaining why (`src/lib/platform.ts`). The cause is an **image artifact**, not an accelerator or CPU-model bug: current arm64 CHR images (7.20.8 – 7.23beta5 verified) ship an AArch64 kernel over a **32-bit ARM userspace** — the appended initramfs `/init` is `ELF 32-bit LSB ARM, EABI5, static`, and the 7.22.1 `system` package holds **101 further ARM32 executables and 18 ARM32 shared objects** (the only AArch64 executables are `kexec` and `vmcore-dmesg`). Apple Silicon implements **no AArch32 at any exception level**: `ID_AA64PFR0_EL1` reports AArch64-only for EL0/EL1, and QEMU under HVF passes that hardware register straight through (`hvf_arch_init_vcpu()` re-reads the live vCPU register and only edits the GIC bit; the `-cpu` model is inert). So the guest kernel never sets `ARM64_HAS_32BIT_EL0`, `compat_elf_check_arch()` rejects the `EM_ARM` `/init` with `-ENOEXEC`, the initramfs has no fallback init, and Linux panics at t≈0.076 s with `No working init found`. TCG's emulated models do provide AArch32 EL0, so the same unmodified image boots to `MikroTik Login:`. Grounded in the guest's own panic-time capability bitmap: the M4/HVF guest printed `0x20012,28000230` (8 caps, `ARM64_HAS_32BIT_EL0` absent) against TCG's `0x20013,28402230` (11 caps, present). Full chain in [`docs/m4-hvf-arm64-investigation.md`](docs/m4-hvf-arm64-investigation.md).

    **Scope is every Apple Silicon generation, not M4+.** The first report came from an M4 (tikoci/mikropkl#11) and the original fix keyed on `hw.optional.arm.FEAT_SSBS == 0`, which M4 is the first Apple chip to report — but SSBS was a **coincident marker of the reporting host, not the mechanism**. Linux 5.6 treats SSBS as an optional mitigation and boots fine without it (`cortex-a53`/`neoverse-n1` lack it), while *no* Apple CPU since 2020 implements AArch32. The SSBS predicate therefore left M1/M2/M3 on HVF and panicking — a live bug, not merely a narrow gate. Ordinary server ARM (Ampere, Graviton) does implement AArch32 EL0, which is why arm64 CHR runs there under KVM; this is an Apple-Silicon-and-CHR-artifact interaction, not a RouterOS-on-ARM defect.

    **No QEMU flag or version fixes this.** Apple's `Hypervisor.framework` exposes only `hv_vcpu_config_get_feature_reg()` — there is no setter — so no macOS VMM can present a feature the silicon lacks (UTM's fork behaves identically). A QEMU version floor is therefore *not* a valid restore signal, and the deferred `getQemuVersion()` guard the SSBS-era decision contemplated has been dropped. **The restore signal is the guest artifact:** a future arm64 CHR whose appended `/init` *and* required system-package executables/shared objects are all AArch64, confirmed by a real HVF boot. That check is mechanical (§"Reproduction" in the investigation doc) and could become a release-time guard.

    **Escape hatch.** Because the fallback is unconditional, `--accel <auto|tcg|hvf|kvm>` (and the `accel` setting / `QUICKCHR_ACCEL` env var) forces the accelerator verbatim and bypasses detection — so a fixed future image can be tested on HVF without a code change, and so forcing TCG stays possible for debugging elsewhere. `--accel hvf` on an arm64 guest still prints the panic caveat rather than silently obeying. This complements, and does not replace, the reactive respawn-once net in #8. Covered by `test/unit/platform.test.ts`. → [#97](https://github.com/tikoci/quickchr/issues/97)

11. **A download is bounded by whether it is *moving*, not by a flat total** — `src/lib/download.ts` is the one download path for both `images.ts` and `packages.ts`. Before it, each caller had its own bound and they disagreed: images aborted at a flat `120_000` per attempt with three retries, packages had **no** deadline and **no** retries, so its only bound was whatever the calling test imposed.

    A total-duration deadline cannot distinguish *slow* from *stuck*. It fires on a healthy transfer whose only sin is being large, and the retry path then re-downloads from zero — one slow transfer becomes three.

    **The deadline sat inside the natural variance of a healthy transfer, which is why it was intermittent.** Measured locally 2026-07-31 against `download.mikrotik.com` — same 52.2 MB `all_packages-arm64-7.22.1.zip`, same link, same client, four consecutive attempts:

    | attempt | elapsed | throughput | under the old flat 120 s? |
    |---|---|---|---|
    | 1 | 118.4 s | 0.421 MB/s | yes, by 1.6 s |
    | 2 | 94.2 s | 0.528 MB/s | yes |
    | 3 | **123.5 s** | 0.403 MB/s | **no — aborted** |
    | 4 | 82.2 s | 0.606 MB/s | yes |

    All four completed. **One in four exceeded the deadline**, and a fifth attempt through the new streaming path measured 129.4 s (0.385 MB/s) — so the range on one ordinary link is 82–129 s against a 120 s bound. The deadline was inside the distribution, and an unchanged healthy download therefore passed or failed on link jitter alone.

    That is a sharper statement of the defect than a clean abort would have been, and it is why CI saw "both images needed two retries" *sometimes* rather than always. CI's cold ~0.35 MB/s (→ ~149 s) sits **below** this whole local range, which is why a hosted runner hit it far more reliably than this laptop does — run 30606079288, and B7's local suite where `provisioning.test.ts` spent 619 s of 992 s downloading with zero QEMU processes alive.

    ⚠️ **Do not restate this as "the old code always aborted that download."** An earlier draft of this entry said so on the strength of a single 129.4 s measurement; the next attempt completed in 101.5 s and disproved it as a general claim. One measurement of an intermittent is a signal, not a fact — the same rule the CI program runs on.

    **Two deadlines, and the failure says which fired.** Neither alone is sufficient: stall detection alone would let a transfer trickling at one byte per second run forever, and a size-derived budget alone cannot fail a wedge quickly.
    - **Stall** (`DOWNLOAD_STALL_MS`, 30 s) — reset on every received chunk. Bounds *silence*, so a moving transfer is never aborted for being slow.
    - **Transfer budget** — `DOWNLOAD_BUDGET_BASE_MS` (30 s for connect/TLS/headers) plus `content-length ÷ COLD_DOWNLOAD_FLOOR_BYTES_PER_S`, a **floor** throughput of 120 000 B/s ≈ ⅓ of the slowest cold transfer ever measured. The 52.2 MB zip gets 465 s. When the server sends no `content-length` the stated `DOWNLOAD_NO_LENGTH_BUDGET_MS` (15 min) applies and the outcome says so — verified that `download.mikrotik.com` *does* send it on both image and package artifacts, so the size-derived budget is the production path.

    **Retry policy follows from that split.** `DOWNLOAD_STALLED` is retriable — a wedged socket usually moves on the next attempt. `DOWNLOAD_TOO_SLOW` is **terminal on purpose**: the budget is already ~3× the slowest throughput on record, so retrying re-downloads from zero on a link measured slower than the floor, which is the exact behavior this decision removes. It is a signal that the floor is wrong (or the link genuinely is), not something to paper over with another attempt.

    **The floor constant has one home.** It lives here and `test/integration/timeouts.ts` re-exports it; `coldDownloadTestTimeout()` is *derived from* `transferBudgetMs()` rather than recomputing it, which makes one of #106's partial orders (`download deadline < test timeout`) structural instead of coincidental. Two constants asked not to drift are how they drift.

    **Streaming also bounds memory, which the old path did not.** `await response.arrayBuffer()` held the entire artifact in RAM before writing it — 52.2 MB for the largest. Measured: a `Bun.file().writer()` sink writing 40 MiB in 1 MiB chunks keeps the file growing on disk in lockstep with flat RSS (22 MB throughout), so the transient allocation is gone. Recorded as a side effect, **not** as a claim about #76 — a 52 MB transient is not a plausible cause of a runner losing communication, and B8a/B8b should not treat this as having changed that picture.

    **Partial transfers cannot poison the cache.** Downloads stream to a **per-call** `<dest>.<pid>-<uuid>.part` and are renamed only after a verified transfer. Per-call, not a fixed `<dest>.part`, because both callers guard only with `existsSync(destPath)` — two concurrent downloads of one artifact (two `quickchr start`s, parallel tests) would otherwise interleave writes into one file, publish the corrupt result, and delete each other's in-flight file. The unique suffix removes the collision without deduplicating the work; coalescing concurrent downloads needs a lock at both call sites and is a larger change.

    Verification is two checks, because either alone leaves a hole: bytes received must equal `content-length` **when one was sent**, and a **zero-byte body is always a failure** regardless of framing. The second exists because Bun's fetch hands back an *unknown-length* response for a malformed `content-length` — measured against a raw stub, `4096.5` and `-5` are passed through as headers but deliver 0 bytes, while a blank value is stripped to `null` — and an unknown-length response has no length to verify against. Without it the empty file would be renamed into the cache and served as a complete artifact forever. No artifact quickchr downloads is empty, so this costs nothing. Both callers gate on `existsSync(zipPath)`, so a truncated file left at the destination would be served as a complete cached artifact forever. Resuming with a `Range` request would be better than either deadline (MikroTik sends `accept-ranges: bytes`) and is deliberately out of scope — this decision bounds and classifies a transfer, it does not change the transport. Covered by `test/unit/download.test.ts`. → [#116](https://github.com/tikoci/quickchr/issues/116)

    ⚠️ **Testing note.** `Bun.serve` **drops an explicit `content-length` when the body is a `ReadableStream`** and frames the response `transfer-encoding: chunked`, so a stub built on it cannot exercise the size-derived budget at all — every response looks unknown-length. The anchor tests use a raw `Bun.listen` HTTP stub for that reason.

## Port Layout

| Offset | Service    | Guest Port |
|--------|------------|------------|
| +0     | HTTP/REST  | 80         |
| +1     | HTTPS      | 443        |
| +2     | SSH        | 22         |
| +3     | API        | 8728       |
| +4     | API-SSL    | 8729       |
| +5     | WinBox     | 8291       |
| +6—9   | Custom     | —          |

On Windows, the monitor/serial/QGA channels use TCP localhost at `portBase+6/+7/+8` (Winsock can't bind `\\.\pipe\` paths), so the `+6..+8` "Custom" slots are effectively reserved there.

### Allocation rationale & open questions

Ports are assigned in fixed 10-port blocks from `DEFAULT_PORT_BASE` (9100), scanning existing machines and probe-binding to avoid collisions. Two rough edges are **maintainer decisions, not yet settled** — don't "fix" them unilaterally, since they change the persisted-state / port contract (tracked as `needs-decision` work):

1. **Base 9100 is a poor default** — collides with JetDirect/PDL (printers) and trains agents to memorize "quickchr = 9100 for REST", which only holds for the *first* instance (the second gets 9110). Open: random base in a clean high range (persisted per machine) vs caller-requested range vs both; and whether that's a breaking change or a setting with 9100 as the default. → [#56](https://github.com/tikoci/quickchr/issues/56)
2. **Fixed blocks are overfit** — extras bolt on at `+6..+9` and collide with the Windows IPC offsets above (one slot before spilling); manual `extraPorts` bypass collision checking. Open: dynamic variable-size blocks vs a fixed core pool + a separate extras pool. → [#57](https://github.com/tikoci/quickchr/issues/57)

Current consumers are all first-party tikoci projects (centrs, donny, restraml), so a port-scheme migration can be coordinated across them — but `@tikoci/quickchr` is published and public, so a change to the persisted port contract still needs an issue, a CHANGELOG/docs note, and (for the `ChrInstance`/`StartOptions` surface) a deprecation/migration path, not a silent break.

## Storage Layout

```text
~/.local/share/quickchr/
├── cache/                     # Downloaded images
│   ├── chr-7.22.1.img.zip
│   ├── chr-7.22.1.img
│   └── ...
├── machines/
│   └── 7.22.1-arm64-1/
│       ├── machine.json       # Config + state
│       ├── disk.img           # Working copy
│       ├── efi-vars.qcow2     # UEFI vars (arm64; qcow2 so savevm works — #31)
│       ├── monitor.sock       # QEMU monitor
│       ├── serial.sock        # Serial console
│       ├── qga.sock           # QGA (x86 only)
│       ├── qemu.pid           # PID file
│       ├── qemu.log           # Output log
│       └── serial.log         # Serial console tee (only with QUICKCHR_SERIAL_LOG=1)
└── failures/                  # Boot-failure reports (see below)
    └── boot-failure-<machine>-<iso8601>.json
```

**Global settings** live separately, under the XDG **config** tier (not the data tree
above): `~/.config/quickchr/quickchr.env`, dotenv-style (`QUICKCHR_KEY=value` lines).
Managed via `quickchr settings get|set|print|reset`. Precedence per key: CLI flag >
`QUICKCHR_<KEY>` env var > `quickchr.env` > built-in default. The 6 managed keys:
`default-channel`, `default-arch`, `accel`, `cache-max-size`, `timeout-extra`,
`secure-login`.
See MANUAL.md's CLI reference and environment-variables sections for the full surface.

## Platform Support

| Platform               | x86 CHR | arm64 CHR | Notes |
|------------------------|---------|-----------|-------|
| macOS x86_64           | HVF     | TCG       | Intel Mac |
| macOS arm64 (native)   | TCG     | TCG¹     | Apple Silicon, bun is arm64 |
| macOS arm64 (Rosetta)  | TCG     | TCG¹     | Apple Silicon, bun reports x86_64 |
| Linux x86_64           | KVM     | TCG       | KVM requires `/dev/kvm` writable |
| Linux aarch64          | TCG     | KVM       | x86 TCG on arm64 Linux |
| Windows x86_64         | TCG     | TCG       | HVF/KVM not available |

¹ arm64 CHR is forced to **TCG on every Apple Silicon generation**: the image's required userspace is 32-bit ARM and Apple CPUs implement no AArch32, so an HVF guest panics at init (see Design Decisions #10). x86 CHR also uses TCG because HVF cannot virtualize x86 on an arm64 host. Override with `--accel hvf` to test a future AArch64-only image.

**Acceleration detection** (`detectAccel`):
- An explicit override (`--accel` flag > `QUICKCHR_ACCEL` env > `accel` in `quickchr.env`) short-circuits detection entirely; `auto` (the default) runs the checks below.
- macOS: checks `kern.hv_support` via sysctl. Intel x86 guests get HVF; **all guests on Apple Silicon get TCG** — x86 because HVF cannot virtualize across architectures, arm64 because the CHR image needs AArch32 (Design Decisions #10). A Rosetta process is identified via `sysctl.proc_translated`.
- Linux: checks `/dev/kvm` writability.
- Falling back to TCG is safe but can be significantly slower; measure with the
  target host, guest architecture, and RouterOS version.

## CI System

**Workflows**: `.github/workflows/{ci,main,sweep,integration,release,ros-versions,lint-powershell}.yml`.
Full artifact map, dispatch recipes, and failure-diagnosis guide live in
`.github/instructions/ci.instructions.md` — this section is the high-level rationale only.

**The 2026-07 refactor (#29)** replaced the organically-grown scheme (integration on every
PR push, three parallel copies of the runner logic, `continue-on-error` green-washing)
with a layered design:

- **`ci.yml`** — fast PR/push gate (~3-5 min, no QEMU): lint ∥ unit+coverage ∥ Windows
  unit ∥ `Integration freshness` (PR-only, required). Integration left the PR path
  because a 25-minute CHR suite per PR push bought little signal per minute; the quality
  bar moved to main.
- **`main.yml`** — full integration suite + examples smoke (+ PowerShell lint) on
  linux/x86_64 + linux/aarch64 (KVM when available; hosted arm64 may fall back to TCG)
  on every push to `main`; examples are part of the
  default flow, not a weekly extra. The **freshness gate** (`scripts/ci-freshness.ts`) makes this honest:
  PRs merge only while the latest completed main run is green, so a red main visibly
  blocks everything instead of rotting in the Actions list. Superseded push runs cancel;
  cancelled runs carry no signal (the gate skips them).
- **`sweep.yml`** — weekly all-platform sweep (+ examples smoke). A separate file from
  main.yml *by design*: its TCG legs (macos-x86, windows-x86 — bounded to the anchor
  smoke subset via `tcg-smoke: true` to cap weekly cost) may red without blocking PRs,
  but are never green-washed — red is red. On manual `integration.yml` dispatches TCG
  legs run the FULL suite by default (`tcg-smoke` defaults off): `platforms=all` means
  all platforms, full set — smoke is opt-in, never an implicit narrowing.
- **`integration.yml`** — the single reusable integration unit (`workflow_call` +
  `workflow_dispatch`). A plan job resolves **`platforms` × `routeros-targets`** (both
  comma lists; `gating`/`all` platform aliases) into one cross-OS matrix — one dispatch
  can cover e.g. gating platforms × three RouterOS versions, no wrapper jobs or repeat
  dispatches. `test-filter` narrows; `run-examples` defaults ON. Each runner boots its
  native CHR arch — `detectAccel()` picks KVM/HVF/TCG. Agents dispatch this to ground
  platform hypotheses without waiting for a PR cycle.
- **`release.yml`** — one-click publish from committed release state: freshness gate
  (no suite re-run — main is kept continuously release-able) + `package.json` version
  with a matching non-empty CHANGELOG section → tag + GitHub Release + `npm publish
  --provenance` (odd minor → `next`, even → `latest`). CI never bumps versions or pushes
  to protected `main`.
- **`ros-versions.yml`** — daily: new RouterOS versions (per channel) with no linux-x86
  record in `ci-data/tested-versions.json` ride the `routeros-targets` matrix of a
  single integration dispatch.

**Metrics as byproduct (#30)**: the library appends every successful boot to
`<dataDir>/boot-log.ndjson` (and stamps `lastAccel`/`lastBootMs` into machine.json);
integration jobs assemble that + per-file timing into `metrics.ndjson`
(`scripts/ci-metrics.ts`); collect-metrics callers push per-run files + the
`tested-versions.json` rollup to the orphan **`ci-data`** branch. Never a second test
run; never affects pass/fail — the aggregate job is best-effort by contract (fold/push
failures warn and stay green, because a red there would trip the PR freshness gate over
side-band data). A full run marks exactly **its target's resolved version** tested —
never every version it happened to boot (upgrade/pinned-channel tests boot others
incidentally; crediting those suppressed the scheduler for versions no full suite ever
targeted). The per-run files are the source of truth; `ci-metrics refold` rebuilds the
rollup after fold-logic changes.

**What survives losing a runner (#76, #77)**: measured on run
[30665449265](https://github.com/tikoci/quickchr/actions/runs/30665449265), whose three
`macos-x86` legs stopped communicating mid-suite. This is the constraint every
lost-runner instrument has to be designed around, so it is recorded rather than
re-derived:

| Evidence | Survives? |
| --- | --- |
| Uploaded artifacts | **No** — `if: always()` steps never run |
| The archived job log | **No** — the blob returns `BlobNotFound`, even though a *normally failing* leg in the same run keeps its full log. Failure preserves the log; runner loss destroys it |
| The jobs API step ledger | **Yes** — the test step stays `in_progress` with a start time, later steps `pending`, and the job carries a `completed_at` |
| A check run PATCHed during the leg | **Yes** — it is server-side the moment it is written, and stays un-closed |

So a progress marker has to be *pushed out of the runner as it goes*; nothing collected
at the end can work. That is what `ci-leg-checkpoint.ts` (per-file check-run updates) and
`ci-leg-ledger.ts` (planned-vs-completed reconciliation into `ci-data/attempted-legs.json`)
implement. The ledger is a **separate file from `tested-versions.json` by decision**: the
version scheduler reads that file as a presence test, so recording an aborted run there
would make the version look tested and silently stop rescheduling it.

The same run also pins down #76's shape: the three legs died **62.0 / 64.1 / 65.0 minutes
into their own jobs**, staggered with their own start times across 14 minutes of
wall-clock. So the boundary is per-job elapsed, not a shared external event and not a
round 60-minute constant. Read `completed_at` as an **upper bound** on the wedge — it is
when the service gave up on a silent runner, and the interval between the runner going
quiet and that verdict is uncharacterized.

**Merge policy**: squash-only, PR title → main commit subject (write PR titles as
conventional commits), PR body → commit body, branches auto-delete. Review threads must
be resolved before merge (`required_conversation_resolution`) and automated reviews must
have actually posted — see CONTRIBUTING.md "Pull Requests & Merging".

**Coverage**: `unit-tests` parses `bun test --coverage` output and compares against thresholds
(default 75% functions, 60% lines). Failures emit `::warning::` annotations but do NOT block
merges (`continue-on-error: true`). Thresholds are overridable via dispatch inputs
`min-funcs` / `min-lines`.

**Artifacts**:
- `coverage-report` — full per-file coverage table (14 days)
- `{integration|main|sweep}-logs-<platform>` — bun test output + timing + metrics.ndjson +
  machine.json + qemu.log (7 days); durable timing lives on the `ci-data` branch

### Boot-Failure Forensics (`src/lib/diagnostics.ts`)

Discovered constraint from the `ci:slow-platform-flake` series (#76 #79 #80 #91):
**a failure that cleans up after itself is unfixable.** Both `BOOT_TIMEOUT` paths
used to `stop()` + `remove()` the machine and embed `qemu.log.slice(-1200)`, which
on every recorded CI failure contained nothing but the cleanup SIGTERM. Four rules
came out of that:

1. **`waitForBoot()` classifies, it does not just return `false`.** A boot that
   never becomes REST-ready is several different bugs wearing the same error:
   `refused` = QEMU never bound the port (host-side); `probe-timeout` = it bound
   and nothing behind it answered (guest-side); `reset`/`wrong-body` = the guest
   answered and RouterOS is still settling (the #69 shape). Collapsing all of
   them to a boolean is why #79 stayed open across two full CI runs. The tally
   rides on an optional `BootProbeStats` out-param, so normal boots pay nothing.

   **Do not read a live host port as "the guest is up."** Under the default
   `user` (slirp) network mode QEMU's hostfwd `listen()`s for the process's whole
   lifetime and accepts before it tries to reach the guest. Verified locally
   (2026-07-27) by starting CHR with `mem: 32`: 72/72 probes `probe-timeout`,
   `hostfwd: accepting`, `info status: running`, empty `serial.log`. So
   `refused` is much rarer than intuition suggests, and `probe-timeout` — not
   `refused` — is the ordinary signature of a guest that never booted.

2. **The report is written outside the machine directory**, to
   `<dataDir>/failures/`. Every integration test wraps its body in
   `finally { cleanupMachine(name) }` → `remove()`, which deletes the machine
   dir. A report written next to `qemu.log` is gone before CI can upload it,
   *regardless* of `QUICKCHR_PRESERVE_ON_FAILURE`. The report therefore embeds
   log contents rather than pointing at them, and stands alone.

3. **`QUICKCHR_SERIAL_LOG=1` is opt-in, and must stay opt-in.** It adds
   `logfile=` to the serial chardev, which is the only way to see what RouterOS
   printed on a boot that never reaches REST — the socket chardev keeps no
   history, so connecting *after* the failure shows nothing. But serial-console
   provisioning types the generated user password in cleartext, so the log is
   secret-bearing. CI turns it on because CI passwords are per-run throwaways.

4. **Timeouts report where they stalled.** `monitorCommand()` records
   connect → first byte → prompt → command written → response first byte, and
   renders them into the timeout message. "Monitor command timed out" alone
   cannot distinguish #80's hypotheses (monitor never greeted us vs. QEMU took
   the command and went quiet); the phase line can, at the cost of a few
   `Date.now()` calls on the success path.

Two more came out of #105/#106, once the reports above proved only *that* #79's
boot went silent:

5. **Evidence has to localize, so the capture is layered by invasiveness.**
   Host-side per-port classification from `info usernet` (read-only, always on)
   → read-only guest snapshot over the serial console, which stays reachable
   while REST is dead (on whenever a serial channel exists) → the counting-rule
   probe, which writes a mangle rule into the guest and is therefore opt-in via
   `QUICKCHR_DEEP_BOOT_DIAGNOSTICS=1` and skipped under
   `QUICKCHR_PRESERVE_ON_FAILURE=1`. Each layer answers a question the one
   before it cannot: `SYN_SENT` proves a silent drop but not *whose*; only a
   counter inside the guest separates "RouterOS dropped it" from "it never
   arrived". The RouterOS-side details (why mangle `passthrough` and not a
   filter `accept`, why conntrack cannot answer this) live in
   `provisioning.instructions.md`; the `info usernet` reading in
   `qemu.instructions.md`.

   The same secrecy rule as (3) applies one level up: guest payloads go to the
   JSON report, and only credential-free shapes — row counts, booleans, a
   verdict — reach the thrown error, which CI echoes into public job logs.

6. **A timeout that outlives the forensics is not optional.** Integration tests
   hardcoding `300_000` against a 480 s same-arch TCG budget meant bun killed
   the test before `waitForBoot()` gave up, so the capture above never ran —
   four #79 reproductions arrived bare for exactly this reason. Tests derive
   their timeout from `defaultBootTimeout()` plus `BOOT_FORENSICS_BUDGET_MS`
   (`test/integration/timeouts.ts`), and a unit test asserts the invariant
   across every arch/accel combination. Note this is alignment, not tuning: the
   4× same-arch TCG factor is still ~11× the measured 44 s worst case, and
   cutting it needs its own measurement of the package-install and device-mode
   paths (#106).

Corollary for CI cache keys: `actions/cache` only saves when the primary key
*missed*, so a static key means a newly-resolved RouterOS version re-downloads
every run forever (#91). Primary keys must carry a rotating component, with
`restore-keys` doing the prefix fallback.

7. **The boot probe can break the service it is waiting for.** `restGet()`
   implements its deadline as `req.destroy()` — a mid-flight TCP teardown. On
   its own that is harmless. But an aborted request **followed by a completed
   one** corrupts RouterOS `www`: the guest then resets incoming connections,
   and a surviving connection is answered with the status computed for the
   *aborted* request. An `admin:` probe carrying a valid empty password comes
   back `401`. `waitForBoot()` emits exactly that pattern — overrun a probe,
   then succeed on the next one 2 s later.

   Reproduced deterministically on RouterOS 7.21.5, x86/HVF **and** arm64
   cross-arch TCG, with `curl` in a separate process confirming it is
   guest-side rather than a Bun `node:http` artifact
   (`test/lab/www-abort-damage/`). Two consequences for design: a slow-but-alive
   REST layer must be **waited for** rather than aborted, and backing off after
   consecutive aborts matters because it is the abort/complete *alternation*,
   not the abort count, that does the damage. It also means a REST status
   received after any timeout in the same conversation cannot be trusted to
   belong to the request that received it — the #69 surface.

   Bounds, so this is not over-read: locally `www` recovers in 1.3–2.4 s, so the
   *permanent* wedge in #79's CI forensics is **not** reproduced, and neither is
   whatever made CI's very first probe exceed 3 s — locally a failed login
   answers in ~125 ms even at `cpu-load: 100`. Both remain open, and the entry
   point to the CI failure is still the unexplained one.

8. **Readiness is not the end of the evidence.** Items 1–7 all instrument a boot
   that never became REST-ready. #69 is the other shape: the machine boots, it
   passes `waitForBoot()`, and a *later* request is reset — so every instrument
   above was skipped and the failure arrived as one line of `ECONNRESET`.
   `captureRunningFailure()` (`quickchr.ts`) runs the same instrument set against
   a machine that is still up, and does not stop, remove, or otherwise touch it —
   the caller owns the lifecycle and may well keep testing against it.

   What that capture has to add is a `trigger` (`FailureTrigger` in
   `diagnostics.ts`): the failed operation, the error with its `code`/`errno`,
   `sinceReadyMs`, and the credential transition that preceded it. Without it the
   report is a healthy machine with no statement of what broke — a boot-failure
   report explains itself through `restProbe`, and here `restProbe` is null. The
   transition is **stated by the caller, never inferred**: the capture can see
   the auth header, but "who this request authenticates as" and "what just
   changed on the guest" are different facts, and only the caller knows the
   second. An absent field reads as "not recorded", which is true; a guessed one
   would send the next reader down a path nobody actually took.

   The two report kinds share one directory and one 20-file cap, which is why
   pruning orders by the timestamp *inside* the filename rather than by the
   filename: whole-name sort was chronological only while every report carried
   one prefix, and would now rank every `post-readiness-failure-` above every
   `boot-failure-`, deleting the newest of one kind to keep the oldest of the
   other.

   Building that capture surfaced a defect in the instrument it reuses. The
   guest snapshot's per-query budget was sized on the ~0.3 s cost of a command on
   an already-open console session, which left the **~11.4 s serial login**
   unbudgeted — and `bootFailureGuestExec()` then *divided* that budget across
   credential candidates, so no attempt could finish logging in. No credential was
   ever marked working, every later query repeated the same split, and the
   snapshot spent its full 60 s to report `consoleReachable: false` about a guest
   answering serial in 10 ms. It read as a broken console, which is why it went
   unnoticed on the boot path: there the guest usually *is* unreachable, so the
   wrong answer looked right. Queries now carry a login allowance until one of
   them answers (`GUEST_LOGIN_ALLOWANCE_MS`), with the per-candidate floor raised
   to the measured login cost; the 60 s snapshot budget and the 180 s forensics budget
   are unchanged, so no test timeout moves. Locally this took a capture from
   61.1 s / 0 of 7 queries to 14.4 s / 7 of 7.

   The general rule, since it is not obvious from the numbers: a console budget
   has two parts that differ by ~40×, and only one of them is a round-trip.
   `provisioning.instructions.md` has the phase-by-phase measurements.

   Test-side, the same finding forces a single REST client:
   `test/integration/chr-rest.ts` is the only way integration tests reach CHR.
   Tests previously used Bun `fetch()` and, in one file, a hand-rolled `node:http`
   copy of `restGet` — two extra connection-reuse policies that made "client or
   guest?" unanswerable on every reset. Collapsing them is **confound removal,
   not a fix**: run 30507484030 reset through `restGet` too, and
   `test/lab/bun-pool/` never reproduced the pooling bug it was built to catch.

## Design Principles

### Scope Boundary — QEMU Expert, Not Orchestrator

quickchr manages individual CHR instances. Multi-router topologies, test matrices, and workflow orchestration are **out of scope** for the CLI and library. Provide `examples/` as runnable scripts (a `bun run`-able `<name>.ts` plus `.sh`/`.ps1`/`.py` siblings; `grounding/` is the one `bun:test` reference) to inspire, but don't build a framework. The example convention lives in `.github/instructions/examples.instructions.md`; Python examples prefer `uv run` over a venv. Users (and their AI agents) compose quickchr instances into whatever topology they need — we give them reliable building blocks.

### Networking — Discover, Don't Configure

For advanced networking (TAP interfaces, bridges), quickchr **discovers and presents options** but does not manage OS-level network configuration. On macOS, vmnet is straightforward (root + a QEMU flag). On Linux, TAP requires editing system files that vary by distro and network manager — that's the user's domain. quickchr will enumerate available interfaces, generate the correct QEMU flags, and link to tikoci docs for setup guides.

#### SLiRP hostfwd — Why User-Mode Must Be ether1

QEMU SLiRP (`-netdev user`) hostfwd **requires** the guest to have an IP address (default `10.0.2.15`) on the SLiRP-connected interface. Without it, `hostfwd` accepts TCP connections on the host side (creating a half-open state) but the guest never receives data — HTTP requests hang until timeout.

RouterOS auto-creates a DHCP client only on ether1. SLiRP includes a DHCP server that assigns `10.0.2.15`. Therefore **SLiRP must be ether1** for zero-config provisioning. This is why `user` is always the first network in multi-NIC configurations.

When adding shared/bridged as ether2+, a manual DHCP client is needed:
```
POST /rest/ip/dhcp-client/add
{"interface":"ether2","use-peer-dns":"yes","add-default-route":"yes","default-route-distance":"2"}
```

The `default-route-distance=2` ensures the shared route is backup — SLiRP ether1 remains the primary gateway, avoiding ECMP dual-gateway side effects.

**TCG hazard:** SLiRP half-open connections (TCP connect succeeds, data never flows) burn the full per-probe HTTP timeout in `waitForBoot`. Under cross-arch TCG where TCP round-trips are slow, this compounds badly. Lab: `test/lab/slirp-hostfwd/`.

#### Host-Side L2 Capture (MNDP, MAC-Telnet)

A caller can receive the guest's raw Layer-2 frames — RouterOS MNDP (UDP/5678
broadcast) being the first use case — without root or a native helper, using the
TCP `socket` netdev: the host runs a TCP server, the CHR gets a `socket-connect`
NIC, and QEMU streams every guest frame to the host length-prefixed (4-byte BE
length + raw Ethernet). Loopback-only, cross-platform. Writing a frame back over
the same connection injects L2 into the guest (the MAC-Telnet primitive). Recipe:
`docs/mndp.md`; example: `examples/mndp/`.

**Discovered constraint (2026-06-06):** the `socket-mcast` netdev — the documented
multi-VM L2 path — is **broken on macOS**. QEMU's mcast socket sets only
`SO_REUSEADDR`, while macOS/BSD need `SO_REUSEPORT` on every socket sharing a
multicast port; two CHRs on one group don't discover each other and host capture
gets nothing. mcast still works on Linux/CI. Prefer `socket-connect` for host
capture on any platform. Evidence: `test/lab/mndp/REPORT.md`.

#### Guest→Host UDP via the Gateway — No Forward (discovered 2026-06-25)

The dual of `hostfwd`: SLIRP's gateway `10.0.2.2` *is* the host from inside the
guest, so guest-originated UDP to `10.0.2.2:<port>` reaches a host socket bound on
loopback `<port>` with **no forward and no extra NIC**. This generalizes the TZSP
path (`tzspGatewayIp`/`captureInterface`) — it is not TZSP-specific and reaches an
ordinary bound socket, not just a `tshark`/pcap capture. The host socket **must be
unconnected**: SLIRP re-emits from a rewritten loopback source
(`127.0.0.1:<ephemeral>`), which a `connect()`-ed socket would filter. This closed
centrs' btest UDP-coverage gap (issue #18) with no new quickchr feature — it was a
discoverability gap. Recipe: `docs/networking-recipes.md`; evidence:
`test/lab/gateway-udp/REPORT.md`.

#### Port-Range Forwards — Explicit-Host-Only

`--forward`/`extraPorts` accept a range (`name:hostStart-hostEnd[:guest…][/proto]`)
that expands to one `PortMapping`/`hostfwd` per port — QEMU has no native range.
**Design choice:** range host ports must be *explicit*. Auto-allocation draws from
the per-instance 10-port block, which cannot guarantee a contiguous run; requiring
explicit host ports keeps the change additive (the existing
`validateExplicitExtraPorts` collision check covers them) and avoids reworking the
port-block contract. A 64-port cap bounds the generated `hostfwd` string. For
guest-chosen *unpredictable* ports, the gateway path (above) is the better fit for
the guest→host direction. `expandForwardSpec` is the range-aware entry point;
`parseForwardSpec` stays single-port for backward compatibility.

### Platform Priority

macOS → Linux → Windows. Mac and Linux share most code paths with minor `#ifdef`-style branches. Windows is tracked but lower priority — larger RouterOS admin audience there, but fewer recipes and harder to test. Windows CI runner planned after the existing macOS/Linux matrix is stable.

### CLI Design

Tighten before expanding. New subcommands (`logs`, `exec`, `console`) wait for a full command tree review. The CLI should be discoverable without paging `--help` — shell completions help more than a long command list. Use multipass and virsh as reference points for symmetry, not to copy.

**Command-surface principles** (locked — these shape every new subcommand):

- **Interactive prompts are confined to `setup`.** Every other command is non-interactive — no selectors. Without a `<name>` argument, print the list + a tip; don't prompt.
- **`start`/`stop` are pure operations** — no wizard, no creation. `add` creates; `setup` is the wizard.
- **`set`/`get` are for machine config, not re-provisioning.** After first provisioning, don't add commands that re-provision — the surface grows and each post-provision capability needs its own RouterOS edge-case testing. A drifted machine is recreated, not mutated (see *Out of Scope*).
- **`--json` on read commands only** — same content as console output (richer metadata OK), pipe-friendly for `jq`. No `--yaml`/`--serialize`/TSV/CSV; callers pipe `--json` through `jq`/`yq`. For `exec`, `--json` wraps the quickchr response — the RouterOS result stays a string (use `:serialize` in-script for structured RouterOS output).

### RouterOS Verification

Always read back what we write. One extra REST API call after a provisioning action (license, user, package) catches version-specific command drift early. Surface errors with actionable hints rather than silent failures.

### Provisioning Scope

quickchr provisions at first boot and (optionally) on restart:
- **User creation** — create user, set password, optionally disable admin
- **Package install** — SCP `.npk` files, reboot to activate
- **License** — `/system/license/renew` for trial
- **Device-mode** — `/system/device-mode/update mode=rose container=yes ...` for restricted features (containers, traffic-gen, routerboard). Opt-in: not configured unless explicitly requested via CLI `--device-mode` or API `deviceMode` option. CHR ships with `mode=advanced` which is sufficient for most use cases. Device-mode requires a hard QEMU power-cycle to confirm changes — this is the MikroTik-mandated confirmation mechanism (physical button press on real hardware, cold reboot on VM). The wizard defaults to `rose` when the user opts in, since it enables containers. See: https://help.mikrotik.com/docs/spaces/ROS/pages/93749258/Device-mode
- **Config import** — planned: load `.rsc` or `.backup` at creation time

Provisioning via REST API is preferred (simple HTTP calls). Serial console provisioning (prompt detection + buffer tracking, as in chr-armed) is a fallback for locked environments. Key lessons from chr-armed serial work: use `\r` not `\r\n` on PTY; accumulate buffer with offset tracking to prevent re-matching; detect prompts dynamically, don't use fixed delays.

### Exec Transport Design

`quickchr exec` supports multiple transports via `--via=auto|ssh|rest|qga`:
- **auto** (default) — currently REST only; future: try SSH first, fall back to REST `/execute`
- **rest** (implemented) — POST to `/rest/execute` with `{"script": "<command>"}` (RouterOS 7.1+). No SSH needed. 60-second server-side timeout. Uses `resolveAuth()` for smart credential resolution.
- **ssh** (planned) — full RouterOS CLI, supports interactive commands, requires `sshpass`
- **qga** (implemented) — QEMU Guest Agent commands (x86 only today, ARM64 pending MikroTik fix)

**Credential resolution** (`src/lib/auth.ts`): Priority order is (1) explicit `--user`/`--password` override, (2) provisioned user from `machine.json` (`state.user`), (3) CHR default `admin:` (empty password). Both `exec()` and `rest()` on ChrInstance use this.

**`machine.json` holds two kinds of fact, and `clean()` is where they diverge.**
Some fields describe the *guest* — what accounts exist on the disk right now:
`user`, `managedSshKey`, `disableAdmin`, and the per-instance entry in the secret
store. Others are *intent* — what to apply the next time provisioning runs:
`packages`, `deviceMode`, `secureLogin`. `clean()` replaces the disk with a fresh
image, so every guest fact it recorded is false the moment it returns, and nothing
restores them: a post-`clean()` `start()` reaches `_launchExisting(…, undefined)`
because `lastStartedAt` is set, so the erased account is never recreated. `clean()`
therefore clears the guest facts (and deletes the managed keypair under
`<machineDir>/ssh/`) and keeps the intent. Without that, credential resolution
kept preferring `state.user` and authenticated every REST call, exec, and SCP as a
user RouterOS had deleted — the confound removed ahead of #79's QEMU-version
experiment. Priority (3) above is what a cleaned machine must land on, and it is
the truth of a fresh CHR image: `admin` with an empty password.

Output formatting via `--json` flag. RouterOS trick for structured output: wrap commands in `[:serialize to=json [<routeros-cmd>]]` to get JSON from any CLI command. For REST-to-CLI mapping, see tikoci/restraml `lookup.html`.

### Examples Philosophy

`examples/` is **load-bearing agent-onboarding surface** — agents (and humans) open
it before `src/lib/`, so a *wrong* example is worse than none: it teaches the wrong
lesson and costs a later code-review to unwind. Examples are therefore held to the
same bar as the code, and a broken one **gates** extended verification.

Each `examples/<name>/` is a **runnable artifact that does something real** against a
CHR, in the canonical shape (full rules in
[`.github/instructions/examples.instructions.md`](.github/instructions/examples.instructions.md)):

- **`<name>.ts`** — the **primary**: a `bun run`-able script using the library API,
  with `runExample()` guaranteeing teardown (success *or* failure). Not a test.
- **`<name>.sh` / `<name>.ps1`** — the **CLI** mirror (POSIX `sh` + PowerShell),
  sourcing `examples/common.{sh,ps1}`. New examples ship both; existing ones add
  `.ps1` where the CLI flow is simple.
- **`<name>.py`** — optional CLI driver for a non-TS audience, run with `uv run`.
- **`grounding/` is the one `bun:test` example** — kept as a test on purpose, because
  there the *assertions are the documentation* (it's the "how to write CHR
  integration tests" reference). Everywhere else, an agent can wrap a script in
  `test()` trivially, so a runnable script is the better teaching surface.

Makefiles were the old convention and are now **disallowed** in `examples/`
(`scripts/validate-examples.ts` enforces this) — they mixed CLI orchestration with
raw `scp`/`curl`/`ssh`, the opposite of "one example teaches one capability."

Building examples early is a form of "anchor testing" for the CLI surface — it finds
ergonomic issues before we commit to new commands, and each gap surfaced gets a
"friction found" note in the example's README plus a GitHub issue rather than
being papered over (this pass surfaced the missing `quickchr cp`, #23). Coverage of
the CLI/library surface is tracked in
[`examples/COVERAGE.md`](examples/COVERAGE.md).

### Agent-Friendliness — Discoverability Over Features

A recurring lesson from downstream agents (donny, centrs, restraml): the friction is usually **finding** an existing capability, not a missing one. Issue #18 (centrs) nearly rebuilt UDP forwarding, `socket-connect` L2, and the guest→host gateway — all of which already existed — because the only way to choose among them was reading `src/lib/network.ts`. Rule: **every CLI-documented capability also needs a library-facing, by-goal surface** — JSDoc at the call site, a by-goal recipe (`docs/networking-recipes.md`), and coverage in the `routeros-quickchr` skill — or agents won't find it. Connection handoff for harnesses goes through `ChrInstance.descriptor()` / `quickchr inspect` / `quickchr env` (credential-bearing by design), never by reading `machine.json`. The structured, versioned **descriptor v1** contract that centrs consumes for `--quickchr` targets — its shape, per-service semantics, scope boundaries, and staged implementation plan — is specified in [`docs/centrs-interface.md`](./docs/centrs-interface.md) (issue [#71](https://github.com/tikoci/quickchr/issues/71)).

### Out of Scope (decided)

Explicitly rejected, with rationale, so they aren't re-proposed:

- **Cloud deployment** — `tikoci/chr-armed` already does OCI + AWS; revisit only once local CHR is solid and provisioning/image layers can be reused.
- **Multi-CHR orchestration** — building blocks + `examples/`, not a framework (see *Scope Boundary*). Users/agents compose topologies.
- **`quickchr upgrade <name>` / post-provisioning mutation** — replaced by a future *config audit/verify* report (flags drift; user recreates). Re-provisioning has too many failure modes (version bumps, rotated creds, changed deps).
- **Packaging (Homebrew/Deb)** and **service management (launchd/systemd)** — lower priority than core; optional later.
- **Machine templates** — CLI flags are the template, API objects are reusable, the wizard always prompts. No separate template system.
- **`--no-ansi` flag** — ANSI is fine in text output (`grep`/`jq` cope); the real discipline (separate presentation from content) belongs in a centralized error-message surface instead.
- **`machine.json` → YAML** — staying JSON (pretty-printed) for `jq` users; YAML adds complexity without a matching benefit.
- **Separate multi-version/arch matrix runner** — the CI matrix + `examples/version-matrix` cover it.

### Document Maintenance

DESIGN.md is the living home for design decisions and rationale. At the end of any significant work session, agents should review whether new implementation details, design decisions, or discovered constraints belong here. Open/close work in **GitHub Issues** — not BACKLOG.md (see CONTRIBUTING.md "Tracking work"); record grounded RouterOS/QEMU behaviour facts in the narrowest scoped doc (`.github/instructions/*.md`, `docs/`, or `test/lab/<topic>/REPORT.md`); add a CHANGELOG.md entry for user-facing changes. Treat this as a lightweight checklist, not a gate.
