main
11
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
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>
|
||
|
|
3b50091c77 |
mcp: the catalogue is a query — delete the 116 committed tool files
The fleet's agent door answered from plugin/<app>/mcp.json: the tool array each
app's binary projected when it was BUILT, embedded by plugin/embed.go and handed
to zip as Plugin.Tools. 116 files, 49,865 lines, and a second source for a fact
every child already knows.
A second source can only be stale or accidentally correct. This one was stale in
the way no gate in this repository could see: o11y's 353 missing ops live in
github.com/hanzoai/o11y, so a go.mod bump in ANOTHER repo invalidated an artifact
in this one with nothing in the diff to say so. Regenerating it more often is not
the fix — a generator on a hook is still two sources with a race between them,
and the trigger is in a different repository. (
|
||
|
|
f403c603f1 |
cloud: the composition root's verb is Listen, because that is what it does
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
Serve and Listen were two names for one act. zip's App already calls it Listen — `app.Listen(zapAddr, httpAddr)` — and this function's whole job is to build that app and hand it its addresses, so calling it Serve made the entry point disagree with the thing it enters. One verb, all the way down: a plugin's main says cloud.Listen, cloud says app.Listen, and nothing has to be translated in a reader's head on the way through. 117 composition roots move with it. ServePlane is untouched — it names a different act (bind one app's own socket for the internal plane), and collapsing it into this would be the opposite of the point. Also fixes apps/iam's TestMain, which had gone red on every store test: credz.Boot's last resort is cek.EnsureDevKey, and that DECLINES on a codec-linked build by design — a build that can really encrypt must be handed a real key, not invent one. So the throwaway goes in through the same door a deployment uses, and only when nothing else supplied one. Six failures back to the one pre-existing ratchet (iam serves 97 untyped ops against a budget of 88). Co-authored-by: Hanzo Dev <dev@hanzo.ai> |
||
|
|
e6ae1244a7 |
describe: every operation the fleet publishes now says what it does
1465 published operations, 797 described. The other 668 offered an operationId and
nothing else — a generated SDK method with no docstring, a spec-derived CLI command
with no help text, an MCP tool an agent cannot choose between. Now 1491 of 1491.
The gap was structural, not neglect. Almost every one of them was an UNTYPED route:
a proxy to a vendored module, an SSE stream, a WebSocket upgrade, a byte upload, an
All() wildcard, or a surface owned by another repo entirely. None has a handler doc
comment in this tree for zipdoc to lift, which is exactly why 47 apps carried no
zipdoc directive — adding one would have produced an empty file. The seam they
needed existed and had one caller; it now has 523.
Three surfaces had no seam at all and would have been left behind:
- metrics and licensing are vendored modules that deliberately do not import
cloud, so their prose lands at cloud's OWN wire fact in build.go;
- authz is a leaf forbidden from importing cloud, and its handlers are untyped
closures in another module — both seams shut — so its prose lands in
plugin/authz/main.go, the file whose own doc says it is where "cloud's plugin
contract bends to the leaf."
Every sentence was read off the handler, and reading 668 handlers is most of what
this cost. It found ten defects, filed as #376 — two of them money: gpu-charge is
not idempotent, and the finance ledger's peer path emits a vocabulary its reader
does not classify, so credits render empty and deposits sign negative, with the test
green on both paths because it only exercises the S2S mock. None is fixed here.
Describing is not repairing, and a description that flattered the code would have
been worth less than the silence it replaced — so where a route is broken, the prose
says what it actually does.
Three tests used "has prose" as a proxy for "is a typed op". That equivalence held
while prose could only arrive by lifting a typed op's comment, and Describe breaks
it by design — so each of those tests forbade precisely what the seam exists to do.
They now read zip's own registry and assert something stronger: every operation is
either a typed op with lifted prose or a recorded raw address with declared prose,
and either way it carries prose. apps/exec's is a gate over all 56 of its ops, which
matters most on a pure-proxy surface, where the description IS the product surface.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
|
||
|
|
4cfc10d095 |
docs: the first sentence of a product's doc is the customer's sentence, not ours
The package doc's opening line is no longer read only by us. openapi/synopsis.go lifts it verbatim into the app's OpenAPI info.description, the weave lands it on the product tag, and from there it is the MCP tool prose an agent reads and the line `hanzo <product> --help` prints. It is the first thing a paying customer sees about a product they are deciding whether to use. Forty of them opened by describing the implementation instead: "mounts the Hanzo Cloud /v1/code/* surface", "folds hanzoai/esign (the Documenso fork) into the unified hanzoai/cloud binary as an in-process subsystem (HIP-0106, task #100, epic #96)". A customer reading the tag list learned our file layout, our epic numbers and our mount order — and not one thing about what they get. Several ran past 200 characters, so the CLI help column truncated mid-clause. So sentence one now answers the only question a buyer is asking: code → search and symbols across your repos, for you and your agents. esign → a document out for signature, signed and filed with an audit trail. world → a live news feed filtered to what your project cares about. NOTHING IS DELETED. Every clause the old opener carried — the mount path, the fork it wraps, the HIP, the tenancy argument — moves down to sentence two, where it is read by the person it was written for. Only the ORDER changed: what the product is, then how it is built. Both were always in the same comment; only one of them projects. The rule the forty now hold, and the reason each is a rule: ≤90 characters (the CLI help column); no "mounts"/"surface"/"subsystem"/"plane"/"binary" (words for where the code lives, not what it does); no /v1 path, file name or HIP number (an identifier no customer can look up); no shouting (the tag list is prose, not a changelog). Sentence two keeps all of it. Two files were already gofmt-dirty on main and are now clean, since they are files this touches: apps/content/doctypes.go (comment alignment) and apps/websearch/websearch.go (list-continuation indent). Regenerated FROM SOURCE, not hand-edited: make describe → 1039 paths, 906 MCP tools. The artifact diff is descriptions only — no path, operation, schema or tool name moves. Co-authored-by: Hanzo Dev <dev@hanzo.ai> |
||
|
|
c0d7b3f969 |
openapi: a product tag says what the product is, in its owning package's words
The document has always known a product's NAME mechanically — the first path segment after /v1/ — and never what the product IS. A caller reading the tag list, an agent reading the MCP door, a CLI printing `hanzo <product> --help` got 144 bare nouns. There is exactly one place that sentence is already written and already reviewed: the package doc of the package that implements the app. So this reads it rather than asking anyone to write it twice. openapi/synopsis.go Synopsis(plugin/<app>) -> the owning package's synopsis. describe.go stamps it into that app's own subset as info.description. openapi/weave.go lifts the tag prose off the subsets it already reads. ONE computation, at the one moment an app describes itself. The weave does not look the mapping up a second time in a second process — it reads the value the app that knows it already wrote down, which is why Weave stays a pure function of its parts. The owner comes from the app's own composition root: plugin/<app>/main.go imports exactly the package it mounts. Nothing else could be the source — four apps are not named after their package (audit->auditlog, evals->eval, plugins->plugin, zero-trust->zt) and one package backs two apps (account, account-bridge), so a name-derived guess is right 107 times and silently wrong 5. An app whose subsystem is another MODULE imports no package here and gets nothing, which is the honest answer. And the comment taken is the one that OPENS "Package …", not go/doc's first-file-in-filename-order fallback. Packages that open their alphabetically-first file with a note about that FILE and state the real package doc in <name>.go would otherwise publish "actions.go — the two GitOps write actions" as the deploy product's description. A misfiled sentence reads exactly like a real one; an absent one does not. 109 of 112 apps have a package doc; 85 of the 144 product tags gain a description. The three without are metrics, authz and licensing, whose subsystem is another module — there is no package here to read. The tag NAME is never conditional on a description: the list stays a function of the document's operations, so nothing enumerating products loses a product because nobody wrote a sentence. The fleet identity remains the fallback for a subset whose package has no doc, and the weave treats a part carrying it as having said nothing. THE LIFTED PROSE LOSES THE HANDLER'S OWN NAME, which is the other half of the same problem. A Go doc comment must open with the identifier it documents, and that identifier is Go's, not the document's: "GetSQL returns one database" reached the OpenAPI description, its summary, the MCP tool description an agent reads, and the CLI help line — naming a function no caller can see. zip drops an exact leading match of the handler's own name from v1.18.13 (main is on v1.18.14, whose lift is byte-identical), and nothing had regenerated against it: 35 packages carried prose the pinned zip can no longer produce. They regenerate here. Three test assertions quoted the leaked identifier and now quote the projection. Every generated artifact is regenerated FROM SOURCE (make -f mk/fleet.mk surface-check, green: 1017 paths). Nothing this commit does moves the wire: of openapi.yaml's 16,439 non-prose leaf facts, 0 changed. The 4 lost and 94 gained are all one thing — surface main already decided and never republished: /v1/insights/e removed and /v1/event given its declared body ( |
||
|
|
e247e255cf |
mcp: ONE door — three hand-rolled registries collapse into the typed-op projection
Typing a route bought OpenAPI prose, an SDK method and a CLI command, and NOTHING
on the public MCP surface. zip has projected every typed op into an MCP tool since
v1.18.6 and cloud never called it: manifest routed /v1/mcp to apps/tools, which
hand-rolled its own tools/list + tools/call over a route-table scrape, and
apps/automations hand-rolled a THIRD catalogue. Three registries for one concept,
and the one the public reached exposed none of the 549 typed ops.
THE DOOR IS THE HOST'S. cmd/cloud sets zip.MCPConfig{Path:"/v1/mcp"} and hands
each plugin its own catalogue at Load. The host is the only process that CAN own
it: MCPTools() is in-process, so a plugin cannot enumerate a lazy sibling, and a
plugin-hosted door costs its own wake on the first list. Measured: POST /v1/mcp
beats ai's "/v1" remainder by specificity, not registration order.
THE LIST IS A BUILD ARTIFACT, so tools/list costs ZERO wakes. It has to be: 112
plugins mount LAZILY, and an MCP client calls tools/list constantly — a door that
fanned out over ZAP to ask would destroy the one invariant that makes 112 services
affordable. The answer is already fixed at build time, by the same typed-op
registry that emits openapi.json, so `<app> describe <dir>` now writes BOTH
projections from ONE mount at ONE instant: openapi.json and mcp.json. They cannot
be generated apart, so a tool cannot exist without its op or carry a stale schema.
The leaf plugin/embed.go go:embeds them (cmd/cloud goes 344 → 345 packages, still
zero from apps/). Measured live with the WHOLE fleet mounted: 549 tools listed,
child count 4 → 4 (the four eager apps, untouched).
tools/call is the ONLY trigger and starts exactly one child — p.target(), the same
single-flighted lazy path a prefix request takes — then forwards the SAME message
to that plugin's own /mcp over ZAP on its 0700 unix socket. Never HTTP. The child's
registry answers, so the host can only NAME a tool, never invoke one the child did
not declare. Measured live: get_v1_pricing woke 1 child and returned the pricing
catalog; get_v1_company answered its own handler's "X-Org-Id required" through the
plugin's full cloud.Serve identity chain.
DELETED, not left dark:
apps/tools/builtin.go (223 lines) — the "full-cloud-control" route→tool scrape.
Structurally dead since the monolith died: in the tools CHILD, GetRoutes() sees
only tools' own ~13 routes, and its schemas were opaque {query,body} objects a
model cannot fill. The new door is what it meant to be, with real schemas.
apps/tools/http.go's mcp/mcpToolList/mcpToolCall/rpcResult/rpcError + the route.
apps/automations/mcp.go's mcp/mcpTools/mcpResultObj/mcpErrorObj + its route.
GET /v1/mcp — a Source view that is GET /v1/tools?source=mcp by its own comment.
Principal.credential + credentialHeaders — replay state only builtin.go read.
KEPT, because it is a different capability: apps/tools' EXTERNAL MCP server
registry (records, KMS-sealed secrets, SSRF-validated dialer, tools/list fan-out),
now owning /v1/mcp/servers alone. Its tools, org skills, agents, functions and
connector actions are ROWS, not code, so no build-time catalogue can hold them —
they are reached through the typed POST /v1/tools/call, which is itself a tool on
the door. Nothing lost: connectorToolProvider already published every connector
action into that one registry.
THE GATE. mk/fleet.mk surface-check (which .hanzo/workflows/cicd.yml → hanzo.yml
app-contract actually invokes) regenerates every app FROM SOURCE and fails on
`git status --porcelain -- openapi.yaml plugin/` — mcp.json is under plugin/, so it
was covered the moment it landed there. PROVEN TO FIRE: adding one typed op to
apps/guide without regenerating turned it red on BOTH plugin/guide/mcp.json and
plugin/guide/openapi.json; reverted, green. Four more, all cheap: no App row may
claim /v1/mcp (fiber MERGES byte-identical patterns, so a Load there would shadow
the door silently); no served path may END in /mcp; no Go source outside cmd/cloud
may name an /mcp path unless it is a named foreign engine (apps/tasks' own
surface, which is not a projection of our ops); every catalogue tool must be an
operationId of its own app, unique fleet-wide, with a NON-EMPTY description —
the last one because a nameless tool is a silent failure a model pays context for.
549 tools across 36 apps, 349KB on the wire. zip v1.18.11 → v1.18.12.
Capability check, precisely: the 17 executable connector actions the deleted
automations door listed are NOT tool names on the fleet door, because they are
per-tenant rows — connectorToolProvider publishes every one of them into the ONE
registry from the same `registry` map that door read, so they are reached through
tools_call with the same activation, price, meter and audit. Nothing is lost; one
hop is added. Same for org skills, agents, functions and external MCP servers.
One door this gate structurally cannot claim: /v1/tasks/mcp is hanzoai/tasks' own
engine surface behind cloud's identity gate, mounted on a raw net/http mux so it
is in no subset at all. It is a foreign engine's tools, not a projection of ours,
so it is NAMED in foreignDoors with the reason rather than deleted.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
|
||
|
|
af84ba499c |
type: eleven ops that projected to NOTHING now describe themselves
bots, entitlements, sbom, translate, agentskills and gateway published 16
operations between them and 16 of 16 carried neither a description nor a
summary — the exact set that reaches no prose, no MCP tool, no CLI command and
no typed SDK method. Eleven are typed ops now; the five that are not each name
the wire fact that keeps them raw, in a CLOSED list a test reads.
entitlements 3/3 gateway 2/2 bots 2/3
sbom 2/3 translate 2/3 agentskills 0/2 (structural)
The five refusals, each re-measured against the PINNED zip v1.18.11 rather
than inherited as prose:
POST /v1/bots/run answers 501 unconditionally. A typed op publishes
a SUCCESS response it can never send, and mints an
MCP tool and CLI command for an operation that
cannot succeed. It is also body-tolerant.
GET /v1/sbom/{wildcard1} a greedy wildcard: fiber binds it as `*1`, the
document publishes `{wildcard1}` as a PATH param,
and a typed op publishes op.Path verbatim — so the
address, the parameter name and the parameter
LOCATION would all move for a wire that did not.
POST /v1/translate a bulk-tier spend denial answers 402/503 carrying
the fleet's NESTED {"error":{code,message}} domain
body; a typed op's only refusal is a returned
error, which zip renders flat.
GET /.well-known/agent-skills/index.json embedded bytes served verbatim
(its sha256 digests are computed over what is
served), a Cache-Control zip cannot set, and an
{"error":…} 404 body errorHandler does not produce.
GET /.well-known/agent-skills/{skill}/SKILL.md text/markdown. A typed op
answers application/json — no In/Out serves it.
WIRE PRESERVED. No path moved, no status moved, no field name moved; the only
published deltas are the three operationId renames typing always makes
(_by_id -> _id) and the prose that did not exist before. Two map[string]any
responses became structs whose fields are spelled in the order encoding/json
emits a map's sorted keys, so the BYTES did not move either —
TestHealthAndIngestKeepTheirBytes asserts that.
The org is never an input field. `url:"-"` (new in zip v1.18.11) lets the :org
segment bind from the URL while staying out of the published body and
invisible to the decoder, and the gate still compares it to the VALIDATED
principal — so it is an address the gate re-checks, never an assertion the gate
believes. Five identity seams needed more of the principal than the org
(SuperAdmin-ness, the user id a write is attributed to, the ?org= a SuperAdmin
targets a tenant with); each is one function, pinned in allowedRequestUses with
its reason, and each fails closed off the HTTP path.
Latent defects the typing surfaced, beyond the two prefix ones fixed above:
- NONE of the six installed cloud.Bridge. Serve installs it binary-wide so
nothing was live-broken, but none of these packages' own test harnesses
runs Serve, so a typed op added here would have 403'd in its own tests with
no hint why. All six install it through the SUBSYSTEM router now.
- apps/entitlements' tests RECONSTRUCTED the router by hand rather than
driving the mount, so they could have gone green against a surface the
binary does not serve. Mount's registration is one routes() function now,
and the tests call it.
- edge.Policy grouped three fields under one section comment, and zipdoc
lifts the comment directly above a field — so "Platform-scope (admin-org
row)" shipped as the published description of cors_origins ALONE. Every
published field in all six subsets carries its own prose now; the check is
in the pass notes in LLM.md.
- Two generic names were qualified before they could collide: bots.botView ->
BotRun (apps/visor already publishes botView for a bot MACHINE) and
translate.Entry -> MemoryEntry (six packages under apps/ declare an Entry).
Neither was published yet, so both were free.
Each package carries untypedByDesign + TestEveryRouteIsTypedOrNamed reading the
REAL mount, whose two ledgers must SUM to the served surface — so a route added
untyped here goes red, and a reason naming a route that is gone goes red too.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
|
||
|
|
85dd3a6513 |
name: a thing the host loads is a Plugin
MountSpec was a compound naming a struct after the mechanism that consumes it. The thing it describes is one of the plugins the host loads: name, price, mount. The directory is plugin/, the framework is the zip plugin framework, and every doc comment already called them plugins in prose. So: Plugin. Not App, which was the obvious first choice and is wrong twice over -- package cloud already declares an App in payloads.go, and the struct itself carries an App field for a subsystem that gates the whole binary. Either collision alone would have made the name ambiguous at every use site. Mechanical: 132 files, plus the parameter and loop variables that carried the old noun (specs, spec, sp) to the noun they actually hold. |
||
|
|
2c4b045b0b |
cmd/cloud + plugin/<app>: the light host is the one binary — scope credentials, forward flags, own "/"
Restructure to the canonical layout: cmd/host → cmd/cloud (the host IS the one real binary; name it cloud), and every other cmd/<app> → plugin/<app>. `ls cmd/` is `cloud/` alone; `ls plugin/` is the 116 per-app + tool dirs. gen-app-cmds scaffolds into plugin/<app> and scans plugin/ for the bijection; the Dockerfile / Makefile / mk / hanzo.yml / weave / controlplane-containment gate all read the new paths. go build ./cmd/cloud links ~399 pkgs and zero subsystems. credz KMS-key leak (#51 follow-up): zip builds each child's env as append(os.Environ(), Plugin.Env...), so a host that keeps CLOUD_KMS_MASTER_KEY_REF hands the root key to EVERY child — the Root posture credz exists to prevent, and now the default entrypoint. cmd/cloud (stdlib credz/launch only — importing credz would drag cek→sqlite and re-fatten the host) mints the launch secret, scrubs the root key from its OWN environment, stamps each child a scoped CREDZ_TOKEN, and re-injects the root key onto the kms broker child's Env ALONE. Every generic child comes up with a token and no key and must ask the broker. Pinned by cmd/cloud/main_test.go and proven by a live dns spawn. helm flag forwarding: cmd/cloud accepts --brand/--domain/--data-dir/--iam-issuer (the args the chart passes the entrypoint) and republishes each non-empty one as its CLOUD_* env, which the per-app children read; an empty flag never clobbers a value already pinned in the environment. console at "/": nothing served the host root once mountConsole moved into the per-app cloud.Serve. Extract the console into a light webui leaf (stdlib + embed + a new strings-only brand leaf, both aliased back into package cloud so no call site changes) so cmd/cloud — the front door — owns "/" and serves the white-labelled SPA. The host stays ~399 packages and imports zero subsystems. Co-authored-by: Hanzo Dev <dev@hanzo.ai> |