Apple Container RLM Backend Design Gate
For ADF: Implement issue #3192 task-by-task; preserve the real Apple-silicon merge gate.
Goal: Add an argv-safe, session-affine Apple Container CLI backend to terraphim_rlm for Apple-silicon macOS 26.
Architecture: A feature-gated Rust CLI adapter implements ExecutionEnvironment, mirrors Docker's per-session affinity, positively probes container system status, and destroys the session container on timeout. Portable tests use an injected fake process runner; real runtime evidence remains mandatory on Apple silicon.
Tech Stack: Rust, Tokio process APIs, Apple container CLI, OCI python:3.11-slim.
Goal
Add an Apple Container execution backend to crates/terraphim_rlm so terraphim-grep RLM work can run in per-container lightweight Linux VMs on Apple-silicon Macs using the CLI installed by:
The backend must preserve the existing ExecutionEnvironment contract, session affinity, timeout/result semantics, capability reporting, KG validation, deterministic fallback, and explicit cleanup. It must not weaken Linux behavior or silently fall back to unisolated local execution without the existing warning.
Why this backend
Apple's container CLI runs one lightweight VM per Linux container, uses Apple Virtualization.framework, consumes/produces OCI images, and is designed for Apple silicon. This gives macOS an RLM isolation path closer to Firecracker's per-workload VM model than Docker Desktop's shared Linux VM.
Authoritative evidence checked 2026-08-10:
- Apple README: per-container lightweight Linux VMs, OCI images, Apple silicon, supported on macOS 26: https://github.com/apple/container/blob/main/README.md
- Technical overview: one VM per container; Virtualization.framework, vmnet, XPC, launchd, Keychain; per-container runtime helper: https://github.com/apple/container/blob/main/docs/technical-overview.md
- CLI reference:
run,exec, CPU/memory, capability drop, read-only root, network, volume,--rm, stop/kill, and JSON status contracts: https://github.com/apple/container/blob/main/docs/command-reference.md - Low-level Swift package: https://github.com/apple/containerization
- Homebrew formula: https://formulae.brew.sh/formula/container (live API showed
1.2.2, bottlearm64_tahoeonly) - Current Apple release inspected: https://github.com/apple/container/releases/tag/1.2.2 (published 2026-08-08)
Current Terraphim ground truth
The extension belongs in terraphim-ai, not the standalone terraphim-grep client crate. terraphim-grep delegates recursive execution to terraphim_rlm.
crates/terraphim_rlm/src/executor/trait.rsdefinesExecutionEnvironment: code/bash execution, validation, snapshots, capabilities, health, cleanup, and per-session teardown.crates/terraphim_rlm/src/executor/docker.rsis the closest lifecycle model: one long-runningpython:3.11-slimcontainer perSessionId,python3 -c/bash -cthrough exec, timeout/result capture, and cleanup.crates/terraphim_rlm/src/executor/mod.rsprobes and selectsFirecracker -> E2B -> Docker -> Localby preference and availability.crates/terraphim_rlm/src/config.rsownsBackendType, default preference, and session affinity.crates/terraphim_rlm/Cargo.tomlgates Docker/Firecracker/E2B backends by feature.crates/terraphim_rlm/tests/backend_demo.rsexercises local and Docker behavior.
Important adjacent constraint: #3188 records that the current Firecracker feature is broken/orphaned. This issue must not depend on repairing Firecracker and must not broaden into #3188.
Design decision
Implement a CLI adapter, not a Rust/Swift FFI binding.
Reasons:
- The user-facing installation contract is
brew install container. - The CLI exposes the required stable lifecycle and execution surface.
- FFI would require a Swift toolchain and macOS linking in the Rust build, expanding portability and release risk without adding required behavior.
- A narrow command-runner abstraction permits deterministic Linux unit tests with a fake CLI while reserving real execution tests for an Apple-silicon macOS 26 runner.
Backend identity and selection
Add BackendType::AppleContainer with canonical serialized/display value apple-container and a backward-compatible serde alias applecontainer if derive behavior previously emitted that spelling.
Default preference:
Firecracker -> E2B -> AppleContainer -> Docker -> LocalAvailability is positive evidence, all of:
- compile/runtime platform is
macos/aarch64; containerresolves onPATHandcontainer system version --format jsonsucceeds;container system status --format jsonexits successfully and reports a responsive API service.
Do not run container system start automatically. It is host administration, may install a kernel or prompt, and lifecycle ownership belongs to the operator. On failure, record a precise tried-reason and continue to Docker/Local.
Configuration
Add minimal backend config to RlmConfig:
apple_container_binary: Option<PathBuf>— optional absolute CLI override; default resolvescontainerfromPATH.apple_container_image: String— defaultpython:3.11-slim.apple_container_cpus: u32— default1; forwarded verbatim as--cpus.apple_container_memory: String— default512M; validated to be a positive integer with an optional case-insensitiveK/M/G/T/Psuffix and no surrounding whitespace, because it is forwarded verbatim as--memory.- Keep the outbound-network policy as-is. CPU and memory are configurable because operators must be able to size a per-session VM for the workload; nothing else is exposed in this issue.
Add BackendSessionConfig { backend: AppleContainer, session_model: Affinity } to defaults. Debug output must not expose unexpected environment values.
Executor lifecycle
Create crates/terraphim_rlm/src/executor/apple_container.rs behind feature apple-container-backend (included in full; no new third-party Rust dependency required).
AppleContainerExecutor holds:
- command runner/CLI path;
- image and resource profile;
DashMap<SessionId, Arc<SessionCell>>to serialize first creation. ASessionCellis aMutex<SessionSlot>plus anAtomicBool"unusable" flag:SessionSlotisActive { bound: Option<name>, pending: Vec<name> }orClosing { pending: Vec<name> }, and the atomic exists so a cancellationDrop— which cannot await the mutex — can quarantine the slot synchronously.pendingholds every name whose deletion is unconfirmed, including a name generated for acontainer runthat failed. A bareOptioncell (Docker's shape) is not sufficient: it cannot distinguish "no container yet" from "this session is being torn down", nor hold a survivor alongside a binding, which are the leaks this design must avoid;- an executor-wide lifecycle
RwLockgate plus a terminalclosingflag, governed by one rule: an owned read permit is acquired before an operation can create, use or reclaim a container — beforecontainer run, beforecontainer exec— and it is held, by the owner of the work, until that operation and every recovery it triggers (timeout, vanished container, runner I/O error, panicked exec, cancellation) has finished.cleanuptakes the write side, and acquiring it is by itself the proof that no earlier operation is outstanding. No recovery registry, no join list and no bounded retry rounds: the permit is the registration, and it exists from before the work starts rather than being installed after it. Lock order is always permit before slot, and no permit holder ever acquires a second permit, so neither deadlock is possible; - validator and capabilities.
Lifecycle semantics:
end_sessionis terminal for that session id. TheClosingtombstone stays in the map, so no concurrent or laterensure_containercan resurrect the session.- Teardown failures propagate to the caller and stay tracked (in
pending), socleanupretries and aggregates them. A container is never dropped from tracking while it may still exist; a containercleanupcannot remove is re-inserted for a repeatedcleanup/Drop.TerraphimRlm::destroy_sessionpropagates the executor's error too, while retry ownership stays with the executor'scleanup. - A generated name is tracked as
pendingbeforecontainer runis spawned, and a session with any pending name refuses to create a replacement. So a failed or timed-out creation cannot leave an anonymous container, and a failed deletion is never displaced by a newer one. cleanupwaits — through the write gate alone — for in-flight creation, in-flight execution, in-flightend_sessiondeletion and every in-flight recovery, including one that has not started yet but whose execution's permit is still held. Once it returns, no cell can be re-inserted into the drained map —end_sessionincluded: it reads the closing flag after acquiring its permit, so one arriving after cleanup, or queued behind cleanup's write gate, creates no tombstone while still owning any container that is already tracked. Then it refuses all further creation. The honest cost:cleanupblocks for as long as an in-flight execution plus its recovery takes.- Execution ownership is runtime-independent from the start: once the lifecycle permit is held and the container is resolved, and before the guest command is launched, the permit and the whole
container execoperation are moved into a dedicated execution owner — an OS thread driving its own current-thread runtime. That owner holds theProcessRunnercall (and therefore the CLI child and its stdout/stderr drain tasks), the outcome, and every timeout / vanished-container / runner-I/O / panicked-exec / cancellation recovery, and it releases the permit only when all of that has finished; the caller awaits a result channel. Establishing ownership before anything can run in the guest is what makes its failure trivial: a runtime that cannot be built or a thread that cannot be spawned returnsBackendInitFailedbefore anycontainer exec, so no guest command was launched and there is nothing to recover — and there is noDrop-time branch that has to choose between releasing the permit early and leaking it. Container resolution runs earlier (ensure_containerprecedes owner creation), so on a fresh session acontainer runmay already have succeeded and bound a container by then; that container stays tracked, bound and reusable exactly as any other resolved session container, and is reclaimed by the ordinaryend_session/cleanuppaths. - Cancelling an execution future therefore fails closed by construction: synchronously the outcome is claimed, the slot is quarantined and a cancellation signal is raised on the CLI call — nothing is spawned, moved or registered, because the owner already exists. The owner then awaits the
ProcessRunnercall unconditionally — never aborting it and never dropping the future that holds the child and drain handles, because only the runner's return proves the child was killed and reaped and the readers stopped — then force-deletes the container under the lifecycle timeout, and lifts the quarantine only on confirmed deletion. There is no bounded backstop: prompt cooperative termination is a hardProcessRunnercontract, and a runner that violates it holds the lifecycle permit indefinitely, blockingcleanupfail-closed rather than letting the backend report a terminal state it has not observed. This holds identically for aSendexecution future moved out of the runtime and dropped on a plain OS thread, and for one whose originating runtime has already been shut down: that runtime never owned the child, the drains or the permit, so its shutdown cannot destroy them. Nothing is detached, and there is no drop site on which the permit is released without an owner.
For each session:
- Generate a deterministic, CLI-safe name
terraphim-rlm-<lowercase ULID>; never accept user-controlled container names. - Start one detached container with argument-vector invocation (never construct a host shell string):
container run --detach --name <name>--cpus 1 --memory 512M --cap-drop ALL- default network/outbound access, matching current Docker policy
- image
python:3.11-slim - guest command
sleep infinity
- Execute Python as argv
container exec <name> python3 -c <code>. - Execute shell commands as argv
container exec <name> bash -c <cmd>. The command is one guest argument; it is never interpolated by a host shell.-c, not-lc: a login shell sources the guest image's profile scripts, which can rewritePATHand the environment this backend passes in via--env, making the same command behave differently per image. - Capture stdout/stderr/exit code and elapsed time into
ExecutionResult. - On
end_session, stop with a bounded grace period, then delete/remove the named container. Treat already-gone as idempotent success; surface other cleanup failures with the backend and container name. cleanup()drains all tracked sessions and attempts all removals, returning an aggregate error only after every cleanup was attempted.Dropmust not claim cleanup succeeded. If no Tokio runtime is available, log tracked leaked names with an operator recovery command; normal correctness relies on explicitend_session/cleanup.
Timeout and cancellation contract
Wrap each container exec child in tokio::time::timeout(Duration::from_millis(ctx.timeout_ms), ...) with piped stdout/stderr.
On timeout:
- kill/reap the host CLI child;
- stop/kill and remove that session's container, because the guest exec process cannot otherwise be proven dead;
- remove the session mapping so the next call creates a fresh VM;
- return
ExecutionResult::timeout(partial_stdout, partial_stderr)with elapsed time and no background process leak.
A timeout must never leave a process continuing inside an affinity container. A container exec that fails with a host I/O error rather than an exit code takes the same path: the error may be raised after the child was spawned, so it is not proof that no guest process ran.
Snapshots and capabilities
Initial backend capabilities:
VmIsolationContainerIsolationPythonExecutionBashExecutionFileOperations
Snapshot operations return typed RlmError::NotSupported { backend: "apple-container", op }. Do not claim snapshot support merely because container export can snapshot a filesystem; the RLM trait promises broader state restoration.
Image and network policy
- Runtime may allow the Apple CLI to pull the configured OCI image, but tests must not implicitly pull.
- Real integration tests first inspect image availability or are explicitly opt-in.
- Do not mount the repository, home directory, SSH agent, Docker socket, or arbitrary host paths by default.
- Do not pass
--ssh, registry credentials, inherited secrets, or host env wholesale. - Keep default outbound networking for parity with Docker/LLM bridge/pip. DNS allowlist enforcement is not implemented by the existing Docker executor, so it is out of scope rather than falsely claimed.
Detailed implementation plan (TDD, small commits)
1. Pin backend identity and config
Files:
- Modify
crates/terraphim_rlm/src/config.rs - Modify
crates/terraphim_rlm/Cargo.toml
Tests first:
apple_container_backend_serializes_as_kebab_caseapple_container_alias_deserializesapple_container_default_session_model_is_affinity- default preference places Apple Container before Docker and Local
- config JSON round-trip preserves binary/image settings
Then add the enum/config/defaults and apple-container-backend feature.
2. Build a command-runner seam and availability probe
Files:
- Create
crates/terraphim_rlm/src/executor/apple_container.rs - Modify
crates/terraphim_rlm/src/executor/mod.rs
Tests first with a fake runner:
- non-macOS/non-arm64 is unavailable without spawning the CLI
- missing binary, failed
system version, and failedsystem statuseach produce distinct tried-reasons - healthy JSON status selects Apple Container
- selector falls through to Local when Apple Container is explicitly preferred but unavailable
- selector never auto-starts the system service
Keep probing injectable/testable; do not rely on this Linux host having Apple software.
3. Implement exactly-once per-session creation
Tests first:
- generated names contain only the allowed prefix/ULID characters
- command argv includes detached/name/cpu/memory/cap-drop/image/sleep, with no host shell
- eight concurrent first commands for one session issue exactly one
container run - two sessions get distinct containers
- failed create does not poison the map; retry can create a fresh container
Then implement ensure_container and lifecycle state.
4. Implement Python/bash execution and result mapping
Tests first:
- Python code is passed as one argv value after
python3 -c - shell metacharacters/newlines remain one guest argv value after
bash -c(not-lc: no guest profile is sourced, so the environment passed via--envis what the command sees) - stdout, stderr, non-zero exit, elapsed time, and metadata map correctly
- validator behavior matches Local/Docker
- command output is not mistaken for container ID; creation reads the authoritative name/ID contract
Then implement execute_code, execute_command, validate, backend identity and health.
5. Make timeout fail closed
Tests first with a controllable fake process:
- timeout kills and reaps CLI child
- timeout force-stops/removes the container
- session mapping is cleared
- next execution creates a new container
- partial stdout/stderr is preserved
- no child remains after the test
Then implement timeout/cancellation. This is a release-blocking invariant.
6. Implement idempotent teardown and cleanup
Tests first:
- unknown session teardown is a no-op
- end-session stop/remove order is correct
- already-absent container is success
- cleanup attempts every tracked session even when one removal fails
- map holds nothing reclaimable after a fully successful cleanup, and retains exactly the container a failed removal left behind
- a failed
end_sessiondelete is returned to the caller and retried bycleanup - deterministic lifecycle-race schedules (end_session vs ensure, cleanup vs ensure, cleanup vs in-flight end_session, aborted execution future, cleanup started in the same tick as the cancellation drop, cleanup queued during
container execagainst timeout/vanished/I-O-error/panic recovery, twelve simultaneous cancellations, an execution future dropped on a non-runtime thread,end_sessionafter and queued behind cleanup, and no map re-insertion after cleanup returns), forced with gates rather than sleeps or repetition - a process runner that withholds its return after cancellation, far past the old five-second abort grace (the owner runtime's virtual time, driven from a task on it through a
cfg(test)clock hook; explicit notifications, no sleeps): no delete starts,cleanupstays pending, the runner future is never aborted or dropped, and recovery/delete/cleanupcomplete only once the runner itself returns - snapshot methods return
NotSupportednamingapple-container
Then implement end_session (terminal, tombstoned, error-propagating), cleanup (gated, retrying, aggregating), the cancellation guard, and honest Drop diagnostics.
7. Integrate docs/demo/status
Files likely to change:
- Modify
crates/terraphim_rlm/src/lib.rsarchitecture docs/re-exports - Modify
crates/terraphim_rlm/src/executor/trait.rsbackend docs - Modify
crates/terraphim_rlm/tests/backend_demo.rs - Add/update focused RLM README/operator docs under
crates/terraphim_rlm/or repository docs - Update
CHANGELOG.mdif required by repo convention
Document:
Add an ignored macOS integration test that verifies Python, bash, non-zero exit, same-session state, timeout recreation, teardown, and no leaked terraphim-rlm-* container.
Verification gates
Portable/Linux gate (must pass in ADF)
Not a gate: cargo test -p terraphim_rlm --no-default-features --features apple-container-backend does not compile, and did not compile before this work.
crates/terraphim_rlm/src/config.rs and src/rlm.rs name terraphim_types
unconditionally while the dependency is optional, and local.rs, executor/mod.rs
and rlm.rs name crate::validator unconditionally while the module is behind
#[cfg(feature = "kg-validation")]. Verify with git stash && cargo check -p terraphim_rlm --no-default-features --features apple-container-backend. Fixing
that feature matrix is a separate change; listing it here as a passing gate would
be dishonest, so it is excluded.
Also run the repository's existing required gate or .adf-gates.sh. If the workspace has pre-existing failures, prove them against clean main; do not expand this issue.
Real Apple-silicon gate (required before merge; unavailable on this Linux orchestrator)
Host: Apple silicon, macOS 26, container installed with Homebrew, system service started, default image present or explicitly pulled.
Capture CLI/server versions and test output in the PR. A Linux fake-runner pass is not evidence that the Apple runtime works.
Acceptance criteria
- [ ]
BackendType::AppleContainerhas stableapple-containerconfig/display identity and affinity defaults. - [ ]
fullincludesapple-container-backend; Linux/non-Apple builds still compile and test. - [ ] Availability requires macOS/aarch64 + healthy CLI and service; the backend never auto-starts host services.
- [ ] Default preference tries Apple Container before Docker/Local without changing Linux's effective choice.
- [ ] One VM-backed container is created exactly once per RLM session under concurrency.
- [ ] Python and bash execute through argv-safe CLI calls with correct result/exit mapping.
- [ ] Timeout kills/reaps the CLI process, destroys the session container, clears affinity, and preserves partial output.
- [ ]
end_sessionis terminal for a session id, propagates deletion failures (as does the publicdestroy_session), and leaves nothing reclaimable on success;cleanupis idempotent, coordinates with in-flight execution, in-flightend_sessionand in-flight recovery (including a recovery that has not begun yet), cannot have its drained map repopulated after it returns — by a recovery, a new caller or anend_session— attempts all resources on multi-failure, and retains what it could not remove. Every possibly-created name — including one from a failedcontainer run— stays tracked until confirmed deleted, and blocks a replacement meanwhile. - [ ] Cancelling an execution future fails closed: the slot is unusable before the drop returns, and the execution owner — established before the guest command was launched, on its own thread and runtime, carrying the execution's lifecycle permit — kills and reaps the CLI child, terminates the stream readers and force-deletes the container before that permit is released, so terminal cleanup necessarily waits for it. Independent of the thread the execution future is dropped on and of the runtime that started the execution, including after that runtime has been shut down. Proven on a real local process across two runtimes (real PID absent from the process table, zombies included, before cleanup can finish), not only on the fake runner.
- [ ] No host directories, SSH sockets, secret environment, or privileged capabilities are exposed by default.
- [ ] Snapshot calls return typed
NotSupported; capability claims are truthful. - [ ] Focused portable tests and repo gates pass.
- [ ] Real Apple-silicon/macOS 26 integration evidence is attached before merge, including zero leaked
terraphim-rlm-*containers. - [ ] Docs include
brew install container, one-timecontainer system start, requirements, fallback diagnostics, and cleanup recovery. - [ ] Independent structural PR review reaches 5/5 with P0=0, P1=0, P2=0 and checks green.
Non-goals
- Swift FFI or embedding
Containerizationdirectly. - Repairing Firecracker/#3188 or implementing E2B.
- Supporting Intel Macs or claiming support below macOS 26.
- Automatic Homebrew installation,
sudo, service start/stop, kernel installation, or DNS resolver changes. - Snapshot/restore, host volume mounts, SSH forwarding, registry-login automation, Kubernetes, GPU, nested virtualization, or container-machine support.
- Fixing the existing Docker output-limit/DNS-allowlist gaps unless a shared extraction is strictly necessary and separately tested.
Risks and mitigations
- CLI drift: pin argv contract tests and record tested CLI/server versions in real integration evidence.
- No Apple runner in current ADF host: portable fake-runner tests are mandatory but cannot satisfy the merge gate; route the final ignored test to an Apple-silicon macOS 26 host.
- Leaked VMs after timeout/crash: fail-closed timeout destroys the affinity container; explicit teardown is primary; names are prefix-scoped for recovery.
- Prompting/system mutation: health probing only; no automatic
system startor kernel install. - False security claims: document default outbound network and unsupported snapshots; do not imply DNS allowlist enforcement.
- Concurrent creation and teardown races: per-session
Arc<SessionCell>with an explicitSessionSlotlifecycle state (not Docker's bareOptioncell, which reintroduces the detached-slot leak), an executor-wide gate whose owned read permit is taken before the work and held through its recovery (permit-before-slot lock ordering), and a retainedClosingtombstone. Pinned by deterministic tests: a gated fake runner parks the CLI call at each lifecycle point and the racing operation is driven from there — no sleeps, no repetition. - Apple memory behavior: Apple documents that freed guest pages may not immediately return to macOS; bounded 512 MiB VMs plus prompt teardown limit exposure.
Execution
Run through ADF using this issue as the contract:
ADF should stop at the PR/review gate. Do not merge without the real Apple-silicon test evidence.
Related: #3188 (Firecracker backend debt; deliberately out of scope).