7 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
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 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 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>
2026-07-30 23:00:51 -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 fc46e91626 type: 39 of 49 ops the document could never describe — and the ten it must not
knowledge, do, flags, research, treasury and storage published 49 operations
between them and ZERO descriptions: no prose, no MCP tool, no CLI command, no
typed SDK method. 39 are now typed ops — one registry entry that is at once the
REST route, the OpenAPI operation with its schemas, the tool and the command.

  do         0 -> 8 of 8      treasury   0 -> 8 of 8
  flags      0 -> 8 of 8      knowledge  0 -> 8 of 9
  research   0 -> 7 of 8      storage    0 -> 0 of 8

The ten refusals are wires this stack cannot yet describe, each recorded at its
own registration so the next engineer re-checks the blocker instead of
re-deriving it:

  - storage's seven data-plane ops + /health. A refused balance answers through
    cloud.DenyResource, which writes the fleet's NESTED
    {"error":{"code","message"}} 402/503 IN BAND; a typed op's only refusal
    channel is a returned error, which zip renders flat. The same refusal
    apps/ml and apps/company already file. /health answers ONE object under TWO
    statuses, which a single WithStatus cannot say. Two of the seven are refused
    twice over: fiber's `*` has no typed-op spelling — zip leaves it in the op
    path while openapi.translate renders the route as {wildcard1}, so Fold would
    fail with "typed op has no live route".
  - POST /v1/kb/import takes an UPLOAD (a vault zip, an .enex, a JSON export).
    zip decodes a typed body as JSON before the handler runs.
  - GET /v1/research/artifacts/:sha256 streams raw bytes under the artifact's
    own Content-Type. A typed op serialises a Go value as JSON.

Defects the typing surfaced, all fixed here:

  - flags.Store.Upsert PANICKED on a body of `null`: it unmarshals into a NIL
    map without error and the next line assigned into it. Any caller could send
    it. Now refused like every other non-object. Pinned.
  - apps/treasury registered a typed op with NO //go:generate zipdoc directive,
    and its handler was a closure — a function literal has no doc comment, so
    /treasury/reserve published an empty description on every projection. Named
    and documented.
  - SIX schema-name collisions the weave caught, because the published schema
    namespace is FLAT (zip keys on the bare Go type name): research's `Totals`
    against admin's, knowledge's authorizeOut/connectorsOut/syncOut against
    integrations' and admin's, and the treasury ledger's `Entry`, `Report` and
    `Policy` against catalog, admin and gateway. Renamed to JournalEntry,
    TreasuryReport, SharePolicy, ResearchTotals and kb*-prefixed. Go identifiers
    only — every json tag is untouched, so no wire moved.
  - the six apps had no cloud.Bridge of their own, so their tests mount on a
    bare app where Serve never runs. Installed per DECLARED prefix.

PUT /v1/flags/defs/:key is the interesting conversion: its body IS an open
PostHog document, stored verbatim. An ordinary struct In would drop every field
it does not name — silent data loss behind a green 200 — so the In states its
own wire form and zip publishes "any JSON" instead of a fabricated object. The
path still wins over the body's own key. Four tests pin it.

Every wire is preserved: 201 and 204 declared on the op (a DEFINED empty Out
publishes "200 with a body" about a 204 — apps/git and apps/tools still do),
query-vs-body binding unchanged, the connector rows' *string keeps
present-but-empty distinct from absent.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-30 10:00:15 -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