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-bundleThe 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-cliThis 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 --versionThe 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/projectKeep 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 feature | Kept on the Compose backend when |
|---|---|
| External network | A 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 volume | A 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 label | A 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 form | A 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 platform | A 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 upThe 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 ofcontext,dockerfile,targetandargs. Paths follow Compose (the context is relative to the Compose file, the Dockerfile to the context) and must stay inside the project.argsmay be a mapping of literal strings or numbers, or a list ofKEY=VALUEliterals. Any$interpolation, a list item without=(host variable inheritance),null, other build keys (secrets,ssh,cache_from,platforms,networkand the rest), remote contexts andimagetogether withbuildrefuse. 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 envsecret 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=1to 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,
uprefuses and asks you to start fresh.hack restartalso 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 freshupbuilds it then. Native builds arelinux/arm64only;platform: linux/amd64is 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 withgraph_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, orE_CONFIG_INVALIDfrom 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 runand other non-foreground paths do not publish ports and keep refusing them withgraph_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.
- 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. - Retain application data with ordinary
downfor 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. - Select the new bundle's
hack-v5by its full path with the same canonical mode-0700HACK_NATIVE_HOME. Check itsinfoidentity and update any explicitly selected guest-relay artifact paths to the verified complete bundle. Normal frontendupvalidates 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. - 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 --jsonReview 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> --jsonThe 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 --jsonInspection 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 --jsonOmitting --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 upacknowledged confirmed graph cleanup, HTTPS retirement and down hooks. The refusal names the exact release command. Inspection reportsstate: "foreign-home",owner: "stopped"and theselectionSha256it 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 runhack downwith 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 inspectandhack 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 --jsonPreparation 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 --jsonThe 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-buildxThe 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
ONBUILDtriggers 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 (
ADDof anhttp,httpsor 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 storedONBUILDtriggers 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. missingreuses a verified image for the same build key without running the client;rebuildalways builds. The key covers the Dockerfile and.dockerignorebytes, 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 downrefuses 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_storageand 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.
| File | SHA-256 |
|---|---|
iptables-1.8.10-r3.apk | 31ab6343f1f3d0fbbf290c4dcf0430b2d08e8073e516e13530dfab25b097d467 |
libmnl-1.0.5-r2.apk | d15e6313880bdd14959f42c1556b4a810ef4894992ae9f73b148126f0cc6021d |
libnftnl-1.2.6-r0.apk | ec1c2b02869fc65bcf7a1105e3a7ac5df1bd9a8bd8b399cb6cf650dd3112c021 |
libxtables-1.8.10-r3.apk | f0accefde240ece6722479b46cb014d7d2f745af7d796e8eec3ced53571e1088 |
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 helloBoth 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_SEARCHandBPFare known container escape or kernel attack primitives.SYS_TIME,SYS_PACCT,SYSLOG,PERFMON,AUDIT_CONTROL,AUDIT_READ,MAC_ADMIN,MAC_OVERRIDE,WAKE_ALARMandBLOCK_SUSPENDact on the VM as a whole: its clock, accounting, kernel log, performance events, audit or security modules, or power management.SYS_PTRACEwould let an application trace Hack's in-container helpers (the dependency relay and the HTTP probe), bypassing the kernel's ptrace restrictions.LINUX_IMMUTABLEcould make files in Hack-owned volumes impossible to remove during cleanup.SYS_RESOURCE,SYS_BOOT,SYS_TTY_CONFIG,LEASEandCHECKPOINT_RESTOREhave 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 warningoptional_dependency_inactive, as in Compose. When the target is active, native waits for itsconditionexactly as for a required dependency and fails startup if the condition is never met. Compose may only warn there, so review reportsoptional_dependency_enforced. A target that does not exist is stillmissing_dependency, andrestart: trueis still refused.stdin_open: truekeeps the container's stdin open, so it never reaches EOF. No host input is attached, as withhack upon Compose.tty: trueis accepted but no pseudo-terminal is allocated: stdout and stderr stay separate, and review reportsterminal_not_allocated. Programs that need a real terminal (colors, prompts) stay on the Compose backend.- A
caddyhostname label withcaddy.tls: internaland nocaddy.reverse_proxyproxies nothing, so native treats it as inert: no route, hostname claim or relay is created, and review reportsinert_route_declaration. The hostnames still appear in runtime host metadata, as on Compose. Any other partial or extracaddy*label set is still refused. Inert hostnames are ordinary literals for a retaining hostname change, andHACK_NATIVE_ADAPTATIONadditionalHostnamesrefuses 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$NAMEand$(...)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 deliversTOKENto that service, refuses withexecution_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.tarFor an existing mutable image declaration, resolve it explicitly first:
./hack-native --candidate-root /absolute/private/candidate-home runtime resolve-image \
--reference '[registry/]repository:tag' --jsonImage 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' --jsonIt 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:
| Budget | Default | Range | Flag | hack up setting |
|---|---|---|---|---|
| Compressed archive per image | 2 GiB | 16 MiB to 8 GiB | --image-budget-mib N | HACK_NATIVE_IMAGE_BUDGET_MIB |
| Decoded content per image | 5 times the archive budget | follows the archive budget | none | none |
| Image cache total | 16 GiB | 16 MiB to 256 GiB, at least one image budget | --image-cache-mib N | HACK_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 --jsonThe 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 --jsonThis 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 --jsonThe 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 persistentup.beforehook (itsname, orhook-Nwhen unnamed) or alifecycle.processesentry 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 enabledlifecycle.sharedHelpersentry whose claim isownedandready, 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 --jsonOnly 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
1orontherefore 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:
| Field | Type and range | Meaning |
|---|---|---|
port | integer, 1–65535 | Container 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}}". |
path | string, starts with /, at most 512 bytes | Request path. Only printable ASCII without spaces is accepted; # is refused. The probe sends GET <path> with Host: 127.0.0.1:<port>. |
interval_ms | integer, 10–3600000 | Time from the start of one request to the start of the next. |
timeout_ms | integer, 1–60000 | Limit for connecting, sending the request and reading the response headers. |
retries | integer, 1–100 | Consecutive failed samples that make the service unhealthy. |
start_period_ms | integer, 0–3600000 | Startup 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: 120000hack 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:
| Mode | Image platform |
|---|---|
native (default) | Linux arm64 only. A declared platform: linux/amd64 keeps the project on the Compose backend. |
prefer-native | The 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. |
amd64 | The 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:
| File | Holds | Where | Commit it? |
|---|---|---|---|
| Project adaptation | The adaptation above (isolatedNetworks, httpProbes, additionalHostnames, additionalHostAliases, workspaceCache, amd64Emulation) | .hack/native/adaptation.json, and optionally .hack/native/adaptation.<env>.json | Yes. Every checkout and worktree shares it. |
| Machine settings | Grants 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.json | Never. 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:
| Field | Variable it stands for |
|---|---|
sharedSource (true or false) | HACK_NATIVE_SHARED_SOURCE=1 |
aws.profile, aws.region | HACK_NATIVE_AWS_PROFILE, HACK_NATIVE_AWS_REGION |
dependencies, dependenciesByEnv | HACK_NATIVE_DEPENDENCIES |
https.caddyBinary, https.caddySha256, https.port | HACK_NATIVE_CADDY_BINARY, HACK_NATIVE_CADDY_SHA256, HACK_NATIVE_HTTPS_PORT |
preparedBase.mode (prefer, require or off), preparedBase.store | HACK_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.jsonis a regular file owned by you with mode0600, 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.profileandaws.regionmust be selectors that native AWS adaptation accepts, and so mustHACK_NATIVE_AWS_PROFILEandHACK_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:
| Warning | Meaning |
|---|---|
shared_source_not_granted | Native up and restart refuse until the project share is granted. |
internal_dns_tls_not_applied | internal.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_probes | routesWithoutProbes routed services have no native HTTP probe after the adaptation for the default env; native up refuses them. |
compose_profiles_ignored | COMPOSE_PROFILES is set in the environment, but native selects profiles only from --profile. Managed COMPOSE_PROFILES values are not inspected. |
aws_region_without_profile | HACK_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 --jsonbuild 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 # optionalprefer 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 upwithrequire, 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
/datamarker. - It runs
down, edits the served file on the host, and runsupagain. 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, everydnsmasq.d/*.confinclude file and the resolver file are bounded regular UTF-8 files with one link and are not symlinks.- Neither
dnsmasq.confnor an include file loads a DNS source Hack did not inspect:conf-file,conf-script,addn-hosts,hostsdir,servers-file, or aconf-dirother than<brew>/etc/dnsmasq.d,*.conf. - The line has the exact Hack-managed form and is the only
.hack.localclaim, includingdnsmasq.dinclude files. - The target is one Hack wrote (
172.30.0.2or::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 --jsonOmit --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/turbopackThe 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 --jsonThe 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> --jsonThese 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
CreatedAtdiffers, or the guest observes a different_datadirectory, 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. Repeatquarantine-cachewith 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> --jsonUnder the same lease, consumer and ownership checks, the guest reads the recorded destination under the volume root lock. The record becomes
quarantinedwhen the recorded directory (same inode) is there, andabandonedwhen 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 --jsonThis 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.