Skip to content

ADR-0007 — macOS as a first-class platform

  • Status: Accepted (2026-07-25)
  • Related: ROADMAP v0.9, ADR-0001 (platform abstraction), ADR-0004 (plugin contract), ADR-0005 (cross-distro package identity), COMPATIBILITY.md
  • Supersedes in part: ADR-0001’s assumption that a “platform” is a Linux distribution, and ROADMAP’s placement of macOS in the post-1.0 1.x line.

ADR-0001 introduced Platform and PackageManager precisely so that macOS and Windows would later be “just new impls”. That claim is now being tested. Two years of Linux-only implementation left assumptions that are correct on Linux and unmarked as Linux-specific:

  • MachineMetadata.distro_family is a closed serde enum. Every host is assumed to belong to a Linux distribution family; there is no os axis at all.
  • rivet_platform::discover_machine reads /etc/os-release unconditionally.
  • resolve_window_manager probes XDG_CURRENT_DESKTOP and Wayland compositor sockets.
  • registry::detect fails with “unsupported Linux distribution”.

None of these are deep. The genuinely hard part of macOS is not detection or package management — it is that ~/Library is simultaneously where every application’s configuration lives and where its credentials, cookies, and caches live, on a filesystem that is case-insensitive by default, behind a permission system (TCC) that returns EPERM for paths a stat says exist.

A second forcing function: v0.9 is the format-freeze milestone. Any change to the on-disk model is dramatically cheaper now than after v1.0 publishes a compatibility guarantee.

1. Split the OS axis from the distro axis.

MachineMetadata gains an os_family field. distro_family keeps its exact meaning — which Linux distribution family — and reads Unknown on macOS.

pub enum OsFamily { Linux, MacOs, Unknown }

Modelling macOS as another DistroFamily variant was the cheaper edit and is wrong: it would make DistroFamily::MacOs flow into the cross-distro package map, the /etc/os-release parser, and every match that reasons about Linux package managers. Two orthogonal facts get two fields.

2. Both enums become forward-compatible.

OsFamily and DistroFamily both gain #[serde(other)] Unknown. Without this, a v1.0 binary reading a snapshot from a later Rivet that added a variant (Windows, or a new distro family) fails deserialization outright rather than degrading. This is the single most time-critical item in the macOS track: it must land before the format freeze, because after v1.0 the failure mode is permanent for every binary already in the wild.

3. Parsers are portable; only execution is cfg-gated.

Every macOS module splits into a pure parsing function and a thin command-running caller. The parsers compile and unit-test on all hosts; only the exec:: calls sit behind #[cfg(target_os = "macos")]. This matches how pacman.rs/flatpak.rs are already structured, and it means Linux CI catches a brew info --json=v2 parsing regression without a mac runner.

4. Homebrew is the native manager; MAS and MacPorts are secondary.

MacOsPlatform::package_managers() returns HomebrewManager, MasManager, and MacPortsManager. Homebrew’s native_manager id is "brew", which becomes a new column in the ADR-0005 curated package map — ADR-0005 already anticipated this (“the same logical concept later absorbs Homebrew”). Casks and taps are additive PackageManifest sections, not overloaded onto native: a cask is not a formula and restoring it needs a different command.

Restore issues explicit per-package brew install calls rather than generating a Brewfile and shelling out to brew bundle. Brewfile would be less code, but it collapses plan() into a single opaque action and Rivet’s whole restore UX is built on a reviewable action list.

5. ~/Library is opt-in per path, and the Keychain is permanently out of scope.

ADR-0004’s rule — plugins declare small reviewable roots, never scan $HOME — is what makes macOS support safe at all, and it is enforced harder here:

  • Every new root under Library/ is audited individually against a deny list (Library/Keychains, Library/Cookies, Library/Containers, known token blobs).
  • Rivet never invokes security(1) and never exports Keychain material, under any flag. Secrets belong in the ADR-0003 vault, placed there deliberately by the user.

6. macOS-specific failure modes get first-class diagnostics, not raw errno.

  • A TCC denial (EPERM on a readable-looking ~/Library path) is translated into an instruction to grant Full Disk Access to the terminal.
  • A case-insensitive destination volume that would collapse two distinct snapshot entries (Foo.conf and foo.conf) refuses the restore rather than silently writing one over the other. This has no Linux analogue and is a genuine data-loss path.
  • Extended attributes and resource forks are not preserved; this is stated on the restore page rather than discovered.

7. macOS ships in v0.9, and v0.9 is rescoped.

v0.9 was “feature-frozen Linux RC”. It becomes “macOS parity, then RC”. The alternative — hold macOS for v1.1 — keeps the existing freeze intact but forces either a MachineMetadata format break in a 1.x minor or a permanently closed enum. Doing it before the freeze is the cheaper order of operations.

  • DistroFamily::MacOs. One-line change; conflates two orthogonal facts and leaks macOS into every Linux package-manager match. Rejected.
  • A separate rivet-macos crate. Cleaner isolation on paper, but the managers are 80–150 lines each and share package_lines and the Platform registry with their Linux peers. A crate boundary here buys nothing and splits the registry. Rejected; modules in rivet-platform instead.
  • brew bundle for capture and restore. Fewer moving parts, but an opaque single-action restore plan. Rejected — see decision 4.
  • Blanket defaults export -g for system preferences. Captures the entire global domain including window positions, recent-document lists, and hardware-specific state, which then restores onto different hardware. Rejected in favour of per-domain exports.
  • Capture the Keychain via security export. Requires interactive authorization per item, produces material Rivet’s threat model says it must never hold, and duplicates the vault. Rejected permanently, not deferred.
  • Ship macOS as x86_64-only and rely on Rosetta. Works, but a Rosetta Homebrew reports the /usr/local prefix and the wrong architecture, which poisons the captured metadata. Native builds for both architectures, fused into a universal binary. Rejected.
  • Positive: the OS axis exists before the format freezes, so Windows later is additive rather than breaking.
  • Positive: ADR-0001’s central bet is validated — macOS is genuinely impl + data, with no redesign of the trait seams.
  • Positive: brew in the package map makes Linux→macOS migration real, which is the most-requested direction for a laptop-refresh workflow.
  • Cost: the curated package map roughly doubles in maintenance surface.
  • Cost: CI grows two macOS runners; release builds grow a lipo step.
  • Risk: ~/Library coverage is a long tail. The deny list is a safety net, not a guarantee — each new plugin root remains a review item, and the docs must say that capture is deliberately incomplete rather than imply parity with a full-disk backup tool.
  • Risk: without an Apple Developer ID, downloaded binaries are quarantined by Gatekeeper. Mitigated by making a Homebrew tap the primary install channel (tap-installed binaries are not quarantined); notarization stays optional.