274 Commits
Author SHA1 Message Date
hanzo-dev 39a81d08e3 platform: an org is its name, and reservation is what makes that safe
CTO naming rule: no prefix on an org. The values directory, the destination
namespace, the AppProject and the image path are all `<org>` —
charts/app/values/<org>/<app>.yaml, namespace <org>, project <org>,
ghcr.io/hanzoai/<org>/<app>. One value, four roles, nothing added to it.

The `tenant-` prefix was a naming convention doing a policy's job, and removing
it is only safe once the policy exists, because namespace.Sanitize is the
IDENTITY on a clean label: without a separator, customer directories and the
cluster's own namespaces share one name space, and an IAM org named
`kube-system` resolves to the real `kube-system`. RESERVATION replaces it —
one predicate, asked on read and on write, over the platform's namespace
family: the brands and their environments, the control and delivery planes,
kubernetes' own, and `admin`. A reserved directory is SuperAdmin-only even when
it is the caller's own org.

`tier` is gone with the prefix. An org is its name, so placement is not a field:
`org` is an ACT-AS, defaulting to the caller's own, and naming another requires
SuperAdmin. Both refusals refuse rather than downgrade, so an escape attempt is
never indistinguishable from a normal request.

The fence's real rule lives in universe's ApplicationSet, so it is VERIFIED, not
documented: checkFence reads the live template out of the clone the write
already makes and refuses to put a declaration on main while that template would
fence it wider than this API reports. It clears itself when universe lands the
reservation form — no flag, nothing to remember — and a branch write is exempt
because nothing is generated from a branch. A comment saying "land the companion
change first" is not a control, and neither is a test that fails on a
developer's machine and skips in CI.

RED FINDINGS, all fixed with red's PoC kept and green:

F1 (HIGH, cross-org read) — the write path derived a directory with
namespace.Sanitize(org) while the read path confined with the RAW owner claim.
Sanitize on ONE side of an authorization compare is a collision waiting to be
named: org "Acme" owns "acme-<hash>", so it never matched its own rows, and any
org whose raw name IS that literal string matched them instead — offline
-computable, since the slugger is public code. Both sides are canonicalised now,
in cd.go owns AND in fleet.go scopeNamespaces, which carried the identical
compare and is LIVE on /v1/platform/fleet. Sanitize is injective, so this is
collision-free and not merely symmetric. cdApps had no tests; it now has a table
over every shape of name Sanitize treats differently.

F3 (build DoS) — a declare build was charged to the constant "platform", so one
org looping deploys exhausted a shared ceiling of 3 and locked the fleet out of
building, with no attribution in the Job labels to see it by. launchDirectBuild
takes the org it is charged to; the ceiling is per-org, like /v1/runner's.

F4 — the default host interpolated the RAW owner claim, so any org without a
clean name got "web.Acme.hanzo.app", not a hostname at all. It is built from the
canonical org, the same value that is the directory.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 11:28:51 -07:00
hanzo-dev b17f9e6a60 platform: deploy an app by declaring it, and read what CD did with it
/v1/platform/apps is the delivery surface for the ONE deploy plane. An app is a
values file in hanzoai/universe under charts/app/values/<namespace>/<name>.yaml;
the fleet ApplicationSet renders charts/app against it and cd.hanzo.ai reconciles
the result. Declaring the file is the whole of deploying the app, so the surface
is three moves: build the repository through the existing BuildKit lane, write
the declaration that names the image, and report the Application reconciling it.

Two things a caller may never choose, because both are fences.

The DIRECTORY. The generator derives the Application name, the destination
namespace, the Helm release name and — load-bearing — the AppProject from the
file's own path: tenant-<org> admits ONE namespace, no cluster scope and six
kinds, while hanzo-platform admits namespace * and ClusterRole/ClusterRoleBinding.
A caller that could name its directory could name its fence, so the directory is
derived from the validated owner claim and is not a request field. tier=platform
is the one lever over placement and it is SuperAdmin only.

The IMAGE REPOSITORY. A declaration is what the cluster pulls, so it is derived
per tenant on the same injective path the build lane already uses. There is no
image field to try.

A branch is not a deploy. The generator reads main, so the default mode pushes
deploy/<ns>/<name>/<tag> and deploys nothing; merging the review is the
deliberate act. mode=commit writes main and proves the image pullable first,
because a declaration naming an image the registry cannot serve is an
ImagePullBackOff with no rollback path.

The git seam is pin.go's, not a second one: the same shallow clone, the same KMS
-held token carried as an http.extraHeader rather than in argv, the same
fast-forward-only push retried by re-reading the tip. This composes with the pin
rather than duplicating it — declare ADDS a service deliberately, which is
exactly what resolvePinFile refuses to do, and the pin then moves its tag. An
update here moves that one scalar too and refuses anything that would rewrite a
hand-maintained declaration.

/v1/platform/cd reads the Applications in hanzo-cd. A cluster with no CD answers
an empty plane; a plane that cannot be READ answers 503 and says why, because
those are opposite facts. /v1/platform/ci answers 501 and names what is missing
rather than fabricating an empty run list — this deployment has no forge API
client. Static sites and bucket listing are NOT added: /v1/platform/sites and
/v1/s3/buckets already serve them.

Proved against the real chart, not a fixture: the generated declaration renders
through hanzoai/universe charts/app with its values.schema.json enforced, and a
key the chart does not declare is refused by that schema. A dry run against a
clone holding the real 105-file inventory wrote a scratch branch and left main
byte-identical.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 11:28:13 -07:00
zeekay 7c50638eb1 merge inc2/main: one line again
CI/CD / containment (push) Successful in 1m55s
Hanzo CI/CD / cicd (push) Failing after 6m48s
CI/CD / gate (push) Failing after 6m49s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
The two private lines had diverged 191/18 off a merge-base six hours old.
Production builds from inc2; every push went to forge; neither knew.

The 14 conflicting hunks, and why each resolved the way it did:

- bridgeReply loses its ctx parameter (inc2). Both lines fixed the same
  production bug — the webhook's ctx made zip drop the stated org, so the
  balance gate answered "no org on the call". forge passed a detached ctx in;
  inc2 deleted the parameter so the webhook's cannot be passed at all, and
  states the tenant with cloud.For. Unrepresentable beats discouraged. Its
  body now calls forge's bridgeIdentity, so the link lookup exists once.
- commerce prefixes are the union: forge's /v1/cart and the /v1/billing/topup
  stem, inc2's accounts, credit-balance and transactions. topup/token is
  dropped — the stem owns its subtree. A prefix missing here never reaches
  commerce; it falls to ai's bare /v1 and answers ai's 404.
- zipdoc-check keeps inc2's target (forge repeated the loop twice) with
  forge's body: per-package, because whole-module load extracts differently
  than the generator it polices, and skipping dot-dirs, because an agent
  worktree is a second checkout and the walk read 203 packages where there
  are 104.
- plane.go keeps both new sections — sandbox ops and the coding-run seams are
  orthogonal, no shared names.
- plane_debit_harness_test.go takes forge's, which delegates to
  internal/planetest instead of holding a second copy. Same API. The copy is
  what hid a bind failure: a unix path is 108 bytes and t.TempDir() spells
  the test's name into it.
- metering keeps Actor and the token counts; plane.Usage has the fields, and
  a debit without them can be re-read but not re-derived.
- o11y v1.5.62 over v1.5.61 — upgrade, never downgrade.

go build ./... and go vet clean. Full suite: 3 red (base, code, commerce),
identical to both parents — this box's SQLite lacks acos and fts5.
2026-08-06 11:06:47 -07:00
zeekay d09ff7317b exec: the tenant is not a header, and the credential is not a path
Red returned do-not-ship on two CRITICALs that chain to unauthenticated
cross-tenant code execution. Both are proven by apps/exec/auth_test.go, and all
four assertions FAIL when the old behaviour is put back — mutation-checked, not
asserted.

1. THE TENANT CAME OFF A HEADER. callCtx preferred cloud.Who(ctx).Org, which is
   zip.CallerOf, which reads the X-Org-Id REQUEST HEADER (zip caller.go:377) —
   and for a request with no validated bearer, SanitizeIdentity deliberately
   RESTORES the client's own header (middleware_identity.go:455). So
   `X-Org-Id: victim-corp` made storeFor open the victim's SQLite file: the run
   executed in their store and /v1/files + /v1/download read their artifacts
   back out. principal.OrgFrom is the org a VALIDATED principal resolved to and
   nothing else; every other app resolves through it and this one did not.
   plane.go's own note — "an org in the argument is an org the caller chose" —
   is the rule it was breaking.

2. THE CREDENTIAL CHECK WAS A LOWERCASE PREFIX LIST OVER c.Path(). fiber routes
   case-insensitively; cloud.RoutePath exists in this repo for exactly that and
   two other gates already use it. POST /V1/EXEC matched the route and missed
   the list: no key at all ran code, a wrong key read another session's bytes,
   and CODE_EXEC_API_KEY UNSET — the documented fail-closed 503 — still ran code.

Same root, so one fix: authorization stops being inferred from the SPELLING of a
request. The middleware normalizes with RoutePath AND parks two facts on the
context — principal.WithOrg (inherited by the typed op, typed.go:82) and an
unexported `admitted` marker. tenantOf is the ONE tenant decision and refuses a
context carrying neither. The prefix list may still drift; drift is now
fail-CLOSED — a missed path is a 403 on a route that should have worked, never a
route that works without a credential.

AND THE DOOR NO LIST COULD HAVE COVERED. A typed op is also an MCP tool and an
op-plane op, and tools/call invokes it DIRECTLY (zip typed.go:474) — no route, no
middleware. `POST /mcp name=post_v1_exec` with no key ran code. Typing /v1/exec
for its SDK value is what opened that door; the handler-side check closes it,
because those doors park no marker and carry no principal.

3. [HIGH] AN EXEC SANDBOX HAD NO CEILING. Single-attach bounds dev/desktop via
   their project; an exec sandbox has none, and the code tool sends no
   session_id, so every call mints a fresh pod on a 15-minute lease — 40 calls,
   40 pods, each 250m/512Mi/2Gi. The reaper is the FLOOR, not the ceiling: it
   ends leases that are over, bounding the steady state and never the burst.
   maxLiveExec=16 is written in node capacity (8Gi, 4 cores) and refuses with
   429, because the caller's correct response is to wait. Counted with a real
   COUNT(*), not len(List) — List is LIMIT 200, which stops counting exactly
   where refusing starts to matter.

4. [HIGH] TWO CLIENT-SHAPE BUGS, both silent. hanzo.chat primes an attachment as
   {id, session_id, name} (Files/Code/process.js) while @hanzochat/agents spells
   it storage_session_id (tools.d.ts) — reading only the second skipped every
   attached file AND the "not available" note, so a user's CSV was invisible with
   no error. CodeFile.Session() reads both, and a ref with neither is reported.
   Artifacts were COLLECTED recursively (find) and LISTED top-level (ls -1A), so
   a nested artifact appeared in the reply and was missing from /v1/files/{sid},
   which the client reads as expired. One find answers both now.

Also: apps/functions.go's doc no longer cites the deleted CODE_EXEC_UPSTREAM.

Tests: 22 in apps/exec (14 + 5 auth + 3 client-shape), 6 in apps/sandbox. Full
suite 3 red — base, code, commerce — all pre-existing and environmental (this
box's SQLite lacks fts5 and acos). Zero new failures. make check green.

NOT FIXED, and it is a decision rather than a defect: the real chat client sends
no credential at all (EnvVar.CODE_API_KEY undefined, handleTools.js sends no auth
header, the chat pod has no LIBRECHAT_CODE_API_KEY), so the guard 401s every
legitimate request. Fixing the bypasses does not make the feature work; settling
what the client presents does, and the house rule says Hanzo IAM rather than a
second shared secret.
2026-08-06 07:52:18 -07:00
zeekay 48c2af53f9 a session is a sandbox: exec stops proxying to a Service with no endpoints
apps/exec was a reverse proxy to code-exec.hanzo.svc.cluster.local:8000, and
that Service has had ZERO endpoints for 33 days — /v1/exec answered 503 in
production the whole time. It was not failing to reach the executor; there was
no executor. apps/functions read the same CODE_EXEC_UPSTREAM and was failing
against the same absence.

cloud already runs the one compute primitive. A LibreChat session IS a sandbox
("a code-exec call = a sandbox with a session lease", apps/sandbox's package
doc), so session_id is the sandbox id, upload/download/list are Write/Read/Read
on that sandbox, and exec holds no store, no session table and no lifetime of
its own. The lease ends on the reaper.

THE BLOCKER, AND THE DECOMPLECTION. Every sandbox operation existed ONLY as
`func X(s *Service, c *zip.Ctx) error` — the domain braided into the transport,
so nothing else in the process could use a sandbox. apps/sandbox/api.go is the
domain as a VALUE: Lease, Get, List, Run, Read, Write, End. The HTTP handlers
become adapters (bind, call, JSON) and apps/sandbox/plane.go is a SECOND adapter
over the same functions. Errors are zip errors in the core, so "not yours" is
404 once, decided where the fact is known.

  (Functions taking *Service, not methods: `Service = cloud.Service[state]` is an
  alias for a generic type in package cloud, and Go cannot define a method on a
  non-local type. apps/git's files.go is the same shape for the same reason.)

cloud.Ask AND NOT A GO IMPORT, and that is correctness, not style. Apps ship as
separate plugin binaries (plugin/<app>/main.go "links only its own subsystem"),
so importing apps/sandbox would give exec a SECOND sandbox service — its own
OrgStore on the same per-org SQLite files, its own reaper racing the real one.
plane.Ask already collapses to an in-process dispatch where the fleet fuses two
apps (zip.Serving/zip.Here), so the ONE call is a function call when they are
co-resident and a socket hop when they are not.

THE CONTRACT IS MEASURED, from ~/work/hanzo/chat, not remembered. Download is
TWO segments — /download/{session_id}/{fileId} (crud.js) — not /download/{id}.
Upload answers {message:"success", session_id, files:[{fileId, filename}]} and
the client throws unless `message` is that literal. /files/{sid} answers a BARE
JSON ARRAY of {name, lastModified} whose name is that same two-segment
identifier. And the code tool tells the model to persist artifacts in /mnt/data,
so sandbox gains workdirFor(class): /mnt/data for exec, /work for dev. Listing
/work would have reported no files after every successful run.

/v1/exec/programmatic answers 501 rather than being routed into the interpreter.
It is a DIFFERENT protocol — a run suspended on each tool call and resumed from
a continuation token — and answering it with a Result its parser cannot read is
a wrong answer where a refusal is an actionable one.

TENANCY, and the trap. cloud.For states a caller only on a context with NO
request behind it: zip.CallerOf reads the request's headers first, deliberately,
so no handler can assert an org a caller did not arrive with. A typed op's ctx
HAS a request behind it, so cloud.For(typedCtx, org) silently states nothing and
the peer answers 403. exec.callCtx is the one rule — pass a ctx that already
carries a principal through unchanged (hanzo.chat forwards the user's IAM
bearer), otherwise detach to Background, state the deployment's brand org, and
put the request's cancellation back with context.AfterFunc.

apps/functions moves to the same door, because task 3 (deleting the orphan
Service) would otherwise turn its 502 into a DNS failure. It calls exec.Run —
one operation, one home — and ENDS the lease, because a function invoke is over
when it answers.

THE FLOOR IS LOWERED HERE, next to its reason. exec published 40 operations
because a proxy cannot describe a contract it does not own; it now implements
one, so it publishes 5 — POST /v1/exec is a TYPED op (CodeRun -> CodeResult),
which is the schema, the MCP tool, the CLI command and the SDK method a proxy
could never carry. The four that stay untyped have real reasons: multipart in,
bytes out, a bare array, and a protocol we do not serve. Numbers taken from the
ratchet's own report, not computed:

  paths       1762 -> 1759  (-3)
  operations  2465 -> 2430  (-35)
  download    10 -> 1 · exec 10 -> 2 · files 10 -> 1 · upload 10 -> 1

Deleted: newProxy, defaultUpstream, CODE_EXEC_UPSTREAM, and the ledger of 40
untyped operations. CODE_EXEC_API_KEY stays — it is the credential the chat
presents, and the guard is now middleware because a typed op takes no handler
chain.

Tests: apps/exec 14 (a sandbox peer double on the REAL plane, so an op renamed
or a field moved fails here and not in production), apps/functions green
including TestInvoke_AllowsAndDebitsCallerOrg, which was RED before this change
— metering moved debits onto the plane and the test still had only an HTTP
double.

Measured against HEAD: 12 packages fail before and after; zero new; one fixed.

LLM.md also carries a concurrent agent's insights.hanzo.ai section, present in
the tree and committed rather than dropped.
2026-08-06 06:52:35 -07:00
zeekay 117f4fed8b Merge remote-tracking branch 'forge/main' into feat/sandbox-executor
# Conflicts:
#	openapi/floor.json
2026-08-06 05:25:48 -07:00
zeekay 93a61b817e the sandbox product is named for what it serves, and the box pool's row is gone
The merge collapsed two manifest rows into one and left a name that matched
nothing. The app was called `sandbox`, it served `/v1/sandboxes`, and its
operations were tagged `sandboxes` because the tag comes from the path — one
thing under three spellings, and the Makefile named a fourth.

It is `sandboxes` now, which is what its path says and what every sibling
collection already does: bots, agents, tasks, books. The package stays
apps/sandbox and pkgOf carries that, which is the case pkgOf exists for.

The floor's `sandbox: 26` was the BOX POOL — a warm pool of pods, a daemon
inside each one, a shared pool-wide key, and a bind handshake so a box could
tell which box it was. All of it is deleted, and the row goes with it rather
than being pinned at 0: the product does not exist, and a floor entry for a
product that does not exist is a claim that something is missing.

That deletion is right and it is not mine to mourn. runtime.go says why in its
own words — the boundary is the pod's runtimeClassName, one field, and
"everything the predecessor built to approximate that boundary in Go is deleted
rather than kept for defence in depth, because a second half-boundary is a
second thing to keep true." Every problem the pool had, I spent tonight fixing:
the key that opened every box, the row whose address the CNI had reassigned, the
identity a warm pod could not learn. None of them exist without the daemon.
There is no key, and a pod is addressed by NAME through the apiserver, which
cannot go stale.

Four operations published an operationId and nothing else. These routes bind
through zip.Ctx rather than being typed ops, so zipdoc has no comment to lift
and openapi.Describe is the seam — the other three already used it. They say
what they do now, including the two facts a caller cannot guess: a 404 rather
than a 403 for another org's id, because a 403 confirms the id exists; and that
the volume SURVIVES a delete unless purge=1, which is the one part that cannot
be undone.

1758 paths, and the weave is green.
2026-08-06 05:25:14 -07:00
antje b3ded8a57f the language servers move out of the fleet, and lsp moves under code
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
apps/lsp ran gopls, tsserver and the rest INSIDE a fleet pod: server.go,
workspace.go and langs.go checked repositories out, fetched their dependencies
and executed a third-party toolchain over untrusted bytes in the same process
that holds a principal, a ledger and a KMS-injected environment. That is the
wrong shape however carefully it is written, and hanzoai/lsp exists to be the
right one — a jailed daemon with gVisor, no egress but a module proxy, and no
git credential of any kind.

So this side becomes a PROXY over that daemon, and keeps the three things the
daemon must never hold:

  TENANT      the org is the validated principal's, never a body field, and it
              is the daemon's isolation key.
  REPOSITORY  the revision and the tree come from git's own object plane, for
              the caller's own org. The daemon cannot fetch a repository — a
              credential that could reach every repository is exactly what must
              not sit next to an unjailed compiler — so a cold revision is
              /ask → 409 {"need":"tree"} → /root → ask again, ONCE.
  LEDGER      the gate runs at the prepare price before any work, the debit
              after the answer. "prepare" and "query" are the two Models, and
              spend.go's meteredApps entry still holds.

The surface moves from /v1/lsp to /v1/code/lsp, and from one door with a
`method` field to five typed ops — hover, locate, symbols, diagnostics,
complete. code and lsp are two reads of ONE repository, not two products: code
is the static index, lsp the live server that follows a symbol out of the
repository and into a dependency. One home means one place to look for it, in
the document and in the MCP tool list alike, and an agent picks a tool by its
name rather than by a union behind one. Nested static prefixes resolve by
specificity, so /v1/code/lsp beats /v1/code and both beat ai's bare /v1 — the
same relation storage's /v1/s3/buckets already has to provisioning's /v1/s3.

git gains ONE op, git_rev: the commit a ref names. It is separate from git_files
because the two questions cost differently — resolving is a ref lookup and runs
on every position query; reading is a walk of the whole tree and runs only when
the daemon says it holds no root. Folded together, a hover would drag a monorepo
across a socket. One resolve (coreRev) now backs both.

plane/agents was generated before agents_run_on_behalf existed; running the
generator carries it in.

The published surface GREW: 1762 → 1766 paths, 2480 → 2484 operations. The
floor's "lsp" product is gone because its five operations are tagged "code" now,
which is the move, and code rises 7 → 12.
2026-08-06 05:19:23 -07:00
zeekay 8374e8d214 Merge remote-tracking branch 'forge/main' into feat/sandbox-executor
# Conflicts:
#	apps/bots/typed_wire_test.go
#	apps/dns/typed_wire_test.go
#	apps/exec/typed_wire_test.go
#	apps/o11y/typed_wire_test.go
#	manifest/order_test.go
2026-08-06 05:11:56 -07:00
zeekay 39ce9a43ec sandboxes: a pod that runs somebody's code, proven against a real cluster
The isolation boundary is the pod's RUNTIME — one field, SANDBOX_RUNTIME_CLASS,
holding gvisor or kata-fc or kata-clh or nothing. That is the whole of the
runtime decision: no fork in the code, no second implementation, and a swap is a
deployment change rather than a release. Empty means the node's default runtime,
which is honest rather than a hole — runsc has to be installed on the nodes
first, and installing it restarts containerd under 204 running pods, so that is
maintenance and not something a release does on its way past.

No daemon inside the pod. Commands go through the Kubernetes exec subresource,
so cloud talks to the apiserver rather than to a pod IP. That deletes three
problems the predecessor had to solve: a shared service key sitting in an
environment the submitted code could read out of /proc, a bind protocol to stop
a recycled pod IP serving another tenant's checkout, and an in-pod HTTP server
that had to be built into every image.

PROVEN LIVE, not asserted — go test ./apps/sandboxes -run TestLive against
hanzo-k8s, 27.7s, image node:22:

    RUNS CODE:        SANDBOX-RUNS-CODE v22.23.2
    EDIT PERSISTS:    export const answer = 42; // edited by the agent
    EDITED CODE RUNS: ANSWER=42
    FAILURE IS DATA:  exit=3 stderr=to-stderr

The file is written through one exec and read back through a SEPARATE one, so
what is proven is a filesystem and not a buffer. The last line is the one an
agent depends on most: a command that exits 3 is a successful call carrying a
failed program, because a caller that cannot tell "your tests failed" from "the
sandbox is broken" will retry the wrong one forever.

The image is an env var, and that is why this could be proven before our own
image exists. A stock node:22 exercises the identical create/exec/edit path.

Named sandboxes, not machines: /v1/machines already exists and is visor's —
whole GPU and VPS instances, sized, quoted and billed. Two products cannot share
a noun. The namespace and pod labels follow the same word, which matters more
than it looks: universe's containment policy selects hanzo.ai/sandbox-class in
hanzo-sandboxes, and a policy naming a label the code does not set is the
enforce-nothing failure this fleet has shipped three times this week.
2026-08-06 04:47:16 -07:00
zeekay 88d3bee3d7 integrations described slack/install twice, so thirteen apps could not build
CI/CD / containment (push) Successful in 2m24s
Hanzo CI/CD / cicd (push) Failing after 51m20s
CI/CD / gate (push) Failing after 51m20s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
apps/integrations carried two openapi.Describe calls for GET
/v1/integrations/slack/install, written by different hands into different init()
funcs. Describe panics on a duplicate — correctly, because two descriptions of
one operation means one of them renders and nobody can tell which — and that
panic fires at init, so it took down every app that links integrations: ads,
automations, campaign, catalogsync, channels, cloudflare, company, content,
destinations, git, guide, integrations and sync all failed to describe.

The earlier one survives. It was already the superset: it has the attribution
constraint (Slack refuses a slack.com URL in that field, so the click has to
route through an address of ours to be counted) AND the tenant point (public, no
principal, binds no org, because minting an org for an anonymous click is the one
thing that would break isolation). The later one had a single fact the first did
not — 503 where the app is unconfigured, rather than a consent URL with an empty
client_id that Slack renders as its own dead end — so that sentence moved across
before the duplicate went.

The floor drops for the merge's own deletion too: /v1/billing/gpu/charge and
/v1/billing/gpu/eligibility are gone because GPU is metered like any other
resource now, and the bespoke prepay path with it. Checked rather than assumed —
a -1 that is not a multiple of two is not a TRACE/OPTIONS removal, and an
unexplained shrink is exactly what the ratchet is there to make someone look at.

1762 paths, 2480 operations, 185 products. Every one carries an operationId and a
summary; 51 still want a long description and 49 of those are hanzoai/ai, whose
prose belongs on its controllers, in that repo.
2026-08-06 03:21:23 -07:00
zeekay 1deb2d39e3 Merge remote-tracking branch 'forge/main' into feat/sandbox-executor
# Conflicts:
#	openapi.yaml
#	openapi/floor.json
#	plugin/meet/openapi.json
#	plugin/tasks/openapi.json
#	plugin/tracker/openapi.json
2026-08-06 03:10:07 -07:00
zeekay ceff43ac30 the document publishes what was declared, not what the router happens to bind
Two ways the same confusion showed up, and one build that could not run.

TRACE and OPTIONS were published — 33 each — because All() binds every method
at a path and the describe loops read that route table as a product surface.
CONNECT and HEAD were already excluded on exactly that principle. TRACE echoes
the request back; it is the Cross-Site Tracing verb and should not be reachable
on a public API at all, so advertising it in the contract is worse than merely
routing it, because the contract is what the SDKs, the CLI and the MCP tool list
are built from. OPTIONS is CORS preflight: a browser sends it, a person never
does, and `hanzo meet options` is not a command anyone wants. Between them, 66
operations — and the generated CLI already carried none of them, so this is the
document agreeing with the projection that was right first.

websearch held its own copy of that method list. It said seven, including the
two, and the day the document stopped publishing them the copy went on
describing operations that no longer existed — which openapi.Methods() exists to
prevent and which the describe gate caught by name. It reads the projection's own
set now.

The floor drops by exactly 66, in this commit, next to the reason.

Separately: every app Makefile claims "Generated by plugin/gen-app-cmds" and none
of them were. The generator wrote plugin/<app>/main.go and left a human to
remember the second file, so `sandbox` arrived with a main and no Makefile, so
`make describe` had no rule for it, so it published no subset, so `make openapi`
failed on an app that was otherwise finished. The generator writes both now, from
the same manifest row, taking the package from each app's own composition root
rather than a lookup table. It reproduced all 117 existing Makefiles with zero
APPS values changed, which is the evidence the rule is the one already in use.
External modules fall out structurally — no package directory here, no Makefile —
so mk/fleet.mk's EXTERNAL list is not copied.
2026-08-06 03:06:19 -07:00
antje d2c44bab81 lsp: live code intelligence over a repo and its resolved deps
Hanzo CI/CD / cicd (push) Canceled after 0s
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
POST /v1/lsp — one door, because there is one value: a language server
rooted at a workspace, asked about a position. code (static index) and
lsp (live server) are two reads of the ONE checkout.

  {repo, rev?, path, line, character, method} → hover | definition |
  references | typeDefinition | implementation | documentSymbol |
  completion | diagnostics

Positions are the LSP's and pass through untouched: 0-based line, 0-based
UTF-16 character. Re-basing them would corrupt every multi-byte line, and
the callers already speak LSP.

Isolation is structural, not checked. The org comes from the validated
principal and is BOTH the pool key and the OWNER SEGMENT of the git URL;
a caller supplies only a repo slug. There is no input from which one
tenant can name another tenant's repository.

Scripts-off by default. A dependency fetch that runs dependency-authored
code is RCE triggered by whatever the caller asked us to check out, and it
buys nothing — servers resolve from source. So: npm ci --ignore-scripts,
cargo fetch (not build), go mod download; the python fetch builds sdists
and therefore does not run. rust-analyzer is additionally told not to run
build.rs or expand proc macros, because otherwise the server does at load
time exactly what the fetch was chosen to avoid. One predicate, one place.
The deployed worker must still be sandboxed — see fetchable's comment.

Language table ported verbatim from hanzo-tools-lsp (same binaries, same
argv, same root markers); install_cmd deliberately dropped — a worker that
can npm install -g at request time is one an attacker can make write to
its own filesystem.

server.go is the testable core: Content-Length JSON-RPC with a SINGLE
reader goroutine demultiplexing responses, server→client requests and
notifications. The python tool reads inline from each call, which drops
every frame that is not the awaited response — which is why it cannot
report diagnostics, and why an unanswered client/registerCapability
deadlocks it. 24 tests drive a fake server over in-process pipes; no
toolchain, no network.

Two bugs the tests found, both real in production:
  - Close() wrote a polite shutdown unconditionally, so a server that had
    stopped reading its stdin blocked it forever — holding a pool slot and,
    at Shutdown, the whole binary.
  - rel() compared a symlink-resolved root against an unresolved target, so
    any data dir with a symlink component (/var on a Mac, a mounted volume)
    made every location "outside" the checkout and handed the caller the
    worker's absolute path instead of a repo-relative one.

Metered, not Free: cost is the COLD start (checkout + fetch + first index),
not the query. Warm point queries are recorded and free, so the pricing
does not teach callers to re-key their workspace. Gate before the work.

apps/code has NO checkout to reuse — it indexes files POSTed to it and says
so. apps/deploy has the only working-tree checkout and is not importable
(it would link k8s into this binary). This is therefore a second one,
following deploy's invocation and hardened env exactly; the fix is hoisting
it into the root cloud package, not made here.

Mount order: apps.Wire() and its integers are gone. manifest.Apps slice
position IS the order, so lsp's row sits after code's, with order_test's
frozen sequence updated in the same commit.
2026-08-06 02:45:09 -07:00
hanzo-dev 46f4f8222a sync github main onto the forge — the lineage the release pipeline cuts from
The forge already carried the front-door body-limit fix (internal/edge). These
seven were only on GitHub, so the next pipeline release would have shipped
without them:

  84e26665 commerce: the 128 silent operations say what they do
  702ecbf7 preflight admits the four headers the console actually sends
  bd4115d3 o11y: the product scope is the fleet, sub-cent debit is not an outage
  8f1f50e0 CORS: allowed origins derive from verified site hosts
  3bfdeee0 slack: the agent turn crosses the plugin boundary over ZAP/UDS
  c78fbe72 a console the customer owns can call the API it was forked from

Resolutions:

- apps/billing/usage_coresident.go — FloorMinor, not RoundMinor. Both fix the
  same 502, but balance.go:126 already floors a row from the same ledger, and
  two roundings on one ledger is how the next discrepancy starts.
- apps/framework — RoleAssignment/roleRef. Without the rename the weave refuses
  the whole document: iam publishes a Role ENTITY and framework published a
  (user, role) GRANT under the same name, and a generated SDK binds whichever it
  read last. revokeRole still named the pre-rename type; it takes *roleRef now,
  which is the URL-path form it actually addresses.
- manifest/apps.go — UNION, not a side. github adds /v1/event.js (the hosted
  analytics tag, previously falling to ai's bare /v1 remainder, which a browser
  reads as a broken script tag); the forge has /v1/replay, which github lacks.
  Taking either side alone drops a live route. The superset check I wrote for
  the last merge is what caught it — it asserted, and it fired.
- Generated artifacts (openapi.yaml, plugin/*/openapi.json, */zipdoc_gen.go) are
  taken from github, not regenerated here: `make describe` on this tree still
  fails for 14 apps, and github's are the set that was verified green at 1735
  paths. A half-regenerated document is worse than a whole borrowed one.

go build ./... exits 0. TestTheServedDocumentIsTheArtifact and the door
body-limit tests both pass.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 02:31:58 -07:00
hanzo-dev 5435fe3a05 slack: @hanzo answers out of the box, and the agent can call tools
THREE things, all measured against production tonight.

1. AGENT NOT FOUND. With the plugin-boundary fix deployed the turn finally
   reached agents, which answered `agents: agent not found`. The bridges ask
   for the conventional ref ("hanzo") and Store.Resolve is a plain row lookup
   with NO seeding anywhere — so an org that connected Slack and did nothing
   else had no agent, and @hanzo could never work out of the box in ANY
   workspace. The conventional ref now resolves to a BUILT-IN default: not
   persisted (a row would fork the definition per org and strand already-seeded
   orgs on a later change), and a row the org DOES create still wins because
   Resolve is tried first. An unknown ref stays a miss — silently substituting
   the chat agent would make a typo in `code: repo` run the wrong thing and
   look like it worked.

2. TOOL CALLING. executeRun did one completion and returned the text; Agent
   .Tools was stored, updated and displayed but never read by a run, so the
   agent could not reach anything. It now runs a bounded tool loop and takes
   the actor, so every tool call is attributable to the org and user that
   caused it.

3. TOOL SURFACE. fleet/mcp.go + fleet/surface.go curate and gate what a client
   may see: the live server projected 1,323 internal ops with zero annotations,
   and Slack saved the first 128 ALPHABETICALLY — a window containing zero
   product tools and 36 credential/auth ops including CreateServiceAccountKey.

Cross-agent collisions found and fixed while integrating: tools.go's truncate
collided with an existing test helper (renamed truncateToolResult, which says
what it bounds), and executeRun gained an actor parameter that two test callers
had not been updated for.

go build ./... clean. fleet and plane suites green; apps/agents' one failure,
TestTargetOpsProjectEverywhere, is the known pre-existing op-id drift.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 02:25:55 -07:00
zeekayandhanzo-dev b4992c630e sandbox: the executor exists, is mounted, and ships in the image
Three things were true at once and each hid the next. cmd/boxd was written and
untracked, so no release could contain it. Cloud's Dockerfile built /cloud and
/smoke and nothing else, so committing boxd would still not have put it in an
image. And apps/sandbox had a Mount with no plugin/sandbox beside it, so the
package compiled, linked into nothing, and answered 404 in production while the
code to answer sat in the tree.

That is why `COPY --from=ghcr.io/hanzoai/cloud:<pin> /boxd` in hanzo/bot's
Dockerfile.box was red by construction — grep -c boxd Dockerfile was 0 — and why
every consumer already pointing at the executor (hanzo.app's ProjectFs,
apps/exec's upstream, apps/functions' invoke) had nothing behind it. The
33-day-old code-exec Service with <none> endpoints is the same fact seen from
the cluster.

boxd builds HERE rather than in hanzo/bot because its types ARE
apps/sandbox/wire's types: the scheduler and the daemon agree because they
compile against one declaration, not because two repos were kept in sync by
hand. It is copied into the FINAL layer, not just the build stage — a binary
that exists only in the builder is not in the published image, and the box image
reads the published one.

boxd is also the half that runs untrusted code, so it refuses to start in the
configuration where that code can steal the pool key: submitted work running as
boxd's own uid can read CODE_EXEC_API_KEY out of /proc/<pid>/environ, and that
key is shared across the pool, so the credential a box hands its own workload is
the credential that opens every other tenant's box. Reproduced end to end. Two
cheaper defenses do not work and pgroup_unix.go records why measurements, not
reasoning: scrubbing the child's env misses that the child reads boxd's, and
os.Unsetenv misses that the kernel serves /proc/environ from the exec-time stack
block rather than the live environment.

.gitignore takes boxd in both spellings. It is the one binary people build from
inside its own directory, since it runs standalone on a laptop, and that file
already carries what committing binaries cost this repo once.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 02:13:45 -07:00
zeekayandhanzo-dev 84e2666543 commerce: the 128 silent operations say what they do
`make describe` refuses to regenerate openapi.yaml while any operation is bare,
so the artifact could not be rebuilt AT ALL and had drifted: on clean main the
host served 1698 paths against the golden's 1696 and
cmd/cloud.TestTheServedDocumentIsTheArtifact was red.

128 of the bare operations were commerce's, and commerce registers its routes
from an embedded module, so there is no doc comment here for zipdoc to lift —
openapi.Describe beside the route is the seam. Nine are written out: the wire
top-up rail, the crypto custody rail, the saved-card family, and the tenant's
payment-rail toggle. The other 119 are seventeen merchant kinds behind ONE
generic REST scaffold, so the mechanics are written once and composed with the
kind. Seventeen hand-copied paragraphs describing one generator is the drift
DescribeRest already exists to prevent one level down.

Every sentence is written from the handlers, and what earns space is what a
caller gets wrong:

  - PUT is a true REPLACEMENT — the body is decoded onto a FRESH entity, so a
    field the body omits is written back as its zero value;
  - POST /<kind>/{id} with NO override is a PARTIAL UPDATE, never a create;
  - a wallet read renders the account's ENCRYPTED key blob and its salt, so
    whoever may read one can attack it offline down to the owner's passphrase —
    which is the reason the kind is admin-gated;
  - a webhook's delivery consults neither `enabled` nor `live`, so enabled=false
    does not stop delivery; deleting the row is what does;
  - a discount is enabled by DEFAULT, so a bare create makes a live discount;
  - the per-kind permission table covers 5 of the 17 kinds. On the other 12 the
    scaffold logs that it is skipping the check and ALLOWS, so the route gate is
    the whole authorization story. Each kind now says which it is.

THREE defects were hiding BEHIND that refusal, because describe-apps stops at
the first app that fails and commerce sorts early. Each is repaired, not
recorded:

  - integrations: GET /v1/integrations/slack/install was bare. The handler
    already carries the prose; it is a raw route, so zipdoc cannot lift it.
  - the weave refused two schema names that meant two things. `Role` was iam's
    role ENTITY and framework's (user, role) GRANT — the grant is now
    RoleAssignment, converted at the handler boundary with the engine type
    untouched. `Application` was iam's OAuth client and crm's startup-program
    submission — the latter is now ProgramApplication.
  - manifest: /v1/event.js, the hosted analytics tag, is served by analytics and
    was routed to ai's bare "/v1". A prefix owns SEGMENTS, so "/v1/event" never
    covered it, and a browser reads that 404 as a broken script tag rather than
    as a routing mistake. Claimed on the analytics row.

All three predate this change and are provable on clean main: regenerating the
stale plugin/iam/openapi.json alone makes the weave fail the same way.

The regenerated zipdoc_gen.go files are the same class of staleness — prose that
was in the Go source and had never reached the artifact.

make describe exits 0. openapi.yaml carries 1735 paths, up from 1696, served
byte-identical to the golden. go build ./... is clean. The pre-existing red
tests (apps/commerce TestBalanceCents/TestInProcessClient, apps/framework's
three, apps/projects TestForkCreatesProjectFromTemplate) fail identically before
and after.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 02:11:32 -07:00
hanzo-dev 6d69166d6c generated: carry the projections the lineage merge left behind
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
The merge that brought the github lineage onto the forge one took the SOURCE of
several ops — agents' session count, affiliates' royaltyFailures among them —
without the artifacts derived from it, so the drift gate read the tree as a
surface that had changed and a document that had not.

Nothing here is hand-written: zipdoc_gen.go for seven apps, affiliates' subset
and the weave, all regenerated from the source already on main. A generated file
committed stale is the same defect as one never generated, and it fails the gate
in the same place for both.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 01:21:51 -07:00
hanzo-dev d9be5e356a commerce, integrations: two live routes join the published surface
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
/v1/billing/topup and /v1/integrations/slack/install were both registered on the
router and described nowhere, so the drift gate stopped: an operation that says
nothing about itself publishes an operationId and no sentence, which is an SDK
method that cannot explain itself and an MCP tool a model cannot pick. Neither
route was reachable from any generated client.

Both are raw by nature, so both state themselves with openapi.Describe rather
than a lifted doc comment: topup is charged and redirected, install answers a
302. The prose is written from the handlers — the gate each one gets, the tenant
scope, and the one rule a caller would otherwise get wrong: for topup that
paymentMethodId is NOT covered by the billing-subject pin and a card owned by
another subject answers 404 rather than 403, because 403 would be an ownership
oracle over other people's cards; for install that it is anonymous ON PURPOSE
and the org is resolved at the provider callback like every other install.

Regenerated: openapi.yaml 1751 -> 1753 paths, 2509 -> 2511 operations, and the
per-app subsets beside them.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 01:08:52 -07:00
zeekayandhanzo-dev 8e0f8b29dc vm's agent binding is typed, so the client stops guessing its wire
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
Visor typed its agent-binding routes (hanzoai/visor 575fc60), which drops the
casibase envelope with them: the answer IS the value, the status IS the outcome.
Nothing here could have kept working — an AgentBinding carries its own `status`
field, so the envelope reader takes "Pending" for an envelope status, decides
the upstream failed, and answers 502. A read of an unbound machine and every
unbind would have gone the same way, on a 204 with no body to decode.

So the client says which wire it is reading, per call site:

	cl.call  the casibase {status,msg,data} envelope at HTTP 200 (23 sites)
	cl.op    a typed op: the value, 204 for void, 404 for a miss (9 sites)

Not a fallback and neither sniffs — which wire an upstream op speaks is a
property of that op, and the two cannot be told apart by looking. They share
`do`, so there is still one request builder, one identity rule, one status map.
`call` shrinks to zero as visor finishes and goes with the last noun.

The addresses converge rather than translate. This package already published
/v1/machines/agents and /v1/machines/:id/agent to its own callers while spelling
vm's side three other ways; vm answers on ours now, so the translation is gone
instead of moved. The owning org travels once, as ?owner — vm derives the
binding's org from the same resolved principal, so the body field repeating it
is gone too.

An upstream 404 is a FACT about the machine (it runs no bot), not a fault:
notFound() recognises it so getAgent answers in its own words and messageBot
keeps its 400 — "no bound agent to message" tells a caller what to do, where a
relayed 404 says only that some lookup missed.

The fake vm in bots_http_test.go now speaks both wires, written to match what
visor's own controllers/agent_wire_test.go asserts against the real handlers —
a fake agreeing only with this client would prove the two agree with each other
and nothing about the service.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 00:52:57 -07:00
hanzo-dev 83818f7d17 GPU is metered like any resource; the bespoke prepay charge path is gone
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
/v1/billing/gpu/charge and /v1/billing/gpu/eligibility were a second,
product-specific way to move an org's money. They duplicated the general
metered path and nothing called them: no first-party caller exists for either
address, for gpuCharge/gpuEligibility, or for the commerce handlers behind them.

A machine is launched through /v1/machines, which fronts the compute provider's
resell endpoint. That path already owns both halves this one hardcoded: it
authorizes the first launch hour against the org's balance BEFORE provisioning
and refuses when the funds are not there, then records the usage keyed on the
machine id the provider minted, and an hourly sweep meters it from there. One
meter bills every resource, so a GPU needs no door of its own.

Deleted with it: proxyGPUCharge and the commerceProxy.post it was the only
caller of, pinSubjectBody, and the manifest prefixes, retired-name mappings and
published operations that named either address. The floor drops by the two
operations that left, so the reduction is reviewed next to its reason.

The ledger rule the endpoint was built to protect is untouched and lives where
it always did: a gpu-tagged withdrawal draws prepaid, never a credit grant,
because billing/bucket classifies it that way for every writer.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 00:46:24 -07:00
zeekay fd14cb10a5 make check, make describe, make openapi -- words a person types
Hanzo CI/CD / cicd (push) Failing after 22m50s
CI/CD / gate (push) Failing after 22m50s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
CI/CD / containment (push) Successful in 1m29s
The three fleet targets were surface-check, describe-apps and openapi-weave:
compound words, and worse, words from the implementation's vocabulary rather
than from what you get. "weave" and "subsets" describe how the thing is built.
"openapi" and "describe" describe the thing.

  surface-check  -> check      regenerate FROM SOURCE, fail on any diff
  describe-apps  -> describe   every app describes itself, and runs zipdoc first
  openapi-weave  -> openapi    compose every app's document into openapi.yaml

The root Makefile's own `describe` target is gone: it chained the generate, the
loop and the compose, which made two definitions of one word. mk/fleet.mk's
describe now absorbs the `go generate -run zipdoc ./...` step, so there is one.
`openapi` deliberately does NOT depend on describe -- it reads the committed
documents and stays a three-second command; `check` is the twelve-minute one
because regenerating is the whole of what makes it a gate.

55 references across 25 files, including hanzo.yml and .hanzo/workflows/cicd.yml,
so the gate CI runs is the gate a person runs by hand.

Also here: zip v1.27.0, which is where the toll's op.invoke seam and the
co-resident Here call come from. The working tree carried
`replace github.com/zap-proto/zip => /home/z/work/zap/zip` while that was
unpublished -- a path that exists on exactly one machine, and go.mod is not the
place to record which one.
2026-08-05 21:33:01 -07:00
hanzo-dev e2311f80b6 commerce's generated families state their own gate, because the seventeen do not share one
Hanzo CI/CD / cicd (push) Failing after 26m10s
CI/CD / gate (push) Failing after 26m25s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
CI/CD / containment (push) Successful in 2m27s
The 119 derived sentences were true about tenancy and silent about everything
else, and the seventeen families they cover are not uniform on the axis that
matters to a caller. Read off mount.go and commerce's api/resources:

  - transfer, wallet and webhook are ADMIN-gated (TokenRequired(permission.Admin)).
    A plain member's token is refused 403 there, on reads as much as writes.
  - collection, discount, product, variant, saleschannel and stocklocation sit
    behind paywall.Require: no active subscription, trial or redeemed invite is
    402 subscription_required, and an unreadable billing store fails closed 503.
  - collection, product, return, subscriber and variant additionally carry a
    DefaultPermissions table that CheckPermissions enforces per method (403).
    The other twelve have no table, and the miss logs and ALLOWS — so claiming a
    scope check on those would be prose the server does not honour.

One sentence repeated seventeen times said none of it, which is worse than the
bare operationId it replaced: it told a reader that a plain token may write a
wallet and that the only refusal on a product is 404. A description nothing
downstream can check is believed. So the table now carries the gate — the only
thing that actually varies — and the sentence is generated from it.

Three corrections to the shape prose while reading the handlers:

POST on the item address is the METHOD-OVERRIDE door, not "an address that
accepts every method". With no override it runs r.Patch; `_method` (form or
query) or X-HTTP-Method-Override set to PUT, PATCH or DELETE runs THAT one, so a
POST here can DELETE the record. An invalid override is ignored, not 405'd —
IsValidMethodOverride only admits those three and the method stays POST.

LIST returns the Pagination envelope {page, display, count, models, facets}, not
a bare array, and it fails closed to an EMPTY PAGE with 200 when no org
namespace resolves — so empty `models` does not mean "no records exist".

PUT decodes onto an empty record carrying only the existing key, which is why
omitted fields are reset; PATCH decodes over the loaded record. Both 404 on an
unknown id. DELETE writes a copy aside under an internal deleted key first, so
the row stops answering but is not erased.

Also: a family that outgrows the table comes OUT of it rather than gaining a
Describe as well — Describe panics on a duplicate key, so doing both aborts the
binary at init. The old comment said the explicit one "wins", which would have
sent the next reader into that panic.

Regenerated: openapi.yaml and plugin/commerce/openapi.json. 119 operations, none
bare; regenerating from source reproduces these bytes exactly.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 18:04:20 -07:00
hanzo-dev 537d70b578 commerce describes its generated resources, and one Artifact stops meaning two things
CI/CD / containment (push) Successful in 1m58s
Hanzo CI/CD / cicd (push) Failing after 22m32s
CI/CD / gate (push) Failing after 29m15s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
TWO THINGS STOOD BETWEEN THE FLEET DOCUMENT AND SOURCE, both revealed only once
licensing stopped failing first — surface-check stops at the first app that cannot
project, so everything behind it was invisible rather than passing.

commerce publishes 17 generated CRUD families over the same store, 7 operations
each: 119 sentences differing in one word. They are DERIVED now, for the reason
openapi.DescribeSPA derives the two SPA addresses — prose written 119 times drifts
119 ways, and the next family the module adds would arrive undescribed and stop the
gate again. Adding one is a line in `resources`. The vocabulary stays plain on
purpose: these are generic store resources, so each sentence states the shape (what
the address is, what the method does, what the id means) and stops. A resource that
earns its own prose keeps an explicit Describe above — Describe panics on a
duplicate, so the hand-written sentence and the loop cannot both claim an address.

The weave then refused for a different reason, and it was right to: schema
"Artifact" meant a RELEASE artifact in licensing (download_url, cosign_signature)
and a RESEARCH artifact in research (git_sha, run_id, retention_class). Schema names
are global in the woven document, so every generated SDK would have bound whichever
it read last — one name, two shapes, silently. research's is the one this repo owns,
so it becomes ResearchArtifact; licensing's stays, being the external module's.

Regenerated: openapi.yaml, the affected subsets and the floor.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 17:12:37 -07:00
hanzo-dev 65c401b656 content: generate is a typed op, and the money wire it stayed raw for is unchanged
POST /v1/content/generate published an operationId and nothing else — no request
schema, no response schema, no MCP tool input, no CLI flags, an untyped `any` in
every generated SDK — for the ONE agentic call in the content loop. It stayed a
raw handler on a stated belief: that a studio-render billing denial answers the
platform's nested {"error":{"code","message"}} at 402/503, and a typed op can
answer only its Out schema or zip's flat {status,code,error}.

That belief is out of date. cloud.Denied carries the money wire's own status and
body as an ERROR — the one refusal channel a typed op has — and cloud.DenyEnvelope
writes those bytes back verbatim; apps/projects, apps/dataset and apps/risk
already pair them. So the op is typed and the 402 body is byte-for-byte what it
was, which TestGenerate402IsTheMoneyWireBody asserts by shape: exactly one key,
`error`, nested {code,message}, code insufficient_balance. Off the HTTP path
(MCP, CLI) deniedErr.Unwrap keeps the status and the sentence.

Two details the conversion turns on. 402 is DECLARED so a generated client knows
the refusal is a shape it can read; it is never the status this op RETURNS, since
statusFor takes Statuses[0] when the Out states none, so a success is still 201
and the 402 reaches the wire from the envelope. And every GenerateInput field is
`url:"-"`, because zip's binder fills an In field from the QUERY as well as the
body: without it the route silently starts accepting ?doctype= and ?source_media=,
inputs the raw handler's c.Bind never took — and a source_media reachable from a
query string is an SSRF surface reachable from a link. A wire widening no status
code shows, pinned by TestGenerateStillReadsTheBodyAndOnlyTheBody.

The prose moves from the openapi.Describe init onto the handler, which is where
zipdoc lifts it from for a typed op, so the description is declared once rather
than beside the wire fact that no longer holds. The regenerated document is the
point of the change: /v1/content/generate now carries a GenerateInput requestBody
and GenerateResult on 201.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 16:39:51 -07:00
zeekayandhanzo-dev d702218872 an accelerator is a build of engine, not a product; and one word hid an app
Hanzo CI/CD / cicd (push) Failing after 50s
CI/CD / gate (push) Failing after 50s
CI/CD / containment (push) Successful in 1m1s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
engine-rocm was a SKU on the Enterprise plan, and nothing else. rocm is a cargo
feature in hanzo-cli/Cargo.toml beside cuda, metal, mkl and accelerate -- peers,
all built from one source tree -- so an AMD owner on Max was refused a binary an
NVIDIA owner on Max could download. That prices the plan by the customer's
hardware brand. Removed in plans v1.4.14, where the test that asserted the SKU
existed now asserts no plan sells an accelerator at all; licensing v0.1.10 says
the same thing where the distinction lives, on Entitlement.ProductID and
Release.Product. Nothing enforced it: no release carries Product "engine-rocm".

.gitignore line 62 was the bare word `tools`. A gitignore pattern with no slash
matches at ANY depth, so it covered apps/tools -- 25 tracked files, the /v1/tools
MCP catalogue -- and plugin/tools. Adding a NEW file to either was refused, and a
refusal to add is how work disappears with no diff to notice it. It sat directly
above a block that anchors every other root binary (/gateway /account /authz
/smoke /gen-app-cmds) and explains why. It is now /tools, in that block, proven
both ways: a new file under apps/tools is visible, a root ./tools binary is not.

Also here: iam v1.34.21 (13 more typed ops, 98 untyped -> 85), commerce's cart,
and the document regenerated from all of it.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 15:33:30 -07:00
hanzo-dev 24de3c8deb surface: project /v1/replay into the document it was added without
Hanzo CI/CD / cicd (push) Failing after 54s
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
The session-replay write door landed as routes and typed ops but the document
was not re-projected, so /v1/replay existed in the binary and in no client: not
in openapi.yaml, not in analytics' subset, and therefore in none of the eight
generated SDKs, the MCP tool list or the spec-derived CLI.

Regenerated from source, which is the same fix surface-check names. The floor
ratchet follows the routes that now exist.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 13:43:08 -07:00
hanzo-dev 0d602fc9dc surface: regenerate the document for zip v1.26.0's operation names
zip v1.26.0 gave an operation ONE naming rule, and the bump landed without
re-projecting the document it renames. 758 operationIds across openapi.yaml and
55 plugin subsets still carried the old spellings — post_collaborator_rpc_documentId
and v1.admin.affiliates.post_id_approve where the rule now says
post_collaborator_rpc_by_documentid and post_v1_admin_affiliates_by_id_approve.

Nothing about the surface moved: same paths, same operations, same prose. An
operationId is the METHOD NAME in eight generated SDKs, the tool name in the MCP
list and the command in the spec-derived CLI, so leaving it stale means every
client offers a name the document no longer agrees with.

This is what surface-check exists to refuse, and it is why the gate has been red
on main: `make describe`, then commit what it wrote.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 13:40:25 -07:00
hanzo-dev 989044e51d surface: the embedded SPAs describe themselves, from one declaration
The release gate is car 0 of the train, and it stops at any app that cannot
project its own document. /meet and /tracker are bound with All(), so each
publishes every method the generator knows — fourteen operations apiece with no
handler to lift a sentence from, because a static bundle has no typed op. meet
died there and tracker would have next.

tasks had already solved it, in forty lines this file would have needed three
copies of. So the declaration is asked for rather than restated:
openapi.DescribeSPA(prefix, name) derives both addresses, the working methods
and the leftovers from the one fact that differs, and tasks now calls it too.

The copy tasks carried had ALREADY drifted — it promised a missing asset
"answers 200 with HTML rather than 404" after spa.Handler had begun answering
404 under assets/, so the published document described behaviour the binary no
longer had. The shared sentences state spa.Handler's actual policy, including
the assets/ exception and the 503 an unsynced bundle answers with.

Regenerated: openapi.yaml, plugin/{meet,tracker,tasks}/openapi.json, and the
floor ratchet (1700→1705 paths, 2354→2383 operations).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 13:40:25 -07:00
hanzo-dev 969d755ce6 commerce: the agent's payment tool runs the credit screen the browser's URL does
The screen was ROUTER middleware. zip records a typed op ONCE and four projections read
that one record — the REST route, the MCP tool, the by-name call plane and the CLI — and
all four dispatch to the op's HANDLER (registeredOp.invoke). Router middleware is composed
around the fiber handler the REST route is served through and around nothing else. So
POST /v1/payments was screened and `takePayment`, which is published in tools/list, was
not: an agent calling the tool reached commerce's one card money move with no decision in
front of it, and the settlement it produced taught the model nothing. The control was
present on the browser's door and absent on the agent's, and every HTTP test stayed green
because HTTP is the one plane it worked on.

The screen is composed onto the HANDLER of both doors now — screen.op for the typed op,
screen.route for the raw top-up — so it sits inside the value every projection invokes.
Only the composition point moved. The decision, the payer rule and the settlement key are
the landed ones, resolved once per payment into one `payment` value that the screen judges
and the record teaches, so the two halves cannot name different subjects.

Composing on the handler also stops the screen reading the wire, which is what lets it
work off the REST path at all. The amount is the DECODED input's, because over MCP the
request body is a JSON-RPC envelope with the payment inside `arguments` — a body reader
states no value on exactly the door an agent calls, leaving the sharpest axis a credit
door has blind there while it reads fine on the browser's. The settlement is the RETURNED
receipt, because over MCP the op's answer is wrapped in a tools/call result and no HTTP
response exists when the handler returns. The payer, the address and the jurisdiction
still come off the request, which the app-wide Bridge parks for /mcp and for the op plane
exactly as it does for a REST route, so all three resolve on every plane with a connection
behind it; a call with no request at all resolves no payer, is screened as that state, and
is refused by the handler's own gate rather than exempted from the screen.

The structural check reads WHERE THE VALUE WENT rather than which spellings appear. Every
credit door's handler expression must be built from an identifier that holds the screen,
and nothing anywhere may compose the screen as router middleware. The check it replaces
was satisfied by the exact registration that carried this bug.

Shadow is unchanged. No organisation is armed and defaultRegime is untouched; the screen
reaching two more planes widens the record, not the enforcement.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 13:30:13 -07:00
zeekayandhanzo-dev 56ffd25b69 commerce: the fleet served the last three steps of a sale and not the first
/v1/store/{storeid}/authorize, /capture and /charge have been served by this
binary for as long as it has existed. The cart they operate on had no address at
all: hanzoai/commerce implements the whole noun — models/cart, api/cart, the
route table — and cloud never mounted it, so the documented flow began at step
two. The docs taught /v1/cart anyway, and it resolved to ai's bare /v1
remainder, whose prepaid balance gate would have made filling a basket require
the balance the basket exists to create.

Four typed ops close it, on the payments.go pattern rather than a passthrough:
openCart, getCart, setCartItem, discardCart. The RULES are not restated — the
module's own cart.Cart.SetItem still resolves a product or a variant into a line,
updates a quantity in place and drops the line at zero. What is added is the part
that was actually missing: an address, a declared input, a declared answer, and
prose, so the operation reaches the OpenAPI document, the MCP tool list, the SDKs
and the CLI instead of being a route and nothing else.

One way to change a line. There is no DELETE beside the set: quantity zero
removes, because that is what SetItem does and a second spelling of one act is a
second set of edge cases. Quantity is the RESULT and not a delta, so a retry is
safe and a double-submit cannot double an order.

Identity is not an input. The org comes from the validated principal cloud.Bridge
parks on the context, never from a field, so a cart is created, found and amended
only inside the caller's own namespaced store. Another tenant's id answers 404 on
both the read and the write — never 403, which would confirm it exists.

Exercised against a running binary behind a real JWKS issuer, not a fake: open
201, read 200, set refused 400 on each of its four bad shapes, discard 200 and
idempotent, unknown id 404, anonymous 403, and a second org refused acme's cart
id on both verbs.

The wider admin surface — products, orders, customers, promotions, inventory,
tax, regions, fulfillment — is implemented in the module and still unrouted here.
This adds the cart and claims nothing else.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 13:29:22 -07:00
hanzo-dev 02f2ef9cb1 commerce: the agent's payment tool runs the credit screen the browser's URL does
CI/CD / containment (push) Successful in 37s
Hanzo CI/CD / cicd (push) Failing after 58s
CI/CD / gate (push) Failing after 58s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
The screen was ROUTER middleware. zip records a typed op ONCE and four projections read
that one record — the REST route, the MCP tool, the by-name call plane and the CLI — and
all four dispatch to the op's HANDLER (registeredOp.invoke). Router middleware is composed
around the fiber handler the REST route is served through and around nothing else. So
POST /v1/payments was screened and `takePayment`, which is published in tools/list, was
not: an agent calling the tool reached commerce's one card money move with no decision in
front of it, and the settlement it produced taught the model nothing. The control was
present on the browser's door and absent on the agent's, and every HTTP test stayed green
because HTTP is the one plane it worked on.

The screen is composed onto the HANDLER of both doors now — screen.op for the typed op,
screen.route for the raw top-up — so it sits inside the value every projection invokes.
Only the composition point moved. The decision, the payer rule and the settlement key are
the landed ones, resolved once per payment into one `payment` value that the screen judges
and the record teaches, so the two halves cannot name different subjects.

Composing on the handler also stops the screen reading the wire, which is what lets it
work off the REST path at all. The amount is the DECODED input's, because over MCP the
request body is a JSON-RPC envelope with the payment inside `arguments` — a body reader
states no value on exactly the door an agent calls, leaving the sharpest axis a credit
door has blind there while it reads fine on the browser's. The settlement is the RETURNED
receipt, because over MCP the op's answer is wrapped in a tools/call result and no HTTP
response exists when the handler returns. The payer, the address and the jurisdiction
still come off the request, which the app-wide Bridge parks for /mcp and for the op plane
exactly as it does for a REST route, so all three resolve on every plane with a connection
behind it; a call with no request at all resolves no payer, is screened as that state, and
is refused by the handler's own gate rather than exempted from the screen.

The structural check reads WHERE THE VALUE WENT rather than which spellings appear. Every
credit door's handler expression must be built from an identifier that holds the screen,
and nothing anywhere may compose the screen as router middleware. The check it replaces
was satisfied by the exact registration that carried this bug.

Shadow is unchanged. No organisation is armed and defaultRegime is untouched; the screen
reaching two more planes widens the record, not the enforcement.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 13:26:13 -07:00
hanzo-dev 512693979b commerce: the agent's payment door is screened by the credit gate the browser's is
Hanzo CI/CD / cicd (push) Failing after 2m0s
CI/CD / gate (push) Failing after 2m1s
CI/CD / containment (push) Successful in 2m21s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
commerce holds ONE card money move and this binary opens two addresses onto it.
POST /v1/billing/topup/token was screened; POST /v1/payments ran the same
billing.TakePayment — the same bounds, the same idempotency guard, the same
mintauth-authorized deposit — with nothing in front of it. So the risk gate was
not a bound on the mint, it was a bound on one entrance, and the other entrance
is the one published as an MCP tool for an agent to call.

Both doors now hold the SAME screen, resolved once at the composition root and
handed to each registration. The gate becomes a zip.Middleware because a typed op
has no handler chain to sit in — zip composes middleware around it at
registration — so one gate reaches both forms; screenChain hands the raw route
its chain as `next`. The decision, the payer rule and the settlement key are the
landed ones, not copies.

Because both doors resolve the payer through payerOrg + principal.Subject and key
their settlement on the gateway's own payment id, a burst split across the two
accrues on ONE subject and one payment reached through both converges to one
observation. settlementOf learns the typed door's spelling of the ledger receipt
(`id`, where the browser answers `transactionId`) so a settlement whose processor
states no reference is still keyable there, and the settlement check reads the
whole 2xx band because the typed op declares 201.

The read op is not screened: it mints nothing, and a scorer that is present and
mute must not be able to withhold a customer's own receipt.

Shadow is unchanged. No organisation is armed and defaultRegime is untouched;
widening the gate widens the record, not the enforcement.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 10:01:14 -07:00
antje 7ad7ec9ca7 projections: the fleet document follows the typed conversions, under fleet-unique names
Hanzo CI/CD / cicd (push) Successful in 13s
CI/CD / gate (push) Successful in 13s
CI/CD / containment (push) Successful in 36s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
The weave refuses one schema name with two shapes, and regenerating the
subsets for the converted surfaces surfaced exactly that: billing's series
bucket collided with admin's usagePoint; affiliates' directory tally, share
link row, payout family, sweep report and analytics summary collided with
referrals, link, authors, treasury and platform; and link's typed inputs
collided with agents, usage and visor. Each colliding type takes a name the
fleet does not already speak — sample, totals, tally, codeView, remittance,
settlement, accruals, enrollReq, readingReq, readingView, ingestReq,
ingestResp, boardResp — and no byte moves: a Go type's name never appears
in its JSON.

The subsets regenerate from the live routers (the commerce dedupe, the
declared Cache-Control response headers, notify's raw template object, the
renamed schemas) and openapi.yaml is rewoven from them — the document the
SDK repos pull.
2026-08-05 08:55:31 -07:00
antjeandhanzo-dev 2ea6f6ca84 platform: 32 raw routes become typed ops, and the prose moves onto the handlers
Hanzo CI/CD / cicd (push) Successful in 12s
CI/CD / gate (push) Successful in 12s
CI/CD / containment (push) Successful in 1m0s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
zip projects every TYPED op — zip.Get[In, Out] and friends — into REST, OpenAPI,
MCP tools, the CLI and eight generated SDKs from ONE registry entry. A raw
app.Get(path, func(*zip.Ctx) error) serves the same bytes and reaches none of
them: it publishes an operationId and nothing else, so the SDK method cannot
explain itself and the CLI command has no help text. Every route on /v1/platform
was raw. All 32 are now ops, and NONE stays raw — not one of them streams,
serves bytes, redirects a browser, or speaks a third party's protocol, so not one
of them had a reason to.

The wire is unchanged, and that is the whole claim: same paths, same JSON field
names, same status codes, same refusal sentences. converted_test.go is the proof
rather than the assertion — it walks every converted route asserting status and
the exact set of JSON keys a client's decoder binds, and the SAME FILE passes
against the pre-conversion tree. The expectations were read off the old
c.JSON(status, …) calls, so it is a regression test and not a mirror.

WHAT THE CONTRACT GAINED. The subset went from 9 published schemas to 43: every
route's real input and output is now in the document instead of an unnamed body
a generator had to guess at. Three statuses the routes have always sent became
sayable — 201 on create, 202 on deploy/preview/promote/rollback/run/runner, 204
on the two deletes — where zip previously had no vocabulary for them and the
document said 200 about a route that has never sent one. Two routes answer with
their own status because the ANSWER states it: /v1/platform/health is 200 or 503
carrying the real reason as a typed body, and add-domain is 201 for a claim it
created or 200 for an idempotent re-add. Both declare their set, so a generated
client expects what the service sends.

THE PROSE MOVED, IT DID NOT CHANGE. ~340 lines of openapi.Register/Describe —
a SECOND source that had to be edited in lockstep with the router and, being
second, could not be — are gone. The same sentences are now doc comments on the
handlers they describe, lifted by zipdoc into zipdoc_gen.go, which is the only
road prose travels to the document, the tool list, the SDKs and the CLI.

FOUR NAMES YIELDED, because the fleet's schema namespace is FLAT and
single-valued: one name, one shape, wherever two apps meet, or every generated
SDK binds whichever it read last. buildView and pipelineView are published by
apps/agents and apps/world, healthView by apps/compliance, and Drift by
apps/plugins with an entirely different shape. Platform's were not published, so
platform's yield — the rule apps/templates followed when its Template became a
StarterKit — each to a word its own doc comment already used: the console
projections are rows on a board, and Drift is the verdict it says it is. The
JSON field is still `drift`; only the Go type is renamed. fqdn.Record collides
with apps/projects and was left alone: it is the same type, so the shapes are
identical and the weave accepts it.

THE GATE MOVED, IT DID NOT CHANGE. cloud.Guard wraps a zip.Handler, and a typed
op has none — it receives a context and its decoded In. So the fleet board's
cloud.Admin/cloud.Super gate is now the first line inside each op (board.admit),
applying the same Scope.Admits over the same AuthorityOf. gate.go gains ONE
exported method for it: Scope.Refusal, the 403 Guard itself now returns, so the
two forms of the one gate cannot answer differently and the refusal sentence
stays derived from the scope rather than written at a call site. It is a METHOD
and not a function taking a Scope because Refuse already names the 402 every
spend gate renders — two refusals that mean different things do not share a name.

An In field is never a tenant key: the org comes from the validated principal, so
cross-tenant identifiers remain structurally not inputs. The three cloud.Request
call sites are declared in allowedRequestUses with their reason — this plane
SPENDS the caller's identity (the run fee, the audit actor) rather than only
reading it, and /v1/runner authorizes on a shared credential no org can express.

No middleware is installed here. cloud.Bridge belongs to whoever composes the
app, and the tests compose their own.

Tests: the full apps/platform suite is green on Linux, where the SQLCipher codec
finds the RAM-backed scratch it fails closed without; the same suite cannot run
on darwin without a tmpfs, before this change or after.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 08:20:47 -07:00
hanzo-dev 62e19ca722 zip v1.25.2 — regenerating iam gives its 95 types their names back
CI/CD / image (push) Successful in 19m39s
CI/CD / gate (push) Successful in 1m19s
Hanzo CI/CD / cicd (push) Successful in 1m18s
CI/CD / containment (push) Successful in 1m22s
CI/CD / rollout (push) Failing after 6s
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Failing after 2s
`make describe` could not be run to completion and have its output committed,
which is the condition every stale artifact on main has grown behind. Not
because of the apps that refused it — those are fixed — but because the run
itself was not faithful: regenerating plugin/iam under v1.25.1 stripped the
`iam.` qualifier off all 95 of its schemas, and the bare `Role` that fell out
collided head-on with framework's. The weave refuses that, correctly, so the
one command told to repair the document produced a document that could not be
woven.

THE COMMITTED SUBSET WAS THE CORRECT SIDE, which is the opposite of the usual
direction and is the whole reason this is a dependency bump and not a
regenerate. `iam.Role` is the right name; bare `Role` was the defect.

zip qualifies a composed child's types by the child so that two children may
both honestly call a type Role, and it read that name off the ENCLOSING
definition — which for a route declared inside a group is a group, and
App.groupConfig strips a group's name on purpose, a group being a prefix and a
prefix not being an author. hanzoai/iam declares its whole surface in groups.

o11y is the control and it is what made this legible: it composes the same way,
registers on its child directly rather than through a group, and kept all 777 of
its o11y.* names through the very same regeneration that stripped iam's 95. Same
run, same binary, same toolchain, opposite answers — so the cause was in the
composition and never in the machine.

Fixed upstream in v1.25.2 (zap-proto/zip: "a group has no name, so it must not
answer for who wrote the op"), which carries the nearest NAMED ancestor down the
walk. A root's own groups are untouched, so a service that composes nothing
still names its own types exactly as it did.

Regenerated with the bump, and the delta is the proof: across all 119 subsets
the ONLY file that moves is plugin/iam, by 11 descriptions — no path, no
operationId and no schema name anywhere in the fleet. Those descriptions are
iam v1.34.5 → v1.34.20 catching up, and two of them are load-bearing: oauth/logout
now says it ACTUALLY ends the session (it spent a release answering
{"status":"ok"} without revoking anything), and authorize now documents
prompt=none|login|select_account.

  woven 1700 paths / 2083 schemas / 180 tags from 119 apps — byte-identical
  /v1/commands: 2328 commands over 2354 operations

go.sum also drops the entries for three superseded versions that a previous bump
left behind; `go mod tidy` is the only thing that touched it.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 05:31:57 -07:00
hanzo-dev 1a9bfd840b risk: taking a model live is an act for an admin of that organisation
Arming and tuning arrive at one address in one body, and only one of them is
self-service. PUT /v1/risk/policy was gated by the billing check and the
tenant guard, neither of which asks about authority, so any member of an
organisation could state {"live":true} and take its model out of shadow —
the decision that lets the model freeze that organisation's customers'
payments, taken by whoever held a token.

Setting `live` now requires cloud.Admin: an admin OF THAT ORG, with
SuperAdmin as the stated superset rather than the requirement, because the
organisation is arming ITSELF and requiring platform sudo would make every
customer's governance decision ours to take. Stating the appetite and the
sample stays self-service.

The check reads the platform's one predicate set and lives beside ops.gate
and caller, which is where this package is allowed to reach for the raw
request. It runs after the principal resolves and before anything is
written, so an unauthorised caller learns nothing about which appetites the
op accepts — the ordering ops.adopt already makes.

Disarming is deliberately left self-service: returning a model to shadow
cannot freeze a payment.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 03:39:34 -07:00
hanzo-dev ee5f8ade2b risk: taking a model live is an act for an admin of that organisation
Arming and tuning arrive at one address in one body, and only one of them is
self-service. PUT /v1/risk/policy was gated by the billing check and the
tenant guard, neither of which asks about authority, so any member of an
organisation could state {"live":true} and take its model out of shadow —
the decision that lets the model freeze that organisation's customers'
payments, taken by whoever held a token.

Setting `live` now requires cloud.Admin: an admin OF THAT ORG, with
SuperAdmin as the stated superset rather than the requirement, because the
organisation is arming ITSELF and requiring platform sudo would make every
customer's governance decision ours to take. Stating the appetite and the
sample stays self-service.

The check reads the platform's one predicate set and lives beside ops.gate
and caller, which is where this package is allowed to reach for the raw
request. It runs after the principal resolves and before anything is
written, so an unauthorised caller learns nothing about which appetites the
op accepts — the ordering ops.adopt already makes.

Disarming is deliberately left self-service: returning a model to shadow
cannot freeze a payment.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 03:35:16 -07:00
zeekayandhanzo-dev 21652f34be a rail that can be switched off says so
CI/CD / image (push) Successful in 18m11s
CI/CD / gate (push) Successful in 19s
CI/CD / containment (push) Successful in 1m20s
Hanzo CI/CD / cicd (push) Successful in 19s
CI/CD / rollout (push) Failing after 6s
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Failing after 2s
commerce v1.50.7 added PUT /_/commerce/providers/{name} — the verb behind "a payment
rail can be turned on and off" — and the dep bump landed without prose for it, so
`commerce describe` refused and the app could not project its own document. The
subset is fail-closed, so nothing was written and the drift stayed invisible until
the next regeneration asked.

It gets the sentence, and the same gate its GET twin states: the tenant comes from
the IAM owner claim and nowhere else, so a cross-tenant write is not expressible;
404 for an unknown provider is byte-identical to the cross-tenant probe's answer. The
one thing worth saying that the shape does not: this owns a single bit and never the
credential, which is why a rail can come back with the same stored secret.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 01:45:40 -07:00
zeekayandhanzo-dev d2cfd1e54c a subset is generated, or it is wrong
The weave was red on main and nothing downstream could move: openapi/weave_test.go
is the SOLE writer of openapi.yaml, so the served document, the CLI, every SDK, MCP
and the docs were all frozen behind

  operationId "get_v1_billing_portal_methods" is claimed by both
  "GET /v1/billing/methods" and "GET /v1/billing/portal/methods"

The derivation was never at fault. operationID(method, path) walks every segment and
yields two distinct ids for those two paths, and it has verified uniqueness inside
From since 2026-07-27. The subset that claimed otherwise was written on 2026-08-04 —
so no run of `commerce describe` at any version could have emitted it. It was
hand-edited: d7024e88 moved the three saved-card verbs to this app and wrote the two
new paths into plugin/commerce/openapi.json by copying the /v1/billing/portal/methods
block, operationId and prose together. The copied description then described itself
("the SERVICE-TOKEN face of the same list a customer reads at /v1/billing/methods",
published AT /v1/billing/methods).

It was hand-edited because it could not be generated: `commerce describe` refused,
and still refused here, with eight operations saying nothing about themselves. So the
fix is the prose, and the file follows from it.

  - Eight operations get the sentence they owe a caller: the customer saved-card
    family (GET/POST/DELETE /v1/billing/methods), its portal POST, and the top-up
    rails nobody had described at all — wire instructions, crypto options, the
    deposit mint and the deposit poll. Five of them were not in the published
    document in any form.
  - The two portal twins stop claiming a proxy that no longer exists. Both families
    are served in this process; they are two addresses because they admit two
    principals, not because either forwards to the other (apps/billing/billing.go
    says the same at the spot the hop used to sit).
  - mount.go said the opposite of the manifest — that /v1/billing/methods belongs to
    billing and "a registration here is unreachable in the fleet", beside a live
    registration of it. manifest.Apps gives commerce both prefixes and withholds them
    from billing. Left alone, that comment invites deleting a route that works.
  - /v1/commerce/{deposits,deposits/:id/confirm,deposits/:id/status,webhooks/:provider}
    lose their prose. The broker-dealer proxy behind them was deleted and the manifest
    stopped naming them; what was left was prose for routes nothing serves, which
    renders nowhere and reads in source as though it were live. openapi/floor.json
    drops commerce 8 → 4 in the same commit, which is where the ratchet asks for the
    reason to be.

AND THE ASSUMPTION THAT LET IT LAND IS NOW A CHECK. Weave did refuse the document —
but a collision inside ONE part reaches it with no app attached, so it could name the
two addresses and nothing else, and which of 123 subsets shipped them was a search.
openapi.Subsets is holding the app's name when it decodes the bytes, so it asks there
whether the part is injective — the same uniqueOperationIDs, one statement of the
rule, asked once per part and once over the composition, because neither fact implies
the other. TestSubsetsRefusesAnAppWhoseOwnIDsCollide fails without it.

Regenerating also lands 13 subsets that had gone stale behind the red gate, and drops
zipdoc entries that were lifting commercemid.RequestContext's doc comment — a
paragraph about mint-gated context locals — as eight routes' descriptions.

plugin/iam is deliberately NOT regenerated: its current projection introduces a
schema "Role" that means something different from framework's ({role, user}), which
Weave refuses and which needs a rename in one of those two apps. That is a separate
defect this one merely uncovered.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 01:42:27 -07:00
hanzo-devandzeekay a284ccf282 scope, plugins: the three composition failures become tests, and two more are named
Three defects shipped today because the only thing that could see them was a
running binary and nothing ran one. Each is now the smallest program that
reproduces it.

scope_group_test.go — the group, which broke twice in one day, in opposite
directions:

  - TestGroupWithLaterUseComposes. `g := app.Group(p); g.Use(mw)` while the
    routes register through ZipApp on the ROOT. Group returning the raw router
    hangs middleware on a node whose subtree is necessarily empty; zip refuses
    that at boot, which is what crash-looped fifteen plugins. Asserted with
    zip.App.Build — Listen minus the sockets, verdict returned rather than
    thrown. RED against the raw-router Group with the exact production text.

  - TestGroupPrefixesWhatRegistersThroughIt. The opposite kind: a program that
    composes perfectly and answers somewhere else. A child Group must prefix
    what registers through it down BOTH paths — the route methods and OpScope,
    because `zip.Get(app.Group("/v1"), "/bots", h)` is a real idiom here — and
    must leave an absolute path at the subsystem root alone. Asserted on the
    composed route table, since no compose check can see a route that merely
    MOVED. RED separately against each half of the fix.

  - TestGroupUseOutsideThePrefixesFailsTheMount / ...IsAllowed. Confinement
    through the door Group opened, and its limit: middleware at a prefix the
    subsystem does not own installs nothing and fails the mount, while a BARE
    group there is ordinary — a prefix is just a path.

plugin_surface_test.go — the two drifts, DERIVED from the specs the mains
declare (go/ast) and the routes the committed projections hold, so a subsystem
added tomorrow is checked tomorrow:

  - TestHealthOwnershipMatchesWhatIsRegistered. OwnsHealth is a claim with two
    halves. Claimed falsely, one address is declared twice and zip refuses the
    program — reverting plugin/authz/main.go reproduces the crash verbatim:
    `GET /v1/authz/health: declared by "authz" at serve.go:324 and by "authz" at
    serve/mount.go:31`. Claimed while owning nothing, the address silently 404s.
    The second half asks the manifest which health address is this app's rather
    than assuming /v1/<name>/health — plan answers /v1/plans, storage /v1/s3.

  - TestDeclaredPrefixesCoverTheSurface. A grant a main WRITES must cover the
    surface it serves. One-directional on purpose: containment, never equality,
    or deploy's 14-leaf row makes its own /v1/deploy bridge an escape. It checks
    the half a document can answer; middleware has no address, so the other half
    is `make compose`, which runs the binaries.

plugin/bot/main.go — bot declared OwnsHealth: true and registers no health
route anywhere, so the field's only effect was to suppress serve.go's generic
route and leave GET /v1/bot/health answering 404. Measured before: 404. After:
{"service":"bot","status":"ok"}. Nothing changes in the fleet, where /v1/bot/health
routes to `runtime`; this is the standalone binary's own liveness answer.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 20:33:39 -07:00
hanzo-devandzeekay 7d12120120 explorer: chain indexing is named for what it is, freeing "graph" for the graph layer
apps/graph was never a graph database. Its package doc says so: "chain data:
your block indexers and how far each has caught up, plus the on-chain price
feeds." It proxies luxfi/indexer (explorer REST) and luxfi/graph (GraphQL) --
"graph" here meant GraphQL, not a property graph.

Both upstreams already mount under /v1/explorer (client.go: graphd default
prefix), so the name follows the contract the app already speaks rather than
inventing one.

Wire unchanged: the app keeps /v1/indexers and /v1/oracles, keeps its frozen
mount position, and the woven document moves only x-app and the tag prose.
References to luxfi/graph -- GRAPH_URL, graphQLPath, the upstream log key --
stay, because those name the upstream, not this app.

/v1/graph is now free for the embedded per-org graph layer.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 20:17:44 -07:00
zeekayandhanzo-dev a8b952f473 bot: the control plane and the door to its executor are one product, not two
CI/CD / containment (push) Failing after 10s
Hanzo CI/CD / cicd (push) Successful in 12s
CI/CD / gate (push) Successful in 13s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
apps/runtime was never a second product. It was the transport to @hanzo/bot —
the TS service that executes a run — plus a relay of that service's own ops
paths at /v1/bot/*. What separated it from apps/bots was a LANGUAGE boundary,
Go surface and TS executor, wearing the shape of a product boundary. Both
answer for the same thing: a bot doing your work on a real desktop.

So they merge. runtime.go becomes transport.go, ops.go becomes relay.go,
bots.Mount mounts both faces, and the manifest holds one row for the pair.

THE WIRE DOES NOT MOVE. Every path this fleet serves is the path it served
before — the whole diff to openapi.yaml is `x-app: runtime` -> `x-app: bots`
on the seven relayed operations. No CLI, SDK, MCP tool or doc regenerates to
a different address, because none of them has a different address to go to.

Two things the merge had to earn rather than assume:

  - apps/coding also dispatches to that executor, so it followed the transport
    from apps/runtime to apps/bots. That is not new coupling wearing a new
    name: coding runs its tasks ON the bot runtime, which is what the import
    now says out loud.
  - the typed-or-named gate was TWO gates, one per old package, each blind to
    half of what is now one surface. They are one gate over the whole product,
    and mountWith mounts the relay so the gate can actually see it. Two gates
    stapled together would have kept passing while measuring nothing.

/v1/bot is still shared with apps/bot, whose three deeper prefixes win on it by
specificity. That sharing is the remaining defect and it is not this commit's
to fix: apps/bot's product is connected machines, not a bot, and it is the one
that has to vacate.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 20:12:26 -07:00
zeekayandhanzo-dev a6b174e4e4 one Mount signature, checked by the compiler; Router stops restating zip's
Two names in this file each meant two things, and both cost the fleet something
real.

1. Plugin had TWO mount fields with TWO signatures

    Mount MountFunc                    // func(Router, Deps) error
    App   func(*zip.App, Deps) error   // "for a subsystem that gates everything"

App braided a POLICY question (may this subsystem install middleware over the
whole binary) into a TYPE question (what shape is its entry point). Because the
grant carried its own signature, a subsystem could take the grant just to get
the concrete *zip.App — and three did. agent, ai and commerce each held app-wide
middleware authority they never asked for, purely because their Mount named a
concrete type. hanzoai/agent calls Use nowhere; agent's grant bought it nothing
and risked everything after it in the mount order.

Nothing reported this. The field's own doc claimed "apps.TestWireFrozen fails on
a new one"; no such test exists anywhere in this repo — grep it. A contract
stated in a comment and violated five times means nothing is checking it.

Unbraided: App becomes `Global bool`, and every subsystem — scoped or global —
goes through spec.Mount. The grant now decides only WHICH Router arrives (the
bare app, or a scope bound to declared prefixes). One shape, so MountFunc is the
whole enforcement and it is the compiler: at 123 composition roots a divergent
Mount cannot be assigned and cannot link. It found a sixth violator I had missed
by grep on the first build — cloud.MountMetrics, whose doc says "adapts
hanzoai/metrics into a MountFunc" while its signature took *zip.App. It is one
now, and mounts SCOPED (hanzoai/metrics installs no middleware either).

The concrete app stays reachable through ZipApp, the named hole that already
existed for exactly this and reports nil rather than pretending.

2. Router restated zip.Router instead of embedding it

Ten method lines were COPIED here. Copying an interface makes cloud a second
place zip's routing surface is defined, and the two agree only while someone
keeps them agreeing. When zip v1.23 widened one signature (Use took Component,
not Handler), every implementor that had spelled the methods out had to move in
lockstep — which is what stalled v1.19+ adoption across the fleet.

Embedded, a zip routing change costs this file zero edits, and Fiber() +
Plugins() are visibly what cloud ADDS rather than being buried among ten lines
cloud merely echoes. It carries zip.OpTarget along, which is a gain, not a
widening: scope, *zip.App and commerce's mintRouter all already have OpScope,
and a Router that IS an OpTarget is one zip.Get[In, Out] accepts directly.

Regression set EMPTY, compared BY NAME against a pristine worktree at the same
commit: 148 failing tests across 29 packages before, the identical 148 after
(comm -13 on the sorted name sets is empty in both directions).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 19:54:38 -07:00
zeekayandhanzo-dev d7024e88cc saved cards are served in-process, not proxied to nowhere
Hanzo CI/CD / cicd (push) Successful in 11s
CI/CD / containment (push) Failing after 14s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
CI/CD / gate (push) Successful in 11s
A signed-in customer got 401 listing their own cards, and the checkout's
prefill failed on every load: cloud's billing app forwarded /v1/billing/methods
to commerce over HTTP, and that proxy is unconfigured here
(CLOUD_COMMERCE_HTTP_URL is unset), so the customer address for saved cards has
never worked on this deployment.

An internal HTTP hop to a service compiled into the same binary is the wrong
shape whatever its config, so the three verbs move to the commerce app and are
served in-process on the same pinned-subject chain as their portal twins — the
gate that keeps a caller inside its own account whatever it sends. The prefix
moves with them, because the router must deliver where the handler lives.

Also drops /v1/commerce/deposits and /v1/commerce/webhooks from the manifest
and the published document: the broker-dealer proxy behind them is deleted, and
a manifest that claims an address nothing serves is how a path silently routes
to the wrong app.

The routing oracle is what found all of it — both halves, in both directions.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 19:38:27 -07:00
hanzo-dev 28ae459464 Merge remote-tracking branch 'origin/main' into fix/product-key-on-the-op
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 16:21:18 -07:00
hanzo-dev 01f848aa46 product: the bearer is the op's input, not a subtree's middleware
/v1/search and /v1/vector belong to provisioning; product owns exactly
four routes inside them. Hanging requireKey on the two parent groups
claimed both subtrees, so the confinement gate refused the boot — and it
was right to: in the unified binary that middleware would have gated
provisioning's routes with product's key.

The credential is a REQUEST fact, so each op now declares it: keyedIn
carries the Authorization header as a typed input field (zip's stated
replacement for exactly this middleware) and requireKey opens every
handler. Same statuses in the same order — unset key 503s, wrong key
401s, search and vector keys never cross — and the four addresses are
byte-identical; the document gains only the header parameter each op
always required but never published.

Verified: apps/product suite green, bin/product boots (was the one
compose failure in 120), openapi weave green with no path added, moved
or removed.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 16:21:02 -07:00
antje 8ea5a0abab zip v1.24.4: the framework reports, so the app stops repeating it
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
Every program now emits its request log natively — method, path, status,
duration, trace and span, the caller when the environment parked one — through
its own logger, so the per-app Logger install is deleted along with the three
test copies of it. The framework also propagates trace context and serves
/metrics from the one registry.

Kept deliberately: the OTel meter pipeline (metrics_http.go, installMeter) and
the span path. The datastore pipeline they feed is what /v1/summary and
availability READ, and the framework's export has not yet been proven to land
where those readers look. Two instruments briefly is safe; a blinded status
page is not. The compiler found the callers a text search missed — the span
middleware records the RED metrics — which is exactly why they stay until the
export path is measured.

Also carries luxfi/metric v1.9.1 transitively pinned by zip — the registry
that records by default.
2026-08-04 16:07:15 -07:00
hanzo-dev 6c412d9bda risk: model family is a value, so a second family cannot wear the first's parameters
CI/CD / image (push) Failing after 28m27s
CI/CD / gate (push) Successful in 12s
CI/CD / containment (push) Successful in 1m27s
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
Hanzo CI/CD / cicd (push) Successful in 11s
geometry is a closed sum and family derives from the geometry's own type, so
"half-space parameters attached to a transformer" does not compile and this
package carries no check for it. family leads the content address, domain
separated, because every term after it is one family's arithmetic — two
families' numerically identical masses can no longer be named as one value.

The detector seam is the six methods the learner already calls. The half-space
counters are the first implementation and behave as before. legacy() is the one
remaining braid, at the disk boundary, and refuses a family the resume column
cannot carry rather than recording a shape for a model that is not one.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 15:26:05 -07:00