Commit Graph
14 Commits
Author SHA1 Message Date
hanzo-dev 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. (593aa309 did regenerate it, which
is why the file reads 365 today. The next cross-repo bump silently un-fixes it.)

So the host asks. POST /v1/mcp is the HOST's own handler now (zip's is Disabled,
so exactly one handler holds the address). A tools/list forwards the CALLER's own
message to every composed subsystem's own /mcp over its private ZAP socket, in
parallel, and unions the replies — zip.App.Start resolves a cold child on the
same single-flighted path a prefix request takes, so the first list pays one
start per app and nothing after it does. A tools/call goes to the app that listed
the name, verbatim; the child's own registry decides whether the tool exists.

A SUBSYSTEM THAT DOES NOT ANSWER IS NAMED, in result._meta["hanzo.ai/unavailable"],
because a silently-short list and a stale file are the same defect: the caller
cannot tell an app that serves nothing from one that did not answer. Measured on
the built binaries — host + real o11y child, kms pointed at a dead address:

    tools=364, unavailable=[{kms, connection refused}]

364 and not 365 because o11y projects get_v1_o11y_logs twice; the door serves the
first and logs the collision. THAT DUPLICATE IS WHY cmd/cloud's tests were red on
main — zip refused the Load ("tool is already served by plugin o11y"), a boot
failure. One name still has one owner; it is no longer fatal to the fleet.

Also gone with the mechanism they configured: manifest.App.Open and zip's
one-open-plugin rule. The host forwards the caller's own request to EVERY
subsystem now, so each answers for this caller out of its own rows, and being
asked per caller is no longer a privilege one app holds.

The release gate moved with the door. Car 3 compared the live tool count against
`jq -s length` over the committed files — both sides were the same bytes, so it
proved only that the image carried its own tree, and it passed while o11y's
catalogue held 12 of 365. It asks the better question now: did every subsystem
answer. A broken deployment used to match the files exactly.

plugin/<app>/openapi.json SURVIVES, for the one reason the catalogue could not:
the weave carries each subsystem's prose, and that prose is lifted from the app's
SOURCE at describe time (openapi.Synopsis). A running child has no comment to
read and would answer with its deployment's brand blurb, which the weave would
publish as the description of every product tag. Deleting that half waits on the
synopsis becoming a declared value.

Tests are against RUNNING subsystems (fleet/mcp_test.go): real zip children on
real ZAP sockets, exact sets, bodies never status codes. Mutation-checked three
ways — unmount a child, silence the outage report, ask only the first app — all
three go red.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 14:22:10 -07:00
hanzo-dev 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>
2026-08-02 09:53:56 -07:00
hanzo-devandzeekay 7e3bb4af9b describe: regenerate the woven document so the prose fix reaches the wire
`make describe` — zipdoc lifts the doc comments, each app projects its own
router, the weave composes them. Path count is unchanged at 1058: this
moves prose, not surface.

It also picks up drift nobody had regenerated: apps/git's zipdoc_gen.go was
missing the import/inbound/mirror ops' prose entirely, and apps/admin's had
gone stale. Both were invisible because the weave only proves the subsets
compose with each other — the drift gate is what regenerates from source.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 10:25:03 -07:00
hanzo-dev 1c9160bbad git: the four void ops declared a status they never send
DELETE /v1/git/repos/{name}, /keys/{id}, /repos/{name}/subscriptions/{id}
and /repos/{name}/mirrors/{id} each return a nil *Out, which zip writes as
204 with no body — git's own tests have always asserted it (git_test.go:188,
ssh_test.go:118, lifecycle_test.go:95,170,434). The document said 200 with a
`noContent` JSON body, so every SDK generated from openapi.yaml expected a
status and a payload the service has never sent.

The cause is one character. zip keys a 204 response on the Out type having NO
NAME (openapi.go: a named OutType publishes "200 with a body"), and git was
the only app in the fleet that DEFINED `noContent` instead of aliasing it —
and therefore the only publisher of a `noContent` schema. `type noContent =
struct{}` restores the fleet's one form; the wire is untouched.

TestVoidOpsPublishTheStatusTheySend makes it a gate rather than a fix: over
every typed op, in both directions — a void op must publish 204 and no
content, and every other typed op must publish a 2xx that carries a schema.
It fails on the old shape, all four ops, with the reason.

Also true-up the prose the gate exists to replace: untypedByDesign holds 30
refusals, not 24 (the project-scoped :org/:project/:repo trio doubled the
smart-HTTP family to 12 and the declared pack bodies to 8), the host-gated
root paths are 12 and not 9, and both closed classes are recorded — the
bodyless POST on /gc and the raw newline in 215 fleet summaries, now 0.

The ZAP five keep their refusal and gain the fact behind it:
zapface/dispatch.go:89-91 forwards env.Msg to the ZAP client as its error
text, so a typed op's {..., error} body would decode to an empty Msg and
every ZAP failure would arrive with no message at all.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-31 14:24:02 -07:00
hanzo-dev 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>
2026-07-31 10:00:47 -07:00
hanzo-dev 668c63ac33 docs: lead every money/identity app doc with the product, not the plumbing
The first sentence of an app's package doc is not internal prose. It projects
verbatim into three places a paying customer reads — the CLI group help line,
the OpenAPI tag description, and the MCP tool prose — so a sentence that opens
"mounts the ... surface", "is the ... plane", or names a /v1 path describes the
implementation to someone who asked what they bought.

Rewrites the opener of 32 app packages across billing/money and
identity/security to state what the customer gets, and reflows the displaced
detail into sentence two. Nothing is deleted: every path, mount note, store
shape and tenancy invariant that was in sentence one is still in the doc, one
sentence lower, where an engineer reading the package still finds it.

  billing   money door -> your org's balance, what it has spent, the cards it pays with
  books     "at /v1/books" -> chart of accounts, ledger, bank reconciliation, the reports
  o11y      "ONE owner of the observability plane" -> your logs, metrics and traces
  usage     "the usage plane at /v1/usage" -> what your org ran and what it cost
  principal "ONE place the data plane turns a request into an org" -> the guarantee
            that one org never reads another's data

iam is left alone: "Hanzo's identity provider: users, organizations,
applications, and the OIDC/OAuth2 endpoints every Hanzo service authenticates
against" already leads with the product.

Six of the 32 (finance, payout, metering, money, idv, principal) back no
plugin and so project nowhere; they are rewritten anyway, because the reason
the rule exists does not depend on which reader arrives.

Regenerated openapi.yaml + the 23 plugin subsets from source. The diff is the
description line and nothing else — no route moved.

apps/metering also drops a stale claim to live in the commerce repo; it lives
here.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-30 22:44:46 -07:00
hanzo-dev 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 (6fc2d88c), the six
project-scoped git smart-HTTP paths (811ff080), and the sessions' `terminal`
property (afdda829). The three bare-root git paths reach no app, so they join
router_test.go's unreachable ledger, recorded on the first regeneration that
published them.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-30 21:33:38 -07:00
hanzo-dev 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>
2026-07-30 10:11:38 -07:00
hanzo-dev de9b59624f zip v1.18.9: the document stops saying four untrue things, and authz stops publishing a removed API
The bump alone changes 23 published artifacts, because zip v1.18.9 fixes what the
projection SAYS rather than what any route does. No wire moves.

  phantom request bodies 41 -> 9. A POST binding its whole input from the path
  published a required body whose only property was the path param, so every
  generated SDK gained an argument the caller must build to repeat a value it
  already passes in the URL. The 9 left are the raw-body family (git-upload-pack,
  bank-statement import, a deck upload) — they eat bytes, not JSON, and owe a
  binary content type via openapi.Binary rather than an empty object.

  time.Time stopped publishing as a $ref to a schema with no properties and now
  says format: date-time. Its fields are unexported, so reflection over them
  described nothing: every timestamp in every generated SDK was untyped.

  summaries lost their embedded line breaks — one sentence on one line, which is
  what the spec, the CLI's one-line help and an SDK's first docstring line all
  want.

  imported types' FIELD docs reach the document at all. zipdoc matched the parsed
  and type-checked views of a struct by byte offset, which only agrees for a
  package loaded from source; an imported type's position comes from export data
  with a synthetic offset. So every op whose In and Out live in the call plane
  published a description and zero field descriptions.

AND ONE STALE PUBLISHED SURFACE, which the regeneration exposed rather than
caused. plugin/authz/openapi.json documented GET, POST and DELETE
/v1/authz/policies. hanzoai/authz v1.10.15 does not serve them, and says why in
serve/mount.go: the grant set belongs to IAM, "a second writable copy behind this
surface would be a second source of truth for who may do what". So cloud was
advertising a writable authorization-policy API that had been deliberately
removed, and three methods in every generated client answered 404. Verified as
pre-existing by regenerating the subset at origin/main on the OLD zip: the same
three paths vanish.

That drift means the gate was red on main and stayed red. It is invoked
(hanzo.yml:134), which leaves the two ways it could have been red and unnoticed —
worth a look, not a guess.

Also: openapi-apps now honours OPENAPI_NEEDS_BROKER, which only openapi-check
did. The gate's own failure text says "fix: make openapi", and that fix routed
through openapi-apps, which mounts kafka, which fails closed with no broker — so
the single command told to repair a red gate could not run. One exemption list,
read everywhere it applies.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-30 07:08:28 -07:00
hanzo-dev d6bcf7ea1d git: a refused route still owes the document its body — eight declared
Follow-on to the CLI-collision commit, using the seam apps/books added an hour
earlier. git's 24 refusals were correct as refusals and stay raw — the webhook's
HMAC covers the raw bytes it verifies before json.Unmarshal, smart-HTTP streams
x-git-* pack media, the UI renders html/template, and the ZAP adapters' error
envelope ({status:"error", msg}) is a shape zip's errorHandler cannot produce —
but "cannot be a typed op" had been read as "must be undocumented". All 24
published an operationId, tags and NOTHING else, which no SDK generator can tell
apart from a route that takes no body. The published forge webhook therefore
offered a delivery with nowhere to put it, and three ZAP procedures a repo name
with nowhere to put it.

Eight now declare the request they actually read, through openapi.Register in an
init (Register panics on a duplicate; routes() runs once per Mount):

  POST /v1/git/webhook                       pushEvent
  POST /v1/git/zap/{createRepo,getRepo,deleteRepo}   zapProcReq
  POST {/v1/git,}/{org}/{repo}/git-{upload,receive}-pack   openapi.Binary

Pure description: no route, status, field or byte moves, and openapi.yaml gains
81 lines with ZERO deletions. The two ZAP procedures that read NO body
(listRepos, usage) are deliberately left silent — declaring one for them would
swap an honest silence for a fresh falsehood — as are the ref advertisement and
the twelve HTML pages.

No RESPONSE is declared, and the reason is worth keeping: the ZAP envelope's
data is repoView / []repoView / usageView, names zip's typed fold already
publishes as components off the typed /v1 ops, so reflecting them here through
openapi.schemaOf would put two derivations behind one schema name — precisely
what openapi.Weave exists to refuse. The pack responses and the HTML pages have
no seam at all: openapi.Binary is request-only by design.

declaredBodies + TestRefusedRoutesDeclareTheBodyTheyRead pin it in both
directions — a declared body that vanishes fails, and a body declared for one of
the sixteen that read none fails too — and both directions were verified red
before this was committed green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-30 05:20:54 -07:00
hanzo-dev d4597bfcb1 git: type the four creators — 201 is a declaration now, not a side channel
The four routes that made a repo, an ssh key, a subscription and a mirror
target were the last /v1/git routes with a real JSON shape still registered as
raw handlers. They stayed raw for ONE reason, written in each of their doc
comments: they answer 201, and zip's typed registrar wrote 200 for a value and
204 for none with no seam to say otherwise. zip v1.18.2 closed that gap —
zip.WithStatus(201) declares the status ON the op, so the document's response
object is keyed on 201 and a generated client expects what the service sends.
That reason is now stale, so the routes convert:

  POST /v1/git/repos                      createRepo   -> repoView
  POST /v1/git/keys                       registerKey  -> keyView
  POST /v1/git/repos/:name/subscriptions  subscribe    -> subscriptionView
  POST /v1/git/repos/:name/mirrors        addMirror    -> mirrorTargetView

Each was a route and nothing else — no schema, no prose, no MCP tool, no CLI
command, no SDK method. Each is now one registry entry with all of them. The
wire is unchanged and the suite proves it rather than asserting it: 201, 400,
403, 404 and 409 on these four paths are pinned by existing tests (git_test,
ssh_test, lifecycle_test, hardening_test, public_repo_test, tenant_isolation_test)
and they pass untouched.

Registration moves onto the group. Every zip.<Verb> in routes() now takes `g`
rather than the *zip.App with an absolute path, so the /v1/git prefix lives in
exactly one place and each op's path is the prefix composed with its leaf — the
same composition the router does, and the identity every projection keys on.
cmd/zipdoc resolves it the same way since zip v1.18.3, so the prose reaches
both the document and the tool list.

The repo scope resolver collapses to one function. repoScope() existed only so
the raw creators could share the typed ops' preamble; with no raw creators left,
scoped() absorbs it and there is one way to turn a :name into a validated repo.

The principal keeps carrying the user. tenantFrom already read the validated
org and project off the request; it now reads c.User() there too, so registerKey
gets its owner from the bridge like everything else. An In field is
caller-supplied — a key written under a user read from one would let a caller
register a key in someone else's name.

Prose is product surface now, so it had to be TRUE, and one line was not:
addMirror's comment named git.hanzo.ai as an allowed mirror TARGET. It is
precisely the host mirrorOutHostAllowed excludes on purpose (Red MED-1 — an
internal SSRF that would make the server force-push with the shared token at an
arbitrary internal path). Typing lifts that sentence into the published
description and the MCP tool description, where it would have told SDK users and
agents to do the one thing the code refuses. Corrected to the real allowlist,
{github.com, gitlab.com}.

24 typed ops now, 24 raw. What is left raw has no JSON shape to type and says so
per route: the smart-HTTP pack protocol streams binary, the browser UI serves
HTML, the ZAP adapters answer a {status:"error", msg} envelope at a non-2xx that
a typed op cannot produce, and the webhook HMACs the RAW bytes and verifies
before it parses — typing it would invert that order and decode an
unauthenticated body.

Regenerated: apps/git/zipdoc_gen.go, plugin/git/openapi.json, openapi.yaml.
Described operations across the fleet: 170 -> 174.
2026-07-28 22:30:24 -07:00
hanzo-dev 49f8eeecd9 zip: take v1.18.1, and let the document say what the handlers already knew
zip v1.18.x closes four gaps between what a typed op DOES and what its document
SAYS. Taking it regenerates the published spec — the file the SDK repos pull —
and unblocks the migration these routes were waiting on.

  * A typed DELETE no longer reads a request body; its input is the URL. All 20
    of cloud's DELETE ops keep working: 15 take only path params, and the other
    5 take scalars that bind from the query exactly as the document already said
    they would. DELETE /v1/marketing/suppressions is the one with no path param
    at all, so its whole input is now ?channel=&address= — which is what a
    client generated from openapi.yaml has always sent.
  * A URL-borne field's `validate:"required"` reaches its parameter, so an
    argument the handler refuses to run without stops being described as
    optional.
  * A bodyless op's example survives. openapi.Parameter had no name for it, so
    the round-trip through Typed() dropped it — exactly the "dropped honestly,
    if nothing here has a name for it" its own doc comment warned about. Every
    GET and DELETE reached the published reference with no example at all;
    adminDeleteSpendCap now carries example: cap_1 on the path and acme on the
    query, from the one Example its doc comment already had.
  * A path parameter is typed from the field it binds to, so ?sizeGiB= is an
    integer in the document because it is an int in Go.

openapi.yaml is 245 lines SHORTER despite 128 new example/required/query lines:
a named struct is now one definition every op $refs instead of being inlined at
each use. 148 schemas across the admin subset alone.

probe/ is inverted, which is what it told whoever came next to do. It pinned two
zip limitations — a typed op cannot see its own URL, and a templated path is
emitted with no parameter object — and both are gone, so it now asserts the
capabilities: the whole URL binds, and /v1/agents/sessions/{id} declares its
parameter, typed. The 16 of 25 clients/agents routes that carry a path param
are unblocked. What stays pinned is the DEFAULT, not a framework limit: an op
with no Authorize installed answers an anonymous MCP caller.

commerce moves to v1.49.30 for the same reason — Mint decorates a zip.Router,
so it now answers for where a typed op lands, gate included.

Pre-existing failures unchanged (functions/provisioning/storage/kmsreseal need
billing config, S3 and a real KMS); the two probe failures are fixed.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 18:02:37 -07:00
antje 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.
2026-07-28 16:48:54 -07:00
hanzo-dev 2c4b045b0b cmd/cloud + plugin/<app>: the light host is the one binary — scope credentials, forward flags, own "/"
Hanzo CI/CD / cicd (push) Successful in 22s
CI/CD / gate (push) Successful in 22s
CI/CD / containment (push) Successful in 1m37s
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>
2026-07-28 12:37:17 -07:00