Skip to content

Support macOS 26 + macOS 27 from one binary: availability-gating strategy for OS-27 FoundationModels APIs #205

Description

@Arthur-Ficial

Problem

When this dev machine moves to macOS 27 and we build apfel on the newer Xcode/SDK, and especially when we start adopting OS-27-only FoundationModels APIs (e.g. the server-side LanguageModelSession / LanguageModelExecutor provider protocol that anthropics/ClaudeForFoundationModels conforms to, explored on the experiment/anthropic-messages-api branch), we must not drop macOS 26 (Tahoe) support. This issue records the researched best-practice strategy so the move is deliberate, not accidental. No code change yet - this is the plan.

TL;DR (the strategy)

Ship one universal binary whose deployment floor stays at macOS 26, built on the newest SDK, with every OS-27-only API guarded by @available(macOS 27, *) + if #available(macOS 27, *) { new } else { honest macOS-26 path }. FoundationModels exists on both 26 and 27 (only the APIs differ), so this is a runtime-availability problem (#available), not a module-existence problem (canImport). Weak linking resolves the OS-27 symbols to null on macOS 26; the #available guard guarantees we never touch them there.

Why this is the right approach (and what to avoid)

  • One binary, not two. Every surveyed Swift project (Sparkle, Ice, Rectangle, Stats, Kingfisher, supabase-swift) ships a single artifact targeting the oldest supported macOS and branches at runtime. Two binaries = distribution drift; avoid.
  • Build on newest SDK, deploy to oldest target. Package.swift platforms: is the deployment floor, not the SDK. Build with the macOS 27 Xcode/SDK (only it has the OS-27 declarations) while the floor stays at macOS 26. Weak linking handles the rest. (Apple: Frameworks and Weak Linking)
  • platforms: is a single floor, NOT a list. Do not write platforms: [.macOS(.v26), .macOS(.v27)] - that is not how it works. Keep exactly platforms: [.macOS(.v26)] and let the floor never drift up when the build SDK changes.
  • Use if #available, not #if canImport(FoundationModels) for the 26-vs-27 split. The framework imports on both OSes; only specific APIs are new. canImport is only for a platform that lacks the framework entirely (e.g. a future Linux ApfelCore, where it already pairs with the existing import guards).
  • Do NOT use @backDeployed to get Apple's OS-27 types onto macOS 26 - SE-0376 only back-deploys your own function bodies, never new Apple types/protocols (their implementation lives in the OS). (SE-0376)
  • @available is a promise to the compiler, not a runtime guard. The actual OS check must be an if #available on the call path, or it crashes on macOS 26.

apfel is already ~80% set up for this

  • Package.swift:7 already pins platforms: [.macOS(.v26)] (single floor - correct).
  • Sources/TokenCounter.swift already uses the exact pattern: if #available(macOS 26.4, *) { real token count } else { chars/4 fallback } - extend the same way for macOS 27.
  • /health already reports runtime model state via SystemLanguageModel.default.availability mapped to ModelAvailability (.available / .appleIntelligenceNotEnabled / .deviceNotEligible / .modelNotReady) - this capability check is orthogonal to the OS-version gate and stays as-is.
  • Homebrew depends_on macos: :tahoe means ">= macOS 26", so it already admits macOS 27; one notarized tarball already serves both. No distribution change needed.

Action checklist (when we adopt OS-27 APIs)

  • Keep the floor pinned. Leave Package.swift at platforms: [.macOS(.v26)]. Add a make preflight assertion that the built binary's min-OS load command is still 26.0 (e.g. vtool -show / otool -l | grep -A4 LC_BUILD_VERSION) so a release built on the macOS 27 SDK can never silently ship a 27 floor.
  • Gate every OS-27 API. Annotate new types/functions @available(macOS 27, *); guard call sites with if #available(macOS 27, *) { ... } else { ...honest macOS-26 path... }. The else aligns with apfel's "honest about limitations" principle (degrade or honest-501, never crash).
  • Isolate the OS-27 surface behind a protocol + factory (Kingfisher DisplayLinkCompatible / supabase-swift _Clock pattern): an always-available protocol, an @available(macOS 27, *) conforming type, a macOS-26 fallback type, and one if #available factory. Keep ApfelCore and the CLI version-blind - no @available sprinkled across the codebase. (Note: gate the conforming type, never the conformance - conformances cannot be made conditional on OS.)
  • CI matrix for N and N-1. Adopt Sparkle's include: matrix shape (pair each runs-on: macos-NN image with a pinned DEVELOPER_DIR/Xcode), adding macos-27 alongside macos-26 once the GitHub-hosted image exists. Buys build + unit-test coverage on both; model-dependent integration tests remain local-only (hosted runners have no Apple Intelligence) - unchanged from today.
  • Document the OS support policy. A short README/SUPPORTED.md line: "apfel supports macOS 26+; OS-27-only features are additive and runtime-gated" (cf. Sparkle/Rectangle README policies). If we ever raise the floor, keep a pinned older release for macOS 26 users.
  • Real-hardware verification (the only way to prove it): run the branch-selection on a macOS 26 machine (legacy path runs, OS-27 path never entered, no crash) AND a macOS 27 machine (new path selected and works). GitHub CI cannot exercise this.

References


Filed from research on the experiment/anthropic-messages-api work, where the macOS-27 blocker first surfaced: the ClaudeForFoundationModels library requires OS 27 and cannot build on macOS 26, so apfel's /v1/messages wire support is verified at the protocol level today and the full library round-trip waits on macOS 27. No code change in this issue - planning only.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions