13 Commits
Author SHA1 Message Date
zeekay 88d3bee3d7 integrations described slack/install twice, so thirteen apps could not build
CI/CD / containment (push) Successful in 2m24s
Hanzo CI/CD / cicd (push) Failing after 51m20s
CI/CD / gate (push) Failing after 51m20s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
apps/integrations carried two openapi.Describe calls for GET
/v1/integrations/slack/install, written by different hands into different init()
funcs. Describe panics on a duplicate — correctly, because two descriptions of
one operation means one of them renders and nobody can tell which — and that
panic fires at init, so it took down every app that links integrations: ads,
automations, campaign, catalogsync, channels, cloudflare, company, content,
destinations, git, guide, integrations and sync all failed to describe.

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

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

1762 paths, 2480 operations, 185 products. Every one carries an operationId and a
summary; 51 still want a long description and 49 of those are hanzoai/ai, whose
prose belongs on its controllers, in that repo.
2026-08-06 03:21:23 -07:00
zeekayandhanzo-dev 84e2666543 commerce: the 128 silent operations say what they do
`make describe` refuses to regenerate openapi.yaml while any operation is bare,
so the artifact could not be rebuilt AT ALL and had drifted: on clean main the
host served 1698 paths against the golden's 1696 and
cmd/cloud.TestTheServedDocumentIsTheArtifact was red.

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

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

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

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

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

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

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

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

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 02:11:32 -07:00
hanzo-dev 26f69224fa projects: the operator vouch is an admin scope, not a membership
Binding a custom domain WITHOUT proving control of it — the bind lands
VERIFIED and routes immediately, and the "a host we operate" refusal does not
apply — is platform authority. The gate read

	vouched := c.IsAdmin() || s.State.operatorOrgs[org]

and that second term is bare MEMBERSHIP. operatorOrgs defaults to the
deployment's own brand org (config.go getenv CLOUD_BRAND, brand.Default
"hanzo"), so the set is {hanzo} in every deployment, and `org` is the
IAM-validated effective org, gated upstream by isMember alone. Every staff
account whatever its role, plus anyone a brand-org admin ever invited, could
bind login.example-bank.com live with no DNS-01 proof: attacker content served
at any custom-domain customer whose DNS already points at our edge, and the
name denied to its rightful owner for good, since a verified row is first-come
and global.

vouches() now names the two grants, both admin-scoped:

	SuperAdmin            platform sudo (owner == the reserved admin org).
	                      Cross-tenant by construction, so it vouches in ANY
	                      org — the operator switched into a customer's org to
	                      bind the domain it manages DNS for. Unchanged.
	operator-org ADMIN    the deployment named this org an operator AND IAM
	                      says the caller administers it.

The second is a conjunction of two independently administered facts: a
capability the deployment grants to an org, and the role IAM grants inside it.
The set names an org; it never names an authority. The org-admin bit is asked
of the EFFECTIVE org — the same value SanitizeIdentity keys X-User-IsOrgAdmin
on — so the pair reads "admin OF this operator org" and never "admin of some
org I switched out of". Both bits are stripped on ingress and re-minted only
from validated claims.

Fail-secure: an issuer that stops signing the org role drops the operator-org
grant to a PENDING claim carrying the DNS challenge, the same self-service path
every other tenant takes. SuperAdmin onboarding never depended on that claim.

TestOperatorVouchNeedsAdminScope drives the real handler over four identities.
On the parent commit it fails with the exploit verbatim — a plain member's bind
of login.example-bank.com comes back {Status: live, Verified: true}. It also
holds the positive cases, so a fix that merely disabled operator onboarding
would not pass. TestOperatorVouchIsVerbatimEndToEnd keeps the verbatim-owner
pin and now has both callers be org admins, leaving the org name as the only
axis; it went from panicking to passing because it no longer builds the whole
surface.

The harness registers the ops it drives rather than calling routes(), which
composes only on the Router production gives it: routes() declares middleware
with Group(prefix, mw) and registers its typed ops on the App with full paths,
so on the bare *zip.App a test holds the prefix node has no routes beneath it
and zip refuses to compose. cloud.Listen mounts on a *scope, whose Use and
Group install at the root and gate by request path, so the same registration
composes there. What is driven is the production chain at the production paths:
cloud.Bridge parking the request, siteOf resolving the tenant, bindDomains
deciding.

The lifted prose and both published specs carry the corrected contract; the old
text told customers that membership of the operator org was the vouch. zipdoc
regenerates; the OpenAPI subsets cannot be projected for this app yet, so those
two files take the identical substitution zipdoc made.

apps/projects: 55 -> 58 tests passing, no test that passed on the parent fails.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 09:20:42 -07:00
hanzo-dev ad15df5781 openapi: regenerate the subsets — the ai door renamed three resources and gave permissions back to IAM
Hanzo CI/CD / cicd (push) Successful in 18s
CI/CD / gate (push) Successful in 18s
CI/CD / containment (push) Successful in 1m13s
CI/CD / image (push) Successful in 18s
CI/CD / rollout (push) Failing after 11s
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Failing after 1s
plugin/ai/openapi.json was last written at 3b50091c, with go.mod pinned to
hanzoai/ai v1.832.10. d5d768f1 took v1.832.12 for an unrelated key fix and
754fb821 took .13; neither regenerated a subset. Since then the published
document has been describing a router the binary no longer has.

v1.832.12 rewrote the ai door's resource table. Three resources moved address and
one was deleted:

	/v1/ai/applications      -> /v1/ai/deployments        8 operations
	/v1/ai/sessions          -> /v1/ai/signin-sessions    7
	/v1/ai/users             -> /v1/ai/usages/user-names  1
	/v1/ai/users/table-infos -> /v1/ai/usages/by-user     1
	/v1/ai/permissions          deleted                   6

The six deleted are GET and POST /v1/ai/permissions and GET, PUT, PATCH and
DELETE /v1/ai/permissions/{owner}/{name}. Every handler behind them was an iam.*
call to the IAM server — controllers/permission.go held no rows of its own — so
the address was a second door onto /v1/iam/permissions, which apps/iam serves and
still serves. Upstream deleted the controller with the routes and says so in the
same edit.

So openapi.yaml has been advertising 23 addresses that answer 404 and hiding 18
that are served: the 17 renamed above plus POST /v1/iam/oauth/device/info, whose
prose was written in b5d8f927 and has been waiting for a regeneration to reach the
document. Every SDK, the CLI's command tree and docs.hanzo.ai are projections of
this file, so a client generated from it calls /v1/ai/applications and gets
nothing.

floor.json comes down by six, which is the act it is designed for: the deletion is
upstream, deliberate and already shipped, so the number moves in the commit that
carries it rather than being absorbed by a re-measure. paths and operations follow
(-1 and -5 — the six net against iam's one), and iam goes up by one.

Forty-two other subsets change prose and schema only: a summary now leads with
what the operation does rather than with the Go identifier that implements it, and
visor's cluster list publishes the degraded field its response type already
carried.

Nothing in the router changed here. Regenerating a second time reproduces all 119
generated files byte for byte.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 20:39:35 -07:00
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-dev 702003f5f7 projects: 37 of 39 routes become typed zip ops
Every route apps/projects serves is now a typed zip.Get/Post/Patch/Delete
with a real In and Out and a doc comment, except the two the WIRE refuses.
A typed op is the only registration that reaches OpenAPI, the SDKs, the CLI
and the MCP door with a SHAPE; openapi.Describe gave those 39 operations
prose and no schema, which is an SDK method that takes nothing and returns
nothing. The 37 Describe calls for the routes that could be typed are gone —
the prose is the doc comment now, and the schemas are the Go types.

Measured on the regenerated subset: 39 operations, 14 with a requestBody
(was 0), 33 with a typed 2xx content schema (was 0). The six without are
four DELETEs that answer 204 No Content — declaring content on a 204 would
be a documented lie — and the two refusals.

REFUSED, and it is the wire, not effort:
  POST /v1/projects/:slug/deploy         (projects.go:452)
  POST /v1/platform/sites/:slug/deploy   (projects.go:479)
Both take a zip or tar(.gz) of the built site — raw in the body or as a
multipart part — OR a JSON git descriptor chosen by Content-Type, and answer
200 for an artifact published or 202 for a build queued. A typed In is one
JSON shape and zip.WithStatus declares one success status, so typing either
would refuse the archive upload that is its main path and mislabel half its
answers. Both keep their openapi.Describe prose.

THE WIRE DOES NOT MOVE. Same 28 paths, same 39 methods, same statuses, same
json tags, same middleware order, same body caps. Four things were needed to
keep it that way and each is pinned by a test in typed_wire_test.go:
  - requireBody replays c.Bind's 400 on a bodyless write. zip's typed decode
    skips an empty body, so a bodyless PATCH would have become "200, nothing
    changed" instead of the 400 it has always sent.
  - the bind and list domain answers are TWO Out types. They were two shapes
    already (bound vs claims); folding them into one with omitempty would
    drop an empty list from the wire — [] becoming absent.
  - the two 204 DELETEs return a nil Out under zip.WithStatus(204), so they
    stay 204-with-no-bytes.
  - the two answers that were map[string]any declare their fields in
    alphabetical json-tag order, because encoding/json sorts map keys.

THE MONEY WIRE, decomplected. Six ops gate on balance, and a refusal is the
fleet's nested {"error":{"code","message"}} 402/503 that zip's error envelope
does not speak. resource_billing.go now states that denial ONCE (denial) and
offers it two ways: DenyResource writes it, for an untyped handler; Denied
returns it and DenyEnvelope writes it back verbatim, for a typed op. This is
the blocker apps/storage, apps/ml and apps/company each filed as their reason
for staying untyped; it is now solved in the one place the denial lives.

TENANCY unchanged: the org is org() — principal.Org plus the SuperAdmin
"admin" bucket OrgFrom cannot express — resolved ONCE in apps/projects/typed.go
and never an In field. That is one new entry in allowedRequestUses, with the
three facts (tenant, platform sudo, payer) that earn it.

NOUN GATE: every schema this app publishes is package-qualified —
projectsProject, not projectView, which apps/platform already claims with a
different shape, and projectsDeployment, not deployment, which apps/o11y
claims. The one unqualified name is Record, the shared internal/fqdn type,
which any app using it emits identically.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-31 14:54:10 -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 55ef5731e0 docs: the first sentence is the product the customer bought, not the plumbing
A package's first sentence is not prose that stays in the file. openapi.Synopsis
reads it at describe time, describe.go stamps it into that app's own subset as
info.description, the weave lifts it onto the product's OpenAPI tag, and the same
string is the CLI group help line and the MCP door prose. So it is the ONE
sentence a paying customer reads before they know anything else about us — and
across this slice it was answering a question they never asked.

"Package functions mounts the Hanzo Cloud /v1/functions surface" tells a buyer
where our routes live. It does not tell them they can publish code and call it
over HTTP. "Package meet is the CONTROL plane for the virtual office" names our
half of a split they cannot see. "Package o11y is the ONE owner of the cloud
binary's observability plane — ... every part of the concept:" published a colon:
the paragraph had no sentence break, so the whole registry-internals paragraph
WAS the description. Thirty-seven packages led with a mount point, a route
prefix, an implementation noun or an internal ordering argument.

Each now opens with what the customer gets, in their words, and every fact that
was in the old opener is kept verbatim one sentence down — the route prefix, the
tenancy boundary, the fail-closed behaviour, the topology. Nothing is deleted;
it is reordered so the first sentence answers "what is this" and the rest answers
"how does it work". The four packages here with no customer (k8s, s3admin,
datastore, controlplane) keep an internal first sentence, minus the ALL-CAPS and
the word "binary" that made them read like release notes.

openapi.yaml and plugin/*/openapi.json are regenerated from source in the same
commit, because a doc change IS a published-surface change and the drift gate is
right to say so.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-30 22:52:20 -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
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