main
274
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
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> |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
117f4fed8b |
Merge remote-tracking branch 'forge/main' into feat/sandbox-executor
# Conflicts: # openapi/floor.json |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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 |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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 |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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: |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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. |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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. |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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:
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
28ae459464 |
Merge remote-tracking branch 'origin/main' into fix/product-key-on-the-op
Co-authored-by: Hanzo Dev <dev@hanzo.ai> |
||
|
|
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> |
||
|
|
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. |
||
|
|
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> |