main
17
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
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>
|
||
|
|
f340b6cb72 |
dataroom: the room an agent can open, because a typed op is the only kind it can see
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
/v1/dataroom/* has served fourteen routes and reached no agent. An untyped route appends no op to zip's registry, and the MCP door renders tools FROM that registry — so the fleet answered tools/list with 570 tools and not one of them opened a data room. The hole was never the mount (dataroom mounts, and /v1/dataroom/health has been answering in production); it was that every route was an untyped relay. Ten of them are typed ops now — every JSON route on the admin surface, which is the whole demo surface: open a room, put documents in it, grant a party access, and list what exists. Each still relays the bundle's own (status, body); what changed is that it now carries an In and an Out, so the same declaration yields the tool, the OpenAPI operation, the SDK method and the CLI command. The package doc claimed NONE of these could be typed, on two premises the shared kit answers: that a relayed answer is opaque (it is not — the bundle's shapers are total and schema.go types them, which is what the models are), and that a typed error path would overwrite the bundle's envelope (it does not — BundleErr carries the bundle's status and BYTES). captable had already disproved both; this makes that the second use rather than the second copy, so Scalar, SizedIn, BundleErr and Envelope move to apps/goja, beside the bundle seam they serve. ScalarList is the one piece captable did not need. A bundle substitutes an EMPTY list for anything that is not an array, so an agent told allowList is a `string` sends one, the room discards it, and the call SUCCEEDS having ignored the access control — a link meant for one investor admitting everyone, reported as success. Declaring the array is what puts that failure out of reach. Four routes stay relays for reasons in the wire, each named at its registration: the upload takes the file itself as the body, the two /file routes answer with a byte stream, and the three public viewer routes have no principal to read. Proven: the demo flow end to end over the typed routes; the reads byte-identical to the bundle they replace; the cross-tenant link index still written, so a granted link still opens for an anonymous visitor; org scoping; the room's own refusal envelope intact; and the ten tools present with descriptions and schemas. The regenerated captable subset is operationId-only (40 lines, 0 schema changes) — pre-existing drift between the committed spelling and what zip v1.24.1 derives, corrected by regenerating from source rather than by hand. The same drift had left one captable test asserting a tool name nothing produces; it now asserts the derived one. Co-authored-by: Hanzo Dev <dev@hanzo.ai> |
||
|
|
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 |
||
|
|
3b50091c77 |
mcp: the catalogue is a query — delete the 116 committed tool files
The fleet's agent door answered from plugin/<app>/mcp.json: the tool array each
app's binary projected when it was BUILT, embedded by plugin/embed.go and handed
to zip as Plugin.Tools. 116 files, 49,865 lines, and a second source for a fact
every child already knows.
A second source can only be stale or accidentally correct. This one was stale in
the way no gate in this repository could see: o11y's 353 missing ops live in
github.com/hanzoai/o11y, so a go.mod bump in ANOTHER repo invalidated an artifact
in this one with nothing in the diff to say so. Regenerating it more often is not
the fix — a generator on a hook is still two sources with a race between them,
and the trigger is in a different repository. (
|
||
|
|
f403c603f1 |
cloud: the composition root's verb is Listen, because that is what it does
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
Serve and Listen were two names for one act. zip's App already calls it Listen — `app.Listen(zapAddr, httpAddr)` — and this function's whole job is to build that app and hand it its addresses, so calling it Serve made the entry point disagree with the thing it enters. One verb, all the way down: a plugin's main says cloud.Listen, cloud says app.Listen, and nothing has to be translated in a reader's head on the way through. 117 composition roots move with it. ServePlane is untouched — it names a different act (bind one app's own socket for the internal plane), and collapsing it into this would be the opposite of the point. Also fixes apps/iam's TestMain, which had gone red on every store test: credz.Boot's last resort is cek.EnsureDevKey, and that DECLINES on a codec-linked build by design — a build that can really encrypt must be handed a real key, not invent one. So the throwaway goes in through the same door a deployment uses, and only when nothing else supplied one. Six failures back to the one pre-existing ratchet (iam serves 97 untyped ops against a budget of 88). Co-authored-by: Hanzo Dev <dev@hanzo.ai> |
||
|
|
f5d46949d0 |
api: captable already says share and equity — /classes and /plans
GET|POST /v1/captable/share-classes, PATCH .../share-classes/:id
-> /v1/captable/classes[/:id]
GET|POST /v1/captable/equity-plans -> /v1/captable/plans
A capitalization table has one kind of class and one kind of plan. The prefix
already supplies both qualifiers, so `share-` and `equity-` were the group name
repeated inside each member — and they were the only two members that did it:
the siblings are stakeholders, shares, options, safes, convertibles, rounds,
investments, summary, every one a bare plural.
The bundle's own vocabulary does NOT move. shareClasses.create,
shareClasses.update and equityPlans.create are tRPC procedure names in the
upstream captable bundle, and the Go types are still ShareClass and EquityPlan
— those name the DOMAIN objects, which really are share classes and equity
plans. Only the addresses stop repeating the group.
console moves in the same change: lib/api/captable.ts builds both URLs,
CapTableModule.tsx prints one in a failure hint (a hint naming a dead address
is worse than no hint), and proxy-allow.ts enumerates the sub-paths its
`captable` head admits. The allowlist itself is by first segment, so it needs
no new entry.
Regenerated: apps/captable/zipdoc_gen.go, plugin/captable/{openapi,mcp}.json,
and openapi.yaml woven from the subsets.
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
c0d7b3f969 |
openapi: a product tag says what the product is, in its owning package's words
The document has always known a product's NAME mechanically — the first path segment after /v1/ — and never what the product IS. A caller reading the tag list, an agent reading the MCP door, a CLI printing `hanzo <product> --help` got 144 bare nouns. There is exactly one place that sentence is already written and already reviewed: the package doc of the package that implements the app. So this reads it rather than asking anyone to write it twice. openapi/synopsis.go Synopsis(plugin/<app>) -> the owning package's synopsis. describe.go stamps it into that app's own subset as info.description. openapi/weave.go lifts the tag prose off the subsets it already reads. ONE computation, at the one moment an app describes itself. The weave does not look the mapping up a second time in a second process — it reads the value the app that knows it already wrote down, which is why Weave stays a pure function of its parts. The owner comes from the app's own composition root: plugin/<app>/main.go imports exactly the package it mounts. Nothing else could be the source — four apps are not named after their package (audit->auditlog, evals->eval, plugins->plugin, zero-trust->zt) and one package backs two apps (account, account-bridge), so a name-derived guess is right 107 times and silently wrong 5. An app whose subsystem is another MODULE imports no package here and gets nothing, which is the honest answer. And the comment taken is the one that OPENS "Package …", not go/doc's first-file-in-filename-order fallback. Packages that open their alphabetically-first file with a note about that FILE and state the real package doc in <name>.go would otherwise publish "actions.go — the two GitOps write actions" as the deploy product's description. A misfiled sentence reads exactly like a real one; an absent one does not. 109 of 112 apps have a package doc; 85 of the 144 product tags gain a description. The three without are metrics, authz and licensing, whose subsystem is another module — there is no package here to read. The tag NAME is never conditional on a description: the list stays a function of the document's operations, so nothing enumerating products loses a product because nobody wrote a sentence. The fleet identity remains the fallback for a subset whose package has no doc, and the weave treats a part carrying it as having said nothing. THE LIFTED PROSE LOSES THE HANDLER'S OWN NAME, which is the other half of the same problem. A Go doc comment must open with the identifier it documents, and that identifier is Go's, not the document's: "GetSQL returns one database" reached the OpenAPI description, its summary, the MCP tool description an agent reads, and the CLI help line — naming a function no caller can see. zip drops an exact leading match of the handler's own name from v1.18.13 (main is on v1.18.14, whose lift is byte-identical), and nothing had regenerated against it: 35 packages carried prose the pinned zip can no longer produce. They regenerate here. Three test assertions quoted the leaked identifier and now quote the projection. Every generated artifact is regenerated FROM SOURCE (make -f mk/fleet.mk surface-check, green: 1017 paths). Nothing this commit does moves the wire: of openapi.yaml's 16,439 non-prose leaf facts, 0 changed. The 4 lost and 94 gained are all one thing — surface main already decided and never republished: /v1/insights/e removed and /v1/event given its declared body ( |
||
|
|
e247e255cf |
mcp: ONE door — three hand-rolled registries collapse into the typed-op projection
Typing a route bought OpenAPI prose, an SDK method and a CLI command, and NOTHING
on the public MCP surface. zip has projected every typed op into an MCP tool since
v1.18.6 and cloud never called it: manifest routed /v1/mcp to apps/tools, which
hand-rolled its own tools/list + tools/call over a route-table scrape, and
apps/automations hand-rolled a THIRD catalogue. Three registries for one concept,
and the one the public reached exposed none of the 549 typed ops.
THE DOOR IS THE HOST'S. cmd/cloud sets zip.MCPConfig{Path:"/v1/mcp"} and hands
each plugin its own catalogue at Load. The host is the only process that CAN own
it: MCPTools() is in-process, so a plugin cannot enumerate a lazy sibling, and a
plugin-hosted door costs its own wake on the first list. Measured: POST /v1/mcp
beats ai's "/v1" remainder by specificity, not registration order.
THE LIST IS A BUILD ARTIFACT, so tools/list costs ZERO wakes. It has to be: 112
plugins mount LAZILY, and an MCP client calls tools/list constantly — a door that
fanned out over ZAP to ask would destroy the one invariant that makes 112 services
affordable. The answer is already fixed at build time, by the same typed-op
registry that emits openapi.json, so `<app> describe <dir>` now writes BOTH
projections from ONE mount at ONE instant: openapi.json and mcp.json. They cannot
be generated apart, so a tool cannot exist without its op or carry a stale schema.
The leaf plugin/embed.go go:embeds them (cmd/cloud goes 344 → 345 packages, still
zero from apps/). Measured live with the WHOLE fleet mounted: 549 tools listed,
child count 4 → 4 (the four eager apps, untouched).
tools/call is the ONLY trigger and starts exactly one child — p.target(), the same
single-flighted lazy path a prefix request takes — then forwards the SAME message
to that plugin's own /mcp over ZAP on its 0700 unix socket. Never HTTP. The child's
registry answers, so the host can only NAME a tool, never invoke one the child did
not declare. Measured live: get_v1_pricing woke 1 child and returned the pricing
catalog; get_v1_company answered its own handler's "X-Org-Id required" through the
plugin's full cloud.Serve identity chain.
DELETED, not left dark:
apps/tools/builtin.go (223 lines) — the "full-cloud-control" route→tool scrape.
Structurally dead since the monolith died: in the tools CHILD, GetRoutes() sees
only tools' own ~13 routes, and its schemas were opaque {query,body} objects a
model cannot fill. The new door is what it meant to be, with real schemas.
apps/tools/http.go's mcp/mcpToolList/mcpToolCall/rpcResult/rpcError + the route.
apps/automations/mcp.go's mcp/mcpTools/mcpResultObj/mcpErrorObj + its route.
GET /v1/mcp — a Source view that is GET /v1/tools?source=mcp by its own comment.
Principal.credential + credentialHeaders — replay state only builtin.go read.
KEPT, because it is a different capability: apps/tools' EXTERNAL MCP server
registry (records, KMS-sealed secrets, SSRF-validated dialer, tools/list fan-out),
now owning /v1/mcp/servers alone. Its tools, org skills, agents, functions and
connector actions are ROWS, not code, so no build-time catalogue can hold them —
they are reached through the typed POST /v1/tools/call, which is itself a tool on
the door. Nothing lost: connectorToolProvider already published every connector
action into that one registry.
THE GATE. mk/fleet.mk surface-check (which .hanzo/workflows/cicd.yml → hanzo.yml
app-contract actually invokes) regenerates every app FROM SOURCE and fails on
`git status --porcelain -- openapi.yaml plugin/` — mcp.json is under plugin/, so it
was covered the moment it landed there. PROVEN TO FIRE: adding one typed op to
apps/guide without regenerating turned it red on BOTH plugin/guide/mcp.json and
plugin/guide/openapi.json; reverted, green. Four more, all cheap: no App row may
claim /v1/mcp (fiber MERGES byte-identical patterns, so a Load there would shadow
the door silently); no served path may END in /mcp; no Go source outside cmd/cloud
may name an /mcp path unless it is a named foreign engine (apps/tasks' own
surface, which is not a projection of our ops); every catalogue tool must be an
operationId of its own app, unique fleet-wide, with a NON-EMPTY description —
the last one because a nameless tool is a silent failure a model pays context for.
549 tools across 36 apps, 349KB on the wire. zip v1.18.11 → v1.18.12.
Capability check, precisely: the 17 executable connector actions the deleted
automations door listed are NOT tool names on the fleet door, because they are
per-tenant rows — connectorToolProvider publishes every one of them into the ONE
registry from the same `registry` map that door read, so they are reached through
tools_call with the same activation, price, meter and audit. Nothing is lost; one
hop is added. Same for org skills, agents, functions and external MCP servers.
One door this gate structurally cannot claim: /v1/tasks/mcp is hanzoai/tasks' own
engine surface behind cloud's identity gate, mounted on a raw net/http mux so it
is in no subset at all. It is a foreign engine's tools, not a projection of ours,
so it is NAMED in foreignDoors with the reason rather than deleted.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
|
||
|
|
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> |
||
|
|
b48721ea5a |
captable: three more writes become typed ops — the carrier the bundle's leniency needs
/v1/captable was 17 typed of 31, and the fourteen relays were all held to be untyped for one reason: the goja bundle validates with COERCING helpers, so a Go struct would accept less than the route does. Re-checked against goja/src/validate.ts, that reason is true of eleven of them and not of three. The blocker is `num`/`intNum`/`optNum` — a number OR a numeric string — and a float64 field cannot accept `"1.5"` NOR carry the token it rejects onward, so it moves both what the route takes and which envelope refuses it. Those eleven stay relays. PUT /company, PATCH /stakeholders/:id and POST /rounds/:id/close carry no number. Every field they take goes to reqString, optString or optDateString, and a verbatim `scalar` carrier hands each token to the bundle unchanged — so the bundle stays the ONLY validator, of what is accepted and of how it says no. The carrier is a string KIND, so every projection describes these fields as `string`, which is what they are; and it is NOT a pointer, because encoding/json nils a pointer for an explicit `null` without calling UnmarshalJSON, which would collapse the absent-vs-null distinction stakeholders.update writes columns on. The relay's 413 survives: a typed op never sees the request, so the body size is recorded in the input's own UnmarshalJSON and read back AFTER the tenant — which keeps a 403 ahead of a 413 for the caller that has both problems. writes_test.go proves it rather than asserting it: every case is sent through the typed route AND dispatched on the bundle the way the relay did, and compared on status, Content-Type and bytes — including a number where the bundle reads an optString (a 200 storing "5", which a *string would have 400'd), a number where it reads a reqString (the bundle's 400 with its `errors` list, which zip's envelope has nowhere to put), null vs absent vs "" on the partial update, and a body that is not an object at all. Two orderings do move, both for a request with no valid tenant: malformed JSON or an oversized body now answers before the 403, because zip decodes ahead of the handler. Named in writes.go rather than left to be found. Co-authored-by: Hanzo Dev <dev@hanzo.ai> |
||
|
|
e6a5d92882 |
captable: the blocker was the REQUEST, not the response — 6 more ops, 17 of 31
apps/captable had 11 typed ops (the collection reads) and 20 raw relays. All 20
refusals rested on one claim: a /v1/captable write relays the goja bundle's own
(status, body), including four envelopes cloud has no vocabulary for — 400
{success,message,errors}, 404/409 {success,message}, and the top-level catch's 500
— so a typed op whose error path renders zip's {status,code,error} cannot express
them without moving the wire.
That half is true and now solved rather than avoided. bundleErr carries the
bundle's status and its BYTES as a Go error; bundleEnvelope, a group middleware
registered beside cloud.Bridge, writes them back untouched. A reachable non-2xx is
no longer a reason for a captable route to stay raw. bundleErr.Unwrap yields a
*zip.HTTPError, so OFF the HTTP path — an MCP tools/call, an in-process CLI invoke,
neither of which passes the middleware — the answer is still the bundle's status and
message rather than a blanket 500.
The REAL blocker is on the request, and it is why 14 routes are still raw. The
bundle validates with COERCING helpers (goja/src/validate.ts): `num` accepts a
number OR a numeric string (z.coerce.number), `optString` accepts any scalar and
calls String(v), and addStakeholders accepts a single object OR an array. zip
decodes a typed In with encoding/json, which answers 400 "invalid body" to every
one of those — so typing would make the route accept LESS. Each of the 14 now names
the field that does it instead of citing the response.
That splits the surface on a line that is checkable, not a matter of taste: a route
with NO REQUEST BODY has nothing to coerce, so its In is faithful by construction.
There are exactly six such routes left, and they are the six typed here:
GET /v1/captable/rounds/:id round + its cheques
DELETE /v1/captable/stakeholders/:id
DELETE /v1/captable/shares/:id
DELETE /v1/captable/options/:id
DELETE /v1/captable/safes/:id
DELETE /v1/captable/convertibles/:id
17 of 31 operations typed and described, up from 11. plugin/captable/openapi.json
and openapi.yaml gain 160 and 148 lines of schema and prose and lose NOTHING: 21
captable paths and 31 captable operations before, 21 and 31 after, zero removals,
zero additions.
WIRE, PROVEN BY TEST — bodyless_test.go re-derives the pre-typing answer on every
run (a direct Dispatch of the same bundle route with the same params IS what the
raw relay wrote) instead of trusting a recorded golden, and compares status,
Content-Type and body bytes on BOTH arms:
- 2xx, through zip's typed JSON writer: the round detail is byte-identical on the
closed PRICED round (closeDate/pricePerShare/preMoneyValuation/shareClassId all
set, one cheque with a comment) and on the OPEN SAFE round (all four null, no
cheques); each delete's {"success":true} is compared against a direct-dispatch
delete of the matching row in a second identically-seeded tenant.
- non-2xx, through bundleErr: every 404, plus the 400 that MOTIVATES the whole
mechanism — refusing to orphan issued equity carries an `errors` LIST, and
zip's envelope has nowhere to put it. The list survives, byte for byte, under
the same bare `application/json` the relay sent.
Both proofs were watched go RED: stubbing errors.As out of bundleEnvelope fails
three tests, and swapping two fields of captableRoundDetail fails the byte
comparison.
DELETE takes its input from the URL and carries no body — zip's hasBody rule, which
the document reads too — so the five deletes publish a path parameter and no
requestBody, which is what the raw routes already did (readBody=false).
ONE LATENT DEFECT, FIXED. The typed reads' non-2xx arm was NOT unreachable. Their
read() turned any non-2xx into zip's own 500 "captable dispatch failed", and the
bundle's top-level catch answers 500 {success,message} on a SQL error — so a read
that hit one had silently moved from the bundle's envelope (with its message) to
cloud's (without it) when the reads were typed. read() is gone; every op now goes
through one call(), so that arm relays the bundle's bytes again, as it did before
typing. Unreachable-by-construction arms (getCompany/capTable's defensive
notFound) are unaffected.
Registration order is unchanged in effect: no two /v1/captable routes overlap on
method + pattern, so moving the six into the typed block cannot shadow anything.
Gate: make -C apps/captable {test,vet} green (baseline was green), 12 tests, all
pass; zipdoc regenerated and idempotent; ./openapi (the weave) and ./manifest
green; apps/{esign,goja,company} — the other goja-bundle leaves — still green.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
|
||
|
|
90dd9b309f |
type(captable): the 11 reads become typed ops; the 20 writes cannot move their wire
/v1/captable relays a goja bundle's own (status, body). The eleven READS end in
okRes on every reachable path, so 200 is the only answer and the body is a shape
Go can state: they become zip typed ops and reach the document, the MCP tool
list, the CLI and the generated SDKs — 11 operations that carried a path and a
method and nothing else now carry a schema and prose.
The twenty WRITES stay untyped, each with the reason written at its registration.
The shared one is not effort, it is the wire: the bundle authors its own error
envelope — 400 {success,message,errors}, 404/409 {success,message} — and a typed
op's failure path can only render zip's {status,code,error}. Typing one would
change what every existing client parses on every validation failure, so it does
not get typed. Two also read the body in ways a Go struct cannot state (a single
object OR an array; `quantity` OMITTED meaning "the whole certificate", which a
zero value cannot say). That is the same class as multi-status: a contract detail
zip has no vocabulary for yet.
FIELD ORDER IS LOAD-BEARING, once. The bundle's rows cross goja as
map[string]any and are serialised by encoding/json, which sorts object keys, so
every model declares its fields in alphabetical json-tag order and the typed
response is BYTE-identical to the relay it replaces — not merely equal as JSON.
Nullability is the DDL's: a nullable column is a pointer, so null stays null.
TestTypedReadsAreByteIdenticalToTheBundle pins that against the bundle's own
bytes on an empty tenant AND on one written through the untyped relays with every
nullable column exercised both ways.
cloud.Bridge goes on the group: a typed op receives only a context, so the
validated org reaches it by being parked there, never as an In field — an In field
is caller-supplied, so a tenant key read from one is a cross-tenant read the
caller asserted for itself. TestTypedReadsAreOrgScoped proves the typed plane
refuses byte-for-byte as the untyped one does and that one tenant never sees
another's rows.
Verified beyond the suite: the concrete route table is unchanged (same 31
method+path pairs, nothing added, nothing lost), the bare prefix still 404s, and
a 110-line dump of every route's answer — success and every error branch, anon
refusals, cross-tenant reads — is byte-identical between this tree and main
across three runs each.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
|
||
|
|
85dd3a6513 |
name: a thing the host loads is a Plugin
MountSpec was a compound naming a struct after the mechanism that consumes it. The thing it describes is one of the plugins the host loads: name, price, mount. The directory is plugin/, the framework is the zip plugin framework, and every doc comment already called them plugins in prose. So: Plugin. Not App, which was the obvious first choice and is wrong twice over -- package cloud already declares an App in payloads.go, and the struct itself carries an App field for a subsystem that gates the whole binary. Either collision alone would have made the name ambiguous at every use site. Mechanical: 132 files, plus the parameter and loop variables that carried the old noun (specs, spec, sp) to the noun they actually hold. |
||
|
|
2c4b045b0b |
cmd/cloud + plugin/<app>: the light host is the one binary — scope credentials, forward flags, own "/"
Restructure to the canonical layout: cmd/host → cmd/cloud (the host IS the one real binary; name it cloud), and every other cmd/<app> → plugin/<app>. `ls cmd/` is `cloud/` alone; `ls plugin/` is the 116 per-app + tool dirs. gen-app-cmds scaffolds into plugin/<app> and scans plugin/ for the bijection; the Dockerfile / Makefile / mk / hanzo.yml / weave / controlplane-containment gate all read the new paths. go build ./cmd/cloud links ~399 pkgs and zero subsystems. credz KMS-key leak (#51 follow-up): zip builds each child's env as append(os.Environ(), Plugin.Env...), so a host that keeps CLOUD_KMS_MASTER_KEY_REF hands the root key to EVERY child — the Root posture credz exists to prevent, and now the default entrypoint. cmd/cloud (stdlib credz/launch only — importing credz would drag cek→sqlite and re-fatten the host) mints the launch secret, scrubs the root key from its OWN environment, stamps each child a scoped CREDZ_TOKEN, and re-injects the root key onto the kms broker child's Env ALONE. Every generic child comes up with a token and no key and must ask the broker. Pinned by cmd/cloud/main_test.go and proven by a live dns spawn. helm flag forwarding: cmd/cloud accepts --brand/--domain/--data-dir/--iam-issuer (the args the chart passes the entrypoint) and republishes each non-empty one as its CLOUD_* env, which the per-app children read; an empty flag never clobbers a value already pinned in the environment. console at "/": nothing served the host root once mountConsole moved into the per-app cloud.Serve. Extract the console into a light webui leaf (stdlib + embed + a new strings-only brand leaf, both aliased back into package cloud so no call site changes) so cmd/cloud — the front door — owns "/" and serves the white-labelled SPA. The host stays ~399 packages and imports zero subsystems. Co-authored-by: Hanzo Dev <dev@hanzo.ai> |