# ADR-0035: OS-Gated ASR Backends with Auto-Upgrading Defaults

## Status

✅ Accepted (2026-05-25)

## Context

[ADR-0033](adr-0033.md) picked `candle` as the production ASR runtime for
[#802](https://github.com/rust-works/omni-dev/issues/802) and
[ADR-0034](adr-0034.md) picked `tract-onnx` for speaker embedding
([#805](https://github.com/rust-works/omni-dev/issues/805)). Both are pure-Rust
and cross-platform, so their coexistence in the production binary is
unconstrained: any CI runner builds them, any user host runs them.

[#873](https://github.com/rust-works/omni-dev/issues/873) is measuring
**Moonshine v2** via [`mlx-audio`](https://github.com/Blaizzy/mlx-audio) as a
streaming-native ASR baseline. Moonshine's architecture is sliding-window
self-attention applied at *training* time — i.e. streaming and offline inference
use the same kernels, and there is no length-dependent WER drift. The published
numbers (245 M params, 6.65 % English-avg WER, 107 ms MacBook Pro latency) put
it between sherpa-onnx Zipformer (6.81 %) and parakeet-mlx offline (3.65 %) on
quality, with fundamentally different streaming-vs-offline behaviour than
Whisper or parakeet.

**MLX is Apple-Silicon-only.** It binds to Metal Performance Shaders and only
runs on `target_os = "macos"` + `target_arch = "aarch64"`. Once MLX-requiring
code enters the workspace, the project either:

1. Loses cross-platform builds — `cargo build` on Linux / Windows / macOS-Intel
   fails because the MLX dep cannot link.
2. Carries a Cargo feature flag that users must enable or disable per platform —
   adds setup ceremony and turns "this binary works everywhere" into "this
   binary works everywhere if you used the right `--features` line."
3. Treats MLX as out-of-process — the Rust workspace stays cross-platform; the
   MLX runtime lives in a sidecar (Python or otherwise) that's only installed on
   Apple Silicon. Failure on unsupported hosts surfaces at *construction* time
   with a clear error, not at build time.

[ADR-0028](adr-0028.md) established (3) as the project's precedent for "risky or
non-portable AI backend": the `claude-cli` backend runs as a sandboxed
subprocess rather than as a linked dependency. That precedent applies here.

The integration-shape question is separate from the build-portability question.
Some MLX-backed models may eventually be available through Rust crates (e.g.
`mlx-rs` is maturing); others (Moonshine via `mlx-audio`) are Python-first. The
project needs a rule for picking the shape *per backend*, not a single shape for
all MLX paths.

Finally, the *default* backend is now a per-platform question. `mock` was the
unambiguous default while no real backend existed. With `whisper-candle`
shipping ([ADR-0033](adr-0033.md)) and an Apple-Silicon-only backend on the
horizon ([#873](https://github.com/rust-works/omni-dev/issues/873)), the default
could:

- Stay `mock` forever (explicit `--backend` for everything real). Reproducible
  across hosts; high friction.
- Upgrade to `whisper-candle` everywhere once it's stable (one cross-platform
  default). Reproducible across hosts; ignores platform-specific wins.
- Upgrade per platform to the best available (auto-upgrade hierarchy). Best UX;
  introduces per-host behaviour differences that tests must pin away.

[ADR-0033](adr-0033.md) §"Out of Scope" deferred "flipping the factory default
from `mock` to `whisper-candle`" until at least one release of the install
convention has shipped, so first-time users without an installed model see a
clear CLI error rather than a confusing missing-file at `transcribe`. This ADR
picks that deferred decision up — the mitigation lives in §3 of the Decision
below.

**The unit of decision in this ADR is the OS-gated backend architecture as a
single coupled choice**, not five orthogonal ones. The descriptor registry
(§1), per-backend integration shape (§2), auto-upgrade default (§3),
`voice doctor` probe (§4), and bounded installer command (§5) each rest on the
others — auto-upgrade needs `is_available_on_this_host()` predicates which
live on descriptors; `voice doctor` is the descriptor registry's observable
projection; the installer's failure-mode story is what makes auto-upgrade's
"sidecar tier is opt-in" guarantee true. Splitting them across multiple ADRs
would force forward references between Proposed records and obscure the
single decision the reviewer is being asked to take a position on.

## Decision

Five policies, taken together.

### 1. Per-backend OS gating happens in the factory, not in the Cargo dep graph

Backends declare their OS / architecture / install-state requirements through a
`BackendDescriptor` value. The factory enumerates *all* known backends
regardless of host and refuses to construct ones whose requirements aren't met
on the current host. Errors include actionable hints (install command,
alternative backend name, or "this backend is Apple-Silicon-only").

Unlike [ADR-0022](adr-0022.md)'s layered model catalog (embedded YAML + user /
project overlays), the descriptor set is bounded by code — adding a backend
requires a Rust PR by construction. ADR-0022's YAML overlays exist because new
Claude / GPT models ship faster than omni-dev releases and users need a way to
declare unknown identifiers without waiting for a release. ASR backends are
tightly coupled to Rust dependencies and trait implementations, so a
runtime-overlayable backend set would be a contract the implementation could
not honour: a YAML row naming a backend whose crate isn't in `Cargo.toml`
cannot produce a working `Transcriber`. The asymmetry is intentional.

Cross-platform backends (`mock`, `whisper-candle`) are unconditional Cargo deps.
Apple-Silicon-only Rust backends, **if** any land, use
`[target.'cfg(all(target_os = "macos", target_arch = "aarch64"))'.dependencies]`
so non-Apple-Silicon builds simply don't pull the dep — no Cargo feature flag
needed.

**Descriptor visibility is a cross-platform contract.** A `BackendDescriptor`
for every known backend is registered unconditionally — descriptor *values* are
never themselves behind a target-cfg, even when their runtime implementation is.
This is what lets `voice doctor` enumerate the full set on every host and tell a
Linux user *why* `whisper-moonshine-mlx` is unavailable rather than silently
omitting it. Concretely:

- **In-process cross-platform backends** (`whisper-candle`, `mock`): descriptor
  and runtime live in the same cross-platform module.
- **In-process target-gated backends** (hypothetical future Apple-Silicon-only
  Rust backend): descriptor lives in a cross-platform module; the descriptor's
  construction arm `cfg`-routes to either the real `new()` (compiled in on the
  matching target) or to a fixed "not available on this host" error (compiled in
  everywhere else). The target-gated Cargo dep is only referenced from the
  cfg-matching arm.
- **Subprocess sidecar backends** (MLX-backed Moonshine): descriptor is always
  cross-platform; runtime probe is a process / file lookup, not a linked
  dependency. Sidecars enumerate everywhere by construction.

`cargo build` on every supported host always produces the same binary surface
from the user's perspective: same `--help`, same backend names listed in `voice
doctor`. Differences appear at construction time with clear errors, not at build
time.

### 2. Backend integration shape is decided per-backend, not globally

Both patterns are first-class:

- **In-process** for backends whose runtime is a stable cross-platform Rust
  crate (current example: `whisper-candle` via `candle-core`).
- **Subprocess sidecar** ([ADR-0028](adr-0028.md) pattern) for backends whose
  runtime is platform-gated, Python-first, requires elevated trust, or is
  otherwise awkward to link statically. Current example: any MLX-backed
  Moonshine integration whose ergonomic shape is `mlx-audio` (Python).

The Rust core never directly depends on the sidecar's implementation language or
runtime. It speaks to the sidecar over stdin/stdout using the same JSONL event
shape produced today by `voice transcribe` — concretely, serde-serialised
[`TranscriptEvent`](../../src/voice/transcriber.rs) values (event schema per
[#799](https://github.com/rust-works/omni-dev/issues/799) and
[#801](https://github.com/rust-works/omni-dev/issues/801)). The IPC direction
(sidecar → core) is the reverse of the user-facing direction (core → stdout), so
the first sidecar PR shares the deserializer with the user-facing serializer but
introduces the read side; no new wire format.

**Version handshake.** Each sidecar declares its protocol version on stdout
before any `TranscriptEvent` flows; the Rust core refuses to consume events
from a sidecar whose protocol version it does not recognise and surfaces an
"upgrade or reinstall" error pointing at the relevant `voice install-*`
command. The handshake also runs as part of `is_available_on_this_host()` —
an installed-but-incompatible sidecar reports as unavailable in `voice
doctor`, with the version mismatch as the reason, rather than failing
mid-transcribe. This is the analogue of [ADR-0028](adr-0028.md)'s
`claude --version` preflight probe; the concrete byte-level handshake is
deferred to the first sidecar's follow-up issue, but the contract is fixed
here.

Picking the shape:

| Property                                              | In-process Rust crate            | Subprocess sidecar                    |
| ----------------------------------------------------- | -------------------------------- | ------------------------------------- |
| Cross-platform Rust support is mature                 | Required                         | Not required                          |
| Build matrix should stay simple on unsupported hosts  | Required (via target-cfg dep)    | Always — no Rust dep                  |
| Project policy already isolates the runtime           | N/A                              | ADR-0028 precedent                    |

If both columns apply, prefer in-process (fewer moving parts). If either of
the second column's first two rows applies, use a sidecar.

**Cost is a tie-breaker, not a contract row.** Per-call latency is friendlier
to in-process backends; long-lived sidecars amortise spawn cost across a
session but pay a startup tax on first use. When the rule above already
picks an integration shape, cost is informational. When both shapes are
genuinely viable (a mature cross-platform Rust crate exists *and* the
ADR-0028 sidecar policy also fits), prefer in-process for latency-sensitive
batch use and sidecar for long-running streaming where the spawn cost is
once per session.

### 3. Default backend auto-upgrades per platform; tests must pin

Resolution order for the active backend (in priority):

1. Explicit `--backend <name>` flag on the relevant CLI command.
2. `OMNI_DEV_VOICE_BACKEND` env var.
3. **Per-platform best available**, by the hierarchy `whisper-moonshine-mlx`
   (Apple Silicon only, if its sidecar is installed) → `whisper-candle`
   (cross-platform, if its model is installed) → `mock`.

The default *names* live in a registry, not in `match` arms scattered through
the codebase. Each `BackendDescriptor` carries its `is_available_on_this_host()`
predicate; the factory walks the priority list once and picks the first
available.

**Tie-breaking within a tier.** Priority among backends that share a tier
(e.g. a hypothetical future second Apple-Silicon-only ASR backend alongside
`whisper-moonshine-mlx`) is not decided up-front. The priority list is an
ordered Rust constant; introducing a second tier-mate is a code change that
necessarily picks an order, and the choice is recorded in that PR's
description rather than re-litigated as an architectural question. The
ordering attribute matters only when two tier-mates are simultaneously
installable on the same host, which by construction can only happen after a
deliberate code-and-install change.

**Per-command priority lists live in the registry, not in command code.**
The auto-upgrade hierarchy above is the *default* priority list, used by
`voice transcribe`. When [#806](https://github.com/rust-works/omni-dev/issues/806)
introduces `voice listen` (streaming) and the streaming-vs-batch trade-offs
mean a different priority is right for that command, the registry exposes a
*named* priority list per command (e.g. `priority_list("voice.transcribe")`,
`priority_list("voice.listen")`) and each command names the list it consumes.
The list contents — which backend appears in which slot — remain Rust
constants in the registry module so `match` arms cannot re-emerge inside
individual command modules. Adding a new command means naming an existing
list or registering a new one; it never means reaching into descriptor
selection inline.

**Relationship to ADR-0033's deferred default-flip.** ADR-0033 deferred flipping
the default from `mock` to `whisper-candle` to avoid a confusing missing-file
error for first-time users. The auto-upgrade hierarchy here resolves that
concern directly: `whisper-candle` is only chosen when
`is_available_on_this_host()` confirms the model files are present
([`resolve_whisper_model_dir`](../../src/voice/models.rs) + the
`ensure_model_present` check that [`CandleTranscriber::new`](../../src/voice/backends/candle.rs)
already performs). A fresh install with no `voice install-model` run falls
through to `mock` automatically — no confusing error, no per-release waiting
period. This ADR therefore *supersedes* ADR-0033's deferred flip; the
out-of-scope bullet in ADR-0033 can be considered resolved when the registry
factory lands.

**Tests pin `--backend mock`.** The factory's `for_tests()` constructor (or
equivalent) bypasses the auto-upgrade entirely so unit and integration tests are
bit-reproducible across developer machines. It composes with — and largely
obviates — the existing env-var-guarding pattern in
[`src/voice/factory.rs`](../../src/voice/factory.rs)'s `tests::ENV_GUARD`: tests
that previously had to serialise around env-var mutation can call `for_tests()`
without touching the environment at all. The `update-snapshots` skill's
contract already implies this — snapshots must not flap based on host
capability.

This decision is revisitable when the streaming runtime selection
([#806](https://github.com/rust-works/omni-dev/issues/806)) lands. The
auto-upgrade hierarchy may grow streaming-specific arms (`voice listen` may
prefer a different backend than `voice transcribe`).

### 4. A `voice doctor` (or equivalent) capability probe is required

`omni-dev voice doctor` lists every known backend, its availability on the
current host, and the reason it's unavailable when it isn't. Output is JSONL
when piped, markdown when on tty (matching the existing `voice transcribe`
convention).

The probe replaces "users guess why `--backend whisper-moonshine-mlx` fails on
their Linux machine" with a single, scannable status table. It also gives the
factory's auto-upgrade decision an observable trace — users on Apple Silicon who
expect MLX but don't get it see exactly why.

**Relationship to ADR-0007's preflight pattern.** [ADR-0007](adr-0007.md)
established that each command's `execute()` validates prerequisites at the top
before any significant work begins, and [ADR-0028](adr-0028.md) extended that
with a lock-step rule: adding an AI backend updates both `client.rs` dispatch
and [`src/utils/preflight.rs`](../../src/utils/preflight.rs). The voice
subsystem deliberately does not extend `preflight.rs` for ASR backends. The
cost asymmetry that motivated ADR-0007 — wasted AI tokens, mid-rebase failure
requiring `git rebase --abort` — does not apply to `voice transcribe`, whose
failure mode is at worst a re-run on the same WAV. The factory's
`is_available_on_this_host()` check at construction time surfaces the same
actionable errors before any audio processing begins, so the user-visible
guarantee ADR-0007 provides is preserved. `voice doctor` is the explicit
probe; the factory's construction error is the per-invocation check; neither
goes via `src/utils/preflight.rs`. If a future voice command grows an
expensive-before-transcribe phase (multi-file batch, network-backed model
auto-download), revisit this and add a `preflight::check_voice_backend()` arm
in lock-step with the factory.

### 5. The `voice install-mlx-runtime` command's scope is bounded

The MLX-backed sidecar needs an installer command parallel to `voice
install-model` but materially more complex — installing a Python runtime is not
the same UX as downloading safetensors. This ADR mandates the command exists
and is OS-gated; the full design is deferred to the follow-up issue, with the
following bounds:

- **In scope** for `voice install-mlx-runtime`: detecting a usable Python
  interpreter, creating a self-contained venv under
  `~/.omni-dev/voice/mlx-runtime/`, pinning `mlx-audio` and its transitive
  dependencies to versions the Rust core was tested against, refusing to run on
  non-Apple-Silicon with the same actionable error the factory uses, idempotent
  re-run on a fully-installed venv.
- **Out of scope** for `voice install-mlx-runtime`: managing a system-level
  Python install, mutating the user's existing Python environments, downloading
  the Moonshine model weights themselves (those go through the existing
  `install-model` UX, which already handles `hf-hub`-style downloads
  atomically).

The two install commands stay separate because their failure modes are
different — `install-model` fails on network or disk; `install-mlx-runtime` can
additionally fail on missing Python, incompatible Python version, or pip
resolver errors. Conflating them would muddy the error surface and make the
no-op skip path harder to reason about.

**Pinning `mlx-audio` versions in Rust source couples sidecar updates to the
Rust release cadence.** A security or correctness fix in `mlx-audio` cannot
reach users without a new omni-dev release that updates the pin. This is
acceptable for v1 — the alternative (runtime-resolved version ranges) opens
a much larger compatibility-testing surface and undermines the
version-handshake contract in §2. If the cadence ever becomes a real
operational problem, the escape hatch is the same one [ADR-0022](adr-0022.md)
took for models: layer an optional user-side pin file over the embedded
default. Defer that until there is a concrete need.

## Consequences

### Positive

- **One binary surface per host architecture.** No Cargo features to enable per
  platform; no per-platform build artifacts. `cargo install omni-dev` and `cargo
  build --release` work the same way everywhere.
- **Cross-platform CI matrix stays simple.** Linux, Windows, and macOS-Intel
  runners build identically to today. macOS-Apple-Silicon optionally exercises
  sidecar-backed code paths via an additional job.
- **`voice doctor` enumerates the same set everywhere.** The cross-platform
  descriptor registration in §1 means users on every host see every known
  backend, with per-host availability and reasons; descriptors are never
  silently missing.
- **Apple-Silicon users automatically get the best available backend.** No
  flags, no setup beyond the existing `voice install-model` (and a future
  `voice install-mlx-runtime` for the sidecar).
- **ADR-0033's deferred default-flip is resolved without the waiting period.**
  The model-presence check in `is_available_on_this_host()` is the mitigation
  ADR-0033 was holding the flip back for — fresh hosts without a model fall
  through to `mock`, so the concerning failure mode never appears.
- **ADR-0028 pattern reused, not reinvented.** Subprocess sidecars for risky /
  non-portable AI backends is now a project-wide pattern with two real
  applications (`claude-cli` and any MLX-backed ASR).
- **The Rust core stays language-pure.** Python is permitted *inside the
  sidecar*; the workspace's `Cargo.toml` never grows a Python dep or a PyO3
  binding. Sidecar-language drift is a runtime concern, not a build concern.
- **Failure modes are observable.** `voice doctor` makes per-host capability
  explicit; construction errors include install hints; the auto-upgrade
  decision is a printable rationale, not an opaque selection.

### Negative

- **Per-host quality differences become a real failure mode.** Two developers
  on Linux and Apple Silicon may produce different transcripts of the same
  audio under default flags. Mitigated by: (a) the factory's `for_tests()`
  always picking `mock`, (b) explicit `--backend` in any workflow where
  reproducibility matters, (c) `voice doctor` making the difference
  inspectable.
- **More moving parts at runtime.** Sidecar lifecycle (spawn, health check,
  graceful shutdown), IPC framing, version compatibility between Rust core and
  sidecar. Mitigated by reusing the existing `TranscriptEvent` serde shape and
  the ADR-0028 escape-hatch pattern, both of which are already in production.
- **Bug surface shifts from build-time to runtime.** "Doesn't compile" becomes
  "fails at construction with a runtime error." Mitigated by `voice doctor` and
  by acceptance tests in the implementing PR that exercise the unsupported-host
  error path on Linux/Windows runners.
- **Some failures are install-state-dependent, not OS-dependent.** A user on
  Apple Silicon who hasn't run `voice install-mlx-runtime` is in the same
  "backend unavailable" state as a Linux user. The error message has to
  distinguish "this OS doesn't support it" from "this OS supports it but you
  haven't installed it" without becoming a wall of text.
- **The descriptor cross-platform contract is discipline, not a compiler
  guarantee.** Nothing stops a future contributor from hiding a descriptor
  behind a `cfg` and quietly breaking the "every host sees every backend in
  `voice doctor`" property. A doctor-output snapshot test (asserting the
  expected descriptor set on the canonical OS — see Coverage expectations) is
  the enforcement mechanism.

### Neutral

- **The Cargo workspace gains no Python dep.** Sidecar implementations may be
  Python, Rust, Go, etc.; the Rust core treats them as opaque subprocesses.
- **Sidecar binaries are not bundled in the release.** Each has its own install
  command. Trades one-shot installation for separable, per-runtime updates.
- **The auto-upgrade hierarchy is per-command, not per-process.** `voice
  transcribe` and `voice listen` may pick differently; that's intentional once
  #806's streaming runtime is chosen.
- **Existing ADRs remain in force.** [ADR-0033](adr-0033.md) (candle ASR) and
  [ADR-0034](adr-0034.md) (tract-onnx speaker embedding) are unchanged; this
  ADR sits above them and describes how additional backends fit alongside.
  ADR-0033's deferred default-flip out-of-scope item is the one exception —
  resolved by §3 above.

## Coverage expectations

Mirrors the by-design gaps in [ADR-0031](adr-0031.md), [ADR-0033](adr-0033.md),
and [ADR-0034](adr-0034.md). Sidecar-backed code paths cannot run in CI on
hosts that lack the sidecar; the in-process unsupported-host construction path
*can* and *must* be covered. The following gaps are uncovered by design and
should not be chased:

| Surface                                                                | Why uncovered                                                                                       |
| ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| Sidecar-backed `Transcriber::transcribe` (spawn + JSONL framing + read) | Needs the sidecar runtime installed on the runner; Apple-Silicon-only sidecars don't run on Linux/Windows CI. |
| `voice install-mlx-runtime` actual Python detection + venv build + pip install | Hits the network and writes to the user's home directory; coupling CI to PyPI is a hazard. The clap-parse layer, format-summary helpers, and target-detection helpers *are* covered. |
| `voice doctor` per-backend availability probes for unsupported hosts   | The dispatch logic and rendering *are* covered with stub `BackendDescriptor`s; the actual availability check for a backend that requires foreign state (sidecar running, model installed) is the gap. |

All other code paths in the OS-gating layer — `BackendDescriptor` registry
construction, factory auto-upgrade decision, construction-time error messages,
`voice doctor` rendering — must have unit tests driven by in-process fixtures
(stub descriptors with synthetic availability predicates). When a coverage gap
appears in those files that isn't on the list above, the right response is to
add a test, not to extend this exception list.

A `voice doctor` golden snapshot test (per
[ADR-0030](adr-0030.md)) is the load-bearing enforcement that the descriptor
registry stays cross-platform: the snapshot must list every backend, with the
Apple-Silicon-only entries carrying a clear "unavailable on this host" reason
when the runner cannot satisfy them. A regression that hides a descriptor
behind `cfg` shows up as a missing row in the snapshot diff.

**macOS-Apple-Silicon is the canonical OS for this snapshot.** It is the
target where every tier of the auto-upgrade hierarchy is realisable
(`whisper-moonshine-mlx` → `whisper-candle` → `mock`), so the snapshot
exercises the widest cross-section of `voice doctor`'s rendering. The
snapshot file is generated on macOS-Apple-Silicon and reviewed there; the
test refuses to regenerate on a non-canonical OS so contributors on Linux or
Intel-macOS cannot inadvertently overwrite it with a narrower output. A
Linux-canonical companion snapshot may be added later — that variant is the
stronger check that an Apple-Silicon-only descriptor has not been hidden
behind a `cfg` (because Linux *must* render it as unavailable rather than
omit it), but it requires `voice doctor` to render stably on a host where
the MLX runtime cannot be installed at all. Defer that work until a
contributor needs it.

## References

- Issue [#873](https://github.com/rust-works/omni-dev/issues/873) — Moonshine v2
  measurement spike; the proximate driver for MLX gating.
- Issue [#806](https://github.com/rust-works/omni-dev/issues/806) — streaming
  ASR; will re-engage the auto-upgrade hierarchy.
- Issue [#799](https://github.com/rust-works/omni-dev/issues/799) and
  [#801](https://github.com/rust-works/omni-dev/issues/801) — `TranscriptEvent`
  schema origin.
- [ADR-0007](adr-0007.md) — preflight validation pattern; this ADR deliberately
  routes voice-backend availability through the factory rather than
  `preflight.rs`.
- [ADR-0022](adr-0022.md) — layered model catalog; contrasted with the
  compile-time-only descriptor registry in §1.
- [ADR-0028](adr-0028.md) — sandboxed subprocess pattern for the `claude-cli`
  AI backend; precedent reused here.
- [ADR-0030](adr-0030.md) — CLI snapshot golden testing for the help surface;
  the same pattern enforces the descriptor cross-platform contract.
- [ADR-0031](adr-0031.md) — `AudioSource` (hardware-capture seam) and the
  original by-design coverage gap pattern.
- [ADR-0032](adr-0032.md) — `AudioInput` / `Transcriber` (ASR seam).
- [ADR-0033](adr-0033.md) — `candle` as the production ASR runtime; this ADR
  resolves its deferred default-flip.
- [ADR-0034](adr-0034.md) — `tract-onnx` as the speaker-embedding runtime.
