hack.foo
Native backend (beta)

Full reference

hack-native is an experimental Apple Silicon macOS executor installed alongside Hack's supported CLI. It does not replace hack, select a Docker context, migrate projects, or install DNS or trust automatically. Its graph commands support a bounded subset; this bundle is not application parity or release qualification.

Build with the repository's pinned Bun 1.4.4, Rust 1.97.1 and Zig 0.15.2 toolchain, Python 3, and the Rust aarch64-unknown-linux-musl standard library already installed. The build requires a clean checkout and refuses missing prerequisites; it does not install toolchains:

mise exec -- scripts/build-native-candidate.sh /absolute/new/hack-native-bundle

The executor includes native-config-plan for explicitly selected authored projects. Packaging checks the copied executable's pure native planner with a private synthetic project and requires the candidate home to remain empty. This checks compiled planning capability; it does not start a provider, change the default backend or qualify live authored execution.

The destination must not exist. The bundle contains hack-native, the static Linux ARM64 hack-relay-guest, hack-storage-root-witness with its native-storage-tool.json byte manifest, compiled normal CLI hack-cli, the hack-v5 entrypoint, the compiled hack-config-compiler, generated hack.project.schema.json and hack.local.schema.json, provider pins, this guide, SHA256SUMS, and one content-addressed shared MCP bundle under mcp/BUNDLE_ID/. Its manifest and native adapter, owner, and compiled backend are included in the outer checksums. The relay uses the committed guest Cargo lockfile and Zig linker wrapper, with a separate build target directory. Packaging verifies its ELF architecture and absence of an interpreter or shared-library requirements before publishing the bundle. It contains no provider installation, runtime state, project data, or credentials. It can be copied outside the source checkout; Rust, Zig, Bun and the checkout are not needed to run the resulting executable. Host system utilities and the pinned provider remain runtime prerequisites.

Authored persistent mounts resolve the witness tool only from the explicitly selected native binary's sibling bundle, before hooks or native startup. The helper is built from the same checkout with the pinned musl target and Zig linker; packaging checks static Linux ARM64 ELF structure and records its hash, size and source revision. Admission holds both manifest and helper descriptors, rejects changed bytes or file identities, and passes the captured path/hash to Rust for independent admission before provider effects. No download, host execution or emulation fallback occurs. Saved inspection and cleanup use the already installed, receipt-pinned guest helper and do not require the host artifact. The byte manifest is not proof of kernel ABI, durable storage or application acceptance. The first supported qualification corpus is explicit UID/GID0 Bun with SQLite; arbitrary owner changes and stock PostgreSQL handoff remain refused pending separate qualification. For versioned candidate packages, channel rules and publishing gates are described in the prerelease guide, and the separate hack-next installation path is described in the candidate installer guide. The build re-signs the compiled frontend, config compiler, and MCP backend with local ad-hoc signatures and strictly verifies all macOS executables before generating checksums. This checks code integrity; an ad-hoc signature does not establish a publisher identity or provide Apple notarization. Verify SHA256SUMS after copying the complete bundle. hack-relay-guest runs inside the Linux guest, not on macOS. For graph dependency selections, set artifact to this bundled file's absolute path and artifact_sha256 to its entry in SHA256SUMS; the runtime verifies it again before delivery. The native-stream-relay feature does not replace this dependency relay.

The compiler and both generated schemas stay beside hack-cli as one checksummed group. Packaging and installation refuse partial, changed, or aliased groups. The compiler is executable; the schemas are private data. Older bundles with no compiler or only the complete compiler/project-schema pair remain installable and selectable. A local schema always requires the compiler and project schema alongside it. An existing channel with an older retained installer must explicitly run upgrade-manager with the reviewed installer before selecting a bundle with the new group, or use a fresh channel root. This upgrades the manager; it does not migrate the retained runtime homes.

Shared MCP remains opt-in. Select the verified nested bundle with the existing installer; substitute --cursor or --codex as needed:

/absolute/bundle/hack-cli mcp install --claude --scope user \
  --bundle /absolute/bundle/mcp/BUNDLE_ID --cli /absolute/bundle/hack-cli

This selects the normal CLI for tool calls. It does not activate a native project runtime or change other clients. Keep the complete bundle while clients reference it. To return to standard stdio, remove the selected client's Hack entry and reinstall it without bundle options. Channel rollback does not rewrite those client entries. Older prereleases without MCP assets remain installable. The candidate installer verifies the complete nested inventory, identity, checksums, modes and code signatures before selection; it never auto-enrolls clients.

Verify the copied bundle, create a dedicated home, and explicitly select it:

cd /absolute/copied/hack-native-bundle
shasum -a 256 -c SHA256SUMS
mkdir -m 700 /absolute/private/candidate-home
./hack-native --candidate-root /absolute/private/candidate-home info --json
./hack-native --candidate-root /absolute/private/candidate-home --version

The home must already exist, be owned by the current user, and have mode 0700. Use its canonical absolute path without symlink components (on macOS, use /private/tmp rather than /tmp). Every command requires this explicit home, including help and version. Its checkout field is the historical receipt name for this stable home; channel reports installed-candidate. State remains under <home>/.hack-local. Keep this home fixed across binary upgrades: receipts and child processes bind to its identity. Moving an executable is supported; moving an active home or adopting another installation's state is not.

Run the normal project CLI through the bundle's hack-v5 entrypoint:

export HACK_NATIVE_HOME=/absolute/private/candidate-home
/absolute/copied/hack-native-bundle/hack-v5 ps --path /absolute/project

Keep the bundle together and invoke this entrypoint by its full path (do not copy or symlink only the script). With HACK_RUNTIME_BACKEND unset (or native) it selects the native backend and the adjacent executor, even if an inherited HACK_NATIVE_BINARY selects another binary. An explicit HACK_RUNTIME_BACKEND=compose runs the same bundled CLI on the Compose backend with your own Hack home and existing global routing services. It needs no HACK_NATIVE_HOME and removes the native selectors. Other values refuse with exit code 64. The candidate installer guide describes Compose testing and its shared-state limits: run stable hack global up, then any stable hack doctor or hack doctor --fix, then stable hack daemon stop, confirm pgrep -fl "hack daemon start" prints nothing, and run no stable hack command while the candidate uses your Hack home. Stable hack global up (v4.2.0) starts a stopped hackd again, and so does stable hack doctor --fix (it asks first only in a terminal). If the check lists a process, the guide gives the launchd remedy. The entrypoint turns off daemon autostart in newer builds, so the candidate does not start the stable hackd on its own: a candidate hack doctor --fix skips its hackd repair without asking and prints the command to run yourself, which runs the stable CLI. Do not commit the project's generated .hack/.internal/ directory; newer generators rewrite files such as compose.override.yml. The entrypoint preserves arguments, exit codes and signals through exec, and never replaces the installed hack. Project-specific native adaptation, dependency and routing selections remain explicit; this entrypoint does not prepare a provider or migrate existing projects. An unversioned local build reports the repository package version. A versioned prerelease build reports its explicit candidate version and includes source provenance. The hack-v5 entrypoint alone does not identify a published release. Unsupported native workflows still report their existing refusal rather than falling back to Docker.

Native up accepts an explicit branch or the branch inferred from a linked Git worktree when its exact source root can be shared with a fresh or matching pool. The runtime checks source compatibility before startup hooks; restart repeats this check before stopping the existing graph. A different root in an occupied pool is refused. This does not dynamically add mounts to a running VM: simultaneous worktrees with different source roots still require separate pools.

Unfiltered writable source sharing still requires HACK_NATIVE_SHARED_SOURCE=1. An exact repository at $HOME/.codex/worktrees/<id>/<repo> is allowed only when bounded Git registration metadata verifies its worktree root, common directory and backlink. The enclosing Codex directories are never exported. Sensitive ancestors, aliases and unsafe ownership or permissions remain refused.

The internal planning path accepts a canonical lowercase DNS-label --branch. It gives each checkout/branch pair a separate enrollment and source namespace; omitting the selector preserves existing base-instance identities. The frontend rewrites declared project, service, legacy and OAuth route names after applying native aliases, then carries the same branch selector through original review, normalized review, source capture and execution review. Original Compose files and private environment values are unchanged. Hostnames outside the configured project bases remain explicit, unchanged claims; normal route collision checks still apply. A same-checkout branch namespace does not create an isolated source tree. These planning contracts do not qualify multiple independent source roots inside one VM pool.

An existing frontend branch mapping may retain a graph admitted before native branch namespaces. If its current scoped review differs, the frontend first checks native selection against that exact saved run, owner, namespace and plan: restore selection for a stopped graph, or authenticated service selection for an active graph. A native unbranched review of the same canonical project and unchanged original Compose must then return the saved namespace. Only that retained run continues with unbranched review and execution arguments and keeps its original adapted route labels, before branch hostname rewriting. This preserves legacy source contracts that did not enroll hostname changes. The selected generation, restore selection and shared-source semantic compatibility are checked again before admission; mappings, graph receipts and ownership namespaces are not rewritten. Active legacy restart also rechecks its service, container, boot and receipt generation after compatibility review, before cleanup eligibility. A changed selection refuses before stopping the graph. This preflight check does not make cleanup atomic with that generation; existing ownership and frontend finalization checks still apply. Fresh and already branch-scoped graphs keep their normal branch namespace. This compatibility path does not authorize adopting an unrelated branch or project.

Compose projects that stay on the Compose backend

In v5, an unchanged Compose project (.hack/hack.config.json with .hack/docker-compose.yml) that uses any of the following features runs on the Compose backend. Native support for them is planned after v5.

Compose featureKept on the Compose backend when
External networkA top-level network sets external: true or external: {name: ...}. A reviewed x-hack-isolated: true replacement is not external: it creates an owned internal bridge. A network that the selected adaptation (HACK_NATIVE_ADAPTATION or the committed .hack/native/adaptation.json; see Native settings files) lists in isolatedNetworks gets the same replacement, so it is not external either.
External volumeA top-level volume sets external: true or external: {name: ...}. The native runtime only creates volumes it owns, and there is no native replacement for an external volume.
Reserved ownership labelA service, network or volume label key starts with com.docker.compose., io.hack-local., io.hack.native-config., io.hack.native-file. or io.hack.storage-witness.. Compose, the native runtime and native-config Compose use these labels to prove which resources they own.
Build outside the basic formA service build is not the basic form that hack config import --dry-run accepts (a relative context and optional relative dockerfile and target), or the service also sets image. Build arguments, secrets, SSH, cache options, platforms and other build options are outside the basic form. With HACK_NATIVE_BUILDS=1 (see below), only a build field other than context, dockerfile, target and args, or image together with build, counts; native build admission reviews the values of those four fields itself.
Non-native platformA service sets a platform other than linux/arm64 or linux/arm64/v8, for example linux/amd64. The native VM runs Linux arm64. A missing or null platform is unset and passes. linux/amd64 passes only for a service that the HACK_NATIVE_ADAPTATION selection opts in to amd64 emulation; every other platform value stays on the Compose backend.

Native hack up and hack restart check for these features after selecting the project and before branch, environment, lifecycle, storage or runtime work. They refuse with E_NATIVE_PROJECT_UNSUPPORTED and one line that names each feature found, without network or volume names, label keys, paths or platform values. Nothing is started, stopped or written. Every service is checked, including services outside the selected profiles.

The check reads only .hack/docker-compose.yml, as native start does. It does not merge Compose override files, follow extends or include, or read environment defaults such as DOCKER_DEFAULT_PLATFORM: a platform counts only when a service sets its platform field. The native runtime's own review reports extends and include as unsupported fields.

The check applies the selected adaptation first, as native start does: HACK_NATIVE_ADAPTATION, else the committed .hack/native/adaptation.json or the adaptation.<env>.json for --env <env>. Without --env, a network counts as isolated only when every committed adaptation that could apply isolates it, because the effective env is not known yet. An unchanged Compose file whose external network is listed in isolatedNetworks therefore stays on native, and native start replaces that network with an owned isolated one. A linux/amd64 service that amd64Emulation opts in stays on native the same way. The selection is read only when the Compose file has an external network or a non-native platform. If it cannot be read, is not version 1, its isolatedNetworks is not a list of unique names, or its amd64Emulation is invalid, the command refuses with the same one-line E_CONFIG_INVALID message native start gives, before any runtime request.

To run such a project, select the Compose backend:

HACK_RUNTIME_BACKEND=compose hack up

The bundle's hack-v5 entrypoint and the hack-next launcher accept the same selection; see Test both backends.

Other labels keep their native behavior. Routing (caddy*) and dependency cache (hack.dependencies.*) labels are reviewed as before, and other ordinary labels are refused with the general native configuration refusal.

Hack's own one-shot marker is not an ownership label. A service labeled exactly hack.service.one-shot: "true" (or hack.service.one-shot=true in list form), the label hack config import --dry-run reports as compose_one_shot_job, is reviewed natively as a one-shot: the review plan marks it one_shot, and native up waits for it to exit successfully instead of waiting for it to keep running. The marker adds no container label. Only that exact key is recognized. Any other hack.* key, including another hack.service.* key, is still refused (external_route_or_owner_label in the native review), and a marker next to a reserved ownership label does not exempt it. The native review also refuses a marker whose value is not the string true (invalid_one_shot_label), a one-shot with restart: always or unless-stopped (nonterminating_one_shot), and a one-shot that also declares routing labels (one_shot_route_conflict).

hack config adopt, including --dry-run, still requires the Compose backend, because it reads and adopts the Compose backend's existing containers and volumes. On the native backend it refuses before reading anything and names the rerun: HACK_RUNTIME_BACKEND=compose hack config adopt --dry-run. See Legacy Compose adoption.

Native image-project up builds Compose build: services only when you opt in with HACK_NATIVE_BUILDS=1 (and the usual HACK_NATIVE_SHARED_SOURCE=1). Without it, a build: service refuses before any hook or VM effect and names the variable. With it:

  • Accepted forms are build: <context> and a mapping of context, dockerfile, target and args. Paths follow Compose (the context is relative to the Compose file, the Dockerfile to the context) and must stay inside the project. args may be a mapping of literal strings or numbers, or a list of KEY=VALUE literals. Any $ interpolation, a list item without = (host variable inheritance), null, other build keys (secrets, ssh, cache_from, platforms, network and the rest), remote contexts and image together with build refuse. Values are never shown.
  • Build args are not secrets: as with Docker, the image records them in its history. A build arg whose value equals a hack env secret value of this project and environment refuses before any hook or VM effect, naming the service and the argument but never the value. The refusal does not suggest the Compose backend, because a Compose build would record the value in the image history too. Pass secrets with BuildKit build secrets instead; native builds do not support them yet.
  • Every build refusal names the service. A refusal from the runtime's build source policy (for example a base image that is not a public Docker Hub reference) says that it is the build source policy and what to change. Other build failures name the service and keep the runtime's message.
  • After the runtime is up, each distinct build runs once with runtime build-image (below), and its filtered output streams to your terminal. Every service with that build is then pinned to the built content ID, so review, readiness and restore treat it like an image service.
  • An existing image for the same build inputs is reused without building. Set HACK_NATIVE_REBUILD=1 to build again; base-image tags then resolve again too. Edits to other files in the context do not trigger a rebuild, as with Compose.
  • Restoring an unchanged retained run uses the retained images and never builds; if one is missing, up refuses and asks you to start fresh. hack restart also never builds: it reuses the running or retained image, or refuses before stopping anything.
  • "Unchanged" means the same Compose bytes. A run that mounts no project source and uses no dependency cache still restores after its Compose file was edited and put back, or after any project file was touched; build-context edits do not rebuild it. Its review then differs only in the project source inventory (file sizes and modification times), which such a run never reads, and restore keeps the retained ownership plan, images and data.
  • As with Compose up, only build services in the selected profiles are built and pinned. A build service in a profile you did not select is not built, and restore and restart need no image for it; selecting its profile in a later fresh up builds it then. Native builds are linux/arm64 only; platform: linux/amd64 is still refused.

Native review reads Compose ports without reading environment values. Each entry may be short ([host_ip:][published:]target[/protocol], with an IPv6 host in brackets such as [::1]:8080:80) or long (target, published, host_ip, protocol). The host IP and published port may each be one interpolation with a literal default, such as "${SEARCH_BIND:-127.0.0.1}:${SEARCH_PORT:-8108}:8108". Native always uses the literal default, even when the variable is set, and warns with interpolated_port_default; the plan records only the variable names. A reference without a literal default (${PORT}, ${PORT:?message}), a nested default, an interpolated target or protocol, or one reference that spans several components is an unresolved_port_binding error at services.<name>.ports[<index>]. The review still reports every other finding, and a service in an unselected profile does not block the plan.

Native publishes only on host loopback. 127.0.0.1 and ::1 are kept. A bare port or an all-interface binding (0.0.0.0, ::) is narrowed to 127.0.0.1 or ::1, with a proposed_loopback_binding warning. Compose would bind every interface in that case; native does not expose development services to the network. A specific non-loopback address, such as a LAN address, is an unsupported_host_binding error. Keep such a project on the Compose backend.

Native foreground hack up publishes the reviewed ports of active services on host 127.0.0.1. Each published port is forwarded through an owned application bridge to the container's target port, after the graph is ready. The rules:

  • Only TCP with an explicit host port is published. UDP, an engine-assigned host port (a bare "3000"), and IPv6 loopback (::1) publication are refused with graph_port_publication; keep those projects on the Compose backend.
  • One graph publishes at most 16 ports. Routes and published ports share the pool's 32 application bridge sockets.
  • Two active services, two graphs in the same pool, or a graph and any other host process cannot hold the same host port. Before graph admission, and again under the provider lease just before binding, native refuses a host port that already accepts connections on 127.0.0.1 (published_port_in_use, or E_CONFIG_INVALID from the frontend check). The other listener is left untouched.
  • A connection is forwarded only to the container incarnation the bridge was started for. A replaced container does not receive traffic from an older publication.
  • Stopping the foreground owner (Ctrl-C) closes each host listener before its bridge and the containers stop, then retires the publication. Dead-owner cleanup retires a publication only after proving its owner is gone. A retaining restart publishes the same ports again.
  • graph run and other non-foreground paths do not publish ports and keep refusing them with graph_subset.

The loopback check is point-in-time. A process that binds the same port on all interfaces between the check and native's own bind can still share loopback traffic with it; stop such listeners before hack up.

hack doctor reports the same features for the current project in its compose backend features check. With --json, the check's composeBackend.features lists the closed names external_network, external_volume, reserved_label, unsupported_build and foreign_platform. On the native backend a found feature is a warning; on the Compose backend it is informational. Native doctor applies the same adaptation selection as native up without --env before it reports. When it finds an external network, the message also names the isolatedNetworks selection above, which keeps that network's project on native.

A basic build is not one of these features, but without HACK_NATIVE_BUILDS=1 native image-project up refuses it, so doctor does not report it as ok then. On the native backend the check warns, names the variable and the next steps (set HACK_NATIVE_BUILDS=1, preview the authored native conversion with hack config import --dry-run, or select the Compose backend), and its JSON adds composeBackend.nativeUpRefusals: ["basic_build"]. On the Compose backend the same note is informational. With HACK_NATIVE_BUILDS=1 set, doctor classifies builds the way native builds admit them and reports no basic-build refusal.

Publication control tokens are delivered to the relay through a one-shot inherited pipe (--control-fd 0), never through process arguments. The relay reads exactly 32 lowercase hexadecimal bytes, requires EOF, and closes the descriptor before binding its listeners. Missing, malformed, or stalled input refuses startup; the old token argument form is not accepted. Rebuild the runtime and relay together.

Tmux lifecycle sessions receive their owner token and environment through source-file - on stdin. The owner token stays in the session environment; existing sessions with the older owner option remain readable. Commands sent to an existing tmux pane receive selected environment values through a one-shot mode-0600 file inside a private mode-0700 temporary directory. The CLI's internal launcher reads and removes that payload before starting the command, preserving keys such as UID without value-bearing arguments. Leading command-local assignments override selected values; the existing pane shell still parses multiline commands and pipelines. The command keeps the pane's terminal stdin. Failed sends remove their staged payload; if the pane never executes a successfully sent command, the private payload remains until it is consumed or explicitly cleaned up.

Native up and restart also stay in the foreground in every project: --detach refuses with one E_USAGE line. Run hack up in its own terminal and keep it open, use hack ps and hack logs from another terminal, and press Ctrl-C to stop it. For detached startup, use the Compose backend.

Foreground image-project up prints each startup step (input, runtime, images, services) and one ready line, Project is ready (SERVICES; run RUN), after the graph is ready and its run mapping is published. Under it, up lists each routed service's reviewed hostnames as HTTPS URLs (with the HACK_NATIVE_HTTPS_PORT port unless it is 443) and the published loopback ports. Without the native HTTPS frontend it says that routes are not served on this host. Ctrl-C prints the stop sequence and, once owned cleanup is confirmed, one stopped line that says whether named volumes and the stopped run mapping were kept (Native project stopped. when no run mapping was published). Until the ready line, startup holds the runtime's exclusive provider lease, so graph admission cannot interleave with another operation. A hack ps from another terminal during that window waits briefly, then reports that the runtime is busy (runtime_busy, exit 0) instead of failing. After the ready line, hack ps and hack logs run alongside the foreground command.

While the project runs, foreground up reports each service container that stops, as attached Compose up does (SERVICE exited with code N), including one-shot services. It keeps running while any service runs; once every service container has stopped, it prints that, stops the graph through the ordinary owned cleanup and exits 0, like Compose up without --abort-on-container-exit. A service whose restart policy has a pending restart (restarting) is not stopped: up keeps running and reports the service only if it later exits for good, for example when on-failure:N runs out of retries. Unlike Compose, the exit that triggered an engine restart is not printed separately. The monitor reads graph observe, which takes no provider lease, so it never contends with ps, logs or exec.

Runtime refusals print their code and, when the runtime marks its message as a fixed literal that cannot carry values, that message and the service it concerns (for example Service web: Pinned image architecture or implicit volumes are incompatible.). Formatted runtime messages stay omitted. A start refused before the runtime reserved any graph state reports that nothing was created and no cleanup is needed, with the startup diagnostic, instead of unconfirmed cleanup.

Network aliases and the Compose project name

Native review accepts networks.<name>.aliases on a service attachment, as Compose does: each alias is an extra DNS name for that service on that network only. Aliases must be literal DNS names (letters, digits, -, _ and dots; case is folded), at most 8 per attachment. Interpolated aliases and the other attachment keys (ipv4_address, ipv6_address, priority, link_local_ips, mac_address, driver_opts) remain unsupported_field. Native networking keeps one owner per DNS name, where Compose would round-robin: an alias that two active services declare on one network, an alias that is another active service's name on a network both join, or an alias that is also one of the same service's extra_hosts names refuses with network_alias_collision. When one of the two services is left inactive by the selected profiles, review reports the warning inactive_network_alias_collision instead; selecting that profile refuses. The graph verifies every reviewed alias as part of each container's identity. hack run one-off containers keep only the service name, so they never become a second owner. Aliases are part of the reviewed plan: changing one is an ordinary plan change, never a hostname-only change.

Compose also supplies its project name to interpolation as COMPOSE_PROJECT_NAME and names each container <project>-<service>-1, which other containers can resolve. When a service references COMPOSE_PROJECT_NAME, native up and restart declare the instance's Compose project name (the same <project> or <project>--<branch> the Compose backend passes to docker compose -p) as the normalized document's top-level name. Review then binds the variable to that name (warning compose_project_name_binding) and adds the container name as an alias on every network the service joins, under the same collision rules. No caller can supply a different value. The name must follow Compose's rule (lowercase letters, digits, - and _, starting with a letter or digit), and a container name longer than one 63-byte DNS label refuses with compose_container_alias_too_long. A document that does not reference the variable keeps identical normalized bytes and gets no container-name aliases, so a hard-coded <project>-<service>-1 name does not resolve natively; use the service name or ${COMPOSE_PROJECT_NAME}. Without a declared name, for example when the internal planning path reviews the original file directly, review warns with compose_project_name_unbound and execution refuses with execution_environment_missing. Plans and receipts that carry the new fields are not readable by older bundles; clean up with the owning bundle.

Manual candidate upgrade and rollback

Manual candidate bundles are selected by their full path. The separate prerelease installer manages verified versioned selections with independent homes; it does not migrate runtime data. hack-v5 update refuses before discovering an installed binary or contacting a release server; install a separately reviewed, complete bundle to change the candidate.

  1. Keep the previous complete bundle and its SHA256SUMS. Copy the new bundle into a separate directory and verify every checksum before using it. A checksum failure or incomplete copy is a failed installation; preserve it for inspection.
  2. Retain application data with ordinary down for each owned graph, confirm its foreground owner and host processes have retired, and stop the owned candidate runtime. Keep existing bundles in place while any of their child processes live.
  3. Select the new bundle's hack-v5 by its full path with the same canonical mode-0700 HACK_NATIVE_HOME. Check its info identity and update any explicitly selected guest-relay artifact paths to the verified complete bundle. Normal frontend up validates the retained selection offline, resumes its clean owned VM once if stopped, and then repeats live retained-data and restore-generation checks. Restore the application and read back a saved data marker before accepting the upgrade. A restore selection (--expect-generation) or recovery selection reviewed by an older bundle can use the previous retained-volume encoding (see the volume identity audit) and then refuses as changed even when no volume changed: run the review and selection again with the new bundle.
  4. For rollback, retain-stop the new candidate in the same way, select the previous verified bundle with the same home, and repeat application/data readback. A prior binary refusing newer state is an unsupported downgrade, not a successful rollback. Preserve that state; do not rewrite receipts or adopt another home's data.

The provider runs with a short temporary HOME alias, /private/tmp/hkl-<owner>, a symlink to <home>/.hack-local/run/smolvm/home that keeps its Unix socket paths within the macOS length limit. A clean runtime down keeps this alias, so no other local user can take its name while the pool is stopped. It removes only the provider socket files the stop proved closed, through the real data directory.

Host cleanup or a reboot can still remove the alias. For a clean stop, observation commands such as runtime status accept the absent alias and create nothing, and the next runtime up, runtime recover or approved-host update restores the exact alias under the pool lock before using it. If another entry (a file, a directory, another user's link) holds that path by then, it is never followed, replaced or removed: the pool records a fresh random /private/tmp/hkl-* alias in its receipt and creates that one instead. A pool with dependency sockets refuses a fresh alias with socket_alias_collision, because its provider record pins those socket paths under the original alias; remove the entry at that path and retry.

For any other receipt with an absent alias, normal commands report provider_home_missing; they do not initialize another pool or recreate the alias during observation. Explicit runtime recover --json can restore only that absent exact alias, after verifying the receipt-bound dead provider, both recorded disks, free VM lock, closed disk handles, and no active provider command. Existing files, directories, foreign links, a live or reused PID, and uncertain ownership refuse without replacement. Recovery rechecks the receipt before exclusive creation, then validates socket absence and flushes the disks before recording recovered-unclean. If those final checks fail, it reports provider_home_restored_recovery_incomplete, keeps the exact owned alias and retained data, and requires inspection before retry. No guest is started by alias recovery. Interrupted starts without a recorded process or both identified disks remain outside this repair path.

A physical macOS reboot may also renumber the mounted filesystem device. Strict disk and source checks still refuse a changed device number; missing-HOME recovery does not waive them. For an offline stock pool, inspect the separate migration:

./hack-native --candidate-root /absolute/private/candidate-home runtime host-filesystem-recovery --json
./hack-native --candidate-root /absolute/private/candidate-home runtime recover-host-filesystem --expect-sha256 <selection_sha256> --accept-legacy-device-rebind --json
./hack-native --candidate-root /absolute/private/candidate-home runtime recover --json

Review the inspection before selecting its hash. This explicit legacy migration requires an absent recorded provider whose start predates the current host boot, no active provider commands, exclusive existing operation/VM locks and closed disk handles. Both disk inodes, sizes and ext4 UUIDs, the exact source path/inode and its ownership must be unchanged; only one common old-to-new device-number change is allowed. The inspection is read-only. Publication atomically changes only the owner's disk and source device numbers, retaining its phase and process record. Normal commands keep their strict identity checks.

Legacy receipts have no original host boot UUID or filesystem volume UUID. Calendar timestamps corroborate a reboot, and matching retained file identities constrain the migration, but neither proves original volume continuity. The opt-in explicitly accepts that limitation; copied or relocated pools are outside this procedure. Prepared-base pools and pending owner/network/activation updates require separate recovery and are refused. A torn owner publication preserves owner.pending and blocks another migration; do not delete or adopt that file manually.

This does not start the VM, restore HOME, retire stale sockets or rewrite historical graph receipts. Use ordinary recovery afterward. Historical shared-source graphs retain the prior identity and may require verified cleanup plus a new generation; successful metadata migration alone does not establish application recovery.

After that explicit provider recovery and one audited VM boot, a retained graph from the immediately preceding guest boot can still carry old host device numbers in its publisher, relay-control, and dependency-socket receipts. Inspect and select one run's host-pin recovery separately:

./hack-native --candidate-root /absolute/private/candidate-home graph inspect-host-pin-recovery --run-id <run-id> --json
./hack-native --candidate-root /absolute/private/candidate-home graph recover-host-pins --run-id <run-id> --expect-selection <selection_sha256> --accept-legacy-device-rebind --json
./hack-native --candidate-root /absolute/private/candidate-home graph recover-cleanup --run-id <run-id> --expect-receipt <original_receipt_sha256> --json
./hack-native --candidate-root /absolute/private/candidate-home graph retire-recovered-publisher --run-id <run-id> --expect-owner <owner> --json

The first command only inspects. The second publishes a private, exact-run witness before cleanup; it changes no graph, publisher, control, or dependency receipt. It requires the provider's repaired current disks/source, unchanged recorded inodes and raw receipts, dead owners whose recorded starts precede the current physical host boot, refused socket listeners, and the immediate guest boot transition. It also binds retained volume names and actual labels. Cleanup and retirement can use the witness only for those selected old pins; ordinary reads and later publisher generations remain strict. Missing or foreign pins, active listeners, changed resources, pending journals, or further guest boots refuse without adopting another run. A completed older cleanup journal is retained as history and does not itself block a later selected generation.

Legacy receipts do not identify the original APFS volume. This explicit device-number rebind cannot prove pre-reboot volume continuity. Completing these commands retains the old run's data and proves cleanup of its dead generation; it does not migrate graph.source.shared, restore the application, establish route readiness, or claim overall v5 acceptance. Same-run source continuity requires a separate explicit witness-bound transition after cleanup and publisher retirement. If macOS removed the foreground or relay-control directory itself during reboot, this command refuses: the old pin receipts no longer exist, and absent pathnames cannot stand in for their recorded owner identities. That case requires a separate selected absence-recovery procedure.

When a physical host reboot removed both the deterministic foreground publication root and this run's relay-control root, inspect the distinct absence path with the private original provider Owner and its exact pre-migration host-filesystem inspection:

./hack-native --candidate-root /absolute/private/candidate-home graph inspect-absent-publication-cleanup --run-id <run-id> --original-owner-file <private-original-owner.json> --host-inspection-file <private-inspection.json> --json
./hack-native --candidate-root /absolute/private/candidate-home graph recover-absent-publication-cleanup --run-id <run-id> --original-owner-file <private-original-owner.json> --host-inspection-file <private-inspection.json> --expect-selection <selection_sha256> --retain-data --accept-unpinned-post-reboot --json

Inspection does not create a publication root. A failed selected action can leave only its private lock reservation; inspection recognizes that exact lock-only state. Recovery locks the foreground publication before acquiring the VM lease, then durably records the selected absence before any cleanup. It retains volumes, verifies the stopped receipt, and records a separate absent-publisher retirement. Ordinary publication and cleanup cannot infer ownership from missing paths. The old foreground PID and original physical volume are not proved by legacy graph receipts; this path requires explicit acceptance of that post-reboot limitation and refuses a changed boot, present or foreign publication, stale selection, pending state, or changed resources. An interrupted recovery can resume only its exact selected intent on the same host and immediate guest boot. A later successful restore treats this witness as history; it never grants cleanup of the new generation.

This operation does not change historical shared-source device identity. Source-mounted projects require the separate selected source-continuity step before normal same-run restore. The command's stopped/data-retained result is not proof of application startup or routing.

The frontend project-run mapping also records directory device numbers. If native inspection succeeds after provider recovery but ordinary project commands refuse the mapping, inspect the exact instance with the current bundle:

./hack-v5 doctor --path /absolute/project --branch my-instance --native-run-mapping inspect --json
./hack-v5 doctor --path /absolute/project --branch my-instance --native-run-mapping repair --expect-selection <selectionSha256> --accept-legacy-device-rebind --json

Omitting --branch uses the same linked-worktree default as project commands; detached linked worktrees require an explicit instance. Inspection writes nothing. A mapping that still matches the checkout reports state: "current" with its run, the native graph phase (or null while the pool is stopped), whether the graph's run, owner, namespace and plan match the mapping, and repairRequired: false. Only a mapping whose scope no longer matches goes through the legacy device-rebind inspection below, which returns the selectionSha256 that repair requires. Repair requires the exact selection and explicit acceptance of unproven original filesystem volume continuity. It changes only the three scope directory device numbers, preserving canonical paths, inodes, branch, run, owner, plan, environment selection, profiles and AWS selector. The selected graph and current project share must still match native authority. An audit copy retains the original private mapping. Held locks, pending restart state, substituted directories, stale hashes and changed native identities refuse publication.

This is a metadata repair, not an app restart: it does not retire sockets, modify graph history, remove data or establish browser readiness. Doctor's ordinary --fix never applies it. Run the normal project command separately after reviewing the result; its existing native graph and ownership checks still apply.

A run mapping from another native home

Each launcher version has its own native home, but the run mapping lives in the project. After an upgrade, a mapping the previous version left behind names that version's home. Project commands from the new home (ps, up, down, doctor --native-cleanup inspect) refuse it with a line that names the owning home and what to do:

  • Stopped cleanly. The owning home's frontend records show that the run's latest hack up acknowledged confirmed graph cleanup, HTTPS retirement and down hooks. The refusal names the exact release command. Inspection reports state: "foreign-home", owner: "stopped" and the selectionSha256 it needs:

    ./hack-v5 doctor --path /absolute/project --native-run-mapping inspect --json
    ./hack-v5 doctor --path /absolute/project --native-run-mapping release --expect-selection <selectionSha256> --json
  • Live. The owning frontend's PID still exists. The refusal names the home and PID. Stop it with Ctrl-C in that hack up, or run hack down with the version that owns that home, then retry. Release refuses.

  • Interrupted or unproven. The frontend exited without acknowledging cleanup, or the home has no readable record for the run (for example a legacy mapping, or a home that was moved or replaced). Use the owning version's hack doctor --native-cleanup inspect and hack down. Release refuses.

Release proves ownership; it never just overwrites. The stored scope must differ from this checkout in its native home only, that home must still be the recorded directory, and the release holds the owning home's finalization lock, so that home cannot start the run again meanwhile. A retained hack up in the owning home records an admission under that same lock before its last mapping check and before any runtime effect, and keeps it until it registers its own frontend lifetime. Release refuses while an admission exists (a live startup is reported as live; one whose startup exited is reported as interrupted). Owning homes from versions without this admission record are not covered by it. A changed selection, held project locks or a pending restart refuse, as does a down-hook journal that is owed, unknown or another run's. A completed record of the same run, which a successful stop keeps, is allowed. Release removes only the project's mapping and keeps its exact bytes in an audit file beside it. The previous home's graph and named volumes stay in that home; this home's next hack up starts the instance with fresh data.

Compatibility is qualified for specific bundle hashes and state formats. The displayed version alone does not establish frontend/executor or downgrade compatibility. V4 and candidate homes remain separate; this procedure does not migrate V4 data into the candidate or replace the installed hack. DNS, trust and client configuration are outside this bundle-selection procedure.

Provider setup is separate and explicit. Obtain the exact archives identified in provider-pins.json, then run:

./hack-native --candidate-root /absolute/private/candidate-home runtime prepare --archive /absolute/smolvm-archive.tar.gz
./hack-native --candidate-root /absolute/private/candidate-home runtime prepare-engine --archive /absolute/docker-archive.tgz
./hack-native --candidate-root /absolute/private/candidate-home runtime prepare-network-tools --directory /absolute/private/pinned-apks
./hack-native --candidate-root /absolute/private/candidate-home runtime probe --json

Preparation keeps each archive it extracted (providers/smolvm-installing/release.tar.gz and providers/docker-installing/release.tgz). If something outside Hack changes a prepared tree, for example a cleanup or backup tool that prunes empty directories, every start refuses with artifact_digest_mismatch and the message names the repair. A stopped runtime's runtime status and runtime down still work, because they run nothing from the tree, so an installer quiescence check is not blocked. To repair the tree, stop the runtime and run:

./hack-native --candidate-root /absolute/private/candidate-home runtime repair-provider-tree --json

The repair holds the provider lock and requires a stopped (or uninitialized) runtime. It requires the preparation record to match this executor's pins and the kept archive to be an owned regular file with the pinned SHA-256. It then copies and hashes the archive again, extracts it, checks the code signature (SmolVM), and requires the fresh tree to match the digest recorded at preparation. Only then does it rename the changed tree aside as providers/<tree>-changed-<id> and put the fresh tree in its place. It keeps the changed tree for inspection and never deletes it; remove it yourself once you no longer need it. Runtime state, disks and volumes under run/ are not touched. An intact tree is reported with repaired: false. A repair that fails a check changes no tree and keeps its private -repairing- staging directory. Without a kept archive the repair refuses (provider_repair_unavailable); prepare a fresh candidate home instead.

The pins also name a BuildKit client (buildx, darwin-arm64) for native image builds. Prepare it before using HACK_NATIVE_BUILDS=1; it is optional otherwise:

./hack-native --candidate-root /absolute/private/candidate-home runtime prepare-builder --archive /absolute/docker-buildx

The command takes the exact release file named in provider-pins.json. It checks the SHA-256 before it changes any state, then copies the file into private candidate state and checks its code signature. If a prepared client exists, the command checks it again and does not replace it. If the prepared tree changes, runtime probe refuses; it reports builder_prepared when the client is present and unchanged.

runtime build-image --request <file> --json builds one image in the running runtime's engine with that client. Native up calls it for build: services when HACK_NATIVE_BUILDS=1 is set; you can also run it directly. The request is a private JSON file (a singly linked, mode-0600 file owned by you):

{"version": 1, "project_root": "/absolute/project", "context": "app",
 "dockerfile": "Dockerfile", "target": null, "args": {"NAME": "literal"},
 "policy": "missing", "timeout_seconds": 1800}
  • The context and Dockerfile must be inside the project and reached without symlinks. The Dockerfile is read, never run, to find every image it can pull. Each image must be a public Docker Hub reference; it is pinned to a digest before the build starts. Base images with stored ONBUILD triggers refuse. The client builds from a private copy of the Dockerfile and ignore rules that were scanned, so editing either file during the build has no effect on it.
  • Build arguments are not secrets, exactly as with Docker: the built image records their values in its history, and an argument that selects a base image appears in the resolved base references that the receipt and the result report. Never pass a secret as a build argument. BuildKit build secrets are the right mechanism for secrets; native builds do not support them yet.
  • Every build runs under a BuildKit source policy: only the pinned base images resolve as images, and network sources (ADD of an http, https or git URL, and image blob sources) are denied. The engine would otherwise fetch those from outside the isolated build network. The policy also covers sources that a base image's stored ONBUILD triggers add; such bases refuse before the build anyway.
  • Argument values reach the client only through a private definition file (mode 0600, removed after the build), never its command line or its environment; the client's environment is a fixed list of its own settings. Names must be plain identifiers. Names BuildKit or buildx interpret themselves (BUILDKIT_*, BUILDX_*, SOURCE_DATE_EPOCH) and the platform arguments refuse.
  • Build output is shown on stderr after filtering, as defense in depth: every argument value is replaced with [redacted], also when quoted or escaped the way the client prints values, terminal control characters are removed, lines are cut at 16 KiB and output stops after 4 MiB.
  • missing reuses a verified image for the same build key without running the client; rebuild always builds. The key covers the Dockerfile and .dockerignore bytes, target, arguments and versions, not the context's other files (as with Compose).
  • The runtime must have network egress and the isolated build network that a runtime started by this version sets up. An older runtime refuses with native_build_runtime_restart: stop it and start it again.
  • runtime down refuses while a build runs. Ctrl-C or SIGTERM cancels the build and stops the client's whole process group; so does the time budget (default 30 minutes, at most 2 hours). Both also apply while base images resolve, before the client starts: the budget covers the whole operation.
  • A cold build needs at least 6 GiB free on the runtime's storage disk, or twice the last image of the same build if that is larger; otherwise it refuses with native_build_storage and deletes nothing. After each successful build, BuildKit's cache is pruned to 8 GiB. The cache holds only build steps; pruning never removes images, containers or volumes.

runtime reclaim-build-images --json removes build images that a newer build of the same key replaced. It keeps every image a build receipt names as current, every image that any retained run's saved state mentions, every image a container still uses (the engine refuses those), and every image whose ownership labels are not exactly this runtime's. Each removal deletes that one image only, never its untagged parent images. It refuses while a build runs, and removes nothing if it cannot read all saved run state. The report lists removed images and why each other one was kept.

Before startup, network-tool preparation additionally requires all four pinned Alpine aarch64 APKs in a current-user-owned private directory (mode 0700), with regular, singly linked mode-0600 files. Acquire the matching public package bytes; the command never downloads packages or accepts substitute versions.

The pinned files come from the Alpine v3.19 main repository for aarch64 (https://dl-cdn.alpinelinux.org/alpine/v3.19/main/aarch64/). Other Alpine releases, such as v3.20, publish packages with the same file names but different bytes; those fail the hash check below.

FileSHA-256
iptables-1.8.10-r3.apk31ab6343f1f3d0fbbf290c4dcf0430b2d08e8073e516e13530dfab25b097d467
libmnl-1.0.5-r2.apkd15e6313880bdd14959f42c1556b4a810ef4894992ae9f73b148126f0cc6021d
libnftnl-1.2.6-r0.apkec1c2b02869fc65bcf7a1105e3a7ac5df1bd9a8bd8b399cb6cf650dd3112c021
libxtables-1.8.10-r3.apkf0accefde240ece6722479b46cb014d7d2f745af7d796e8eec3ced53571e1088

All APKs are validated before candidate state is changed. Installation publishes a complete private directory atomically; an existing installation is revalidated and reused without overwriting files. Corrupt installs or interrupted network-tools-installing staging are retained and refused for manual review. The executor bundle includes no APKs; prepare them explicitly before runtime up. This command changes only host candidate artifacts and does not boot or modify a VM.

Preparation verifies the pinned archive hashes before extraction; SmolVM also requires its existing signature verification. No fallback to global providers or v4 state is enabled. Runtime startup is a further explicit operation subject to host resource admission. Read --help for the candidate command surface.

The development profile reserves the declared 32 GiB storage disk and 10 GiB overlay ceiling plus 16 GiB of host disk space before a fresh startup. Starting again on a verified running pool, or restarting an exactly clean stopped pool whose last down completed, reuses existing disks and reserves only the 16 GiB host reserve; runtime probe --profile development --json reports the basis in disk_budget_basis, and retained_reserve_refusal names why a stopped pool's disks were charged again. A restart uses that reserve only when free disk is below the fresh budget; with enough room it starts on the fresh budget as before. Any other stopped pool keeps the fresh budget. The unchanged memory budget is a 10 GiB free-plus-file-cache estimate: 6 GiB guest, 2 GiB provisional provider overhead and 2 GiB host reserve. Normal and warning macOS pressure can qualify only with that headroom, normal thermal/performance state and stable swapouts. Development samples before the startup lease and again under the lease before allocation, keeping the same swapout baseline across both windows. Critical, unknown or incomplete observations refuse; there is no bypass option. It reports host load without refusing solely because unrelated jobs raise the one-minute average. The research profile keeps its normal-only pressure policy, 16 GiB raw-free-memory floor, 100 GiB disk floor and host-load qualification envelope.

For live Development effects, the host headroom floor is 2 GiB under normal pressure and 4 GiB under warning, plus measured provider footprint above the 6 GiB guest and 2 GiB provisional overhead estimate. Changed swapouts and thermal warnings still refuse effects. This does not cap provider footprint or qualify runtime efficiency. Admission JSON records the numeric pressure level and typed state; the legacy memory_pressure_normal boolean remains false for warning. Saved reports do not grant resource authority.

The normal hack-runtime-candidate / hack-local development build remains bound to its source checkout, including when all Cargo features are enabled. Only the separate hack-native binary opts into installed-home discovery. Existing provider and receipt ownership checks remain in force.

For an existing owned graph, bounded service diagnostics use its run identifier:

./hack-native --candidate-root /absolute/private/candidate-home graph logs --run-id RUN_ID --service web --tail 200 --json
./hack-native --candidate-root /absolute/private/candidate-home graph exec --run-id RUN_ID --service web --json -- /bin/echo hello

Both commands select the current container and recheck its boot, receipt generation and ownership before reading or executing. Logs include exited services, default to 200 lines (maximum 1,000), and return bounded, UTF-8-lossy text with a truncation flag. lines lists the retained lines in engine order, so stdout and stderr interleave by time; stdout and stderr hold the same lines per stream. Retention keeps whole lines, newest first, within 32 KiB of JSON text and sets truncated when older lines were dropped. Only a single line longer than that budget is cut. hack logs SERVICE --no-follow prints lines in that order, each to its own stream. Authored projects return only stdout and stderr, and each stream also stays within 16 KiB of JSON text: older whole lines go first, and a newest line longer than that keeps its most recent bytes.

graph observe --run-id RUN_ID --json reports each service container's current state (running with the engine's health, exited with its code, dead, created, absent or unknown) without taking the provider lease. It only reads, checks each recorded container's ownership, runs no probes and grants no authority; foreground hack up polls it to report service exits.

project plan without --json exits non-zero (plan_diagnostics) after printing a report that contains an error diagnostic. With --json it exits 0 and the report is the result: diagnostics and enrollment_compatible carry the outcome, because Hack plans original input that its normalization adapts afterwards. Exec requires a running service and returns Base64 stdout/stderr to preserve arbitrary bytes, an exit code, and a truncation flag. The CLI also exits with the command's exit code. Retained exec output is limited to 1 MiB per stream.

Exec is noninteractive: stdin is closed, no TTY is allocated, and arguments are passed literally without an implicit shell. The default wait is 30 seconds, configurable up to 120 with --timeout-seconds. Timeout or transport failure means completion is uncertain: the command may still run, and Hack does not replay it. Exec inherits the container's static environment. When the selected service has a committed private environment attachment on the current boot, it reuses that service's exact read-only launcher mounts and original expiry. No values are copied into engine metadata, and exec never renews credentials. With the current launcher, managed exec resolves a command name such as bun against PATH inside the selected container after applying its environment; an explicit path still runs literally. Older launcher mounts require a restart before using command names. Changing overlays is unsupported. Neither command persists application output in runtime receipts; output can contain sensitive application values and should be handled accordingly.

Explicit normalized public Compose input

A frontend can pass a reviewed public Compose document without replacing the original configuration or relaxing source exclusions. Add all three flags to project plan, project capture, project publish-source, project verify-source, or fresh graph run / graph serve:

--normalized-file /absolute/public-normalized.yml
--expect-original <sha256-of-original-compose-bytes>
--expect-namespace <reviewed-workspace-namespace>

Keep --file pointing to the original Compose file inside the selected project; relative paths retain that file's directory as their base. Obtain the namespace from hack-native --candidate-root HOME plan --project PROJECT --json. Review the normalized project plan result, then pass that exact --expect-plan to capture, publication, and graph execution. Every operation rechecks the original hash and namespace. The normalized file must be a nonempty regular file of at most 256 KiB; symlink files and hardlinked files are refused. Its bytes are retained in memory, not copied into candidate state. The input is public configuration only: managed secrets still use the separate graph serve --environment-stdin private envelope.

The file-based enrollment, source-sync, graph restart, and graph restore commands do not accept normalized input. A stopped normalized graph instead uses graph restore-selection --run-id RUN_ID --json, followed by graph serve-restore with the same run and original input identity, the returned --expect-generation, a freshly reviewed plan, and fresh environment, dependency and route selections. Normal foreground hack restart performs this selection and retained-data restore. It verifies the old containers and networks are absent and the retained volumes still have their recorded identities; it does not silently create replacement data. When the original Compose file is unchanged, retained startup also selects the authenticated container image IDs from that stopped graph. Mutable tags are not resolved again for those services. Missing images still refuse native admission; this selection never replaces generation, source or resource checks. Changing the original Compose file uses normal image resolution and the existing compatibility rules, rather than silently adopting an old image for a new declaration.

Editing the Compose file of a stopped graph and running hack up again reconciles the same run, as docker compose up does. Every container and network is recreated from the new review, so changed commands, images, environment and added or removed services take effect. A named volume whose key the run already retains is reattached under its recorded name after the same label and identity checks; a newly declared volume is created fresh; a retained volume the new file no longer declares stays owned and unmounted until hack down --remove-data (Compose also keeps project volumes). Retained data is only ever reattached to the run and namespace that created it.

Graphs with hack.dependencies.* caches reconcile the same way. A cache's identity covers only its own inputs: the initializer's service definition and resolved command, entrypoint, environment and user, the cache declaration, its image, the declared lockfile and runtime-file contents, and the cache volume layout: every service that mounts the cache volume, with its target, subpath and :ro mode. Any of those services can write the shared contents, so adding, removing or changing such a mount is a cache input; mounts stay as declared, as in Compose and v4, and are not forced read-only. Caches are shared across linked worktrees and branch instances only while every service other than the initializers mounts them read-only. A service that mounts the cache read-write can change its contents through its own image, command, environment or source, which the identity does not cover, so that cache is scoped to this checkout and branch instance and never shared. To share it again, mount it with :ro in the other services. A run created by an earlier build whose cache has such a writer moves to its own instance cache once instead of reusing the shared one. Any other edit to another service, such as an environment value on the app, keeps the cache. An edit to one of those inputs, including a lockfile, selects the cache for the new identity as a fresh up would, so the initializer installs into it; the old cache is shared and is only released by this run. Each such up publishes the current source snapshot. A retained named volume is never reused as a dependency cache: declaring one refuses and names the service and the hack.dependencies.cache-volume field. Caches recorded by earlier builds, whose identity also covered the whole Compose file, are kept while that file is unchanged and replaced once on the first edit. Live source, a cache scope carried across a source-device rebind or a pending initializer cache release keep the narrower rules below: a Compose edit other than a routed hostname change refuses there with graph_plan_change_unsupported, and a hostname change mixed with other edits refuses with graph_hostname_change and the service name. Every refusal says to revert the edit, or to run hack down --remove-data and then hack up. hack restart of a stopped graph refuses and points to hack up. For a shared-source graph that reconciliation does not select (live source, a carried cache scope, a pending source-device rebind or initializer cache release) and that was admitted with a retained compatibility contract, ordinary source-content edits may change the reviewed plan ID: restart checks the stable execution, exclusion, mount and dependency-cache inputs before cleanup and again under the provider lease. The original installer publication and graph ownership remain pinned. Changed configuration or cache inputs refuse before cleanup; older receipts without that contract continue to refuse source-edited restart. Source exclusions and runtime ownership checks remain in force. Existing commands without normalization flags keep their file-based behavior.

New normalized shared-source graphs also capture a separate hostname-change contract. A retaining restart can change only the literal hostnames of already routed services: the service set, upstream ports, TLS settings, probes, commands, environment declarations, images, mounts, networks and volumes must stay the same. The one exception is the reserved runtime host metadata keys (HACK_BRANCH, HACK_COMPOSE_PROJECT, HACK_DEV_*, HACK_ALIAS_*, HACK_SERVICE_* and HACK_RUNTIME_METADATA) that Hack generates into the normalized Compose: their public values are derived from the hostnames, so the normalized fingerprint ignores them. A value you declare for one of these keys in .hack/docker-compose.yml is an ordinary literal, so changing it still refuses. A metadata change without a hostname change also still refuses. This includes changing HACK_NATIVE_HTTPS_PORT, and also selecting or removing the native HTTPS frontend when its port is not 443, because the advertised URLs gain or lose that port. Start the retained graph with the HTTPS frontend selection and port it was admitted with. Both original and normalized Compose declarations are checked; only fingerprints are retained. Changed dependency-cache inputs still refuse. The old ownership plan and named data identities remain fixed, while normalized input provenance and route intent advance together. Returning to the old names requires the same checks. Existing hostname claims are checked before cleanup and again at publication. Older receipts without this contract cannot be retrofitted from their redacted plans and continue to refuse hostname changes.

doctor --domain-migration apply remains a file-only operation. It retains old aliases and does not restart the graph or activate DNS/trust. Run an explicit retaining restart after reviewing its changes, and separately verify the exact browser origin. A successful file rollback likewise requires a retaining restart to change running routes; it does not imply a live migration succeeded.

Graph execution respects the declared container root mode: read_only: true keeps the root read-only, while false or omitted permits a writable container layer. Removing the owned container discards that layer; use named volumes for durable data. Source bind mounts still require read-only publication. Writable container layers do not imply writable source synchronization.

A service restart policy (always, unless-stopped, on-failure or on-failure:N) becomes the guest engine's own restart policy, so restart timing, backoff, retry counting and the manual-stop rule follow Docker. no or omission keeps automatic restarts off. A one-shot service with service_completed_successfully readiness accepts only on-failure[:N], which retries until it succeeds or its retries run out; always and unless-stopped refuse there, as in Compose. While the engine waits to start the next incarnation, hack ps reports the service as restarting, and it satisfies no readiness goal. A policy-restarted container keeps its container ID but gets a new start time, so Hack treats it as a new incarnation: any proof bound to the previous one refuses rather than adopting it.

The port and route bridges that serve published ports and local HTTPS routes are bound to one incarnation and cannot yet follow a restart (HACK-1935). A routed service, or a service with published ports, therefore starts without its restart policy: the container runs with the engine policy no, and native review reports restart_policy_not_applied for it. Foreground up prints one line per affected service before startup, for example Service web: restart: unless-stopped is not applied. Restart policies are not applied yet to services with published ports or routes (HACK-1935); the service starts without automatic restarts. Other services in the same project keep their policy. For the same reason the graph refuses a restart policy, with graph_restart_policy, on other services with a native HTTP probe, services with host dependencies, services that receive managed environment values (delivery is single-use and time-bounded) and dependency-cache initializers. Remove the restart key from those services, or keep them on the Compose backend.

Every owned stop first sets the engine policy back to no and confirms that with a fresh inspection, then requests the stop. A restart that was already pending is cancelled by the stop, and cleanup still requires an observation that is neither running nor restarting before it deletes anything. A guest engine restart or VM reboot while a graph is still owned restarts its always and unless-stopped containers, as Docker does; owned cleanup or recovery stops and removes them. hack run one-off containers never inherit the service restart policy.

An image that declares VOLUME (for example a database image) runs natively only when the service mounts a named volume at each declared path, as in db-data:/var/lib/postgresql/data. Hack never creates an anonymous volume: a declared path without exactly one whole named volume of the project refuses with graph_image_volume_uncovered, and a read-only (:ro) mount, a bind mount, a volume.subpath mount or a dependency-cache volume at that path does not count. On first use the engine copies the image directory's contents, owner and mode into the new, empty volume; later runs mount the retained data unchanged. With Docker's default capabilities (see Container capabilities and the security model), an image entrypoint that starts as root and hands the data directory to another user (chown and then a user switch) works. A service whose cap_drop removes a capability that handoff needs (CHOWN, DAC_OVERRIDE, FOWNER, SETUID or SETGID, or ALL) without adding it back fails there; set an explicit numeric user: that matches the image's data user instead. hack doctor warns about that case and about data services on mutable image tags. A native graph can own at most 16 named volumes, dependency caches included.

Container capabilities and the security model

On the native backend, the development VM is the isolation boundary between project code and your Mac. Containers inside the VM are a compatibility layer: they give each service the process environment it would have under Docker, not a second security sandbox. This matches how the Compose backend runs on Docker.

Compose-file services therefore get Docker's default Linux capability set for the pinned engine (Docker 29.5.2): CHOWN, DAC_OVERRIDE, FSETID, FOWNER, MKNOD, NET_RAW, SETGID, SETUID, SETFCAP, SETPCAP, NET_BIND_SERVICE, SYS_CHROOT, KILL and AUDIT_WRITE. Stock images that start as root, change the owner of their data directory and then switch to an unprivileged user (most database images do this) work without a user: override. no-new-privileges stays on, so a process cannot gain capabilities through setuid or file-capability binaries. privileged: true is refused.

cap_add and cap_drop follow Docker's rules. Names are case-insensitive, and the CAP_ prefix is optional. cap_drop removes names from the default set, and cap_add is applied after it, so a name in both lists is kept. cap_drop: [ALL] keeps only the names in cap_add. cap_drop accepts any capability the engine knows. Unknown names are refused, as Docker refuses them. Interpolated names are not resolved and are refused as unknown.

cap_add accepts the default names plus a closed set whose effect stays inside the container's own namespaces or cgroup limits: NET_ADMIN and NET_BROADCAST (the container's own network namespace), SYS_NICE (scheduling and NUMA policy, often declared for database images), IPC_LOCK (memory locking, still charged to the memory limit) and IPC_OWNER (the container's own IPC namespace). Plan review refuses every other addition with capability_refused, including cap_add: [ALL]. One VM can host several projects and worktrees, and Hack runs its own helpers inside service containers. These refused capabilities would act on that shared kernel or on those helpers:

  • SYS_ADMIN, SYS_MODULE, SYS_RAWIO, DAC_READ_SEARCH and BPF are known container escape or kernel attack primitives.
  • SYS_TIME, SYS_PACCT, SYSLOG, PERFMON, AUDIT_CONTROL, AUDIT_READ, MAC_ADMIN, MAC_OVERRIDE, WAKE_ALARM and BLOCK_SUSPEND act on the VM as a whole: its clock, accounting, kernel log, performance events, audit or security modules, or power management.
  • SYS_PTRACE would let an application trace Hack's in-container helpers (the dependency relay and the HTTP probe), bypassing the kernel's ptrace restrictions.
  • LINUX_IMMUTABLE could make files in Hack-owned volumes impossible to remove during cleanup.
  • SYS_RESOURCE, SYS_BOOT, SYS_TTY_CONFIG, LEASE and CHECKPOINT_RESTORE have no reviewed development use case yet.

Hack always writes the effective set explicitly (CapDrop: ["ALL"] plus CapAdd), rather than relying on the engine's implicit default. Startup compares that exact set against the inspected container. Authored native services, persistent-data initializers and Hack's helper containers keep every capability dropped.

A stopped graph whose containers were created by an earlier candidate (with every capability dropped) differs from this profile. graph restart on such a graph refuses with graph_config_mismatch; it never adopts the old containers. Normal hack restart and hack up recreate the containers around the retained volumes, so they use the new profile.

The normal-CLI integration helpers prepare modern environment overlays and worktree inheritance in memory, with null declarations in public normalized configuration. An explicit AWS profile adapter can replace the recognized read-only ${HOME}/.aws:/root/.aws:ro mount with temporary, service-scoped credentials. It preserves unrelated mounts and refuses custom credential-file selectors. Credentials are not written to Compose or the project snapshot, and are not automatically renewed after expiry. Normal native foreground hack up uses these adapters within the capability limits described below.

Compose fields with a native meaning

Native review accepts these Compose fields. Where the native result differs from Compose, review reports a warning; nothing is dropped silently. A plan that does not use a field keeps its earlier plan ID. A plan or receipt that does use one is refused by an older bundle, so clean up such a graph with the bundle that started it.

  • depends_on.<service>.required: false. When the selected profiles disable the target, the edge is skipped with the warning optional_dependency_inactive, as in Compose. When the target is active, native waits for its condition exactly as for a required dependency and fails startup if the condition is never met. Compose may only warn there, so review reports optional_dependency_enforced. A target that does not exist is still missing_dependency, and restart: true is still refused.
  • stdin_open: true keeps the container's stdin open, so it never reaches EOF. No host input is attached, as with hack up on Compose. tty: true is accepted but no pseudo-terminal is allocated: stdout and stderr stay separate, and review reports terminal_not_allocated. Programs that need a real terminal (colors, prompts) stay on the Compose backend.
  • A caddy hostname label with caddy.tls: internal and no caddy.reverse_proxy proxies nothing, so native treats it as inert: no route, hostname claim or relay is created, and review reports inert_route_declaration. The hostnames still appear in runtime host metadata, as on Compose. Any other partial or extra caddy* label set is still refused. Inert hostnames are ordinary literals for a retaining hostname change, and HACK_NATIVE_ADAPTATION additionalHostnames refuses an inert service.
  • Nested interpolation defaults such as ${A:-${B:-fallback}} resolve inner expressions first, as Compose does, up to four levels and within the shared 1 MiB expanded budget. Only the :- and :+ operators are supported at every level; ?, unbraced $NAME and $(...) inside a default word are refused. A word that is not selected is still checked, but its inputs are not required.
  • A reference to a Hack-managed value of the same service, for example ${TOKEN:-fallback} when Hack delivers TOKEN to that service, refuses with execution_environment_owner. Managed values are delivered privately and are never interpolated into commands, users, health checks or other values, and a default is never used in their place. Declare the key bare (TOKEN: or - TOKEN) to receive the managed value.

Registry image acquisition without Docker

The candidate can fetch a digest-pinned image from any OCI distribution registry without Docker, OrbStack or a running guest: Docker Hub, GitHub Container Registry, Google Artifact Registry and Container Registry, Quay, Amazon ECR, and private registries on their own host names.

./hack-native --candidate-root /absolute/private/candidate-home runtime fetch-image \
  --reference '[registry/]repository@sha256:REVIEWED_MANIFEST_DIGEST' \
  --archive /absolute/private/new-image.tar

For an existing mutable image declaration, resolve it explicitly first:

./hack-native --candidate-root /absolute/private/candidate-home runtime resolve-image \
  --reference '[registry/]repository:tag' --json

Image references are normalized as the Docker CLI does before any request: alpine is docker.io/library/alpine:latest, alpine:3.21 is docker.io/library/alpine:3.21, and user/img is docker.io/user/img:latest. index.docker.io becomes docker.io. Tags, digests, registries, ports and nested repository paths are kept as written; Docker Hub references must name a namespace/repository. Repository names must be lowercase, and a normalized name is limited to 255 bytes. Uppercase digests and malformed registry ports are refused. Check a reference without network, credentials or candidate state:

./hack-native --candidate-root /absolute/private/candidate-home runtime admit-image \
  --reference 'alpine:3.21' --json

It returns the canonical reference and a kind of docker_hub, registry or image_id; an unacquirable reference refuses with registry_image. Native foreground hack up runs this admission for every authored service image before startup hooks, the VM or any provider starts. Compose plan review applies the same syntax rules, keeps the authored image text in the reviewed plan, and does not change existing plan identities.

Local registries

A registry on this host or its local network is refused unless it is named exactly: localhost and *.localhost, loopback, private, link-local, CGNAT and multicast IPv4 literals (including numeric spellings such as 0x7f.0.0.1), single-label hosts such as registry:5000, and mDNS .local names. The same rule applies to the address a connection actually uses: a public-looking name that resolves to any address that is not globally reachable unicast is refused with registry_local_address. That includes loopback, private (including IPv6 fc00::/7), link-local, CGNAT, benchmarking (198.18.0.0/15), documentation, multicast, reserved and other special-purpose blocks, local-use NAT64 (64:ff9b:1::/48), and IPv6 forms that embed such an IPv4 address. IP literals follow the same rule. This covers the registry, its redirects and its token endpoint. A private registry on an internal network therefore also needs an explicit opt-in. Allow one with --allow-local-registry [http://]host[:port] (repeatable) on admit-image, resolve-image, fetch-image and ensure-image. Without http:// the registry must serve HTTPS; with it, plain HTTP is used for that registry only. Native hack up reads a comma-separated list from HACK_NATIVE_LOCAL_REGISTRIES, for example HACK_NATIVE_LOCAL_REGISTRIES=http://localhost:5000.

Registry credentials

Requests start anonymous. When a registry answers 401, the candidate first requests an anonymous Bearer token, then uses the host user's existing Docker credentials, in the Docker CLI's order: a credHelpers entry for the registry, then credsStore, then an auths entry (auth, username/password or identitytoken). Helpers run as docker-credential-NAME get from PATH; a helper may show its own approval prompt, such as a keychain prompt, and is stopped with its process group after 60 seconds. Processes a helper leaves running cannot extend that wait. The configuration is ~/.docker/config.json, or --docker-config DIR (native hack up passes DOCKER_CONFIG when set).

Credential lookup is read-only: nothing is logged in, stored, erased or rewritten. Credentials and tokens stay in memory for one acquisition, are sent only to the registry's own origin or its token endpoint, never to a redirected blob host, and never appear in output, receipts or errors. Only a 401 from the registry origin itself is answered; a redirected blob host that refuses access fails with registry_redirect_refused and no token or credential request. Docker Hub tokens come only from auth.docker.io. For other registries, an anonymous token may come from the registry origin or a public HTTPS host, but Docker credentials are sent only to a token endpoint on the registry origin, or over HTTPS on port 443 at the registry's own host name or a parent domain of it (for example gitlab.com for registry.gitlab.com). Any other token endpoint refuses with registry_realm_refused before credentials are read. Run docker login (or your registry's helper setup) yourself before acquiring a private image.

Content, budgets and limits

Omitting the tag selects latest. Resolution returns pinned_reference, source_digest, manifest_digest, image_id and platform; it downloads only bounded metadata and verifies one unambiguous Linux ARM64 image configuration. Pass the returned pinned reference to fetch-image, not the original tag. Resolution creates no archive or runtime state and does not prove that all image layers fit the budget or that the application starts. A missing tag or object reports registry_image_not_found; no replacement image is selected.

The archive must not already exist. The result reports source_digest, the selected Linux ARM64 manifest_digest, config image_id, archive_sha256, and archive size. Pass that exact archive, hash and image ID to runtime load-image after preparing the candidate guest. Fetching and importing remain separate operations.

The fetch accepts OCI and Docker v2 indexes and manifests. It verifies the pinned index or manifest, the selected ARM64 manifest, the config and every layer against its descriptor digest and size while streaming them to disk, then validates the assembled archive again before publishing it. Layers may be gzip, zstd (including multiple and skippable frames) or uncompressed tar, at most 128 per image; foreign, non-distributable and external-URL layers refuse. The importer checks each decoded layer against the config's diff_ids.

Sizes are bounded by an explicit, configurable budget instead of a fixed cap:

BudgetDefaultRangeFlaghack up setting
Compressed archive per image2 GiB16 MiB to 8 GiB--image-budget-mib NHACK_NATIVE_IMAGE_BUDGET_MIB
Decoded content per image5 times the archive budgetfollows the archive budgetnonenone
Image cache total16 GiB16 MiB to 256 GiB, at least one image budget--image-cache-mib NHACK_NATIVE_IMAGE_CACHE_MIB

The manifest states every layer size, so an image larger than the archive budget or the cache's remaining budget refuses before any layer transfer or disk write. load-image applies the same flags. The whole acquisition has a 360-second deadline; each layer request allows at least 1 MiB/s. Native hack up gives one ensure-image request 10 minutes for acquisition and import, and a completed archive is reused on the next attempt. Redirects stay on the registry origin, Docker Hub's known blob hosts, or (for other registries) public HTTPS hosts on port 443; local destinations and embedded credentials refuse. Proxy settings are not used. TLS uses the bundled public root certificates, so a registry signed by a private certificate authority is not reachable yet. Host names are classified before connecting, and resolved addresses are checked again before each connection (see Local registries).

No implicit image refresh occurs. Keep the archive only as long as needed for owned imports; fetch does not install a background cache or cleanup daemon. The complete archive is synced, validated and published without replacing an existing path. A failed write or verification leaves no final archive; process interruption may retain a hidden temporary file beside the requested output. A directory-sync failure reports that a complete archive was published but durability could not be confirmed.

runtime ensure-image --reference REF [--emulation native|prefer-native|amd64] --json resolves, caches and imports one image into the running pool; the image policy flags described above follow --json. Without --emulation (or with native) it admits Linux arm64 only. prefer-native selects arm64 when the index publishes it and otherwise the exact linux/amd64 image; amd64 selects only linux/amd64. The result's platform names the imported platform, which the image configuration must confirm. An amd64 archive is cached under its own address, never reused for arm64, and imports only into a pool booted with translation; see amd64 emulation with Rosetta 2.

Explicit writable development source

An isolated development pool can opt into a direct host project mount with runtime up --profile development --project-share /absolute/project --unfiltered-source. graph run and graph serve select that mount with --shared-source. This shares the entire approved project, including ignored local files, with guest read/write access. Filtered source publication remains the default. Do not use the unfiltered mode for a checkout whose local files must remain hidden from its services. Only the exact canonical project root is accepted; home/credential directories, aliased roots, and changes to a running pool's mount intent are refused.

One development pool can host several project roots and linked worktrees, so one installed native home serves every project on the machine. The first root is the pool's primary share. Native up in another root adds that root itself when the pool is stopped, before any lifecycle hook runs, with the same unfiltered-source consent the first root gave; the share is mounted at the next boot. The explicit form is runtime project-share add --project-share /absolute/other-project --unfiltered-source --json. A running pool's mounts never change: up in a root outside the set refuses before hooks and says to stop the projects using the pool (down in each), stop the pool (runtime down --json on the native home), and run up again. Remove an additional root (for example a deleted worktree) from a stopped pool with runtime project-share remove --project-share /absolute/old-worktree --json; removal refuses with project_share_in_use while any retained graph still names that share, and the primary share is never removed. A pool holds at most eight shares. Boot refuses with project_share_stale while any share's root moved, changed or was removed. Every update is journaled like the stopped network extension, and startup refuses while the journal is unfinished.

Projects in one pool are separated the way Compose projects in one Docker VM are: each graph binds only its own root's share (bind paths stay under that share), graph namespaces derive from the canonical root and branch, cleanup acts on one run's own labelled resources, and resource admission is one budget per pool. The VM boundary between projects that separate pools gave no longer applies inside one pool. Legacy device-rebind and host-pin recovery stay single-root and refuse on a pool with additional shares.

Shared-source graphs with dependency caches still require a freshly published source revision. Cache initializer services read that immutable publication through read-only source mounts; their named cache volumes remain writable. This prevents concurrent host edits from changing the inputs halfway through installation. Other services use the direct host mount with their declared read/write mode. Because virtiofs preserves host ownership, those services keep DAC_OVERRIDE (part of the default capability set) even when cap_drop removes it. This allows root processes to access the explicitly shared tree; it does not bypass a read-only mount. An installer that needs to modify source files is not supported in this mode yet. Restart after dependency-input changes with a new review and publication. Moving or deleting a project prevents further activation, but does not prevent owned runtime teardown. Teardown never removes the shared host tree.

Service CPU and memory limits are optional. Services without an explicit Compose limit share the admitted VM pool (Docker NanoCpus: 0 and Memory: 0); the VM's CPU and memory admission bounds remain unchanged. Explicit limits retain per-service validation and aggregate reservation checks, and graphs remain limited to 32 services. This preserves ordinary Compose omission semantics; it makes no performance guarantee or claim about fairness between workloads.

Frontend integration status

Two native frontends exist. An authored .hack/hack.project.json project is owned by src/lib/native-authored-command.ts, which admits only the bounded macOS slices in the authored command contract: foreground up and restart, owner-mediated down and down --recover, saved-run ps, finite logs --no-follow, noninteractive exec and bounded one-off run. One-off run refuses members with source, persistent-data or private-env mounts; restart requires a proven stop before a fresh foreground start. The rest of this section describes the older Compose-file native frontend for .hack/hack.config.json projects with a Compose file, which keeps its own command coverage below.

Authored native up and restart deliver the same runtime host metadata as native image projects. The configuration compiler carries it as public runtime bindings in the reviewed source selection, below managed values and authored directives, and the runtime adds it to each workload's public environment, never to private delivery. A selected non-443 HACK_NATIVE_HTTPS_PORT appears in HACK_DEV_URL (authored native graphs are not routed, so they have no alias or service URLs); a partial HTTPS selection refuses before any input or runtime work. exec observes the values stored in the running container, and one-off run uses the admitted environment.

Explicit HACK_RUNTIME_BACKEND=native with absolute HACK_NATIVE_BINARY and HACK_NATIVE_HOME selects native observations for normal hack ps and bounded hack logs SERVICE --no-follow. They verify the project/branch run mapping against the current native graph. Logs do not support following or Loki options. A project with no admitted mapping reports not started. hack ps keeps container observations separate from the last graph receipt; an exited container's state shows its exit code, as in Compose (exited (1)). For a ready foreground graph, it also checks the live owner: owner_unconfirmed means the owner could not be authenticated, while runtime_degraded means its runtime check failed. A stopped candidate runtime is an ordinary state for ps: with a mapping present it exits 0 and reports runtime_not_running (no phase, no service rows), and text output prints "Native runtime is not running; no services are running." A provider lease held for the whole bounded read, for example by a starting foreground up, is also an ordinary state: ps exits 0 and reports runtime_busy (no phase, no service rows). Reads retry brief lease contention. hack ps --json prints the standard {ok: true, data} envelope; data holds backend: "native", status (not_started, runtime_not_running, runtime_busy, observed, owner_unconfirmed or runtime_degraded), run, phase and items. Bounded logs refuse a stopped runtime with E_DOCKER_UNAVAILABLE and exit 1. Native exec and run refresh admitted host dependencies once before command selection and managed environment preparation. They proceed only with fresh ownership and runtime proof, even if containers still appear running. Unconfirmed ownership or an incomplete refresh requires inspection and explicit retaining recovery; command requests are never replayed. Foreground whole-project hack up additionally requires HACK_NATIVE_SHARED_SOURCE=1 or "sharedSource": true in the machine settings: it exposes the exact project tree, including ignored files, with each mount's declared write mode. Use an explicitly prepared candidate home and the binary from a complete native bundle. It acquires public images, privately supplies managed environment values, runs lifecycle hooks, and publishes the run mapping after native readiness. Ctrl-C waits for owned cleanup and keeps the stopped run mapping and named volumes after confirming cleanup. The VM remains available until explicit runtime shutdown. If startup fails after the graph started resources and the frontend cannot confirm their cleanup, it publishes the authenticated failed run as the project's run mapping before exiting, so hack ps, hack down and hack doctor --native-cleanup inspect select that run instead of reporting the project as not started; nothing is stopped or replayed to do so. When the receipt itself cannot be authenticated, nothing is recorded and the runtime journal keeps the graph for explicit recovery. When the graph owner exited before reserving any runtime state (for example a configuration refusal during execution compilation), inspection finds no journal for the run: startup then reports the failure code and that nothing was created, instead of an unconfirmed cleanup.

Managed values are declared on every authored service, so the reviewed plan is the same for every profile selection, but they are delivered only to the services the reviewed plan marks active. A service outside the selected profiles receives none.

A fresh start that fails or is interrupted before it publishes its run mapping, and whose compute cleanup is confirmed, leaves no run that ps, down or a later up can select; the next fresh start creates a new run with new volumes. After confirming compute cleanup, up therefore removes that unpublished graph's volumes with graph cleanup --remove-data and confirms the removed receipt; shared dependency caches are only released. It still reports the original startup failure. A retained or restored run, and a run whose mapping write was attempted (its reply may have been lost), keep their data. When removal is not confirmed, up prints one line naming the run and the explicit graph cleanup --run-id RUN --remove-data --json request, and keeps the graph.

Ctrl-C (or SIGTERM) on a ready graph whose up.after completed stops the project the way hack down does on the Compose backend: down.before runs first, then graph cleanup, then lifecycle-process retirement, then down.after. Hooks use the environment selection of the running up. A second Ctrl-C cancels a running down hook and proceeds to cleanup; a canceled down.before is not followed by down.after. A failed down.before still stops the graph, because you asked to exit, and the command reports E_LIFECYCLE_FAILED; a failed down.after reports the same after cleanup was confirmed. Ctrl-C while up.after is still running stops the graph without any down hook. With down hooks declared, the graph owner runs in its own process group, so the terminal's interrupt reaches only the hack frontend, which decides when the owner stops. Each down hook run by Ctrl-C also runs in its own process group with stdin closed. Canceling it signals the whole group (SIGTERM, then SIGKILL once the hook's shell exits or after a two-second grace), and output from a canceled hook is drained for at most that grace, so a child such as sleep in sleep 600; echo finished cannot keep the stop waiting. The frontend keeps handling interrupts until down.after and its finalization acknowledgement are settled.

A private, value-free journal beside the run mapping records an intent before each down hook starts and its known completion after the hook's process exits. It holds only the run identity, a random writer identity and a phase, never commands, values or output. A later hack down reads it: completed (or failed) phases are not rerun; phases still owed, for example after the frontend was killed before or between the hooks, run once; a phase that started but whose completion is unknown (the frontend or down died while it ran) refuses with E_LIFECYCLE_FAILED "Native down hook completion is unknown; inspect before retrying." and nothing is replayed. That refusal also blocks the next up before any hook. After checking what the interrupted hook did, remove .hack/.internal/native-runs/*.down-hooks.json to clear it. A hack down from another terminal and the frontend's Ctrl-C claim the same journal record, so concurrent stops run each hook at most once. If that hack down is still running down.before when you press Ctrl-C, the frontend says so and waits for the hook to finish before it stops the graph; it runs down.before itself only if the other attempt failed and handed the record back. Press Ctrl-C again to stop without waiting. A new up supersedes any other leftover record; it does not run an earlier generation's owed hooks. Projects without down hooks keep the previous stop behavior and write no journal.

Without that grant, up and restart refuse with E_PERMISSION before any stop or runtime request; set it and run the command again.

This initial foreground path does not support detached/JSON startup, selected services, or the Compose features listed in Compose projects that stay on the Compose backend, such as external networks. --detach, --json, --target and service names each refuse with a one-line E_USAGE message that names the option and the supported form: run hack up in the foreground in its own terminal and use hack ps and hack logs from another. Routed services and host dependencies require the explicit native selections described below. Unsupported Compose or environment input (for example a service env_file, or a project .env without managed env) and unreviewed route labels refuse with E_NATIVE_PROJECT_UNSUPPORTED. The input refusal names its cause and, where known, the service and Compose field (for example service api field env_file), never values or file contents. Secret managed env without a project env key refuses with E_CONFIG_INVALID and names the recovery actions; other unreadable managed env refuses with E_CONFIG_INVALID without layer excerpts. Invalid branch hosts, project adaptations, AWS selections and changed fresh-exec environments refuse with E_CONFIG_INVALID, as do an unbounded --profile selection, a malformed HACK_NATIVE_DEPENDENCIES listener selection and an occupied HTTPS port. A runtime reply, saved mapping or retained graph that changed or cannot be confirmed (for example a startup receipt for another run, or a mapping owned by another run) is an integrity or ownership refusal, not a configuration one: it prints one closed line naming what was not confirmed, and --json reports it as E_UNEXPECTED. Each prints one closed line and drops nothing. Existing hack.dependencies.* cache declarations publish the reviewed source snapshot and require successful initializer completion before dependent services start. Cache initializers receive immutable source mounts and writable named cache volumes; workspace installs that write elsewhere still require explicit volume mappings. An isolated trial of a real 14-service application reached native readiness, completed Google sign-in, and rendered authenticated search results and images over browser-trusted HTTPS. A normal restart kept its run and Redis data after a source edit. This does not establish every application workflow, crash recovery or comparative performance. Native down requests retaining cleanup from the graph owner and verifies container absence while keeping the exact run mapping. It then retires that branch's owned host lifecycle processes before running after hooks. A stopped graph (after a foreground stop or a previous recovery) also retires remaining owned host processes without replaying guest cleanup; it runs only down hooks the journal above still owes, once, after the original frontend's finalization is acknowledged. Uncertain ownership refuses retirement and leaves the retained mapping intact. Before capturing live bridge helpers for cleanup, the owner retires an exited helper only after confirming its current guest boot, run, container, network and reservation. An interrupted retirement requires a fresh exit observation or an exact stopped slot fence with the allocation and socket absent. A live, replaced or unconfirmed helper remains a refusal; named volumes and sibling runs retain their existing ownership protections. Successful down also requires acknowledgement of the exact frontend attempt captured before cleanup. Missing, changed or unacknowledged finalization refuses success even when compute is already stopped; the run mapping and volumes remain available for explicit recovery. Restart retains its own captured finalization barrier so its selected dead-frontend recovery can run before replacement startup. If the foreground owner concurrently retires its tmux session, cleanup accepts a fresh explicit absence proof. Missing ownership metadata or an unsuccessful query alone does not prove absence, and retirement preserves any replacement owner's state. The next ordinary up verifies that stopped receipt and restores the same run and volumes, including when its clean owned VM has been stopped. It requires the retained frontend's exact finalization to be acknowledged before hooks, storage preparation, VM startup or shared HTTPS admission. This observation never recovers an interrupted frontend automatically, and the later startup ownership check still refuses a concurrent replacement. Offline graph retained-preflight returns durable eligibility only; it never fabricates volume observations or a restore generation. Startup checks the saved environment, profiles and AWS selector before hooks and binds the exact owner and receipt selection to runtime up with paired --expect-retained-run and --expect-retained-selection flags. Native startup holds publication retirement and revalidates the selection under the provider lease and immediately before boot. After resume, live inspection must still prove absent compute and present owned volumes; the existing restore selection pins the current boot and volume identities. Cancellation or timeout has an uncertain outcome and never replays runtime up or graph restore. It refuses an active, changed or uncertain mapping rather than allocating a new data volume. When a Compose edit changes the graph's images, volumes or other resources, the retained data cannot be adopted and up says so. Either revert the edit, or discard the data explicitly with hack down --remove-data (Compose parity: docker compose down --volumes). It works only on a stopped project: run hack down first. It asks for confirmation (--yes skips the question; --json requires --yes), runs no lifecycle hooks, and asks the graph owner to delete only the volumes that this run created. Shared dependency caches are released, not deleted. The request uses graph cleanup --remove-data --retained-only: the runtime re-checks that the graph is still stopped while holding the lock a restore also needs, so if hack up in another terminal restored the run in the meantime, removal refuses (graph_cleanup_not_retained) instead of stopping it, and nothing is deleted. The run mapping is retired only after every owned volume is confirmed absent, so the next up starts a new run with new empty volumes. If removal is interrupted or cannot be confirmed, the mapping is kept and running the same command again resumes from the owner's durable cleanup intent. Ctrl-C (SIGINT) or SIGTERM ends the owned runtime request and releases the operation lock before the command fails. If the process is killed outright, the rerun reclaims the abandoned lock only after its recorded process has exited; a live owner, or a lock held by restart or recovery, still refuses. Output names no volumes or paths. Down hooks resolve fresh managed host values using the environment selection saved by up; hook output goes to stderr with --json. A before-hook failure prevents cleanup; an after-hook failure reports that graph cleanup already completed. Environment/profile/target changes and cache pruning remain unsupported.

New mappings record the effective overlay name, or explicit null for base-only configuration; no environment values are persisted. New readers accept legacy mappings with missing selection, but refuse down hooks for those mappings rather than guessing an overlay. Missing selected overlay files also refuse before cleanup. Older binaries may reject extended mappings: this is backward state-reading compatibility, not support for rolling back binaries over new state. Whole-project foreground restart retains the recorded environment, AWS selector, profiles, run identity and data volumes. It checks development runtime admission (including disk headroom), reviews the normalized plan, and verifies each selected host dependency listener before cleanup. For unchanged original Compose input, active preflight reuses the admitted immutable image IDs, matching retained startup even if a registry tag has moved. It authenticates the complete live graph through native service selection and rechecks the same receipt generation, container and boot after review. Completed initializer services remain eligible. Edited original input uses ordinary image resolution and compatibility review; image reuse grants no authority to bypass native source, resource or ownership checks. Executable selections rediscover the current listener; fixed-PID selections require a deliberate update when their listener changes. Preflight remains immediate and does not adopt an unverified process at the same port. It saves a pending restart intent and waits for the previous frontend to confirm graph, HTTPS and lifecycle cleanup before starting its replacement. Changed selections and unknown legacy startup/finalization records refuse before cleanup. A failed replacement retains its intent: retry restart after resolving the reported problem; a fresh up cannot bypass it. When the saved intent confirms cleanup, retry independently inspects the exact native run: its journal must be complete, containers and networks absent, and retained volumes present. A matching retained frontend mapping may remain; the mapping and intent alone do not prove cleanup. Retry still reviews the public dependency intent and all source, plan, environment, network and admission checks. It captures listener identities after the startup hooks recreate them, rather than requiring already stopped listeners before those hooks run. An interrupted operation lock requires ownership inspection rather than automatic removal. Restart does not implicitly migrate a shared pool's network policy or interrupt other projects. When an interrupted frontend cannot write that final acknowledgement, an explicit restart --recover-frontend --expect-finalization-attempt <32-hex> can resume the saved, already-cleaned restart intent. It can also preflight an exact graph that was stopped before the frontend saved an intent: fresh native observations must prove absent containers/networks and present retained volumes, and repeat that proof after review before cleanup. The normal intent, retaining down, frontend recovery and startup sequence still runs. An active graph keeps its authenticated service selection and live-listener checks; uncertain observations cannot select the stopped path. Stopped preflight retains the admitted image IDs when the original Compose input is unchanged, matching retained startup instead of resolving mutable tags again. Changed original input keeps normal image resolution and the existing compatibility checks. New attempts retain their frontend PID and HTTPS port in the private token. Older v1 attempts additionally require --expect-frontend-pid <previously-observed-pid>; a guessed PID is not recovery evidence. Recovery requires the exact stopped graph receipt with its volumes present and containers/networks absent, no managed HTTPS authority or owner lock, a free recorded HTTPS port, and empty lifecycle state. Legacy recovery also refuses while another packaged Hack frontend is running. The separate recovery marker records that the old owner was proved gone; it does not claim the old frontend ran its finalizers. A live or uncertain owner still refuses, and no managed state file should be edited to bypass the refusal. Before restore, the explicit recovery path also retires the exact dead graph publisher only when its completed dead-owner cleanup receipt, current boot, retained volumes, absent compute, and host relay cleanup still match. The publisher's socket and owner record are moved under a durable inode-pinned journal; interruption between those moves resumes against the same identities. The backend operation is also available as graph retire-recovered-publisher --run-id RUN --expect-owner OWNER --json. An ordinary clean restart does not run this recovery operation. The in-development native run path requires a running project started by a matching candidate. It creates a separate service container with fresh managed environment values and the admitted image, mounts, dependency cache and network. It does not publish service ports or aliases. Commands are noninteractive and bounded to 300 seconds; omitted arguments retain the service's default command. The CLI returns command output and exit status only after owned job cleanup is confirmed. There is no automatic command replay or fallback to Compose. run uses the running project's environment and profiles. --env and --profile are optional; when given they must equal the selection recorded at startup (profiles compare as a set). A different selection, or a legacy mapping without recorded selectors, refuses with E_USAGE before any runtime request. An empty value (--env "", --profile "", or one that is only spaces or commas) also refuses with E_USAGE: it does not mean "inherit" or an empty selection. Omit the option to inherit. A profile list is empty only when every entry is, so --profile "web," asks for web. On the native backend the empty check runs first, before the project is selected, so an authored hack.project.json project refuses an empty value with the same E_USAGE line rather than its E_NATIVE_PROJECT_UNSUPPORTED refusal of any --env or --profile. An authored project on the Compose backend refuses an empty value with the same line before any compiler work, so every native run agrees; a legacy Compose project keeps its meaning, where an empty --env selects the base layers. Native exec uses the running service environment. An empty --env or --profile refuses with one closed E_USAGE line before the project is selected, for authored and legacy projects alike. A non-empty value also refuses: a legacy project with the same line, an authored project with its E_NATIVE_PROJECT_UNSUPPORTED refusal, as for run. run never restarts the project to change its selection. Services with no managed environment values also support run; they keep the admitted image's environment without creating a private environment mount or launcher. The one-off request still carries an explicit empty, identity-bound envelope. Its original deadline remains binding through container creation and start even when there are no environment mounts; expiry refuses a new effect without blocking owned cleanup. Missing or malformed delivery refuses, as do values for another service; ordinary graph startup environment delivery retains its nonempty requirement.

The running-project noninteractive path has a live smoke check against a real multi-service application: hack run www -- bun --version completed after a same-run restart, and a command exiting 7 returned its stdout and exit status. Both left no job resource and preserved the healthy parent graph and its volume identities. An interrupted one-off also completed explicit dead-owner cleanup with data retained. Closing a live one-off client confirmed exact job removal while the parent remained ready-observed, with the same volumes and healthy HTTPS route. Stopped-project bootstrap, interactive input, and repeated crash recovery after a restored one-off remain open. An uncertain shutdown retains its mapping and evidence for recovery; do not delete managed state to retry.

After starting an owned native pool, runtime ensure-image --reference REF --json resolves a registry tag (including Docker Hub shorthand such as alpine:3.21) or accepts an explicit digest, reuses the verified archive cache, and imports through the existing content-ID-checked loader. Tags are resolved on each call; a warm explicit digest needs no registry request. It accepts the image policy flags. The cache holds at most 64 archives within its configured budget (16 GiB by default); an incomplete or conflicting entry requires inspection, and the command never prunes existing data. Lowering a budget keeps existing entries readable, but the loader refuses an archive larger than the current per-image budget. This command is a startup building block, not evidence of complete normal hack up support.

Explicit Compose shm_size supports positive sizes up to 1 GiB, including shm_size: 1gb; omission retains Docker's 64 MiB default. This tmpfs ceiling is not preallocated memory and is not added again to graph memory reservations. Actual shared-memory allocation remains constrained by an explicit container memory cap, when present, and by the admitted VM pool. Restart review preserves the exact shared-memory setting; zero, malformed and oversized values are refused.

For a project using the recognized read-only AWS home mount, explicit HACK_NATIVE_AWS_PROFILE (and optional HACK_NATIVE_AWS_REGION) applies the service-scoped AWS adapter after the normal login hook. It removes only recognized AWS mounts/selectors and supplies temporary credentials through private stdin. Expired or unsupported profiles fail startup; credentials are not persisted or refreshed automatically. Unrecognized mounts and other unsupported bindings still refuse admission. Because the exported credentials are a startup snapshot, the foreground prints one value-free line to stderr ten minutes before they expire and another at expiry: AWS credentials delivered to N services expire at HH:MM; run hack restart to renew. hack exec keeps exporting fresh values for each request.

gcloud Application Default Credentials (file mode)

For a project whose services bind the gcloud ADC file read-only, for example ~/.config/gcloud/application_default_credentials.json:/config/adc.json:ro, explicit HACK_NATIVE_GCLOUD_ADC=file applies the gcloud adapter after the normal login hook, beside the AWS adapter. Accepted sources are that path written with ~, ${HOME}, $HOME or the absolute home directory, in short syntax with :ro or long syntax {type: bind, source, target, read_only: true} with no other keys. The target must be an absolute container file path outside /proc, /sys, /dev, /run and /var/run that no other mount or tmpfs of the service equals, contains or sits under. Writable or directory mounts, other gcloud paths and two matches in one service refuse; so does selecting the adapter when no service matches. GOOGLE_APPLICATION_CREDENTIALS, when declared, must equal the mount target and stays public. CLOUDSDK_CONFIG, CLOUDSDK_AUTH_ACCESS_TOKEN_FILE, CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE, GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES, GCE_METADATA_HOST and GCE_METADATA_IP in a selected service refuse. Ambient GOOGLE_APPLICATION_CREDENTIALS and CLOUDSDK_CONFIG on the host are not consulted.

The adapter reads only <home>/.config/gcloud/application_default_credentials.json: a regular file (not a symlink) owned by you, not group- or world-writable, 1 to 16 KiB of UTF-8 JSON, unchanged during the read. It admits authorized_user credentials and impersonated_service_account credentials whose source is authorized_user. service_account key files are refused, as are external_account types and any credential_source, whose host files, URLs or executables do not exist in the guest. It then runs gcloud auth application-default print-access-token once with prompts disabled, a clean environment and a 30-second limit, and discards the token unread. A missing gcloud refuses with a message naming the CLI; a failed mint or an ADC file that changed during startup refuses. Other refusals use one closed line with values omitted.

Each selected service then loses the bind mount from public Compose and receives the same bytes as a private file member through the version 2 private envelope. The runtime stages it mode 0400 for the container's numeric user in that container's environment allocation and binds it read-only at the authored target. A service with no other private values keeps its own entrypoint and health check; only a numeric image or Compose user is required.

File mode places the ADC refresh token inside the VM for the container's lifetime. That is no wider than the Compose bind mount it replaces: only the selected services receive it, as a read-only snapshot owned by the container user, retired with the container. Google client libraries refresh access tokens from it over normal egress. Host re-login or revocation is not propagated; run hack restart to deliver a fresh snapshot. The run mapping records only gcloud: {"mode": "file"}; restart and retained restore keep that selection and deliver a fresh copy into new allocations. hack exec delivers nothing new: the file is already in the container, and exec only rechecks the mount and selectors. One-off run does not apply the adapter. A mapping with a gcloud selection refuses on earlier candidates.

New immutable source captures use manifest schema 2: the revision binds both selected content and its selection receipt. This prevents an ignored mountpoint appearing after startup from colliding with an earlier publication containing the same bytes under a different selection. Existing schema-1 manifests remain verifiable; they are not rewritten or deleted. Cache keys still derive from their declared dependency inputs and execution identity.

Source inventory and immutable captures exclude agent-local directories, including .codex, .claude, .cursor, and .agents, at any depth. This also excludes Cursor skill aliases into .agents; those aliases do not block project planning. Application symlinks into excluded directories and explicit source mounts of those directories are still refused. Adding the Cursor exclusion changes source-selection identity for affected projects; existing reviewed receipts are not silently adopted. These exclusions do not filter an explicitly authorized writable host project share.

Normal native foreground startup requests outbound public internet access by default. Package downloads and external APIs do not require per-host configuration. This does not publish inbound ports or grant host-service access; the provider's private, loopback and metadata-address restrictions remain separate.

Set HACK_NATIVE_ALLOW_HOSTS only to opt into restricted outbound access: a comma-separated list of at most 32 unique lowercase DNS names, such as registry.npmjs.org,example.com. Wildcards, IP addresses, local names, whitespace, trailing dots and empty entries are refused before lifecycle hooks. The provider permits each selected domain and its subdomains, including TTL-learned public addresses; this is not a URL, port or exact-host-only policy. Redirect destinations also need approval in this optional mode.

Existing pools retain their network policy. To change an existing pool to the normal internet mode, stop its owned runtime and run:

./bundle/hack-native --candidate-root /absolute/private/candidate-home runtime network internet --json

The transition preserves disks and caches and does not start the VM. Remove HACK_NATIVE_ALLOW_HOSTS for subsequent normal foreground starts. Startup refuses a policy mismatch instead of silently changing an existing pool.

To add an approved host to an existing pool, first stop the owned runtime, then explicitly extend its policy:

./bundle/hack-native --candidate-root /absolute/private/candidate-home runtime network extend \
  --allow-host pkg-npm.githubusercontent.com --json

This preserves disks and caches and does not start the VM. It supports additive changes to an existing approved-host policy, not switching an isolated pool to networked mode or removing hosts. A recorded interrupted transition blocks startup until the same command reconciles its exact before/after states. Incomplete staging or foreign changes remain refused for inspection. Include the resulting complete host list in HACK_NATIVE_ALLOW_HOSTS on subsequent foreground starts; ordinary startup still refuses a different policy.

Normal native foreground startup recognizes the bounded caddy, caddy.reverse_proxy, and caddy.tls label contract alongside dependency-cache labels. It requests minimum bridge capacity for declared routed services before pool startup and enrolls only active routes from native review. A fresh pool creates that capacity; an existing pool with equal or greater capacity is reused unchanged. Insufficient capacity refuses startup without resizing or replacing the pool. The internal runtime up --minimum-bridge-sockets N command expresses this requirement; --bridge-sockets N retains its exact-capacity contract. Neither option promises free slots: graph admission still reserves those separately. Host dependencies use the equivalent --minimum-dependency-sockets N selection; --dependency-sockets N remains exact. Each direction can independently request an exact capacity or a minimum. The combined physical transport limit still applies to the pool's actual capacities, including any spare slots. A minimum does not resize an existing pool or guarantee that enough slots are currently free. Each routed service must already declare a matching healthcheck.x-hack-http (see Native HTTP readiness probe); command healthchecks and missing probes are not replaced or inferred. Native review still validates hostnames, ports, networks and labels. Foreground graph ownership supervises and retires Unix route publishers. Without explicit HTTPS selection, this enrollment does not start a hostname authority or Caddy HTTPS server. It never installs trust, configures DNS, or widens an existing pool.

The frontend requests --route-slot SERVICE=auto for each active route. The native graph owner selects distinct free ingress slots across the whole pool under its mutation lock and reserves the complete route set before starting its route relays or publishers. Slots held by another graph, including slots awaiting cleanup, remain unavailable. Insufficient capacity refuses the reservation without publishing a partial route set. Internal callers can still select an exact slot with --route-slot SERVICE=N; a busy slot is refused rather than reassigned. Retained starts resolve automatic selections again against current ownership. This allocation does not resize a pool or enable multi-worktree source sharing. Shared HTTPS lifetime uses separate graph leases.

An empty shared HTTPS configuration left by failed startup is not a released lease. The experimental source API archiveEmptyNativeHttpsOwner can archive only the explicitly selected generation and configuration digest after proving the original observed spawner exited, the selected endpoint/port/authority and publications are absent, and the same pool incarnation remains running. It preserves configuration bytes/inodes and Caddy data. Current startup and archival share a short admission lock; uncertain intent publication retains its barriers for inspection. Older binaries do not honor that lock and must remain quiescent during this explicit recovery. Changed, active or ambiguous state refuses; this is neither automatic recovery nor a public CLI command.

A nonempty shared HTTPS owner from the immediately preceding guest boot requires a separate retaining recovery. The selected graph must already have an exact current completed dead-owner cleanup proof, a retired publisher, retained volumes and no compute, bridge, publication or hostname authority. Stop other uses of the recorded frontend, runtime and Caddy executables first; legacy binaries do not honor the new admission lock. Recovery refuses a live or uncertain process using any of these executable paths, including Caddy serving another candidate home.

Explicit restart --recover-frontend --expect-finalization-attempt ATTEMPT can select this transition for its exact v3 finalization lease. The internal native operation binds every lease field:

./hack-native --candidate-root /absolute/private/candidate-home runtime archive-previous-boot-shared-https \
  --run-id RUN --expect-owner-generation GENERATION --expect-lease-id LEASE \
  --expect-attempt ATTEMPT --expect-owner OWNER --expect-namespace NAMESPACE \
  --expect-plan PLAN --json

The operation holds shared HTTPS admission, retired-publisher protection and the provider cleanup lease while checking the immediate boot transition, original executable hashes, owner/lease files, CA and exclusive port availability. It archives original owner and lease files by same-filesystem rename and signals no process. A present control socket and parent must match their selected identities and be inactive; the socket is renamed within its unchanged parent. If both socket and parent are absent, that absence must remain true throughout archival, alongside the independent process, port and graph proofs. A partial or replaced socket path refuses.

An interrupted archive retains its journal and admission barrier. Only the exact selection with a verified dead recovery process can resume; do not remove lock files or synthesize missing ownership records. Completion preserves Caddy data, volumes and original lease evidence. Explicit frontend recovery consumes that completion as a distinct recovery proof, never as an ordinary live-owner lease release. Keep other shared HTTPS startup quiescent until frontend finalization completes. A newer active owner blocks completed-archive replay; this recovery does not adopt or stop it. Archival alone does not start the application or establish browser access.

Normal foreground startup also selects --auto-dependency-slots. Each graph's logical dependency groups receive distinct physical pool slots under the provider lock. The complete assignment is recorded before listeners bind, so concurrent starts and a crash before binding cannot claim the same capacity. Cleanup verifies owned graph resources, closes its dependency listeners, and releases its reservation before reporting success. A sibling graph's reservations and listeners remain owned by that sibling. Low-level callers without this flag keep exact slot semantics and must also respect existing reservations.

graph dependency-reservations --json inspects the durable claims. A dead owner that never enrolled a graph can release a coherent claim with graph recover-dependency-reservation --run-id RUN --expect-reservation SHA256. Recovery verifies the owner is dead and any recorded socket still has its original identity and no listener. Torn records and sockets without complete ownership evidence remain unavailable; do not delete them to force allocation. Recovery of an already enrolled graph requires verified graph cleanup instead.

HACK_NATIVE_DEPENDENCIES, or dependencies in the machine settings, selects a regular JSON file with explicit host listeners; the variable takes an absolute path. The CLI reads it after lifecycle hooks, so a hook can prepare current listeners before selection. For example:

{
  "version": 1,
  "dependencies": [{
    "service": "web",
    "binding": "search",
    "guest_port": 443,
    "aliases": ["search.example.com"],
    "host_pid": 12345,
    "host_port": 8443
  }]
}

Replace the example PID with the actual listener process. Up to 128 service-specific bindings and eight exact aliases per binding are supported, within 32 listening transports shared with the pool's route bridges.

When several services need the same binding, version 2 lets one entry list them with services instead of repeating the entry for each service:

{
  "version": 2,
  "dependencies": [{
    "services": ["web", "worker", "api"],
    "binding": "search",
    "guest_port": 443,
    "aliases": ["search.example.com"],
    "host_executable": "/usr/local/bin/tunnel",
    "host_port": 8443
  }]
}

Hack expands each such entry into one version 1 entry per listed service, in order, with the same binding, ports and host authority, and then validates the result exactly as version 1. Each listed service receives the binding's aliases that it declares as host-gateway extra hosts (native review accepts no others), so when every listed service declares every alias, both forms produce the same dependency plan. The limits count expanded entries: at most 128 bindings in all. An entry sets at most one of service and services; a version 2 entry that names neither applies to every service that declares one of its aliases, as described with project-wide host aliases below. A service listed twice, or a binding name used by more than one entry, refuses as a duplicate. Version 1 files keep working unchanged.

For a listener started or rotated by a lifecycle hook, replace host_pid with "host_executable": "/absolute/path/to/the/listener-binary". Hack discovers the current PID after the hooks and again before restart cleanup, requiring one same-user process with that exact executable and an exclusive IPv4 loopback listener on host_port. It passes the discovered PID to native review without rewriting the private selection file. The native dependency plan pins the process and socket generation again before admitting the graph. A missing, ambiguous, wrong-executable or substituted listener refuses; an already running graph stays up when restart preflight refuses. Fixed host_pid selections remain supported and carry no permission to follow a replacement process.

Executable selections may also set "host_supervisor_depth": 2 when a retained wrapper launches a replaceable session process which launches the listener. The depth is an integer from 1 through 8 and defaults to 1 (the immediate parent); it is forbidden with a fixed-PID-only selector. Hack captures exactly that many ancestry links, pins the final ancestor's PID, native start time, user and executable, and retains the intermediate executable paths in order. A retry may replace intermediate processes only beneath that exact live ancestor, with the same executable chain and user. Native review rechecks the complete bounded lineage twice; it never searches for a common ancestor or infers a stable wrapper. Bindings sharing one listening transport must select the same executable, depth and reviewed ownership policy. A replaced ancestor, changed chain, adopted listener or inconsistent shared selection refuses before relay grants change.

A listener that Hack itself starts can instead be selected by its owner, with no PID, path or depth in the file. Replace the authority fields with exactly one of:

  • "host_lifecycle": "NAME": a persistent up.before hook (its name, or hook-N when unnamed) or a lifecycle.processes entry of this instance. Hack reads the pane PID from its lifecycle state and requires the live tmux session to carry the recorded ownership token and that pane to be alive. zellij sessions record no pane PID and refuse.
  • "host_shared_helper": "NAME": an enabled lifecycle.sharedHelpers entry whose claim is owned and ready, lists this instance as a holder, and whose PID and birth time are live. An adopted helper refuses.

After the hooks, Hack asks native discovery for the one same-user exclusive IPv4 loopback listener on host_port and requires it to be the owner process or its descendant within eight ancestry links; it then proves the owner again. A listener outside the owner's tree refuses with "Native host dependency listener does not run under its selected owner"; this includes a listener the hook only adopted, or one that detached (for example with ssh -f), even if it was started by the hook. Use a host_executable selection for those. A listener deeper than the owner is reviewed as an executable selection at the discovered depth whose final ancestor must be exactly that owner process (PID and native start time), so a refresh follows replacements only beneath it. An owner that is itself the listener is a fixed-PID selection without replacement authority. Bindings sharing a host port must name the same owner. The owner names never reach native review, and the frontend never persists the resolved PIDs. Restart preflight resolves the same owners against the running instance; a cleaned retry validates the intent only and resolves owners after its hooks.

Startup allows up to 60 seconds total for persistent hooks to create all selected listeners, with cancellable waits and bounded read-only discovery requests. It retries only native endpoint-identity refusals; malformed selections and other failures stop immediately. A refusal is one E_CONFIG_INVALID line that names the reason and the affected entry by position, service, binding, aliases, guest port and host port, each shown only after it validates. For example, a listener that never appears on the selected port reports no listener owned by host_executable answered on host_port before the discovery deadline. Host PIDs, executable paths and the contents of unrecognized fields are never printed. A listener must pass the complete ownership checks before any VM or graph effects. Restart preflight does not wait or stop the graph when its current selection cannot be verified.

For an already ready foreground graph, graph refresh-dependencies --run-id RUN --json asks its authenticated live owner to refresh stale admitted listeners. With a packaged candidate, invoke it as:

./hack-native --candidate-root /absolute/private/candidate-home graph refresh-dependencies --run-id RUN_ID --json

Only host_executable selections (including owner-scoped selections deeper than their owner) can follow a replacement, using the original executable, host port and unchanged same-user ancestor selected by host_supervisor_depth, retaining the reviewed intermediate executable chain. A matching executable or port alone is insufficient. Missing or ambiguous listeners, replaced supervisors, fixed-PID drift and changed ownership refuse. The owner retains the admitted services, bindings, aliases, guest ports and transport slots; the request cannot add or edit them. Successful refresh changes affected relay generations without restarting application containers or replacing volumes. An unchanged graph returns a verified no-op.

A service declared completed that has successfully exited can share those dependencies with running services. Refresh checks its exact container, image and original start time, and requires exited state, PID zero, exit code zero and no dead or OOM state. It journals retirement before revoking every historical binding and proving the old helpers absent. Its receipt then records relay_startup phase completed with no helper process records; it receives no replacement grant or helper. Running siblings still refresh together. Started/healthy services that exit, and failed completed jobs, refuse this path. One-off commands retain the admitted template and create fresh job intent; this state never permits exec in the stopped container.

When a physical dependency slot is used only by completed jobs, refresh can select a replacement for later one-off commands under the same executable and ancestry policy. A separate empty fence requires every old target to be retired, preserves the exact graph and old generation, and commits the new endpoint before future job admission. It issues no grant or helper for the completed containers. A later one-off uses fresh job intent and credentials; old credentials remain revoked. An interrupted empty fence refuses further admission until ordinary owned cleanup and restart, and is never retried or replayed automatically.

The rebind journal's optional completed_services and per-slot terminal_only fields record terminal retirement and empty fences explicitly. Older journals without these fields remain readable, but a previous bundle cannot be assumed to understand the new completed startup phase. Use the current owning bundle for retaining cleanup and journal archival before selecting an older bundle; do not substitute an older binary against active state.

If a required startup listener exits with a service declared started or healthy, the stopped receipt preserves the owned service name, container ID and exit observation before cleanup, including exit code zero. A successful completed service is not recorded as a startup failure. This evidence contains no command, environment or application log values. Older bundles may refuse the new zero-exit evidence; use the owning bundle for diagnosis and retaining cleanup.

A host-dependency listener's transport usually ends before the engine records the container's own exit, for example when a quick initializer finishes. Startup then re-inspects the container for up to 10 seconds before it classifies the exit, so a completed service that exits 0 is accepted and a crashing service reports its own exit. Only a container that still runs after that window reports graph_startup_running_listener_lost.

Startup cleanup removes a failed service's container and its logs. Before that cleanup, the foreground owner reads a bounded tail of each failed service: the recorded startup failure first, then other failed services by name, at most three. Each tail holds Docker's last 64 lines of the service, with at most 4 KiB of whole lines per stream (stdout and stderr), so at most 8 KiB per service. A line is never cut; a single line over the bound is omitted and the report says lines were omitted. The tail travels only in the foreground failure reply. The runtime never writes it to the receipt, journal or any other file, so persisted failure evidence stays value-free.

The frontend still holds the managed environment values it delivered. It redacts all of them, for every service, from the tail before printing it under the startup failure report, and it never saves the tail or the report:

  • Terminal escape sequences are removed first.
  • A value of four or more characters is replaced as [REDACTED] wherever it occurs, even inside a longer word. Its JSON-escaped form and each line of a multi-line value that is four or more characters long, with and without surrounding whitespace, are replaced too.
  • A value of one to three characters is replaced only as a whole token: not next to a letter, digit or underscore. Values like 1 or on therefore do not shred unrelated output.
  • Other control characters except tab and newline are shown as U+FFFD.

Private credential files, such as gcloud Application Default Credentials, are redacted the same way: the whole file text, each of its lines, and every JSON string value of four or more characters, such as a refresh token or private key. When a service received a credential file that is not UTF-8 text, or looks like JSON but does not parse, its output is withheld from the report instead; read it with hack logs SERVICE.

Redaction covers only managed values and credential files. Application output can still hold other sensitive data, such as values a service read from its own files.

A Docker container can remain created with a nonzero error when its first start fails before a process runs. Retaining shutdown accepts this state only with verified ownership, no running PID, no pause/restart/dead state and no OOM flag. The original exit code remains failure evidence; it does not become a successful application exit or a claim that Hack sent a stop request.

If that failure interrupted enrolled cleanup, the candidate frontend can inspect and select a separate retaining recovery after the exact native foreground has exited. The private controls are graph inspect-interrupted-start-cleanup --run-id RUN --json and graph recover-interrupted-start-cleanup --run-id RUN --expect-selection SHA256 --retain-data --json. They require the same guest boot, exact dead foreground, matching pending coordinator operation, and unchanged selected resources and retained volumes. The relay publication must either match its original pinned owner or have both owner and socket absent after foreground exit. The absent pair is bound to the existing operation lock, unchanged parent identity and exact selected coordinator owner, process, publication and attempt; a mixed pair, live process or replaced lock refuses recovery. This is not a general retry of a coordinator effect. Its own journal records each cleanup step. Shared dependency caches stay retained, with their original cache bindings and volume provenance rechecked at effect and completion boundaries. An uncertain stop is never sent again merely because the container still appears running. A crash between stop intent and request can therefore require later terminal evidence before recovery can advance. Likewise, a pending guest helper, probe or environment deletion advances only after complete absence is independently verified. An interruption partway through a guest cleanup script can remain fenced; this control does not replay that script or guarantee automatic recovery from every internal deletion boundary.

Successful recovery requires independent compute, helper, probe and environment absence, then current cleanup acknowledgement, exact publisher retirement and dependency reservation release. The frontend rechecks retained data and completes its existing finalization contract. It still reports the original startup failure. If retirement is interrupted after acknowledgement, a bounded native journal hint prompts fresh inspection of the exact original selection before finishing only the remaining publisher and reservation transitions. The hint grants no effect authority, and a stopped graph alone does not establish complete retirement. Completed recovery journals remain as evidence. A later interrupted startup on the same retained run cannot reuse that earlier generation's selection: native recovery reports graph_interrupted_start_cleanup_stale. Archiving a completed journal at the verified restore boundary is a separate follow-up; never remove the journal manually to enable another recovery. A component acknowledgement is not application readiness, and this recovery does not diagnose or fix an upstream engine panic. Changed or incomplete evidence remains available for inspection; do not remove journals or ownership pins to retry.

Native hack exec and hack run make this request once with a 180-second frontend budget before selecting their command or reading managed environment values. If refresh refuses, the frontend preserves only reviewed native error identifiers such as provider_busy or graph_owner_recovery: graph_dependency_rebind_incomplete. Subprocess text and unknown codes remain omitted. These diagnostics do not confirm whether refresh effects occurred or authorize replay; the command is not dispatched. ps and logs remain observations and do not request refresh. Authenticated relay traffic reports a stale endpoint to the foreground owner, which attempts bounded refresh without idle polling. The owner also watches each admitted replaceable listener process for exit (a kernel exit notice, not a scan), so a replaced listener is refreshed before the next request reaches it. Attempts run one at a time. When no replacement is listening yet, the refusal happens before any effect, and the owner retries after 1, 2, 4, 8, 15 and 30 seconds; after that only a new stale notice re-arms it. Once a rebind has written its journal and not completed, the owner never retries automatically. The affected application request can fail; it is never replayed, and later traffic can use a verified replacement. The explicit operation is also available before traffic. Refresh does not renew startup environment allocations. A lost reply or interrupted transition is never replayed. Partial transitions remain fenced and refuse new admission; inspect owned state and complete retaining cleanup/restart to rebuild the relays. Ordinary recovery keeps the run's persistent volumes. Do not delete managed journals or mappings to retry.

Bindings in different services targeting the same pinned host listener can share a transport slot; authentication, cancellation and expiry remain per binding. The CLI groups selections by host PID and port in first-appearance order, and native review requires the complete captured endpoint identities to match. Multiple bindings within one service retain distinct slots. Aliases must match the service's declared extra_hosts entries targeting host-gateway. No general host-gateway access is enabled. Native dependency review pins the same-user process and exclusive loopback listener generation, then checks that identity again during admission. A stale PID, replaced listener, undeclared alias, extra field, symlink or malformed file refuses admission. This file contains selection metadata only, never credentials. It does not adopt or stop the selected host process; lifecycle hooks retain ownership of listeners they launch. Restart preflight checks those selected listeners before stopping the graph. Startup admission repeats the check after lifecycle hooks, so a listener that changes between preflight and startup still refuses without adopting it; the stopped run and its volumes remain available for a corrected retry. Selected services receive init: true when omitted because the guest relay launcher requires a reaping init process. An explicit init: false is refused, not replaced.

Project-wide host aliases from internal.extra_hosts in hack.config.json, plus checkout-local hack internal extra-hosts entries, follow the Compose backend: native up and restart declare each one on every service as a host-gateway extra host. Native has no general host gateway, so every profile-active service must then have a selected binding for each alias it declares. A missing binding refuses with E_NATIVE_PROJECT_UNSUPPORTED ("Native host alias has no reviewed host dependency binding; configuration was not dropped."), before any runtime work; with no dependency selection at all it refuses before lifecycle hooks. The refusal lists every unbound alias with the services that declare it and where it comes from: the service's Compose extra_hosts, internal.extra_hosts in project configuration, this checkout's .hack/.internal/extra-hosts.json, or the main checkout's map that a linked worktree inherits. It then names the aliases a binding needs and how to remove the alias instead (hack internal extra-hosts unset <alias> hides an inherited entry in this worktree). Listener values are never printed. An entry whose target is not host-gateway (for example a fixed address), a name that is not an exact lowercase DNS name, or a service that already maps the name to a different target also refuses. Service-declared names are matched in the form native execution compiles them to: Compose interpolation resolved as the native runtime resolves it (no ambient environment, so ${UNSET:-word} selects word and ${UNSET:+word} selects nothing), then one trailing dot removed and lowercased. So a service that declares API.Example. or ${UNSET_PREFIX:+x-}api.example is covered by a binding for api.example and does not receive a second copy of a project-wide api.example. A service whose declarations compile to the same alias twice refuses with E_CONFIG_INVALID, naming the service and alias, before any runtime work; native execution would refuse it only after the VM started. A name that refers to a required variable, a Hack-managed value or COMPOSE_PROJECT_NAME is left to native review and execution. Earlier native builds ignored these entries, which let a container resolve the public name instead of the local listener. Native reads these aliases before lifecycle hooks, because they are part of the reviewed configuration; unlike the Compose backend, a hook that changes them makes up refuse after hook cleanup. The check after hooks reads hack.config.json and the checkout-local alias map again, so an edit to either is caught. A local alias map that exists but cannot be read refuses with E_CONFIG_INVALID instead of being treated as empty. Projects without project-wide aliases are unchanged. Unlike a Compose host gateway, which reaches every host port, a service reaches each alias only on the one guest port its binding selects.

To avoid repeating one listener for every service, a version 2 selection names each binding once:

{
  "version": 2,
  "dependencies": [{
    "binding": "search",
    "aliases": ["search.example.com"],
    "guest_port": 443,
    "host_port": 8443,
    "host_executable": "/absolute/path/to/the/listener-binary"
  }]
}

Without services, the entry applies to every profile-active service that declares one of its aliases, and each service receives only the aliases it declares. "services": ["web", "worker"] limits it to those services, in that order, each of which must declare at least one of the aliases; "service": "web" is shorthand for a one-element list, and an entry sets at most one of the two. The CLI expands the entry into the version 1 bindings shown above before discovery and review. The same listener authority applies to every expanded binding, and the 128-binding and 32-transport limits apply after expansion. A repeated binding name, an alias no selected service declares, both service and services, or an unknown or inactive service refuses.

Fresh pools reserve the number of distinct dependency transports from that selection. Existing pool capacity cannot be silently widened. The selection is copied into the temporary exact-plan dependency request and removed when foreground startup exits. No durable listener authority is inferred from a saved selection file. Image-only services omit graph shared-source mode; active project bind mounts still use the explicit project share.

Native HTTP readiness probe

healthcheck.x-hack-http declares the readiness probe native startup uses for a service. It must be the only key in that service's healthcheck: it cannot be combined with test, disable, interval, timeout, start_period, start_interval or retries. Docker Compose ignores the extension field. All six fields are required, and no other field is accepted:

FieldType and rangeMeaning
portinteger, 1–65535Container port the probe connects to on the container's loopback address (127.0.0.1). For a routed service it must equal the port in caddy.reverse_proxy: "{{upstreams PORT}}".
pathstring, starts with /, at most 512 bytesRequest path. Only printable ASCII without spaces is accepted; # is refused. The probe sends GET <path> with Host: 127.0.0.1:<port>.
interval_msinteger, 10–3600000Time from the start of one request to the start of the next.
timeout_msinteger, 1–60000Limit for connecting, sending the request and reading the response headers.
retriesinteger, 1–100Consecutive failed samples that make the service unhealthy.
start_period_msinteger, 0–3600000Startup grace. Failures in this period do not count until the first success.

A sample succeeds only when the response status line is HTTP/1.1 or HTTP/1.0 with a 2xx status. A redirect (3xx) or any other status is a failure, so point path at an endpoint that answers 2xx without a redirect. A service is starting until its first success, healthy after a success, and unhealthy after retries consecutive counted failures. Failures count once the service has succeeded once or the start period has ended; one success resets the count. A sample older than interval_ms + timeout_ms + 1000 milliseconds counts as unhealthy.

services:
  web:
    labels:
      caddy: web.hack.local
      caddy.reverse_proxy: "{{upstreams 3000}}"
      caddy.tls: internal
    healthcheck:
      x-hack-http:
        port: 3000
        path: /health
        interval_ms: 1000
        timeout_ms: 2000
        retries: 60
        start_period_ms: 120000

hack init writes this probe with path / and the values above for each routed service when the native backend is selected. The httpProbes adaptation below accepts only port and path and fills the other four fields with the same values.

For projects whose checked-in Compose file needs explicit native health probes, an adaptation file adds them. Commit it as .hack/native/adaptation.json (see Native settings files), or select any absolute JSON file with HACK_NATIVE_ADAPTATION. This leaves the Compose file unchanged and preserves its identity in native review. Only the following bounded adaptations are accepted:

{
  "version": 1,
  "isolatedNetworks": ["hack-dev"],
  "httpProbes": {"web": {"port": 3000, "path": "/health"}},
  "additionalHostnames": {"web": ["web.hack.local"]},
  "additionalHostAliases": {"web": ["host.docker.internal"]},
  "workspaceCache": {
    "volume": "node_modules",
    "root": "/app",
    "workspaces": ["apps/web", "packages/db"]
  },
  "amd64Emulation": {
    "default": "prefer-native",
    "services": {"legacy-worker": "amd64", "db": "native"}
  }
}

Networks must already be declared external; selection explicitly replaces their connectivity with the owned isolated network. Native up, restart and doctor then treat those networks as owned, not as external Compose networks (see Compose projects that stay on the Compose backend). Probes apply only when no existing healthcheck is present. Additional hostnames append to existing Caddy labels. Additional host aliases append explicit host-gateway declarations and still need separate pinned dependency selections; they do not enable general host access or override fixed-address aliases. Conflicts are refused. Choose actual application health endpoints; a probe is an application readiness contract, not a workaround for failed startup.

The optional workspace cache layout mounts subdirectories of the existing declared dependency volume at each workspace's node_modules. This allows an installer to populate nested workspace dependencies while its project source remains read-only. Every service already consuming the root dependency volume receives the same layout, including the root mount's read-only setting. Unrelated services are unchanged. The layout participates in native dependency-cache identity. This shares compatible workspace installations; it does not implement a cross-project package store or claim deduplication across different lockfiles.

amd64 emulation with Rosetta 2

The native VM is Linux arm64. On an Apple silicon Mac with Rosetta 2 installed (softwareupdate --install-rosetta), amd64Emulation lets selected services run linux/amd64 images under Rosetta 2 x86_64 translation inside the VM. It is off unless the selection opts a service in, and each service takes one mode:

ModeImage platform
native (default)Linux arm64 only. A declared platform: linux/amd64 keeps the project on the Compose backend.
prefer-nativeThe arm64 variant when the image publishes one, so multi-arch images stay native even when the service declares linux/amd64. An image that publishes only linux/amd64 runs under emulation.
amd64The linux/amd64 variant under emulation, even when the image also publishes arm64. Use it when the workload needs x86_64 binaries, for example prebuilt native modules.

default sets the mode for every service without its own entry in services; omit it to keep native. A named service must exist, a service that declares a Linux arm64 platform cannot opt in, and an unknown mode refuses with E_CONFIG_INVALID. Only the exact linux/amd64 platform is emulated; microarchitecture variants such as v3 and other platforms are never selected.

When any service opts in, native up requests a pool with translation (runtime up --amd64-emulation). A fresh pool is created with SmolVM's --rosetta; a stopped pool is updated with machine update --rosetta before it starts; a running pool without translation refuses with amd64_emulation_restart_required and nothing is stopped. Stop every native project that uses the pool, then start again. A pool keeps translation once it has it, so other projects sharing it are unaffected: their services still resolve arm64 images. Without Rosetta 2 the start refuses with amd64_emulation_unavailable before any VM effect.

Every boot audits translation against the pool's recorded intent: the provider's stored machine setting, its running configuration, and the guest's binfmt_misc rosetta registration (the x86_64 ELF match with the fixed rosetta-wrapper interpreter). A pool without translation must have no such registration. The image cache keeps amd64 archives separate from arm64 ones. Under prefer-native, a cached amd64 archive is reused only after the pinned image is confirmed not to publish arm64. The runtime admits an amd64 image only for an opted-in service (x-hack-amd64-emulation: prefer-native or amd64 in the reviewed Compose service, which the adaptation writes) in a pool booted with translation. A service that is not opted in still refuses a declared linux/amd64 in native review.

The mode is part of the reviewed plan. Restart and restored up reuse a service's retained image only when the retained graph recorded the same mode for it; after a mode change the service's image is selected again under the new mode.

Emulated services are slower than native ones and can behave differently from arm64 builds. hack doctor on the native backend reports an amd64 emulation warning with the number of opted-in services in each mode (counts only, no service names), and names the Rosetta 2 install command when it is missing.

Native settings files

Every native selection above can also come from a file, so a project runs with plain hack up instead of a set of exported variables. There are two kinds of file, because they carry different authority:

FileHoldsWhereCommit it?
Project adaptationThe adaptation above (isolatedNetworks, httpProbes, additionalHostnames, additionalHostAliases, workspaceCache, amd64Emulation).hack/native/adaptation.json, and optionally .hack/native/adaptation.<env>.jsonYes. Every checkout and worktree shares it.
Machine settingsGrants and host authority: the project share, the AWS profile, host dependency files, HTTPS, prepared base and approved hosts${XDG_CONFIG_HOME:-$HOME/.config}/hack/native/<project>/settings.jsonNever. It is per machine.

The project adaptation has the same authority as the Compose file it adapts. It grants nothing: its schema is the closed adaptation schema above, so a grant such as sharedSource in it refuses with E_CONFIG_INVALID. Hack reads only adaptation.json and adaptation.<env>.json from .hack/native/, where <env> is a lowercase env name. hack doctor warns about any other file there.

The machine settings file lives outside the project because the project tree is shared read/write with the native VM: a container could rewrite a grant stored in the tree before the next up. <project> is the project name Hack uses (the Compose name:, else name in hack.config.json, else the directory name), lowercased with other characters replaced by -. Linked worktrees and branch instances of one project therefore find the same machine file.

Any checkout can claim a project name, so the file grants only the checkouts it lists in projectRoots: absolute paths of project roots (at most 16). Hack compares the canonical path of the current project root with the canonical path of each entry, so a symbolic link to a listed root works. An entry that no longer exists is skipped. Branch instances share their checkout's root; list each linked worktree you want to grant. Create the file once per machine:

dir="${XDG_CONFIG_HOME:-$HOME/.config}/hack/native/my-app"
mkdir -p -m 700 "$dir"
chmod 700 "$dir"
touch "$dir/settings.json" && chmod 600 "$dir/settings.json"
{
  "version": 1,
  "projectRoots": ["/absolute/path/to/my-app"],
  "sharedSource": true,
  "aws": {"profile": "dev-sso", "region": "us-east-1"},
  "dependencies": "dependencies.json",
  "dependenciesByEnv": {"qa": "dependencies.qa.json"},
  "https": {"caddyBinary": "/opt/caddy/bin/caddy", "caddySha256": "<sha256>", "port": 443},
  "preparedBase": {"mode": "prefer"},
  "allowHosts": ["registry.npmjs.org"]
}

Every field except version and projectRoots is optional. The other fields match the variables:

FieldVariable it stands for
sharedSource (true or false)HACK_NATIVE_SHARED_SOURCE=1
aws.profile, aws.regionHACK_NATIVE_AWS_PROFILE, HACK_NATIVE_AWS_REGION
dependencies, dependenciesByEnvHACK_NATIVE_DEPENDENCIES
https.caddyBinary, https.caddySha256, https.portHACK_NATIVE_CADDY_BINARY, HACK_NATIVE_CADDY_SHA256, HACK_NATIVE_HTTPS_PORT
preparedBase.mode (prefer, require or off), preparedBase.storeHACK_NATIVE_PREPARED_BASE, HACK_NATIVE_PREPARED_BASE_STORE
allowHosts (a list)HACK_NATIVE_ALLOW_HOSTS (a comma-separated list)

dependencies and dependenciesByEnv name host dependency files (the format above) in the same directory: plain file names, never paths. The machine settings do not select the adaptation, the native binary or the native home. A gcloud field or any other field refuses.

Hack refuses the machine file with E_CONFIG_INVALID before any branch, lifecycle, storage or runtime work unless all of these hold:

  • the directory is a real directory owned by you with mode 0700;
  • the directory is outside the project root, and the project root is not inside it;
  • settings.json is a regular file owned by you with mode 0600, not a symbolic or hard link, at most 64 KiB, and unchanged while it is read;
  • every field is valid, including a field that a variable overrides. aws.profile and aws.region must be selectors that native AWS adaptation accepts, and so must HACK_NATIVE_AWS_PROFILE and HACK_NATIVE_AWS_REGION.

Before it reads any other field, Hack refuses the file with E_PERMISSION when projectRoots is missing or does not list the current project root. The refusal happens before any lifecycle hook runs, even when variables override every setting. A file written before projectRoots existed therefore grants nothing: add the project root to it to grant that checkout again.

A missing directory or file means no machine settings.

Precedence. For each setting, a variable that is set wins entirely, even when it is empty, then the machine settings, then the committed project file. Values are never merged. For example, HACK_NATIVE_ADAPTATION replaces the committed adaptation, and HACK_NATIVE_SHARED_SOURCE=0 withholds a share that the machine file grants. A non-empty HACK_NATIVE_AWS_PROFILE selects the AWS variables; HACK_NATIVE_AWS_REGION without it is ignored, as before. Existing scripts that export the variables keep their exact behavior.

Per-env files. adaptation.<env>.json and dependenciesByEnv.<env> replace the base file for runs whose effective env is <env>: the --env you pass, the env a retained run recorded, or the project's default env. This holds even when the project has no hack.env.* files, or has hack.env.default.yaml but no hack.env.<env>.yaml. Otherwise the base file applies. A fresh start, a retained start and a restart of one run therefore always select the same files. The run records which managed env files its env had at startup (none, the base only, or the overlay). A later restart, retained up or fresh exec refuses when that changed, and names the file: an overlay that existed is gone, or an overlay (or, for a run started without any, a managed env file) appeared. To apply an appeared file, run hack down, then hack up. down hooks of such a run get the values it started with (none, or the base values), so down is not blocked. A removed overlay can be restored to keep the run; down hooks of a run that started with it refuse, naming the file, until it is restored.

Identity and recovery. Settings resolution only reads files. It writes no state, so Ctrl-C needs no cleanup, and down, ps, logs, exec and run keep using the saved run mapping without reading the settings. A file-selected run and a variable-selected run with the same contents review the same native plan, so restart retains the run either way.

hack doctor on the native backend adds a native settings check. It reports where each setting came from (env, machine, project or unset), never its value, and the machine settings path with your home shown as ~. With --json, the check's nativeSettings holds origins, adaptationFiles, machineSettings and closed warnings:

WarningMeaning
shared_source_not_grantedNative up and restart refuse until the project share is granted.
internal_dns_tls_not_appliedinternal.dns and internal.tls in hack.config.json are not applied on native, so containers cannot reach routes by their HTTPS names. Set both to false to silence it.
routes_without_probesroutesWithoutProbes routed services have no native HTTP probe after the adaptation for the default env; native up refuses them.
compose_profiles_ignoredCOMPOSE_PROFILES is set in the environment, but native selects profiles only from --profile. Managed COMPOSE_PROFILES values are not inspected.
aws_region_without_profileHACK_NATIVE_AWS_REGION is set without HACK_NATIVE_AWS_PROFILE and is ignored.
unread_project_settings_files.hack/native/ holds files other than adaptation*.json; Hack does not read them.

An invalid settings file makes the check an error with the same closed line native up prints. Each adaptation file (the base and every adaptation.<env>.json) is checked against the Compose file with the validator native up uses. The check runs no runtime request, credential export or network call.

Optional prepared base for a fresh pool

A prepared base formats a new pool's disks from an independently verified snapshot of a sanitized seed pool instead of the stock templates. It changes only pool creation: the first pool in a candidate home, or one recreated during recovery. Existing pools and graph starts inside a running pool are unchanged. It is off unless selected and is macOS APFS only.

Build and verify a base in a private store on the same volume as the candidate home. The installed candidate defaults to <HACK_NATIVE_HOME>/prepared-bases; pass --store /absolute/private/store to use another:

./bundle/hack-native --candidate-root /absolute/private/candidate-home runtime prepared-base build --profile development --json
./bundle/hack-native --candidate-root /absolute/private/candidate-home runtime prepared-base verify --base-id BASE_ID --json
./bundle/hack-native --candidate-root /absolute/private/candidate-home runtime prepared-base status --profile development --json

build boots a disposable seed pool from cloned providers, with no project, sockets, graphs or credentials. It installs network tools under the base owner, removes per-pool identity and runtime residue, and publishes the stopped disks. verify boots a separate disposable verifier from the published base and reads both disks before any setup; a base becomes usable only after it passes. Both need the same host admission as a development pool while they run, and both delete their disposable machine afterwards. If one is interrupted, status lists its work root under abandoned_work, and the next build or verify removes it before starting, except while a provider still runs there. status also reports each base under bases.

Then select it for normal foreground startup:

export HACK_NATIVE_PREPARED_BASE=prefer   # or require; unset or off disables
export HACK_NATIVE_PREPARED_BASE_STORE=/absolute/private/store   # optional

prefer uses the newest verified base bound to the pool's provider, engine, network-tools and capacity pins, and otherwise keeps the stock templates; runtime status records the reason. require refuses before creating the pool. An existing pool ignores the selection. Remove a base with runtime prepared-base remove --base-id BASE_ID; a pool never depends on its base after the first start.

scripts/accept-native-frontend.py checks this path end to end for one owned linked worktree.

  • It builds and verifies a base, then runs ordinary hack-v5 up with require, so the frontend creates the pool from that base with the worktree as its exact share.
  • It checks healthy HTTPS against the home's private Caddy root and writes a /data marker.
  • It runs down, edits the served file on the host, and runs up again. It then requires the same run in a new container, HTTPS serving exactly the edit, and the marker.
  • Finally it disposes of the pool, base, provider alias and fixture with readbacks.

It prints its plan unless --run is given and is a correctness check only. On any failure, or when its --budget runs out, it issues no further commands and keeps all state for diagnosis. The only process it can terminate is one of its own bounded commands, whose direct child subprocess.run kills when its timeout expires; it never signals a foreground or a VM. Ambient HACK_NATIVE_* variables are dropped, so only its declared selections reach the candidate. Its stand-in controls run with python3 -m unittest discover -s tests/python -p test_native_frontend_acceptance.py.

Add --domain-migration to qualify domain changes in that same linked worktree. This opt-in starts with legacy .hack and OAuth .hack.gy project/service aliases, runs the real Doctor preview and application, then performs a retaining restart and requires every old/new .hack.local route to serve the exact source. Another restart after Doctor rollback must restore the original route set and stop serving the removed aliases. Both transitions retain the run, owner, plan, volume bindings and exact data marker. Commands infer the branch from the linked checkout. The driver also checks exact original file bytes/modes, unchanged primary checkout, preview without effects, and refusal to roll back over an independent file edit. Only audited graph inspect and runtime status observations may repeat an exact structured provider_busy refusal, within a 15-second observation budget and the overall run budget. Every attempt retains its output and result. Other errors, malformed replies and timeouts stop the run; mutations, refresh and exec never use this re-observation path.

The resulting evidence/domain-roundtrip.json records this narrower proof separately. It does not qualify a simultaneously running sibling graph, system DNS, certificate trust or a browser session: HTTPS uses the fixture's private root and --resolve. Complete any normal-origin browser check separately before marking that acceptance criterion complete. The driver never installs trust or DNS and still disposes its owned fixture after success; there is no indefinite browser handoff or global change. For a manual browser check, request a fresh phase-specific query (for example, /index.txt?phase=<unique-run-and-phase>) and require the current fixture marker. A new tab can still display cached content from an earlier certificate or route; it does not prove a new TLS connection. Use an uncached request for both positive checks and expected certificate-rejection controls, without bypassing warnings.

Optional native HTTPS frontend

For routed foreground startup, explicitly set all three public selections: HACK_NATIVE_CADDY_BINARY (absolute host Caddy executable), HACK_NATIVE_CADDY_SHA256 (its lowercase SHA256), and HACK_NATIVE_HTTPS_PORT (1–65535, including 443). Unrouted graphs do not start this frontend. The selected process and managed hostname authority belong to the foreground invocation; unexpected loss aborts the graph and reports failure. They close after graph cleanup, while private Caddy data and its CA remain under <native-home>/native-https/data. An occupied frontend owner is refused.

The selected port must be available to the current host user. On hosts that restrict port 443, use an unprivileged test port such as 18443 or separately configure authorized host forwarding. Startup checks bind permission and leaves existing listeners untouched; it never elevates privileges automatically.

Startup verifies each reviewed hostname using loopback, SNI, Host, and the private CA, without relying on DNS or installing system trust. It requests the service's reviewed native HTTP health path and requires a 2xx response, matching the guest readiness policy. It does not render / merely to verify an alias. The guest readiness probes remain required; a health response does not establish a usable browser session. DNS and user-approved trust setup are separate prerequisites for normal browser access.

With the native backend selected, hack doctor --json inspects the active frontend owner, the exact Caddy listener and root certificate, and macOS System-keychain trust. A retained root file or an older same-name certificate does not count as current trust. Doctor also lists superseded Caddy roots still in the System keychain, by count and SHA-256 fingerprint. It never removes them; see Superseded Caddy roots for the removal steps you run yourself. To check a real app route, pass an exact HTTPS origin with --browser-url; Doctor verifies that hostname over loopback with the live root, checks that every host DNS answer points at its IPv4 loopback relay, and reports normal macOS TLS separately. --browser-result records your manual observation for that origin; CLI success alone does not prove a browser session works.

The native candidate refuses hack global install: that command owns the Docker global stack and could change installed v4 routing. For a dedicated custom suffix alongside v4, run hack global dns preview --domain v5.hack.gy and then hack global dns activate --domain v5.hack.gy. Without --domain, these commands use the global default_domain only when it is a custom suffix; changing that shared setting also changes the installed v4 CLI's default. With a built-in default such as hack.local, they stop with a usage message that names hack doctor --fix and the rollback command below. Preview is read-only; activation shows the exact scoped claim and requires interactive confirmation and a native administrator prompt. Run it from a terminal; the bounded privileged steps preserve that terminal's sudo authorization and report the underlying command failure if a step fails. If you approve but the administrator prompt does not complete (it times out after 120 seconds, is interrupted, rejects the password, or Hack is not the terminal's foreground job), activation fails during authorization with that cause, says nothing changed, and suggests running sudo -v in the same terminal first. Only answering no is reported as denied. Before changing files, the candidate checks that the root-owned Homebrew system launchd job matches the inspected dnsmasq command and configuration. It restarts that verified job directly through launchctl, so activation does not need Homebrew metadata downloads as root. Missing, changed or ambiguous service ownership refuses. It adds one owned dnsmasq include file and one resolver file pointing the selected suffix to 127.0.0.1, while leaving the built-in v4 DNS rules intact. An existing or overlapping foreign claim, uninspected configuration source, or uncertain prior activation refuses. Activation verifies direct and system DNS and conditionally removes only its own files if verification fails. To remove this scoped claim, use hack global dns deactivate --domain v5.hack.gy. It requires a matching active private receipt and unchanged file identities across confirmation, then removes only those two owned files, restarts dnsmasq and verifies the parent fallback. The private receipt becomes inactive after proof so a later activation can reuse the suffix. An interrupted activation retains a pending receipt; an interrupted deactivation retains a removing receipt. Both refuse automatic retry until the partial state has been reviewed and explicitly recovered; do not delete these files or receipts blindly. The built-in hack, hack.local, and hack.gy roots cannot be claimed wholesale by this command; a dedicated v5.hack.gy subtree is supported when its v4 parent is verified. Existing project hosts and reviewed routes are not migrated. A suffix outside .local, such as v5.hack.gy, also avoids the macOS Local Network grant that browsers other than Safari need for .local names; see macOS browser connectivity.

DNS activation alone does not prove that an app route, certificate, port 443, or OAuth redirect is ready. Use hack doctor --browser-url for the exact app origin and test the browser session. Google OAuth requires a separately registered compatible redirect origin; .local may be rejected by the provider.

hack doctor --fix --browser-url https://your-app.hack.local can install only the verified live root after interactive confirmation and the native macOS administrator prompt. It checks the owner, root and route again after that prompt and verifies trust after installation. A missing, stale or changed owner refuses repair. The native hack global trust path directs you to this scoped Doctor command instead of falling back to Docker's exported CA. The native trust repair does not set up DNS or a browser's Local Network permission. Both authorization and installation retain the caller's controlling terminal, so terminal-scoped sudo authorization remains valid during the bounded repair.

Doctor's progress spinner stops before any repair asks a question. It stays off through the native administrator prompt and the privileged steps that follow, then resumes, so sudo's Password: prompt (or the Touch ID sheet) stays visible. Doctor runs repairs one at a time. The administrator prompt (sudo -v) runs in the terminal's foreground process group before any sudo -n step. If it does not complete, the CA repair reports an error that names the cause and suggests running sudo -v in the same terminal first. sudo itself prints a password is required when its first password read is interrupted or times out.

hack.local stays the default dev-host root. An older Docker-era install routes it to Docker Caddy with address=/.hack.local/172.30.0.2 in Homebrew's dnsmasq.conf, which the native loopback relay cannot serve. On macOS, native hack doctor reports a native hack.local route check (JSON field hackLocalRoute), and hack doctor --fix proposes a separate DNS repair. It rewrites only that one Hack-managed line to address=/.hack.local/127.0.0.1, in place. It never adds a second line for the suffix, and the .hack and .hack.gy lines and every other byte stay unchanged. If /etc/resolver/hack.local is missing, the repair creates it with nameserver 127.0.0.1. The repair proceeds only when all of these hold:

  • The native relay owner is verified and listening on 127.0.0.1 at its receipted port.
  • dnsmasq.conf, every dnsmasq.d/*.conf include file and the resolver file are bounded regular UTF-8 files with one link and are not symlinks.
  • Neither dnsmasq.conf nor an include file loads a DNS source Hack did not inspect: conf-file, conf-script, addn-hosts, hostsdir, servers-file, or a conf-dir other than <brew>/etc/dnsmasq.d,*.conf.
  • The line has the exact Hack-managed form and is the only .hack.local claim, including dnsmasq.d include files.
  • The target is one Hack wrote (172.30.0.2 or ::1).
  • The old target does not answer on port 443 within 1.5 seconds, so a running Docker Caddy is never broken.
  • Any existing resolver file contains only nameserver 127.0.0.1.

Each unmet condition is a closed refusal reason: relay-not-verified, dnsmasq-config-missing, unreadable-config, uninspected-config-source, no-managed-line, unmanaged-line, foreign-target, foreign-resolver, old-target-live, pending-receipt, invalid-receipt, non-interactive, declined, authorization-failed, busy, restart-unavailable or inputs-changed. declined means you answered no to the confirmation. authorization-failed means you approved, but the native administrator prompt did not complete: it timed out after 120 seconds, was interrupted, rejected the password, or Hack was not the terminal's foreground job. Run sudo -v in the same terminal, then rerun hack doctor --fix (or the rollback command). Refusal details name files and lines by number and never repeat file contents. The repair needs interactive confirmation and the native macOS administrator prompt; without a terminal it refuses and names hack doctor --fix to run in one. Only one repair or rollback runs at a time; a second one refuses with busy. After the prompt the repair checks that the dnsmasq launchd job can be restarted (restart-unavailable otherwise), then inspects everything again and refuses if anything changed. Before changing files it writes a private receipt to <native-home>/native-dns/hack-local-route.json with the old line, new line, SHA-256 hashes of the exact file bytes and timestamps. It replaces the file atomically and keeps every other byte, including a leading byte-order mark. It then restarts the verified dnsmasq launchd job, flushes the DNS cache and verifies live resolution. If a later step fails, it restores the earlier line automatically; when the failure came before any change, it reports that nothing changed. hack global dns preview --domain hack.local shows the same proposal without changing anything.

The repair routes names to 127.0.0.1 but does not change ports. With a HACK_NATIVE_HTTPS_PORT other than 443, such as 18443, URLs still need that port, for example https://app.hack.local:18443. The confirmation prompt names the relay port.

To undo the repair, run hack global dns deactivate --domain hack.local. It needs a terminal and refuses with non-interactive and the same command otherwise. It refuses with unreadable-config and changes nothing when dnsmasq.conf is not a bounded regular UTF-8 file. It restores the exact earlier line and verifies the whole-file hash against the receipt. It removes the resolver file only if the repair created it and its identity still matches, then restarts dnsmasq. If dnsmasq is not running, or the job cannot be restarted safely, the rollback still restores the file and says that dnsmasq was not restarted; the line loads on its next start.

An interrupted repair leaves a pending receipt, and Doctor refuses another repair until the same rollback command handles it. Sometimes dnsmasq.conf changed after the repair, for example when a Docker-era hack doctor --fix or hack global install rewrote the built-in roots. In that case, or when the receipt names another dnsmasq.conf path, restoring would overwrite those changes. The rollback command then never writes the file. Instead, after a plain confirmation, it marks the receipt released and leaves dnsmasq.conf and any resolver file as they are. Doctor then inspects the current file again. Restore the recorded line yourself only if you still want the earlier route. A Docker-era hack doctor --fix or hack global install still rewrites all three built-in roots to whichever target answers, which can undo the native repair.

The runtime's read-only runtime inspect-host-listener --pid PID --port PORT --executable /absolute/caddy --json reports the selected same-user loopback listener identity. The optional --peer-port PEER_PORT proves Caddy accepted that exact still-open TLS socket, including when Caddy enables SO_REUSEPORT. Doctor and native startup use the peer proof before accepting a TLS route. A PID, file path or listener fingerprint alone is not proof of an owned frontend or CA.

The probe pins its TCP connection to loopback independently of SNI and Host. Failed HTTPS verification reports a reviewed TLS/transport error code or VERIFICATION_TIMEOUT_HANDSHAKE / VERIFICATION_TIMEOUT_RESPONSE, without peer values. Unknown errors use TLS_OR_TRANSPORT_ERROR. Startup saves the project run mapping only after these checks succeed. Confirmed graph cleanup preserves the original startup error. If final inspection or cleanup cannot be confirmed, the error instead reports retained-state uncertainty alongside the sanitized startup diagnostic. Any published mapping remains intact; inspect owned runtime and bridge state before retrying. A native cleanup error code is included when available, without its raw output or message. When a foreground owner refuses cleanup, the CLI also preserves its bounded cause code alongside graph_owner_recovery; raw owner diagnostics remain omitted. Failed cleanup is never automatically replayed.

Stop failures can also include stop_failures version 1 with at most 32 distinct admitted service names and fixed stages: connect_timeout, connect, timeout, response, worker, transport or deadline. response covers a rejected or unreadable response; these stages describe the request failure, not whether the container stopped. A batch sends at most four stops at a time and joins every stop worker before the failure is returned. Only connect and connect_timeout are retried inside the batch, because no request reached the engine; these stages mean the engine stayed unreachable for that stop's 30-second retry window or the batch deadline. Container IDs, response bodies, transport messages and credentials are omitted. The authenticated foreground client validates the private receipt and checks matching service membership and container generations before and after the request. Detail never grants retry or deletion authority.

Inspect a partial retaining shutdown

A pending cleanup of started container generations is different from a failed container start. A completed historical startup journal does not make the pending shutdown eligible for interrupted-start recovery. The native graph inspect response includes a read-only pending_cleanup version 1 hint when applicable: partial_shutdown, interrupted_startup_candidate or unclassified. A startup candidate must still pass the separate exact native selection before recovery; the hint itself is not an ownership, cleanup or acknowledgement proof.

Inspect a mapped project through the selected candidate:

HACK_NATIVE_HOME=/absolute/private/candidate-home /absolute/bundle/hack-v5 doctor \
  --native-cleanup inspect --path /absolute/project --branch feature-api --json

Omit --branch for the base instance, or let linked-worktree auto-selection use the configured branch policy. This command performs only native graph inspection, reports aggregate container/volume observations and returns exit status 1 when cleanup is pending. Missing volumes are reported as absent, not presumed retained. It does not run hooks, repeat down, retire a publisher, restart the pool, repair trust or acknowledge cleanup. The JSON envelope describes a successful inspection; the exit status separately indicates pending cleanup. Older executors without the hint report unclassified rather than inventing eligibility.

Preserve the run mapping and private receipt. For a partial ready-graph shutdown, same-boot uncertain stops remain fenced; use the explicit previous-boot cleanup flow below. Before stopping a pool, review every graph using it: a pool restart affects live siblings. Stop/start must remain explicit, with the same pool identity and profile, network, source and socket selections. Select the exact receipt hash and follow the two-step recovery, then retire its exact recovered publisher and independently verify container absence, retained volumes and unaffected sibling data. Another boot rollover, changed receipts, missing data or stale ownership refuses recovery. Do not edit journals, fabricate relay acknowledgement or repeat stop requests to get past that refusal. Application restoration and frontend finalization are separate checks after native cleanup succeeds.

Graphs without ingress routes use the same guarded flow. Recovery binds the persisted empty pre-effect route selection to the pending cleanup and verifies the dead predecessor publication. That selection belongs to the interrupted operation; replacing a selection from an older restored generation still requires independent restore-history proof.

Quarantine a corrupted disposable build cache

Some frameworks keep a regenerable build cache inside a named volume next to other generated output. When such a cache is corrupted (for example a Turbopack checksum mismatch reported as a misleading Module not found), you can move exactly that cache directory aside. Nothing is deleted, and other volumes and directories are unchanged.

First declare the regenerable directories on the volume in the Compose file. Paths are relative to the volume root. List at most eight distinct directories; one entry cannot contain another:

volumes:
  www_next:
    x-hack-disposable-cache:
      - dev/cache/turbopack

The declaration is part of the reviewed plan. It is recorded in the graph receipt when the graph is admitted, and only a receipt-recorded entry can be selected later. A volume that is also a shared dependency cache cannot declare disposable directories. Declaring a path grants no automatic cleanup.

Stop the graph with an ordinary retaining down (phase stopped-data-retained). With the owned runtime running, inspect the single target. This step is read-only:

./hack-native --candidate-root /absolute/private/candidate-home graph inspect-cache-quarantine \
  --run-id <run-id> --volume www_next --path dev/cache/turbopack --json

The response reports the target's target_bytes (regular-file bytes), allocated_bytes, entries and a selection_sha256. The selection binds the exact receipt bytes, the volume name, creation time and root directory identity, the target directory identity and the measured bytes. Review it, then quarantine:

./hack-native --candidate-root /absolute/private/candidate-home graph quarantine-cache \
  --run-id <run-id> --volume www_next --path dev/cache/turbopack \
  --expect-selection <selection_sha256> --json

These commands, and resolve-cache-quarantine below, hold the provider mutation lease. They refuse when any of these is true:

  • the graph is active, its journal is incomplete, or interrupted cleanup is pending
  • the path is not exactly a declared entry, or is absolute, traversing or empty
  • the volume is missing, carries foreign ownership labels, or has changed incarnation (any inspected field other than CreatedAt differs, or the guest observes a different _data directory, as after the volume was removed and recreated under the same name)
  • any container mounts the volume, including stopped containers
  • a component of the path, or the target itself, is a symbolic link or not a directory
  • the target directory was replaced after inspection

The guest renames the directory under a lock on the volume root into /var/lib/docker/volumes/<volume>/hack-quarantine/<id>. That directory is beside the volume's _data root, and no container can mount it. Before the rename the guest checks that the source and the quarantine directory are on the same mount (the same findmnt mount ID, not only the same device number). A rename within one mount cannot fail with a cross-device error, so mv never falls back to copying and then deleting the source. The result reports quarantined_bytes and reclaimed_bytes: 0. The bytes remain for diagnosis until the owning volume itself is explicitly removed (for example graph cleanup --remove-data). Restart, restore and ordinary cleanup never collect them. Then use a normal retaining restart. The framework regenerates its cache.

Quarantined bytes are outside the volume's _data directory. Docker's disk-usage report, and therefore graph storage-inventory, does not count them, so the inventory undercounts a volume that holds a quarantine. Every inspect-cache-quarantine result, including target_absent, reports retained_quarantine_records and retained_quarantine_bytes: the recorded bytes still held in the current incarnation of that volume.

The first quarantine of a volume creates hack-quarantine beside _data. Docker's local driver reports a volume's CreatedAt from the modification time of that directory, so the reported value changes. The selection records CreatedAt as observed evidence, but it is not volume identity: the move still settles, a pending record still resumes, and its bytes are still counted. A digest reviewed before an earlier quarantine of the same volume no longer matches; inspect again. If the volume changes during the guest effect, the command fails with graph_cache_quarantine_unsettled and the record stays pending for explicit resume or resolution. A failed recheck inspection or guest directory-identity read instead returns graph_cache_quarantine_unobserved: the command could not observe the volume after a possible effect. It makes no claim that the volume changed. The record stays pending, and explicit resolution can settle it once observation is available again.

Retained-volume recovery and removal use stable inspection fields plus the guest _data device/inode, so a quarantine does not invalidate their selection. See the volume identity audit for covered workflows, excluded protocols, and compatibility limits.

A successful guest exit with an unrecognized or truncated MOVE reply also reports graph_cache_quarantine_unsettled: bytes may have moved, so the record stays pending for explicit resume or resolution.

Volume identity has a residual inode-reuse risk. If an external actor removes and recreates the volume between IDENTITY and MOVE with the same inspection fields and reused device/inode numbers, quarantine cannot detect the replacement. Exclude external volume removal/recreation during quarantine; the provider lease serializes Hack mutations only. A Hack-written generation nonce is deferred because existing volumes need a durable enrollment and interrupted-enrollment recovery contract; quarantine must not silently create or repair identity for an existing volume.

A private record under the candidate home's run/cache-quarantine/ is written before the guest effect and marked quarantined after the guest syncs the move. If the command is interrupted, inspect-cache-quarantine reports the recorded digest with one of two phases:

  • resume_required: the receipt and volume are unchanged. Repeat quarantine-cache with the same selection. Resumption accepts the recorded directory either still at its source or already at its destination with the same inode. It refuses a regenerated directory, a different destination or both present, and never moves new bytes.

  • resolve_required: resumption is no longer possible, for example because a restart changed the receipt, or because the move keeps refusing. Settle the record without moving anything:

    ./hack-native --candidate-root /absolute/private/candidate-home graph resolve-cache-quarantine \
      --run-id <run-id> --volume www_next --path dev/cache/turbopack \
      --expect-selection <recorded selection_sha256> --json

    Under the same lease, consumer and ownership checks, the guest reads the recorded destination under the volume root lock. The record becomes quarantined when the recorded directory (same inode) is there, and abandoned when the destination is absent, which means nothing was moved. Any other layout, such as a symbolic-link quarantine directory or a different directory at the destination, refuses. After resolution, inspect again for a fresh selection.

You can also resolve a record that could still be resumed. While a record is pending, other quarantines of that volume are refused. A torn record write refuses automatic replay. Records are retained as diagnosis evidence and are not collected automatically. Each volume admits at most 16 records, and the candidate home holds at most 1024. Volume names are specific to a run, so a new run starts with a fresh per-volume budget. When the global limit is reached, every new quarantine is refused; this candidate has no command that collects records.

This operation does not classify the cause of the corruption. One checksum error does not show an interrupted write or a storage integrity fault. Container health also does not prove that application routes work; check the public route separately after restart.

Explicit cleanup after a dead foreground owner

hack up and hack restart refuse a run whose foreground publication is still present and name the next step: a running owner must be stopped with Ctrl-C or hack down; an owner that exited while its services were running is stopped by hack down --recover. That command selects the run's exact receipt with the read-only graph foreground-owner --run-id RUN --json and then uses the same-boot graph recover-live-owner described below, retaining data and retiring the stale publication. If the frontend that owned the run also exited before confirming its cleanup, hack down --recover prints the exact hack restart --recover-frontend --expect-finalization-attempt ATTEMPT step. An owner that exited before its restore was admitted is reported, with data retained, but no command retires that attempt yet.

Read-only hack ps and hack logs hold the provider lease only briefly. Native startup, retained-start eligibility, restore admission, inspections inside mutating commands and owned foreground cleanup wait out such a holder within a bound instead of failing, so polling status during up or restart does not make them fail or strand a foreground publication.

For a fully ready graph whose foreground owner died while its pool remains on the same boot, graph recover-live-owner --run-id RUN --expect-receipt SHA256 selects only that graph. It requires matching dead foreground and relay-owner identities, unchanged receipt and resource inventories, and exclusive publication locks. The relay endpoint may also be absent after the managed owner exited: both relay files must be absent under the original held operation lock, and the exact dead foreground process and ready receipt must select the control root derived for this run, runtime owner, and current guest boot. Recovery persists that absence witness and rechecks its directory/lock identity, process death, endpoint absence, and receipt bytes at cleanup boundaries. A partial publication, replacement endpoint or lock, legacy unscoped root, or changed receipt is refused. This path does not recreate relay metadata or invent a live-owner acknowledgement. The operation stops verified guest dependency listeners, removes owned containers and network resources, retains persistent data, retires stale foreground and relay publications, and releases the graph's dependency reservation before unlocking the pool. It does not restart the pool. Pending startup, one-off or dependency rebind state is refused. A completed dependency refresh is accepted only for the current boot and exact ready receipt generation, with matching terminal services and helper markers. After owned absence is independently verified, its journal is archived before stale publications are retired. An interrupted archive resumes from the committed cleanup proof and exact journal bytes; it never replays refresh. Completion records owner-death evidence independently of the live relay acknowledgement protocol. When a restored publisher is admitted, older absence-retirement records remain validated history; the exact current cleanup proof supplies retention authority.

Before the main recovery intent, the operation journals and normalizes owned bridge reservations that never launched (reserved with no relay intent) or whose exact reservation-v1 helper has exited. The journal binds the original receipt, boot, owner witnesses and complete same-run bridge inventory. It resumes confirmed stop/removal interruptions, including canonical pending fence writes, without removing a replacement socket, helper, allocation or reservation. A helper may remove its own socket on exit; its exact process identity and retained private socket receipt remain required. An originally live, unchanged bridge that exits before main intent enrollment can be added to the journal with fresh exit proof. Starting reservations, unknown or partial metadata, added assignments and replaced identities refuse. Live helpers are left for the existing main recovery path; other graphs are not selected. The normalizer does not run on an existing main intent retry. Completed normalization journals are generation-bound history after an independently verified retained restore.

After completion, retained restore can create a fresh owner; graph retire-recovered-publisher --run-id RUN --expect-owner OWNER remains an idempotent compatibility operation. Interrupted cleanup retains its journal for an explicit retry with the original receipt hash. A pool boot change, replaced process or socket evidence, or ambiguous ownership refuses recovery. The separate previous-boot operation below preserves its existing ownership requirements.

If current-boot cleanup is already confirmed and retained data is intact, but the dead foreground publisher still has its owner and socket while its operation lock pathname is missing, ordinary retirement must refuse. First inspect with graph inspect-missing-publication-lock --run-id RUN --json. After placing the exact candidate home in a maintenance window that prevents older Hack binaries and background clients from starting new operations, use graph retire-missing-publication-lock --run-id RUN --expect-selection SHA256 --accept-quiesced-candidate-home. The explicit action creates a selected private replacement lock, then reuses the current-boot cleanup proof and publisher-retirement journal. It does not alter graph receipts, guest resources or retained volumes. An interrupted repair blocks ordinary publication until the same selected operation resumes and its completion is verified. The CLI cannot itself prove that older binaries obey the publication gate; process listings alone do not establish that maintenance window. Keep the VM and its guest boot unchanged during this recovery.

An interrupted publisher can also leave its separate relay-control publication after cleanup is acknowledged. Selected acknowledged retirement verifies that relay's exact dead process, socket and confirmed cleanup operation before removing its publication. A durable record bound to that publisher permits an exact retry after interruption; records from earlier publishers grant no authority over a replacement. Restore checks for incomplete retirement before reserving dependency slots or creating a relay listener. Relay admission shares the existing bounded provider-admission deadline when waiting for a concurrent recovery operation.

Repeating a completed missing-lock repair can finish the same stopped generation's relay retirement. If the graph has moved to a later generation, the old completion is historical evidence only. A failed current cleanup or ownership check is not permission to discard files; preserve the evidence and use the selected recovery operation.

A graph with completed host-dependency startup and no inbound routes can also use its exact dead relay publication as the previous-boot proof; an empty bridge registry alone never grants cleanup authority. A completed previous-boot or same-boot recovery may remain as historical evidence after restore. When its exact stopped receipt remains in bounded restore history, that receipt must match the completion proof. After history eviction, only a self-consistent proof for the same graph and a different container generation can be treated as inert history; the verified truncated history must independently contain a later stopped generation. This does not establish the evicted completion hash as current authority. Same-boot history also requires validated listener retirement. Incomplete, foreign, one-off, conflicting or missing-history records refuse without modification. Current owner, boot, process, receipt and resource checks still authorize cleanup independently, including on retries. A later previous-boot recovery may archive that validated older completion even after its exact stopped receipt has been evicted. It preserves the original proof bytes and inode by renaming it; it does not reconstruct an evicted receipt or use historical evidence as current cleanup authority. Pending or incomplete proofs, changed graph identities and an unchanged container generation still refuse. Retention and recovered-publisher retirement prefer a completed previous-boot proof that matches the exact current stopped receipt. An older same-boot proof must still pass historical validation; its presence cannot redirect these operations away from the current proof. Pending, incomplete, malformed or conflicting same-boot records continue to refuse both operations. An already completed prior-boot dependency archive is likewise historical metadata. Restore may leave it untouched after validating its original generation, boot, journal and exact artifact hashes, with no active or pending journal and an independently confirmed current retention proof. Eviction of its old stopped receipt does not require repeating completed archival. First-time or interrupted archival still requires the exact selected cleanup proof and ownership checks. An explicitly requested data removal can select a completed same-boot recovery after publisher retirement. It pins the exact completion, current guest boot and archived publisher identity; retries keep the selected proof source and refuse changed evidence. This does not fabricate a relay acknowledgement. Native routed recovery has been qualified with repeated owner crashes, retained data and an unaffected sibling's HTTPS route; this does not establish normal source-mounted worktree parity.

graph recover-cleanup --run-id <run> --expect-receipt <sha256> is a retaining, explicit two-step recovery for a failed enrolled graph. Select the SHA256 of its private state.json receipt while the owned runtime is confirmed stopped. The first invocation records the exact receipt, dead foreground owner, and old boot; it returns awaiting-runtime-start without guest effects. After explicit ordinary runtime up, repeat the same command and original receipt hash. Recovery requires the same pool incarnation and the selected previous boot, removes only verified owned containers and networks, and retains named volumes and dependency caches. It neither marks incomplete package caches valid nor replays initializers.

If a foreground owner died after its graph reached ready-observed and the pool has already restarted once, the same explicit command can select recovery on that immediate successor boot. It requires the retained dead-owner identity, exact receipt hash, and bridge assignments belonging to the recorded previous boot. The recovery intent pins those assignments before effects and refuses replacements on retry. Do not restart again to attempt this path: another boot rollover invalidates the previous-boot evidence. Old guest helper absence follows the verified boot transition; it is not recorded as a live helper acknowledgement.

Recovery retains its own durable intent and completion evidence. It does not forge a relay acknowledgement. A normalized graph can explicitly retire its recovered publisher and select a fresh foreground restore using the retained data. Missing or replaced foreground evidence, pending graph journals, unfinished explicit page-cache release, changed inventories, another boot rollover, and unsupported prior cleanup enrollment refuse. Preserve these files and use the reported reconciliation path rather than editing receipts. Receipt-only export/prune still requires its existing relay acknowledgement. Runtime restart and actual cleanup remain explicit operations.

After recovery completes, explicitly remove that graph's retained data with:

./hack-native --candidate-root /absolute/private/candidate-home graph cleanup --run-id RUN_ID --remove-data --json

This separate destructive request requires the retained dead-owner identity and completed recovery evidence on the same boot. It also works after explicit retire-recovered-publisher: the archived publisher must match the recovery's exact owner fingerprint and completed receipt, with unchanged archived record, socket and lock identities. No application restore is required first. Partial retirement, missing evidence or another boot still refuses removal. Ordinary acknowledged cleanup keeps its own authority even when an older recovery record exists. Before removal it verifies the receipt's ownership labels and journals the current volume inventory. Retries refuse changed ownership or replacements relative to that removal inventory. The older recovery receipt does not independently pin a volume's creation timestamp before the first removal request. An interrupted removal can resume against its recorded inventory; it does not authorize removal of another graph's volumes. Recovery alone continues to preserve data. Keep the recovery evidence until cleanup finishes; deleting it is not a repair.

Private environment delivery preserves the selected numeric image or Compose User, including UID-only values. Docker resolves the application's primary and supplementary groups from the image; the wrapper does not replace them with group zero. UID-only private files and relay/probe helpers use group zero as their own storage/helper metadata, not as the application's identity. Payload files remain owner-only mode 0400 with exact UID, regular-file, single-link, size, and no-follow checks. Named users/groups remain unsupported for private delivery and refuse instead of silently becoming root. An image with no user retains Docker's normal root default; an explicit Compose numeric user takes precedence.

Services with private environment values and services with host dependency bindings (including every service when project-wide host aliases exist) run their process through a wrapper. The wrapper runs exactly what Docker would: the image or Compose entrypoint followed by command (with no entrypoint, command alone), and a bare executable name such as docker-entrypoint.sh or the mysqladmin of CMD mysqladmin ping is resolved through the container's own PATH after the environment is applied. A shell-form health check (CMD-SHELL or a string test) runs through the image's shell (/bin/sh -c by default), as Docker runs it. Stock images therefore need no absolute paths. What a wrapper still cannot run is an empty process, a health test that names no executable, a named (non-numeric) user, a stop signal other than SIGTERM, SIGINT, SIGQUIT or SIGKILL, and a volume target at /, /run or a reserved wrapper path. Native up refuses the Compose-declared cases before the VM starts, and the runtime checks settings inherited from the pinned image before reserving anything. Both name every refused service and field at once (for example service db user (use a numeric UID or UID:GID)). hack doctor on the native backend reports the Compose-declared cases in its native process wrappers check, for every service native may wrap. Only profile-active services are checked by up and restart; a service behind an unselected profile never runs and cannot block them, so doctor lists such services as information only.

Approved-host DNS on pool restart

The pinned Smol provider re-resolves approved hosts at each boot and appends the results to its retained runtime CIDRs. The candidate accepts at most 512 runtime entries, including duplicates, only when all original pinned addresses remain present and every entry is a canonical public IPv4 /32 or IPv6 /128 address. Private, loopback, metadata, broader subnet, missing-pin and oversized results remain refused. The durable provider database must still match the approved host list and original CIDRs exactly; restarting does not expand the approved hosts.

This audit trusts fresh DNS resolution by the pinned provider. It does not independently prove which hostname produced each added address. Existing dynamic DNS and descendant-host policy semantics remain unchanged.

Native HTTPS startup probes each reviewed alias at its service's reviewed native HTTP health path. A 2xx response qualifies directly. Redirects (301, 302, 303, 307, 308) qualify only when their chain ends at a verified 2xx response from another reviewed alias of the same service and health path. Redirect targets must use HTTPS, port443 or the selected frontend port, and no credentials, query or fragment. Cycles, external names and cross-service redirects refuse startup. Every request still connects to loopback with the selected private CA and the alias as SNI/Host; redirect URLs never select network destinations. Response headers are bounded to 8KiB; malformed headers and duplicate Location fields are refused. Only bounded headers are parsed and returned; unrelated repeated headers such as Set-Cookie are allowed.

Normal native exec

With the same explicit native binary/home selection used for up, run hack exec --path /absolute/project SERVICE -- /bin/echo hello. The command targets the current owned project/branch mapping, preserves binary stdout/stderr and the command exit code, and never falls back to Compose. It is noninteractive, with a 30-second observation budget; a timeout may leave the command running and is not retried. --env and --profile changes are refused. A command name such as bun uses the selected container's PATH; no host PATH or implicit shell is used. Normal hack exec resolves the saved startup overlay and AWS profile again, and delivers fresh selected-service values through private stdin. Each request binds the reviewed plan, current container and generation, and has its own short ingress lifetime. Startup leases and files are not renewed or rewritten. The allocation is selected from the current container's exact read-only mounts and checked against its committed current-boot intent. Historical allocations retained by earlier restores do not compete with this selection; pending, mismatched or ambiguous mounted allocations are refused. Legacy mappings without explicit selectors and services with older launcher mounts refuse before execution; restart with the matching candidate to adopt this path. Services without managed values use ordinary exec. The native live qualification of this fresh-delivery path remains separate from its unit and transport tests.

Managed HTTPS authority startup uses the same five-second provider-lock admission budget as ordinary pool startup. This lets a brief concurrent graph inspection finish before admission. Pool identity, socket ownership and certificate admission are checked only after the lock is acquired; expiry refuses with provider_busy and no authority is published. Serving and cleanup are never replayed.

An authority child that exits before readiness can supply a bounded structured native error code without exposing its stderr. Malformed, oversized or missing output leaves the cause unknown. The authority-ready diagnostic now covers only readiness; later socket-identity, permission-port and Caddyfile failures have separate stages. These classifications do not acknowledge cleanup or permit an owner to be adopted or replaced.

A detached HTTPS helper that fails during startup may retain a generation-bound startup-failure.json beside its owner configuration. The CLI reports only the reviewed startup stage and an allowlisted native error code; child output, paths and application values are omitted. If lease cleanup also fails, the original acquisition diagnostic remains visible alongside the unconfirmed cleanup status. This record is diagnostic evidence only: it does not acknowledge retirement or permit a replacement owner. A missing record (including a helper crash before publication) leaves the cause unknown. Preserve the retained owner and finalization records for inspection; do not delete them to force another startup.

When an HTTPS owner refuses an exact lease release, it now attempts a bounded failure response containing only the release stage and a reviewed native error code. The client verifies the complete lease identity and rejects extra or noncanonical protocol data. This response is not a release acknowledgement and never triggers a retry: transport loss still leaves the outcome unconfirmed. For example, an observed graph-verification: provider_busy would locate the refusal without exposing native stderr; it would not prove cleanup completed or permit another owner to start. Preserve the original records while investigating.


Source: v5 (next.12) · docs/guides/native-candidate.md.

On this page