Files
cli/LLM.md
zeekay a63427da5e dev: Gen 6741119; link tests on a tmux of their own; attested installs
hanzo-gen moves to 6741119: programs by full path only, a home read from
the environment alone, run homes that survive no ending, the meter by
brand. So the CLI:

- link names the tmux server it works in (Tmux); a link uses the default
  socket, and its tests start one at a temp socket and kill it after, so
  the suite never reaches a person's tmux
- holds the Store's one config behind a lock held for the whole ask, so
  an identity IAM moves during a heartbeat is never written back over;
  the profile and the meter are asked under it, the meter as the
  session's brand (Seam::open takes the brand)
- finds npm and uv by full path, and hands the __mcp proxy its root
- install.sh checks an attested tool's download (HANZO_INSTALL_SIGNER)
  against its workflow and tag with a signed-in gh, refuses a mismatch,
  and says so when it could not check
- the console's screenshot test writes into a temp dir, not /tmp
- hanzo.yml runs make test with HOME empty and read-only, then empty and
  writable and still empty after
2026-10-16 10:45:00 -07:00

96 KiB
Raw Permalink Blame History

LLM.md — hanzoai/cli

What: the hanzo CLI — one Rust binary (crate hanzo-cli, bin hanzo) that codes with an AI agent, signs in, and deploys/manages/interacts with the Hanzo Open AI Cloud, plus runs the network. clap + tokio, rustls TLS, no C deps, no daemon.

Canonical role: the terminal face of the Open AI Cloud — the CLI, NOT an SDK. SDK model: full cloud SDK per language (hanzo-<lang>/sdk) + AI/agents lib (Python hanzo flagship = hanzoai/python-sdk, Node @hanzo/ai). This repo is the canonical impl of the CLI; discovery/marketing repos link here, never copy it.

Install / run:

  • curl -fsSL https://hanzo.sh | sh — the ONE published install line, on every surface. hanzo.sh drives THIS repo's install.sh once per tool (hanzo, hanzo-mcp, dev); install.sh is the one implementation of "fetch a Hanzo binary" (per-platform asset, sha256-verified, second-name symlink). No token: the repo is public. A tool whose releases are attested names its workflow (HANZO_INSTALL_SIGNER, hanzo.sh's fifth field; gen's .github/workflows/release.yml), and the download's attestation is checked against it and the tag with a signed-in gh — a mismatch refuses, no gh says it was not checked. The .sha256 alone comes from the same release.
  • from source: cargo install --path . · build gate: cargo build / cargo test / cargo clippy --bin hanzo

The coding harness is Hanzo Gen (hanzoai/gen, crate hanzo-gen, pinned by rev in Cargo.toml). hanzo, hanzo dev, hanzo desktop and hanzo sandbox run flatten its hanzo_gen::Args and run hanzo_gen::run with this CLI as its host (commands/dev.rs Store: the IAM store, the provider vault, network/org/code.* config, and commands/savings.rs, the meter read through the seam; ONE config for the host's life, so an IAM identity move sticks across heartbeats). Standalone gen asks this CLI for the same profile with the hidden hanzo config profile (JSON, no secret): the one reader of this config. Every path the harness writes under a home comes from hanzo_gen::Root (Root::need() here; tests pass Root::at(tempdir)), and hanzo.yml runs make test with HOME empty and read-only, then empty and writable and still empty after. A test never reaches a person's tmux either: link's Tmux names the server (the default socket for a link, a temp socket a test kills after). The usage meter and Seam::open take the brand a session runs as. Below, gen:src/… names a file in hanzoai/gen. A change to the harness is a Gen commit, then a rev bump here; [patch."https://github.com/hanzoai/cli"] resolves Gen's hanzo-client to crates/client, so one copy links.

This binary answers to TWO names, and that is by design. cloud's Go control binary (hanzoai/cloud, cmd/hanzo) owns a small set of control verbs and delegates every other verb to US, through cloud/cli/link.go fabricCLI(), which resolves hanzo-node on PATH first and only then hanzo (with a self-exec guard). So a machine carrying the Go control binary installs THIS artifact as hanzo-node. Same build, two names — there is no third CLI codebase, and hanzo-node is not a separate product.

The trap that follows from it: a stale hanzo-node is INVISIBLE. The user types hanzo, the Go binary delegates, and they get whatever old build is sitting under the other name — with no version in sight. That is not hypothetical: a v1.7.2 hanzo-node served roughly 150 phantom /v1/cloud/<verb-noun> commands, because it predates genspec.rs and the registry refutation seam and was built from the vendored spec/products.json (since deleted). Nothing was wrong with the live code; the INSTALL was old. So:

Whatever installs hanzo MUST install hanzo-node at the same version, and both must be this crate. An installer that ships one name, or ships two different versions, produces a CLI whose surface nobody can explain.

hanzo version now makes that skew LOUD (commands/version.rs), in BOTH directions, and it resolves PATH exactly the way a delegating caller would — first match wins, which is the precedence that let a stale build hide:

  • a stale delegate BEHIND us — stale delegate: <path> is vX — this is vY. Same file (hardlink or symlink) or same version is silent.
  • a different file AHEAD of us holding the name — shadowed name: 'hanzo' on PATH is <path> — this is <us>. This is the one that actually bit: a Go control binary at /usr/local/bin/hanzo and this build at ~/.local/bin/hanzo, each answering a different hanzo status.

It never repairs and never refuses; it only stops the failure from being invisible.

--version, -V and version are ONE function. #[command(version)] let clap intercept --version, print its own line and exit — so commands::version::run (the only thing that carries the skew report) never ran for the spelling people actually type, and the two spellings printed different text. main now sets disable_version_flag, declares -V/--version as its own flag, and routes it to that one function before any other dispatch. The answer is ONE line on STDOUT — hanzo <semver> — because that is the shape the Go binary's delegate parser reads (first line, last token, optional v stripped) and the shape every other --version prints. A skew report is a WARNING, not the answer, so it goes to STDERR and a caller piping the version gets exactly one clean line.

install.sh does exactly that: it installs hanzo, symlinks hanzo-node to the same file (a copy where links are unavailable), and warns when another hanzo earlier on PATH would run instead of the build it just placed — the precedence that let a stale twin hide.

The remaining gaps are elsewhere:

  • PyPI carries hanzo, hanzo-cli and hanzo-node as SEPARATE projects that are not this crate. curl hanzo.sh | sh used to install one of them at the name hanzo, into ~/.local/bin, which outranks /usr/local/bin on a normal PATH. It now calls THIS installer instead, so there is one download path and one asset-naming rule, not two.
  • releases are what installers actually resolve, and they are cut by the Release Matrix workflow. A version bump that never becomes a release ships nothing — main can be current while every install path still serves an old build. That is not hypothetical: v1.9.13, v1.9.15, v1.9.16 and v1.9.17 were all tagged while users were still resolving v1.9.12, so hanzo dev was unreachable for everyone who had not built from source.
  • Homebrew is a THIRD install path, and for 35 releases nobody moved it. brew install hanzoai/tap/hanzo read a formula hand-written at v1.9.18, so brew served a build answering hanzo serve where every other path served hanzo up. The tap's own gate could not see it: it checks each url against the sha256 beside it, which stays green forever on a stale but self-consistent pair. Freshness is a fact about the RELEASE, so the tap job in release-matrix.yml writes hanzoai/homebrew-tap Formula/hanzo.rb on every tag — the version line and four checksums, taken from the .sha256 sidecars the build already uploaded rather than a second reading of the bytes, and each rewrite READ BACK, because an anchor that stops matching would leave the previous release's checksum under the new url and say nothing. Four targets: Homebrew has no windows. It pushes the FORGE with FORGE_TOKEN and the push mirror carries it to github, where brew tap clones — the first run wrote github directly and left git.hanzo.ai a commit behind, with the mirror aimed at a github main it no longer contained.

CI runs on the FORGE. This is the whole story, and both halves bit us.

  • runs-on: hanzo-build-linux-amd64 is a git.hanzo.ai label, advertised by the git-runner-0..9 pool (28 labels, every one mapping to docker://catthehacker/ubuntu:act-24.04). github.com has zero runners at org or repo level — the ARC scale set that once served this label is gone, down to the autoscalingrunnersets CRD.
  • The forge reads BOTH .hanzo/workflows and .github/workflows; GitHub reads only .github/workflows. So a pipeline left under .github/ runs twice — once on the forge, and once on GitHub where nothing can pick it up and the job queues in silence (v1.9.13's GitHub copy sat 17 hours). Everything CI lives in .hanzo/workflows/ so there is exactly one build per tag.
  • github.token is a FORGE token. Publishing a GitHub Release with it fails, and fails LAST — after checkout, the tag check, Zig, cargo-zigbuild, the macOS SDK, the test gate and all five cross-builds have passed. The only symptom is could not create or find release <tag>. Use secrets.GH_PAT (org secret at git.hanzo.ai/hanzoai; repo-level secrets are empty and org secrets inherit).
  • The split to keep straight: BUILD is the forge's, DISTRIBUTION is GitHub's. hanzo.sh resolves api.github.com/repos/hanzoai/cli/releases/latest, so assets must land on GitHub Releases no matter where they were compiled. That crossing is the one place this pipeline needs a credential.
  • Never runs-on: ubuntu-latest. That label was deliberately removed from the pool after an upstream workflow wedged all ten runners; a job requesting it queues for its full 24h timeout and reports no error at all.
  • All five platform assets come from that ONE linux/amd64 docker pool. cargo-zigbuild makes Zig the cross-linker, so no macOS or Windows runner exists or is needed — a docker-only, linux-only pool ships linux-{amd64,arm64}, windows-amd64 and darwin-{amd64,arm64}. Anyone proposing per-arch runners to "fix" the release has misread the problem.

Command surface — resource-noun tree hanzo <resource> <command>, plus generated product subcommands hanzo <product> … (derived from the spec, below — the ONE interface to cloud; no hanzo api verb, no raw-path escape). Bare hanzo [flags] [task] is an AI coding session (Hanzo MCP attached, routed + streamed to cloud when signed in).

THE ROOT PAGE IS A PROJECTION OF THE PARSER, and it may not keep a list. commands/man.rs held two hand-written tables of commands beside the generated half it already read off cmd, and nothing held them against the tree. All four ways of being wrong shipped at once: agent run and connector named commands that do not exist, serve had been renamed up, billing printed TWICE once it became a generated product, and up and link appeared nowhere. Naming a command that does not exist is not a typo here — an unrecognized first word is a TASK, so hanzo agent run off the page started a coding session about the words "agent run" and reported nothing wrong, the same class as hanzo status before it was a command. The tables are gone: a GROUP takes subcommands, a COMMAND is terminal, prose is each command's own clap about, and the page is rendered from a BUILT tree because clap materializes help at build time. the_page_names_every_command_and_only_real_ones walks it in both directions plus the page's own prose — scoped to the text this file writes, since a product summary is cloud's sentence and a command it misnames is fixed upstream. Help text elsewhere is under the same rule and was wrong the same way: --config's GLOBAL line cited hanzo cluster list on every subcommand screen, and the product is clusters.

The coding session, and its ONE resolver — hanzo dev starts it on our own dev agent (hanzoai/dev, the default), claude, codex, interpreter (Open Interpreter, a Codex fork driven by dev.rs), or any agent in gen:src/agents/. None is an alias of another — naming one runs THAT agent, or fails saying it is not installed. The tool is a flag (--claude, --qwen, --backend NAME); bare hanzo (no command) and hanzo code with no index verb are hanzo dev.

hanzo                      sign in if needed, then the reader's configured agent
hanzo dev --claude         --codex  --cursor  --agy  --qwen  --opencode  …  --backend NAME
hanzo dev --claude -- -c   args after `--` go to the tool verbatim
hanzo "fix the test"       bare session, default backend

Every other agent is one file, gen:src/agents/<name>.rs: its names, binary, npm package (hanzo upgrade <name> installs it under ~/.hanzo/bin), routable (its model calls can go through api.hanzo.ai under --cloud), toolset (Never / Always / Routed, when it reads a home of Hanzo's own), build (the argv, env and files of one run, through Run) and read (its output on a linked run, through Reader). No agent reads another's options; agents/wire.rs holds only the formats several print (Claude's stream-json for qwen/amp/kode, OpenCode's for opencode/kilo, Pi's for pi/omp, plain text). A linked session pipes stdout through read, so a reader that does not match its agent prints NOTHING: each reader is tested on a stream captured from that agent (agents/testdata/). Readers add per-step usage up and fold cache counted apart into input, because the summary reads every agent but Claude with cache inside input. The key reaches a routed agent through its environment only, never argv or a file; mod.rs tests that for every agent, and that each is handed the toolset exactly when toolset says.

hanzo code with an index verb is the code index (search index context file tree ask, the cloud's /v1/code product). hanzo run does not exist: main::retired refuses it through clap's own error path, one line naming hanzo dev; it runs after hoist and before the parse, because without it hanzo run claude would fall into the bare grammar as a TASK called "run claude". hanzo sandbox run is a separate launcher of the same session path.

Routing is direct by default (--no-route is gone, being the default). A third-party tool talks straight to the account it is signed in to: resolve_routing returns Route::Inherit and touches no env. --cloud (Options::cloud) is the opt-in to Hanzo's metered models (the credential plan: provider key, bearer, stored Hanzo key). dev is ours and its account is Hanzo's, so it routes through the gateway when signed in without a flag. An agent whose file says routable: false (Antigravity, Cursor, Gemini, Copilot, Amp, …) has no gateway wire and is always direct. The session banner's first line says which: your account, direct, or Hanzo cloud.

Connected accounts for the harness. A native sign-in (claude, codex, agy, cursor, including chatgpt and agent) is added to ~/.hanzo/accounts.json and does not replace the others. Hanzo identities (hanzo:{owner}/{name}) are the shared gateway logins, read from config. harness in ~/.hanzo/settings.json, or HANZO_HARNESS_ACCOUNT for one run, names which of those a bare session uses; hanzo means the gateway. Each session appends the account it actually used to ~/.hanzo/usage.jsonl. gen:src/harness.rs is that table; its end-to-end test selects every connected account against a temporary directory.

hanzo desktop is the SAME session pointed somewhere else — at a browser and desktop instead of the repo — and pins the Hanzo toolset on, because those tools ARE how an agent drives a desktop. It is a Target, a value passed to the one launcher, not a second command with its own flag.

hanzo agent run is GONE. Its --mode code was a second spelling of hanzo dev — the same options reaching the same code::run, differing in nothing — and its --mode desktop was the only thing it alone could do, which is now hanzo desktop. Two spellings of one implementation is the duplication this tree exists to avoid; hanzo dev is what a person types.

The spellings differ in NOTHING but how the backend was written. backend::select is the only place a backend is resolved, from (positional | flag | --backend | default), and main::code_session is the only launcher; hanzo dev's dispatch arm (dev_args) names our own agent unless a flag names another tool, and hands straight to it. In select the positional operand is the BACKEND if it is exactly a backend name, otherwise the TASK — reachable only on the bare grammar (hanzo claude "fix it"), since hanzo dev refuses the word. Naming the backend twice (hanzo claude --codex) is REFUSED rather than resolved by a precedence rule nobody could remember. If you are adding a spelling, add it to select; if you are adding a launch path, you are forking the command.

dev and codex share one driver (gen:src/dev.rs Agent) because ours began as a fork of the other and they speak the same -c overrides and JSONL stream — they differ only in the program exec'd, so a third is a const, not a file. The model_catalog_json we write for them must carry EVERY field their parser requires: it rejects the whole document on the first missing one, and that took the session down with it (a lost context window is survivable, a session that cannot start is not). hanzo up is the cloud, local, measured (commands/up.rs). It boots k3s in a hanzo-vm microVM from the k3s disk checkpoint, writes two manifests where k3s applies what it finds at startup, and holds the vm in a daemonized up supervise. Three things are worth knowing:

  • The workload goes in BEFORE k3s starts, so the cluster's first act is to apply it — there is no second tool and no window in which the cluster is up running nothing. cloud-key.yaml (namespace + a fresh 32-byte at-rest key) sorts before cloud.yaml (Deployment + NodePort), which is why k3s creates what the workload needs first. The API lands on 127.0.0.1:8080 through a vsock forward to node port 30080; kube stays on 6443.
  • The image is PINNED in the foreground (image.rs: the registry's manifest endpoint, the anonymous token dance, platform selection) and the digest is handed to the supervisor as --cloud, so what the manifest names, what containerd verifies and what the measurement covers are one decision. The default is ghcr.io/hanzoai/cloud:main because that is the tag with an index for both architectures — :latest is a lone linux/amd64 manifest and no arm64 guest can run it.
  • Every boot is measured. The vm reports its launch register before it says ready (a vm that reports none is refused); the supervisor extends a second register with the image digest and the manifest text, asks the guest what its platform will sign for SHA-512(launch ‖ workload), and files all three in ~/.hanzo/up/measure.json. hanzo up --attest prints it, folded again on the way in so an edited file is refused. The at-rest key is deliberately OUTSIDE the measurement: per-cluster state in a register would make identical software measure differently every boot. vm-measure (crates.io, from hanzoai/vm) owns the fold — the CLI does not have a second copy of it.
  • identity/money: hanzo auth login|logout|show|list|use|token (multi-identity, like gh auth switch), hanzo usage
  • cloud: hanzo up (+ --attest); network/wallet: hanzo network, hanzo wallet (PQ cloud custody KMS/MPC or local)
  • fabric/fleet: hanzo link (below), hanzo fabric|runner; ship: hanzo init|share, hanzo scan; tooling: hanzo config, hanzo version
  • local cloud: hanzo host start|status|stop (see "Where cloud RUNS")
  • the whole cloud, one screen: hanzo status (see below)

hanzo console opens on Compute (commands/up/compute.rs), sparkDash's DGX Spark board in a terminal. ONE data model on both surfaces: a machine's spec + metrics (gen:src/context.rs, cloud's agent.Metrics) — this machine read locally every 2s by gen:src/sample.rs Sampler, siblings from GET /v1/agent/targets (the same heartbeat platform.hanzo.ai /platform/computers draws, history from GET /v1/compute/fleet/samples?unit=), networks from GET /v1/network, accounts from the local identity store. The heartbeat (target::beat) holds ONE Sampler, so every beat after the first carries rates over its 30s window. The console beats while open, so the machine it runs on is on the platform too. A beat that found no server counters is a gap in the reading, never a measurement of zero tok/s, and it must not advance the window: discovery rides on nvidia-smi compute-apps, which takes 1.0-1.3s on a saturated GPU and hits its 2s timeout there, returning no pids (gpu_apps unwrap_or_default). Committing that tick as prev left the next good reading with nothing to diff against, so rates went to zero on ALTERNATING ticks and the board read as idle while 6 streams served 155 tok/s. Sampler therefore remembers the last ports it found (extending, never replacing — a server that moved is still seen) and commit advances the window only on a reading that carried counters. — renders an absent rate, never 0.0. Model serving is found, not configured: the listeners of whatever holds the GPU (nvidia compute-app pids and their parents) plus :8000/:30000, read as vLLM or SGLang Prometheus. Cloud reads run on their own task with a 10s ceiling: a slow API never stalls the local board.

Savings: what a session used, read three ways and never added together (gen:src/savings.rs shared, the meter read in commands/savings.rs; gen:src/summary.rs the end-of-session block; up/savings.rs console page 6). Reported tokens are what a backend's own stream disclosed — headless runs only (Claude result, dev turn.completed); an interactive run is the tool's own screen and reports nothing. Listed price is those tokens at GET {api}/v1/models (pricing.prompt, pricing.completion, pricing.rates.input_cache_read|write; same candidate-id rules as kai-gateway's prices.ts): an estimate, $0.00 for a model the list marks free (enso-auto, the default), no dollars for a model it does not name. Claude counts cache tokens apart from input and dev/codex inside it (Tally, Ended.apart). Metered is GET /v1/usage/summary?range=custom&start&end for the session window, asked only for a routed run that reported nothing, and labelled org-wide (it includes other sessions) and possibly lagging. A run on the backend's own account (the default, no --cloud; Cursor, Antigravity: resolve_routing always Inherit for those two, and External::build attaches no MCP) says "not observed". The cloud summary is read tolerantly (Llm::of): cacheReadTokens, cacheWriteTokens, cachedShare (a fraction), cacheSavedCents and a kai block are taken from llm or the top of the document when present and omitted when not, so the console prints "cache: not reported by this cloud" rather than a zero. Console page 6 also scans kai-gateway's default ports 8787-8793 every 2s for GET /dashboard/savings?format=json&from= and keeps its MEASURED columns apart from its ESTIMATED counterfactual (own colour). t steps the range (24h, 7d, 30d), r reads again. hanzo usage summary stays the generated command and prints JSON; the repo has no table formatter for product ops.

hanzo link is the one way to link a computer (commands/link.rs, link/). Hanzo ZT is the network: hanzozt/zt, an OpenZiti fork — not ZeroTier, not WireGuard. Controller https://zt-api.hanzo.ai, router edge.zt.hanzo.ai:3022. hanzo net is deleted; its verbs are these.

hanzo link                                   identity on the org's network + run-target + shell
hanzo link host [NAME HOST:PORT] [--install [--system]]   host what the identity binds; NAME publishes first
hanzo link dial SERVICE PORT [--install [--system]]       local PORT → SERVICE (bare = <name>.<org>)
hanzo link status                            identity, org services, zt processes, installed units
hanzo link rm NAME                           unpublish
--token-command CMD                          any of the network verbs as a machine's own IAM client
  • A caller is on the fabric AS ITS IAM SUBJECT. Cloud (/v1/network) ensures the identity whose externalId is the token's sub; the tunnel signs in with the same token via zt tunnel … --token-command CMD. zt runs CMD WITHOUT a shell and splits it on whitespace, so network::run_token_command does exactly that. Default CMD is this binary's auth token (--as ORG carried). A person's identity is one per subject, shared by all their machines; only a machine client is per machine.
  • Publish, then role. POST /v1/network/services {name,host,port} → <name>.<org>, <name>.<org>.zt; then POST /v1/network/identities {roles:["<name>-host"]} with the HOST's token. Cloud refuses a -host role for a service the org lacks, so the order is fixed (hanzo up --link had it backwards through net). Publishing, unpublishing and roles are a STEWARD's (org admin, platform, or the org's machine client — cloud 29df3216cb); a 403 says so and points at --token-command.
  • The platform's objects are invisible here, by design. engine.hanzo and k8s.hanzo and their identities (grid.hanzo, link.hanzo, dgx.hanzo) carry role platform, are pinned by universe infra/aws/k8s/link-fabric.sh, and never appear in /v1/network lists. A link reads its identity off the controller's client API (/edge/client/v1/current-identity, signed in by ext-jwt) and never gives one carrying platform an org role: an org-* role would put it in reach of a tenant steward's DELETE, which removes an identity left with no org role. So bare link and link dial skip the ensure for it, link host NAME refuses it, and link host with no NAME touches no API (dgx's engine host). link dial ensures an identity only for a service its org lists.
  • HOSTING AND DIALING ARE TWO IDENTITIES — the rule, enforced. zt has no dial-only mode: zt tunnel proxy also HOSTS every service its identity may bind. A dialer that may bind is one more terminator for those services, forwarding to its own machine's host:port; dialing its own service, every other circuit lands on itself and hangs (measured: strict 200/timeout alternation). So link dial signs in to the controller's client API as the dial identity (/edge/client/v1/authenticate?method=ext-jwt, then /services) and REFUSES an identity granted Bind on anything — that reading sees platform bind policies too, which /v1/network never shows. On a machine that hosts, the dial runs as a separate identity via --token-command. A unit does not re-check after install: giving an installed dialer a host role later breaks it.
  • hanzo link host|dial SUPERVISES zt; it never trusts zt to recover. After a controller restart zt 1.7.4's service refresh can 502 mid-restart and never be retried: the host keeps running with NO terminator (dgx's engine went dark until a manual restart). A proxy closes its listener for good when its service leaves view. So the link runs zt as a child and a guard asks the fabric directly: a host wants every service its identity may bind to have ≥1 terminator (client API /services/{id}/terminators, one held session; the client API cannot say whose a terminator is, so this catches "nobody hosts it", not a lost replica); a proxy wants its port in /proc/net/tcp{,6} as LISTEN (Linux only). 45s grace, 20s polls, two misses in a row, then kill zt and start it again with backoff 1s→60s, reset after 5 min steady. Proven by freezing a throwaway host's zt and severing its router sockets (ss -K): 0 terminators, rebind, 200s again in ~43s. A frozen zt with live sockets keeps a stale terminator, and that is not detected.
  • Units (link/unit.rs): one id, hanzo-link-host / hanzo-link-dial-<svc> for systemd (~/.config/systemd/user or /etc/systemd/system with --system), com.ai.hanzo.link.<id> for launchd. ExecStart is the supervisor itself — hanzo link host|dial … --token-command CMD at the absolute path that installed it — with Restart=always, RestartSec=2. A system unit runs that binary as root, so --system warns when anyone but root can write it. Rewritten + restarted on every --install. zt comes from PATH, else ~/.local/bin/zt, else the pinned release installed there, checked against sha256 digests compiled into link/zt.rs (bump VERSION and DIGESTS together).
  • cloud's Go control binary has a hanzo link of its own ("link this machine into the Hanzo cloud as a node": hanzod + compute worker, --daemon), and it does not delegate the verb. Where it comes first on PATH — root's sudo hanzo on dgx resolves /usr/local/bin/hanzo, a stale Go build — hanzo link … is that program, so run this one by path (sudo ~/.local/bin/hanzo link …). Two links is a one-way violation to settle in hanzo-inc/cloud.
  • DELETE /v1/network/services/{id} is served (cloud f4a951db74) but not in the pinned document yet, so unpublish builds its url off the origin; the next spec re-pin makes hanzo network services rm too.
  • Globals move behind the whole command path (main::hoist), and absorb sets args_conflicts_with_subcommands only on a local command with arguments of its own: clap counts a global as an argument, so hanzo --as X network routers and hanzo --as X engine status were refused before this.

Fleet presence outlives the session (hanzo beat, gen:src/target.rs::present). A target is live only while something beats, and the beat was held ONLY inside hanzo dev and hanzo link — so a lab box at an idle prompt, or one whose last session ended an hour ago, read OFFLINE to the console and to whatever places the next session, while being the least loaded machine in the room. hanzo beat is that guard standalone: same refreshed-credential loop, same Sampler, foreground, no shell. Residency is the service manager's job — units/hanzo-beat.service (systemd user, loginctl enable-linger) and units/com.ai.hanzo.beat.plist (launchd), one per machine.

hanzo status is the fleet view, and it is COMPOSED, never a new API (commands/status.rs). It reads three routes cloud already serves, concurrently, through the ONE authenticated seam (product::Seam — origin from network, bearer from the active identity, no org header): GET /v1/k8s/clusters (clusters), GET /v1/deploy/applications (argocd items), GET /v1/fleet/workers (workers). There is no fourth wire and no CLI-only side channel. Two laws it exists to enforce:

  • Most important first. Anything unhealthy leads the page — an application that is not Healthy+Synced, a cluster not running, a node not online. The rest is grouped and COUNTED, so 336 healthy applications are three lines.
  • A surface that did not answer is UNAVAILABLE, never zero. /v1/deploy/gitops answers 403 not authorized for this deploy console to a non-console identity, and /v1/k8s/clusters legitimately answers {"clusters":[]} — those are DIFFERENT facts. A refusal prints its status and the server's own words (collapsed to one clipped line, so an ingress HTML page cannot eat the page it was meant to explain); an empty 200 prints "none reported"; an answer with no list prints "unreadable". "all clear" is claimed only when every surface was actually read. One failing surface never fails the command — failing to read ALL of them does (non-zero exit).

Before this existed, status was not a command at all: it fell through to the flattened coding-session positional, so typing hanzo status started a headless coding agent on the task "status".

Where the cloud surface comes from — hanzo <product> … is DERIVED, never hand-maintained, and the client links no cloud code: it reads a spec, which is what it does over the wire anyway.

hanzoai/cloud@<tag>            THE DOCUMENT, and the only input. It enumerates,
  openapi.yaml  ── genspec ─→ spec/cloud.json ─→ genproduct ─→ generated.rs
                               and carries the prose and the reflected shapes.

ONE input, PINNED in .spec-lock, and only make spec moves it. It used to be api.hanzo.ai/v1/openapi.json, a host — which cannot say which deploy it was, so a capture taken from it could never be re-derived and never be checked.

  • make spec / make spec-check — the refresh seam and its gate, both through scripts/generate.sh (the same call site hanzoai/ci's client: lane uses). Underneath: genspec reads the document from HANZO_REGISTRY (JSON or YAML) and stamps hanzoai/cloud@<ref> sha256:<digest> into the spec's provenance. --check re-runs the whole derivation and refuses a delta without writing.

  • make verify — THE DRIFT GATE (src/bin/driftgate.rs). The other question: is the document still TRUE of the running server, in BOTH directions? It has ONE home, .hanzo/workflows/drift.yml, carrying all four ways of asking — push, PR, the nightly schedule, a click — plus the same command in the release matrix before an artifact is minted. It is its own JOB, needing only a clone, a compiler and the network, because it lived in hanzo.yml's test: list first and that list runs inside hanzoai/ci's shared lane behind the client step, the KMS login and the toolchain: a gate that runs last in a shared job reports on everything ahead of it, not on the server, and that one was never asked once. See "The drift gate" below.

  • cargo run --bin genproduct — offline, deterministic: spec/cloud.json → the clap tree. --check compares instead of writing; tests/spec_drift.rs runs it, so cargo test pins the tree to its spec.

  • The document is the authority, full stop — not "at product granularity". It is the weave of what each app binary emits from its OWN router, so what it carries is what some router registered and what it omits is what none did. There is no granularity to the rule and nothing left for it to arbitrate between, because there is no second reading to arbitrate with. Multi-segment path params come from the same reading: a fiber * arrives as {wildcardN}, and a {wildcardN} at /v1/<product>/ is a DOOR (counted, below) rather than a parameter.

  • What still needs a HAND decision: METHOD_PRIORITY, VERBS and the path→verb fold in genproduct.rs. src/curation.rs — ONE table naming every product the tree does not surface at its own bare name — holds ONE entry: link → Under("auth"). /v1/link is the AI login manager's registry of provider accounts per machine — credentials — so it is hanzo auth link …, and the bare word link belongs to the command that links a machine to Hanzo ZT. An Under entry keeps every route and moves only the coordinate, so the capability test asks whether a capability's routes reach a command, not whether it is top-level. Every other capability the document carries is a command at its own name. Each of the seven entries it used to hold named a product the document had stopped carrying (csrf, agent, machines, gpus) or a name the parser had stopped spending (help, completions, openapi.json); the curation law refuses the first kind outright, and the second kind is now settled by ARRANGING the clash rather than declaring it — the collision law in commands/product/mod.rs::augment gives the word to whoever the document names, and the parser's own help subcommand stands down for it.

  • The reason is DATA, and the gate falsifies it. DENY/REMAP were two tables with their reasons in comments, and a comment cannot be checked. Each entry now carries a why sentence and, where it claims another spelling reaches the surface, that spelling — which make verify RUNS. genproduct applies the table; driftgate excuses gaps with it and counts how many times it had to. One list, two readers: a product dropped there and a product excused here can never be two different lists.

  • A name may only be reserved by a command that EXISTS. EXCLUDE used to sit beside DENY holding billing/agent/deploy under "local commands own these bare names" — a third statement of one fact (every entry was already in DENY, and product::augment reads the real answer off the parser), and two thirds false: agent and deploy had been DELETED as top-level commands, so the reservation was held for nobody and the 21 documented /v1/deploy/* routes reached no one. EXCLUDE is gone; product::mounted(&Cli::command()) is the ONE filter, shared by augment (which mounts) and catalog (which advertises), and a_reservation_must_name_a_command_that_exists is the gate.

  • THE COLLISION LAW: a local command ABSORBS the product of its own name — one law, applied at every level, replacing the three-entry list that used to call the clash a decision (1.9.23). hanzo billing invoices is the document's operation, hanzo billing balance is the hand-written one, and the rule that settles both is the same: whoever was there first keeps the name. augment mounts the product's remaining nodes into the local command; resolve reads the SAME hand-written tree — which is why it takes it as an argument — so a node the local command declares can never be routed to the cloud seam, and the two halves cannot disagree. It used to DROP the collision, and the bill was 7 /v1/code, 25 /v1/billing and 4 /v1/engine operations served, described, and reachable by nothing, each excused by an entry whose own why admitted it. The law reaches the PARSER'S own affordances too: clap mints a help subcommand during build, which is after augment runs, so a generated help product arrived second and the parser refused two commands with one name. The word goes to the capability the document names — /v1/help is the customer help center, four served operations — and disable_help_subcommand stands the builtin down. Nothing about ASKING for help moves: --help is on every command and is what the man page teaches, bare hanzo prints that page, and hanzo <cmd> help was a second spelling of one act.

    Three operations are still SHADOWED, and it is the one thing the law cannot settle: GET /v1/network, GET /v1/wallet and POST /v1/wallet want list and create inside local commands that already terminate at those words with a different act (the local keychain, the chain selector). One word, two acts, and no rule can name the second without inventing a name — so the local command keeps it, and the test asserts the exception is exactly that shape: a shadowing GROUP would have descended, so an operation losing its route to one is drift.

  • AN ABSORBED LOCAL COMMAND IS A RUNNABLE GROUP, and the test walks to the OP. every_operation_of_an_absorbed_product_resolves_to_its_own_route parses an argv a person could type for EVERY operation of every absorbed product and asserts the op that comes back is the op that went in. It used to assert find_subcommand(node).is_some() — the NAME — which passes when the node under that name IS the local command, so the operation reaches nobody and the test reports the law holding. Same defect as the hanzo logs alias, where "a test that asserted only the MOUNT never saw it"; the cure there was this walk, and it was never applied here. It found two on its first run. hanzo billing balance was a hand-written verb SHADOWING GET /v1/billing/balance — deleted, so billing is now a plain generated product and its help prints its route like every sibling. And hanzo share, which takes a required <TARGET>, listed the absorbed enable and get in its own help while no argv could reach either: absorb now gives a local command that gains subcommands the same rule to_command gives every other runnable group — bare it runs itself, with a subcommand it descends.

  • ONE COMMAND PER ROUTE, and the last duplicate is gone. hanzo connector {add,list,verify,rm} was a second implementation of hanzo integrations {connect,list,verify,disconnect} over the same four routes — cloud's own summary for verify still reads "…live (hanzo connector verify)", which is now prose to fix upstream, never here. ONE thing kept it alive: it held the provider credential off argv and the generated command could not, because the handler declared no body. Cloud types it now (connectIn.token), and a credential is a secret by NAME, so the generated command has no --token at all — a value-bearing argv is a PARSE ERROR rather than a runtime refusal, which is strictly stronger than what the hand-written one enforced. the_credential_of_a_connect_has_no_flag_to_carry_it pins it, so an untyping upstream is loud. The name connector was freed too, and it is a real product: hanzo connector github webhook is POST /v1/connector/github/webhook.

  • A TERMINAL IS PROMPTED; A PIPE IS READ. The generated secret path read stdin either way, so a person running a secret-taking command interactively typed the credential in the clear — the one thing the hand-written connector did better, and the reason it survived. The hidden read now lives in iam::secret beside the other two readers, where SecretSource::Prompt already named it, and both iam::onboarding and the generated tree use that one implementation. It existed twice before and neither copy was reachable from the generated tree.

  • THE CENSUS RIDES IN THE FILE IT DESCRIBES. The fold names a leaf after its own noun, so two methods at one address want one command: PUT and PATCH on an item both read as update. The higher-priority method takes the name and the other reaches nobody. That loss, the method-override tunnels dropped beside it, and the --data split are all DERIVED facts about the pinned document, so they are written into generated.rs's own header — which genproduct --check diffs byte for byte — instead of being pinned as consts somebody edited on each re-pin. They were ELIDED, TUNNELS and NO_SCHEMA, and pinning them is what froze the nightly: the numbers move whenever cloud moves, a machine has no hand, and the write step aborted rather than committing. One mechanism now, and it needs no editing to stay true. 2252 spec operations − 18 tunnels − 1 elided = 2233 commands. Nothing is lost anywhere else in the derivation, and the arithmetic closing is the check.

  • A capability missing from the CLI is missing from the SERVER. There is no second place, and that is the change. It used to read "author it in hanzoai/openapi, or serve it in hanzoai/cloud", and the first half was the loophole: authoring was how 71 operations entered the surface with no router behind them. Serve it, and type it, in hanzoai/cloud — that is the only door. /v1/cd is the worked example: it answers 200 with <title>Hanzo CD</title> and contributes ZERO keys to the route table, because it is the Hanzo CD console, not an API — its own noscript line says "Hanzo CD can be used with the Hanzo CD CLI". Its API is /v1/deploy/*, and hanzo deploy is that CLI. cd is not a product and must not become a command name — it is the shell's own builtin.

The lineage contract — ONE authority (1.9.34). There is no longer a "which half is authoritative about what", because there is no second half. This section used to describe a document that decided existence and a hand-authored master that supplemented it; that arrangement is deleted, and what follows is what replaced it and why.

  • The DOCUMENT decides EXISTENCE, and carries the PROSE and the SHAPE. genspec iterates hanzoai/cloud's openapi.yaml at a release ref and reads nothing else. Every operation it carries is an operation the CLI has; every operation it lacks is an operation the CLI does not have. The request body, the query parameters, the summary and the catch-all marking all come from that one reading — zip reflects the shapes off the live Go type and zipdoc lifts the handler's doc comment, so they ARE the contract, not an approximation of it.

  • A PHANTOM IS NOW STRUCTURALLY IMPOSSIBLE, which is the entire point. A phantom is a command addressing a route no code registers. It can exist only if something other than the code gets to say what exists. Nothing does. Measured on the cutover, against api.hanzo.ai/v1/openapi.json re-fetched the same hour: 2127 of 2127 generated commands match a live route; 0 match nothing. Before, it was 1812 of 1823 with 11 phantoms. That zero is a property of the wiring, not the result of a cleanup pass — there is no input left that could reintroduce one into the generated tree. The qualifier is load-bearing and was missing for a whole release: the HAND-WRITTEN commands take no input from the document at all, and hanzo billing deposit posted to a route cloud has never served inside a pipeline built to make exactly that impossible. Both trees are swept now.

  • WHAT WAS DELETED, and what it actually cost. The master (hanzoai/openapi hanzo.yaml) contributed 71 operations to spec/cloud.json on its own word. Held against the live router: 2 addressed nothing at all (GET /v1/o11y/services, POST /v1/o11y/ingestion), 66 "matched" only a {wildcardN} relay door — which answers identically for a real path and an invented one, so their evidence was unfalsifiable by construction — and 3 matched a literal route the pinned document simply had not caught up with, which re-pinning fixes. A door is not a list. Enumerating behind one is guessing with a citation.

  • WHAT REPLACED IT — a DOOR CENSUS, derived and counted. The doors are the one place the CLI knowingly under-reaches: /v1/<product>/{wildcardN} says a subtree is relayed through here without naming what is in it. genspec counts them off the document every run — BOTH runs, --check included, because the census is a fact about the document and not about writing a file — and STAMPS the count into spec/cloud.json's own provenance, where --check diffs it byte for byte. Today it is 0. A number derived from the document, that falls on its own as cloud types those relays, beats a 3.5MB parallel file guessing at their contents. It used to be a const ceiling as well, which is how it sat at 7 while the truth was 0 — and a ceiling with slack in it would have let seven new relays appear without a word.

  • THERE IS NO EDGE EXCEPTION — and that claim was the load-bearing lie. Prose in this file, in genspec.rs and in driftgate.rs all said the inference surface (/v1/models, /v1/chat/completions) is "answered at the edge, not by that router, so nothing is refuted". It is false, and it was the stated justification for keeping a second authority alive over a whole plane of the API. Measured 2026-08-03, every probe with a nonsense sibling under the same prefix as its control:

    GET  /v1/models            200  |  GET  /v1/models-zzq            404
    POST /v1/chat/completions  401  |  POST /v1/chat/completions-zzq  404
    POST /v1/embeddings        401  |  POST /v1/embeddings-zzq        404
    GET  /v1/tools             403  |  GET  /v1/tools-zzq             404
    POST /v1/event             401  |  POST /v1/event-zzq             404
    

    All server: hanzo, all x-api-version: v1.801.383 — the same router that answers the 404s — and all of them are in the emitted document and in spec/cloud.json. The category never existed.

  • The document IS the deploy, and that is checkable. hanzoai/[email protected] openapi.yaml and api.hanzo.ai/v1/openapi.json carry the IDENTICAL 2333 operations, zero either way, and the host stamps that exact tag in x-api-version. Generate from the git object, never the URL: the object says which deploy it is and a host cannot.

  • THE CURATION LAW. A CURATED entry may name ONLY a product the document carries; genproduct refuses to generate otherwise, naming the stale entries. A name no document mentions asserts one thing — that the server does not serve it — and that is genspec's answer by construction, not a list in the client. Under that law 1.9.20 struck nine names no document had ever carried (console search-docs index-docs chat-docs embed-status account-bridge agent-bindings provisioning do), two "redundant plurals" the document shows to be separate surfaces (/v1/bots is bot RUNS while /v1/bot is bot nodes plus a door; /v1/networks is the org's Zero Trust overlay while the local network SELECTS one), and five relay-door products called enumeration artifacts, each with its own prose and 1–11 served operations (files upload download indexers settings). 1.9.23 struck the last three that were not facts about a command line at all — code billing engine, held on the grounds that a local command owns the name, which is now settled by ARRANGING the collision instead of recording it. Nothing survives. Re-pinning onto today's document struck the last four the curation law refuses outright — csrf, agent, machines and gpus are names it no longer carries — and the three that remained were settled by the same arrangement as code: help because the parser's own subcommand stands down for the capability the document names, completions and openapi.json because no local command was spending either word (hanzo chat completions is /v1/chat/completions, a DIFFERENT address, so the claim read true while /v1/completions reached nobody). driftgate says 0 applied of 0, and EXCUSED = 0 is what pins it there.

  • Refresh: make spec. Gate: make spec-check. Both go through scripts/generate.sh, which is also what hanzoai/ci's client: lane calls, so a maintainer, the push gate and the release all run ONE derivation. --check only decides write-vs-compare; a gate that runs a different generation than the writer tests something nobody ships.

    make spec         # regenerate onto the document .spec-lock names
    make spec-check   # refuse a capture that is no longer its projection
    make verify       # refuse a tree the LIVE server contradicts, either direction
    

    THE CAPTURE NAMES A RELEASE, and .spec-lock is where it says so. FOUR lines, ONE writer — hanzoai/ci's client: lane: repo, path, ref and sha256 of hanzoai/cloud's openapi.yaml at the tag it deployed. There used to be a fifth (master, the hanzoai/openapi commit generate.sh read shapes from) and a second writer to go with it; both are gone with the second input. One input pinned by digest; two runs of one commit cannot disagree.

    This replaced "the spec must name the wire", which was half right and caught nothing. A URL names a HOST, and a host cannot say which deploy it was — so a capture built from the wire keeps claiming the wire forever while the router moves underneath it, and every stale spec ever committed satisfied that assertion. spec/cloud.json now carries hanzoai/cloud@<tag> sha256:<digest>, computed over the bytes actually read, and spec_drift.rs refuses any disagreement between the spec, the lock, and a release-shaped tag.

    THE PIN MOVES BY PULLING, and it is the nightly that moves it. genspec used to be reachable only by a maintainer typing it: grep -rn genspec over this repo's CI returned nothing, and there was no hanzo.yml and no Makefile at all. hanzo.yml's client lane now runs it on every push — as a CHECK, at the ref .spec-lock already names.

    What MOVES the ref is one step in .hanzo/workflows/drift.yml: read hanzoai/ cloud's main off git.hanzo.ai, fetch the document at that COMMIT, write the lock, regenerate, re-capture the evidence, commit to main. It runs even when live-check failed, because production having moved is exactly when the pin wants moving.

    A GENERATOR THAT REFUSES IS A PIN THAT FREEZES, and that is what happened. The re-pin's write step is make spec, under set -euo pipefail: anything that aborts the generator aborts the step, and nothing is committed. Landed re-pins ran nightly at 06:20 UTC through 2026-08-17 and then stopped for five nights while cloud's main moved 507 commits, leaving the tree pinned at a document carrying 191 products and 2445 operations against a contract of 129 and 2252 — and 98 of those products answered 404 on api.hanzo.ai beside a 404 control, i.e. phantom commands. Three separate refusals were live in the pinned generator, each on its own enough:

    1. genproduct sliced a tag description at BYTE 100 to cap the group help line. The lsp tag's prose puts an em dash across that byte, so the slice split a character and the process aborted — end byte index 100 is not a char boundary. The cap counts CHARACTERS now (char_indices().nth(100)), which is also what clap lays the column out in.
    2. THE CURATION LAW refused to generate while csrf, agent, machines and gpus named products the document had stopped carrying. That refusal is correct and its remedy is stated: delete the entry.
    3. ELIDED, TUNNELS, NO_SCHEMA and DOORS were consts a person edited on every re-pin, with a paragraph of reasoning each time. They are a CENSUS of the pinned document — derived facts — so they now ride in the artifact they describe (generated.rs's header, spec/cloud.json's provenance), where --check diffs them byte for byte and a change still cannot pass unread. A number that moves whenever cloud moves cannot be gated on a hand a nightly does not have.

    The lesson generalises past this repo: a ratchet a machine cannot satisfy is not a ratchet, it is a stop. Refuse on what a person must DECIDE (a stale curation entry, a phantom route); record what a person must READ.

    It reads main, not the newest tag, and the tag was the second authority wearing a hat. The document is emitted from cloud's SOURCE, so the ref that names it is main; a tag names a service RELEASE, which is a different thing and lags. Measured the day this changed: cloud's newest tag was two hours and four commits behind a main carrying 256 newly typed operations, so re-pinning to the tag would have moved this repo's pin BACKWARDS. The tag was also never the guarantee it was read as — a tag is cut before production deploys it, which the old prose said in the same breath. What actually measures the deploy is the live evidence, and that is AHEAD (below). A commit sha, never main itself: a branch name is not a value, and the digest beside it has to mean something.

    It used to be described as a PUSH — cloud's release sending repository_dispatch: spec-update with (version, sha, spec_sha256). That event was undeliverable in both directions: this repo has no .github/workflows for GitHub to run, the forge has no repository_dispatch endpoint, and on a push hanzoai/ci's client lane is check-only at the LOCKED ref anyway. Across 104 runs the pin moved once, by hand. The trigger is deleted; the side WITH runners asks.

    A RE-PIN LANDS ONLY BEHIND make verify. cloud's source states a route the moment it is written and production deploys it some time later, so the newest document can name a route the running server does not serve yet — which the drift gate calls AHEAD, counts and pins. The pull request this used to open was never openable (this repo has the pull unit off), so the step commits to main behind the gate instead, and a refusal leaves the lock untouched with the gate's own reasons in the red nightly. Measured: GET /v1/commerce/org 404s against a /v1/commerce/<nonsense> control that also 404s, so the prefix discriminates and the denial is real, while production answers x-api-version: sha-716c08370361. That is a wait, not a defect, and how long a wait to take is a person's call — which is what the patch is for.

    Measured when it was wired: the committed capture was 7 operations short and 8 wrong against a document cloud had already committed — the /v1/billing/* compound-word renames, published-dead and served-undocumented.

    --registry TAKES ONE VALUE, and the union is gone with the refutation it served. It used to be repeatable, so that mid-rollout a route living in cloud main before api.hanzo.ai answered it would not be REFUTED by the wire. Nothing refutes anything now — no second reading proposes an operation for a first one to strike down — so the hedge has no job. A newer route means re-pinning .spec-lock, which is one line and leaves a record. There is also no default value: a default would have to name a host, and every stale capture ever committed satisfied "provenance names the wire".

    make spec                                    # the only spelling anyone should type
    cargo run --features genspec --bin genspec \
        -- --registry <hanzoai/cloud@<tag>:openapi.yaml>   # what it runs underneath
    

    And provenance is not freshness — that is what make verify is for. Recording the source says WHERE a spec came from; it says nothing about whether it is still true. One spec shipped 129 operations across 9 products (commerce 109, admin 7, billing 5, …) addressing routes api.hanzo.ai does not serve, and its provenance was impeccable. genspec --verify used to re-ask the table here and was DELETED, not kept beside a stronger gate asking the same question: it could only refute inside a product the table OWNS, which leaves roughly a third of the surface un-refutable, and it never asked the other direction at all.

    THE DRIFT GATE — make verify (src/bin/driftgate.rs). The shipped surface and the live server, held against each other in BOTH directions, with the host as the arbiter wherever the table cannot answer. make spec-check proves the capture still equals its inputs; only this proves the inputs are still true.

    • 404 IS NOT 403, and one 404 is not a 404. 401/403 mean the route is there and wants a caller; 404 means nothing is at that address; 405 means the path is routed and the verb is not. Conflating them is how a hand analysis of this surface reported three production breaks that were not breaks. And a single 404 is not evidence either — fourteen /v1/pricing paths answered 404 to one concurrent sweep and 200 to every serial re-ask a minute later, so a 404 is confirmed three times, serially, before it counts. A 404 that does not repeat is FLAPPING: present, printed, never drift. THIS RULE IS A TEST, not only a paragraph: it lives in one pure function (verdict), held apart from the transport so it can be stated as one — 13 status codes asserted PRESENT, and the classic "any non-2xx is absence" mutation turns two tests red.

    • Only a GET on a LITERAL path can be asked of a host. A {param} makes 404 mean "no route" or "no such id"; and cloud's router answers 404, not 405, to a verb it lacks at a path it has (POST /v1/admin/credits is in the live table and a GET of it 404s). Everything else is UNDECIDABLE and is COUNTED — a stated gap beats a guessed answer. The probe is only ever a GET: a gate that DELETEs to find out whether something is there is not a gate.

    • A * door is not an answer. The table names the route → served (asked anyway where it can be: a route can be registered with a dead mount behind it). It owns the product and names no such route → REFUTED, decided without asking. It offers only /v1/iam/*, or is silent → ask the host. Roughly a third of the operations sit behind a door or in a namespace the table never mentions.

    • BOTH TREES SEND, so both are swept. The gate's notion of "what this repo can call" was generated::OPS and nothing else, so a route only a HAND-WRITTEN command sends was ruled on by nobody — which is how hanzo billing deposit posted to /v1/billing/deposit, a route cloud has never served, inside a pipeline built to make a phantom impossible. driftgate::sent lifts every /v1 path literal out of the shipped source and holds it against the same document; a literal under a product cloud OWNS that the document does not declare is a phantom by the same law. The document is authority only over the products it carries, and there is no exception list. That rule caught hanzo fabric cluster's six /v1/node/cluster/* literals the moment cloud grew a node product: they had defaulted to the active network's api — cloud — where all six answer 404 beside a 403 at /v1/node, so as shipped the six commands reached nothing. They are deleted, which is what the gate's own remedy says. The count is printed and an empty sweep aborts — a lexical read that quietly matches zero reports "no drift" for the one reason a gate may never report it.

    • Whose defect it is turns on who claimed the route, and there are THREE owners, not two. No document claims it and the host 404s → the CLI's PHANTOM, hard failure — and that class is now exactly the HAND-WRITTEN literals, because a generated coordinate exists only where the document declares it. The LIVE TABLE claims it and the host 404s → cloud's table and cloud's server disagree (CONTRADICTED); today 0, since cloud's ai app stopped publishing from a committed subset and the six stale /v1/ai nouns left the document with it. The PINNED DOCUMENT claims it, the table does not name it and the host 404s → the document is AHEAD of the deploy; today 3 (GET /v1/commerce/org, POST /v1/iam/link, PUT /v1/iam/password), all three among the eleven operations the re-pin onto main added. Both of the last two are CEILINGS: free to fall the moment somebody redeploys, and they may not grow in silence.

      AHEAD used to be reported as a PHANTOM, which was false by construction — "no document claims it" cannot be true of a coordinate generated FROM the document, and the chain digests refuse a hand-edited generated.rs. It pointed a hard failure at the wrong owner and buried the real phantom class underneath a wait nobody here can end.

    • A REDIRECT IS AN ANSWER. The transport followed redirects by default, so GET /v1/o11y/complete/google — a 303 OAuth callback — was followed to /v1/o11y/login?… and that page's 404 was recorded against the callback's name. Two live routes reported as cloud contradicting itself. Same class as reading a 403 as absence: the status this gate reasons about has to be the status of the address it asked about. Policy::none(), and 303 is now in verdict's asserted list beside 302.

    • The other direction asks the BINARY. hanzo <product> --help — whether a person can reach a product is a fact about the built CLI, and a generated product that collides with a local command, or a relocation that stopped relocating, is invisible to any re-derivation and obvious to one exec.

    • A served product with no command is drift unless src/curation.rs says otherwise, and every entry naming a spelling gets that spelling RUN, so an excuse that stopped being true turns CI red. The applied count is pinned EXACTLY (EXCUSED, today 0 — the table is empty and every capability the document carries is a command): an exception nobody counts is how 21 served /v1/deploy operations came to be reachable by nothing while the table dropping them still called it a decision.

    • BLIND fails. No answer at all is not "no drift" — it is "I could not look".

    Run cargo test, never cargo test --bin hanzo. That selector takes only the binary's unit tests, so everything under tests/ compiles and never runs. The release workflow said --bin hanzo from the day spec_drift.rs was written, which is how a spec built off a scratchpad capture passed every build it ever saw.

  • Top-level names come from two places and no third: a generated product, or a hand-written local command. There was a curated ALIAS table (one entry, hanzo logs → hanzo o11y logs) for a nicer spelling of a nested op. It was a hand-kept claim about the route surface — the same shape of defect as a captured route table, one layer up — and cloud began serving /v1/logs/{query,write,health}. Yielding the name was not enough: augment skipped the shadowed alias while resolve still preferred it, so the parser MOUNTED the product and dispatch sent it to the o11y op, and hanzo logs query PANICKED on an argument id its own command never defined. A test that asserted only the MOUNT never saw it; the two halves disagreed in the gap between them. The mechanism is deleted and the test now walks parse → resolve → op.

  • PROSE IS NOT OPTIONAL, and there is no substitute for it. Every command states what it does for the person running it. That sentence is the Go doc comment on the handler in hanzoai/cloud, lifted by zipdoc into the published route table and joined in by genspec — so a command with nothing to say is a missing doc comment in cloud, not a hole this repo may fill. genproduct therefore REFUSES to emit an undescribed op, naming the commands and the remedy; every_command_says_what_it_does pins the same rule against a hand-edited tree; and leaf_named has no branch for an empty summary, because the state cannot exist. The METHOD /path fallback that used to fill the column is deleted, not made conditional: a projection cannot repair its source, and every branch that handles "the source didn't say" turns a fixable gap into a shipped help line that reads deliberate. Nobody files a bug against a design choice. The route still appears in LONG help — reference detail, and what a user quotes when filing a bug — never in prose's place.

    Two content-free channels remain, both for want of a channel rather than a fallback, and neither may be papered over here. A product group with no tags description prints `x` cloud operations (20 products): 10 have a live tag whose description is EMPTY, so the missing thing is a Go PACKAGE doc comment in cloud; 10 are the inference products (models chat embeddings and their siblings) whose operations the document carries but for which it emits no tags entry — so this too is a missing Go PACKAGE doc comment in hanzoai/cloud, not a second document to consult. (It was written up as "the gateway/edge inference surface with no tag in either document", needing "a tag authored in hanzoai/openapi"; the surface is cloud's, the routes are in cloud's document, and so is the only place its prose can come from.) A resource NODE (agents sessions, admin infra) has no channel at all — the seam is a tag per node emitted by zipdoc from the Go sub-router's doc comment, the same lift as the package doc. Until those exist this is a stated gap; a stated gap beats an invented sentence.

  • A MOVED route MOVES the command, and nothing is repaired in place. When a served route changes shape the document changes with it, the old coordinate stops existing, and the command disappears rather than pointing somewhere new. /v1/kms/orgs/{org}/secrets → /v1/kms/secrets (the org moved into the token) did exactly this. A 404 from a generated command is therefore a bug in hanzoai/cloud — its document claiming a route its router does not serve — and the fix is never in this repo. (make verify counts that class as CONTRADICTED.)

  • Why build-time and not a spec fetched at run time: cloud's lazy cmd/host serves no fleet-wide document. zip installs /.well-known/openapi.json only when an app registers typed ops, and the host registers none — it links zip and the manifest, nothing else — while /v1/openapi.json falls through the ai app's /v1 catch-all to ONE plugin. The whole route table exists in one place only in the harness that writes openapi.yaml. Discovering the surface at run time would therefore mean starting all 108 subsystems to ask what they serve, which is exactly what laziness exists to avoid. The spec is a build input; the server is not asked what commands exist.

Where cloud RUNS — the same command tree targets a local checkout or api.hanzo.ai; only the origin differs. src/commands/host.rs is the ONE origin resolver: it returns the active network's api, and when that is loopback it also guarantees a host is listening there — reusing a running one, or starting bin/host from a hanzoai/cloud checkout ($HANZO_HOST_BIN overrides; never $PATH, where host is the DNS tool).

  • hanzo host start|status|stop. Everything else starts it on demand, so these exist only for what demand cannot express: seeing it, and ending it.
  • It is a DAEMON across CLI invocations on purpose. The host is lazy — a subsystem starts on the first request to its prefix (~15ms) and answers in ~0.5ms after — and stopping it per command would make every call cold. stop sends SIGTERM, which zip drains LIFO into every child it started.
  • The local wire is ZAP, not HTTP. The host serves the SAME routes on both; ZAP is the fleet's primary transport and HTTP is a secondary view for third parties who cannot speak it. The CLI is first party, so locally it speaks ZAP over a unix socket in <data>/hanzo/host/ — src/zap.rs, a byte-for-byte port of github.com/zap-proto/http's codec, pinned by golden vectors emitted from the Go encoder itself (src/zap/tests.rs). No HTTP grammar and no port for the CLOUD CALL: strace -e trace=connect hanzo authz health shows connect(AF_UNIX, …/host.zap.sock) twice — the /healthz probe and the call — and no AF_INET for either. The host is still told to bind loopback TCP because cmd/host always listens on both; the cloud path never dials it.
    • What DOES still open a TCP socket on a local run, so nobody is surprised by an strace: crates/event's best-effort telemetry POST to {api_base}/v1/event, which owns its own reqwest client and knows nothing about ZAP. One AF_INET to 127.0.0.1:3690 per invocation, fire-and-forget, HANZO_DO_NOT_TRACK silences it. It is a second wire to the same host and the only one left.
    • Windows is the one platform where local is HTTP, because it has no unix socket to speak ZAP over. Origin::Local therefore carries the socket PATH on unix and the loopback BASE URL on Windows — one variant, because "is this the local host" is the question the credential rule asks and the wire is not it. src/zap.rs is #[cfg(unix)]; so is the -zap flag handed to the spawned host, and healthy() probes whichever wire that platform actually uses. Forgetting this is what broke the windows-amd64 release asset in v1.9.3: tokio::net::UnixStream does not exist there, and no CI caught it because the release matrix had been deleted the same day.
    • Wire notes that will bite a reimplementation: the socket's 4-byte length prefix is BIG-endian and everything inside the frame is LITTLE-endian; a slot's relOffset is measured from the slot's own position; relOffset == 0 is NULL regardless of the length word; an empty field is a {0,0} slot, NOT a zero-length entry.
    • Remote stays HTTPS: zaphttp has no session crypto yet (its transport reserves an X-Wing PQ KEM handshake for later), so TLS still terminates at the ingress. That is the EXCEPTION, and it ends when the ZAP transport carries its own.
    • Measured, so nobody has to guess: steady state ~66µs (ZAP/unix) vs ~98µs (HTTP/loopback) mean over 3000 requests; isolating the WIRE alone (both over TCP) it is ~91µs vs ~100µs. Real but ~32µs — against ~10-20ms of CLI process startup that is invisible to a user. The reason to do it is one wire and one contract, not speed.
  • Against a local host a MISSING credential is not refused here: the call goes out with no bearer and the server decides, the same rule the tree already follows for a 403. State (pid, log, socket, CLOUD_DATA_DIR) lives in <data>/hanzo/host/.
  • The host starts with this install's own at-rest key, <data>/hanzo/host/master.key: 32 OsRng bytes, base64, 0600, made once through private::create (write-once, so concurrent first starts converge on one key) and never regenerated. A CLOUD_KMS_MASTER_KEY_REF already in the environment wins and passes through. The development key pre-release hanzo up builds wrote to ~/.hanzo/cloud/master.key is recognised by SHA-256 only; host::retire_shared removes that key and the store in ~/.hanzo/cloud/data on any invocation, and the host refuses it from the environment.

The session channel — hanzo dev is watchable and steerable from the dashboard while it runs. One channel, two directions, one transport, one vocabulary.

  • OUT (gen:src/session.rs): register on POST /v1/agents/sessions, forward each parsed event to POST …/:id/events, close with PATCH …/:id. Unchanged; already shipped.
  • IN (gen:src/control.rs): drain GET /v1/agents/sessions/:id/control?after=<seq> once a second. Cursor-driven, so an applied command is never redelivered and a reconnect replays exactly what was missed, in order, once. The durable log IS the buffer — nothing is queued locally.
  • The op set is CLOUD'S, not ours. pause · resume · stop · message — the Cmd* constants in cloud/apps/agents/sessions.go, already mirrored by event::Kind::Control. Naming CLI-side synonyms (interrupt, steer) would be two spellings of one verb, so the wire words ARE the type. hanzo implements three of the four; resume is a no-op against an already-running session (a paused session has no process — reopening it is hanzo dev --resume <id> on the machine that holds it).
Command Signal What Claude does Session after
pause SIGINT aborts the in-flight tool, writes [Request interrupted by user for tool use] + a final result (terminal_reason:"aborted_tools"), flushes the transcript, exits 0 paused — resumable, same id
stop SIGTERM same abort path, exits 143 done
message SIGINT as pause, then the supervisor relaunches with --resume <sid> + the new prompt stays running, same id
  • The status comes from the COMMAND, never the exit code. A commanded stop exits 143 and is still a clean done. Folding those readings into one bool is what made a deliberate stop look like a crash; finalize now takes a Status the caller decides.
  • Signals go to the child's own pid, never the process group. Putting the child in its own group would make it a background group against an inherited tty and earn it SIGTTIN on the first stdin read. Claude tears down its own tool subprocesses on abort (verified: no orphan survives a stopped sleep 300), so the direct pid is sufficient.
  • A steer that beats start-up still lands. If the turn never disclosed a session id there is no transcript to preserve, so it relaunches fresh with the new instruction rather than dropping it.
  • AuthZ: the CLI sends the bearer and NOTHING else — never an org. Cloud derives the tenant from the JWT owner and 404s a session belonging to another org, on both the detail read and the drain. Cross-org control is therefore impossible rather than merely refused, and a refused drain is an ERROR here, never an empty-but-successful page that would read as "no commands" (which would also wrongly advance the cursor). Proven by control_tests::another_orgs_stop_cannot_terminate_our_child.
  • Why HTTPS and not ZAP, since the local wire is ZAP: there is no ZAP client to use. zap/rust is server-only (no client type at all), TCP, plaintext, no auth, strict request/response with no server-push; the hanzo-zap crate on crates.io is a different, stubbed codebase; and zap-proto/ws — the bidirectional/pubsub sub-protocol — is a README and a schema file with no implementation in any language. Pointing today's ZAP at a remote peer would also ship a developer's prompts and code in cleartext, which is exactly what src/zap.rs forbids in writing. So this follows the rule the repo already documents: local = ZAP over a unix socket, remote = HTTPS until the ZAP transport carries its own session crypto. Both directions ride the one crate::http seam, so when that lands they move together — there is no second wire to migrate.

The Claude config home, and the carrier — hanzo dev --claude and the user's own Claude Code are two products sharing one binary. Two modules keep them decomplected; both are pure functions of the resolved ROUTE, so every reader agrees without coordinating.

  • gen:src/home.rs — whose config home this run uses. A ROUTED run (Route::Via) relocates CLAUDE_CONFIG_DIR to ~/.hanzo/claude, because that is when a saved /model could outrank ANTHROPIC_MODEL and when Hanzo's injected tiers could leak back out. A direct run (Route::Inherit) and FailClosed keep ~/.claude — on Linux the account the flag promises IS a file in that home (.credentials.json), so relocating would hand the user a login prompt instead of the pass-through. home::relocate = "must we set the env var"; home::of = "which home will actually be read" (transcripts, theme). Seeding is WRITE-ONCE and happens only where we relocated. Everything that reads or writes that home — the launch env, transcript_path, theme::apply — must take the route. A path hard-coded to either home is a file that does not exist on the other one.
  • gen:src/tier.rs — the zen ⇆ carrier table, the ONE source of truth. Claude Code budgets a context window only from ids it RECOGNIZES, so a custom id (zen5-pro) gets the fallback budget and is clamped client-side. A tier therefore names a CARRIER (claude-opus-4-8[1m]) that Claude budgets from, and a per-run --settings modelOverrides overlay rewrites it back to the zen id before the request leaves the process — verified live: Claude displays model claude-sonnet-4-6[1m] while the gateway receives zen5. The overlay rides the RUN, never the seeded home: persisted, it would rewrite a direct or direct-Anthropic session's model to a zen id and 404 against api.anthropic.com. Requires Claude Code v2.1.200+.
    • The table also fills every ANTHROPIC_DEFAULT_*_MODEL slot. This is not branding: Claude resolves subagents, /compact and its background work through those slots, and their built-ins are claude-* ids the gateway does not serve, so an unset slot 400s every subagent.
    • Key a slot on its TIER, never on its zen id. zen5-pro fills two slots (opus and fable); an id lookup is ambiguous between them and silently gave the "max effort" slot the opus carrier.
    • A model absent from the table passes through untouched — the table is a lookup, never an allowlist. The gateway stays the sole authority on which ids are valid.
    • A CARRIED model also gets --append-system-prompt naming its real tier, because the carrier is exactly what would otherwise make it introduce itself as Claude. An append, so the harness keeps its own tool-use/safety prompt; an untiered id rides no carrier and so claims nothing.

Key entry points: src/main.rs (clap tree + bare-hanzo flatten), src/commands/ (one module per resource; product/ = generated cloud tree), src/iam/ (identity, token store, HIP-0111 OIDC PKCE), gen:src/ (coding wrapper). Secrets arrive on stdin only, never argv; credentials in OS keychain or a 0600 file.

--as <org> signs in through <org>-cli (iam/oauth.rs::client, HIP-0111 <org>-<app>; without --as, hanzo-cli). IAM stamps owner with the signing application's org, so hanzo-cli can only return a hanzo identity. Before any browser opens, served reads IAM's public descriptor (/v1/iam/auth/application) and refuses when the client is missing or, under --as, signs in to another org; after the exchange a token whose owner is not <org> is refused and nothing is stored. With no browser here the device grant runs only when the descriptor's grantTypes lists it; otherwise the person signs in anywhere and pastes the callback URL (admin-cli has no device grant: a device code approvable by a session cookie is one click from a SuperAdmin token). The identity is filed beside the default, which stays the default. Refresh and revoke present the client the credential was issued to — its azp, else its one aud — never a constant: IAM refuses a refresh token from any other client and revokes only the caller's grants. A refresh IAM refuses ends that identity's session with its own command (sign in again: hanzo --as admin auth login), never another identity. auth show under --as <org> with no identity held there refuses rather than name the default.

crates/client — the Rust client for api.hanzo.ai, and the CLI is one consumer of it, not its owner. The binary used to carry its own transport in src/http.rs; that file is gone. What was duplicated now lives in a library crate any Rust caller can take by path or pinned git dep, exactly as crates/event already does:

  • Client::r#as(subject) — one credential scoped to ONE end user. It mints through IAM's act grant at POST {issuer}/v1/iam/tokens/issue?id=<subject> — IAM's OWN host, the target in the id query, never a body, because IAM reads the grant off the key. The response is camelCase (accessToken, expiresIn). The token is held until 30s before expiry and dropped on a 401, which re-mints and retries ONCE. No method takes a user id, so no caller can pass the wrong one.
  • Outcome<T> — an approval-gated call answers 202, and 202 is a success status. The two states are arms of one enum sharing no value, so a held call cannot be read as done: it does not misbehave at runtime, it fails to compile. The Held arm carries the Approval (id, clause, reason) that GET /v1/approvals/{id} also answers. status: "held" is not a field — the arm IS the status.
  • Error — one typed enum. A non-2xx is Error::Api { status, body }, the server's own status and body, never a string.
  • Transport — one request in, one Reply { status, body } out; the status is handed back, never flattened. Http is the shipped impl, and src/zap.rs returns the SAME Reply, so the two wires the CLI speaks are interchangeable at Seam::send with no conversion between them. A stub over the same trait is how crates/client/tests/ contract.rs proves the mint address, the cache, the re-mint and the held arm without a network.

Sibling of the shipped TypeScript contract in @hanzo/ai (grant.ts, result.ts, http.ts) — same four rules, same names. as keeps its name in Rust; r#as is the keyword escape, not a rename.

Brand rules (hard): Hanzo is a full AI cloud, never an "LLM gateway" and never positioned vs LiteLLM. /v1/ paths only, never /api/. Zen models (enso, zen5-…) are our own family — never name upstream models.

Spec: ~/work/hanzo/SDK-ARCHITECTURE.md — the canonical one-way SDK/CLI model.

Stated gaps — upstream, and NOT to be patched here. The CLI is a projection, and a projection that repairs its source hides the defect at the one place it can be fixed.

  • Required-ness of query parameters is being lost in cloud's own document. Four shipped operations declare required: false on a parameter whose own description reads "Required.": GET /v1/o11y/{logs,metrics,status} (product) and DELETE /v1/clusters/{clusterId}/pools/{poolId} (provider). The prose says required and the flag says otherwise in the SAME operation object, so this is zip's parameter projection in hanzoai/cloud dropping the flag between the Go struct tag and the emitted document. Until it is fixed clap cannot enforce those four, and the CLI must not restate the constraint by hand — a client that knows a rule the document does not is exactly the drift this pipeline exists to end.

  • /v1/logs and /v1/o11y/logs are two doors onto one noun — "Search your org's logs by label, time and substring" vs "a page of one product's logs for the caller's org". One should win in hanzoai/cloud; the CLI follows whichever does.

  • cloud's document names 6 routes cloud's own server 404s — CLOSED. CONTRADICTED is 0: cloud deleted the door that made apps/ai publish from a committed subset, so the six stale nouns are out of the document. Kept here because the cause is worth recognising the next time it appears, and it is the same disease this pipeline cured on its own side. It was /v1/ai/{applications,permissions,sessions,sessions/duplicated,users, users/table-infos}: cloud renamed those resources (applications→deployments, sessions→signin-sessions, users→usages, permissions back to IAM) and the DEPLOYED binary served the new nouns while the document that SAME binary published still advertised the old ones — because apps/ai projected from a committed plugin/ai/openapi.json subset rather than from the mounted plugin's live registry, so its published names lagged its routes. make verify measured it the whole way, 3 → 6 → 0. Pricing was once named here as "the bulk" of the class; it never was — its 14 paths answer 200 on every serial re-ask, and a concurrent sweep reading transient 404s as fact is why the gate confirms a 404 three times before believing it.

  • version::tests::a_v_prefix_is_tolerated is flaky — 1 failure observed in ~10 full cargo test runs on this box, 0 in 8 consecutive runs after. It writes a stub script and execs it, so it races other tests' spawns. It is now in the release gate; an intermittent red gate teaches people to re-run, which is how a gate dies.

  • 524 WRITE COMMANDS TAKE --data, AND 421 OF THEM ARE AN UNTYPED HANDLER IN hanzoai/cloud. That split is the whole point: a --data blob is the CLI admitting it does not know the shape, and until 1.9.35 the only number anywhere was one aggregate that could not say WHOSE gap it was. genproduct now decides the cause where the decision is made and prints it every run:

    genproduct: 524 write command(s) take --data — 421 because the handler declares
    no requestBody in hanzoai/cloud (commerce 51, iam 44, store 22, billing 16,
    router 15, admin 11, captable 11, git 9, …), 103 because the schema is freeform
    by construction
    

    It had risen with the SURFACE, not with the gap's share of it: 90 releases added 464 operations and the new handlers were written the same untyped way as the old ones. Then 529 → 421 in one re-pin, and reading only that number would tell the wrong story: 93 of the 108 are ai alone (95 → 2), and they did not become typed flags — cloud deleted the door that threw ai's declared contracts away, so those bodies are now STATED and the statement is freeform, which is why the freeform count rose 10 → 103 in the same step. A declared open shape and an absent one are different facts with different owners, and only the second is a gap. 17 write commands did gain typed flags (evals 6, domain 3, experiments 3, benchmark 2, functions 2, security 1) — those are the ones cloud typed rather than merely published.

    The count is 440 of 454 today, and it is written into generated.rs's header rather than pinned as a const: it moves whenever cloud moves, so a const was a hand a nightly re-pin does not have, and the write step aborted on it instead of landing. genproduct --check diffs that header byte for byte, so the number still cannot change unread. A handler that LOST its type is a regression at the source. The freeform ones are not a gap and are not pinned — {}, additionalProperties (incl. POST /v1/admin/credits, whose Go input type genuinely IS map[string]any), a bare type: array, and oneOf (POST /v1/event, which really does accept four shapes). --data - still reads the body from stdin, so none of these forces a secret into argv.

    The worked example is still hanzo authz check: it took --sub --obj --act, and cloud's document declares POST /v1/authz/check with no requestBody at all — the shape was a hand-written approximation of a body nobody published. A client that knows a shape the server never stated is a client nobody can check. It comes back the moment the handler is typed, with no edit here.

    Held against cloud's tree when the gap was 459, 458 of them pinned to a file: 227 to Go source (an openapi.Describe(…) beside a raw g.Post(…) — prose hand-written, shape never reflected: apps/commerce/describe.go 39, apps/captable/captable.go 11, apps/platform/platform.go 9, apps/integrations/integrations.go 9, apps/projects/projects.go 8, …), and 231 only to a committed plugin/<x>/openapi.json subset (ai 118, iam 43, exec 12, company 8, git 6, …), where the published names came from a file rather than the mounted plugin's live registry. That second half is the one that moved: the ai line is closed, and the census wants redoing at 421 before anybody quotes its shape again. The Go-source half has not been re-measured and has not been fixed.

  • TYPED MEANS ALL THE WAY DOWN — arrays repeat, objects dot (1.9.35). A flag typed JSON is a --data blob wearing a type: the document stated a shape and the CLI made the caller hand-write it anyway. genproduct now reads a property schema to its leaves — classify DEREFS first (a $ref to an enum used to be as opaque as a nested object), an ARRAY becomes a REPEATABLE flag over its ELEMENT, and an OBJECT WITH PROPERTIES becomes DOTTED flags rebuilt into the declared object by insert_path. Measured on the same document, before → after: opaque JSON-blob flags 382 → 119, commands carrying one 218 → 149; 167 repeatable scalar flags and 1056 dotted flags exist where there were none; total typed fields 3157 → 4102.

    hanzo admin promos replace --plans pro --plans team     # was --plans '["pro","team"]'
    hanzo agents targets create --metrics.load1 0.4         # was --metrics '{"load1":0.4}'
    

    Two termination guarantees, both necessary: a $ref CYCLE GUARD (a self-referencing schema would expand forever) and MAX_NEST = 3. The cap SATURATES — 3, 4 and 8 derive the identical field set, because the cycle guard stops the recursive schemas first — so it is a guarantee, not a policy about the surface. A branch that expands to nothing falls back to one JSON flag rather than losing the property, and a nested leaf is required only when EVERY step to it is required (clap demanding --a.b inside an optional a would refuse a call the server accepts).

  • A LONE BODY PROPERTY SURVIVES A PATH PARAMETER OF THE SAME NAME. A body key echoing a path param is normally dropped — same value, already a positional. Not when it is the body's ONLY property: dropping it there types the whole write away for a schema that stated a shape. POST /v1/o11y/service_accounts/{id}/roles is the case cloud serves — its {id} is the service account and its body id is the ROLE being assigned — and it was the ONE --data write in the tree that no cloud typing gap explained. It is the one command that moved from --data to a typed flag in 1.9.35; every other --data write is upstream, by construction.

  • THE STDIN-SECRET CHANNEL BINDS TO 65 FIELDS, and that is security-relevant. format: password marks a body property the CLI reads from stdin and gives no flag and no positional, so it can never land in argv, ps or shell history. It bound to zero when cloud's emitted document carried no format: password at all, and a count of zero pinned as "an upstream gap" is a test that passes vacuously forever over a generator emitting --password <VALUE>. So is_secret reads the NAME as well as the format — the two ways of being wrong are not symmetric: a false positive asks for a value on stdin that did not need it, a false negative writes a live credential into ~/.zsh_history. a_stdin_secret_can_never_reach_argv asserts the law over every one of them and refuses to run over NONE — a count of zero is a regression at the source, and it is the emptiness that makes the test vacuous, not the exact number. The number itself was SECRET_FIELDS, pinned exactly, and it moved on every re-pin like the rest of the census. Do not restate the rule here — a client that knows a constraint the document does not is the second authority wearing a different hat. --data - still reads the body from stdin, so a secret can always be kept off argv even where nothing is typed.

  • THERE ARE NO RELAY DOORS LEFT, and that number is the worklist's receipt. A /v1/<product>/{wildcardN} in cloud's own document is the document declaring a subtree relayed through one address without naming what is behind it, so the CLI could offer get/rm/update at the door and nothing past it. It was ELEVEN, then seven (bot collections dns download sbom sentry tasks), and re-pinning onto today's document makes it zero: cloud typed every one of them, which is the whole point of counting them. genspec prints the count every run and stamps it into spec/cloud.json's own provenance, where --check diffs it. The fix for a door was always one of two things, both upstream — type the relay's routes as zip ops in hanzoai/cloud (what /v1/o11y did: a door at v1.801.360, 363 typed operations at v1.801.383), or have the mounted service publish its own document for the weave to carry. Do NOT close one by re-authoring a file here. That is what was deleted, and a door is exactly where it caused the damage: 66 of the master's 71 operations were "confirmed" by a door that answers the same for a real path and an invented one.

  • A catch-all positional is named wildcard1 where one exists. openapi.translate names an unnamed fiber * {wildcardN}, so hanzo kms secrets get <WILDCARD1> is what a person saw where it used to read <REST> — that spelling came from the hand-authored master and went with it. Today's document carries no {wildcardN} at all, so no command shows one. The name is honest where it appears (the router really has a nameless catch-all there) and the fix is upstream in zip's translate, which should give the artifact a conventional name. The CLI must not rename it locally: a projection that improves its source's vocabulary is a projection nobody can check against the source.

hanzo dev startup, measured (2026-10-12)

What a session waits for before its first model call, from hanzo-inc/kai benchmarks/coding (Claude Code, 75 attempts an arm, a task each of five kinds, in a jail with a fresh home): through hanzo dev the agent's first event came at 15 s against 0.8 s run bare, and the whole 13 s was the Hanzo toolset. An agent holds its first call until every MCP server it was handed has answered, and hanzo-mcp is a Python process that imports the cloud client before it answers (5 to 30 s). So:

  • The toolset is attached through hanzo __mcp -- hanzo-mcp … (gen:src/mcp.rs, answered before the command tree is built). The proxy answers the handshake and the lists from ~/.hanzo/mcp/<key>.json, owner-only, and starts the real server on the first call that needs it. A first session, or one after the server changed (its name, size and modification time are in the key, not its path: a link or a bind mount is the same server) or 30 days, starts it at once as before and keeps what it said. Warm, the agent's first event came at 0.4 s. It forwards bytes to the one command it was given and widens nothing.
  • Claude Code defers the toolset's schemas (ENABLE_TOOL_SEARCH=true, claude.rs), on the Anthropic wire only: the request's tool definitions fall from 53 (121 KB) to 12 (44 KB), and the 30 Hanzo tools cost a search when one is used. A model off that wire keeps the client's default.
  • The kit and the cloud session are read together (gen:src/lib.rs), the session for LINK_WAIT (2 s) at most: a slow cloud costs a run its link, never its first model call. The kit's wait is 2 s. The end-of-run block asks the cloud (the price list, 1 s; the meter, 1.5 s) only when stdout is a terminal: a script's process ends when the agent does (Claude Code, after the result 0.9 s median through hanzo dev against 0.08 s bare).