Commit Graph
4 Commits
Author SHA1 Message Date
zeekayandHanzo Dev 5f71d2496b The router is three nouns, and every one of them is concrete
App, Group, Operation. A group is a prefix and what holds under it, an operation
is one typed route declared there, and the verb methods are generic methods —
which Go 1.27 allows on a concrete type and still refuses on an interface. So In
and Out come from the handler and the caller writes no type arguments:

	users := app.Group("/v1/users")
	users.Post("/", api.Create)

	mint := users.Group("/mint").With(middleware.Mint(gate))
	mint.Post("/deposit", api.Deposit)

A Group is a CLOSED HANDLE: two unexported fields, and no method that hands back
the app, the parent or the fiber underneath. A subsystem given the group at its
own prefix cannot reach above it, so confinement is a property of the type
instead of a rule something has to police — which is what a host needs in order
to hand out a bounded surface and know what comes back.

That is bought by giving up the router interface, deliberately. Router and
OpTarget are gone, and with them the ability to implement a route table outside
this package: a decorator is no longer a router, a gate is middleware on a group,
and a bound is a path. The five package-level verb functions go the same way —
a registration is a method on the thing it registers into.

Raw carries what is not a typed operation: a stream, an upgrade, a probe, a
foreign subtree. It names its method, and it is the longer spelling because
opting out of the typed model is the exception and should read like one.



Co-authored-by: Hanzo Dev <[email protected]>
2026-09-17 01:39:17 -07:00
zeekayandHanzo Dev 8f6dcca49e wire: a bodyless method, spelled once — DELETE stops reading a body
BREAKING, on purpose. hasBody called itself "the ONE place that rule lives" and
was the only site that called it; three others re-stated it differently and
DELETE had drifted:

  - typed.go read a body for anything but GET/HEAD, so a typed Delete decoded a
    request the document says has none;
  - cli.go's Remote.Invoke SENT that body, so the CLI's remote path and its
    in-process path disagreed about one op;
  - cli.go's bindIn offered every field as a flag, including ones no URL can
    carry — `--tags '["a"]'` on a DELETE marshalled fine, went out as a query
    value, and was dropped by the binder. The command silently did nothing.

All four now call hasBody. A typed Delete takes its input from the URL: path
params and query string, which is what every client generated from that document
already sent. It is a rule about the WIRE, not about the op — op.invoke still
decodes whatever JSON it is handed, because a call addressed by NAME (MCP
tools/call, zip.Call, a command) has no URL to carry half the input in.

Two things a bodyless op lost in the document are closed with it:

  - REQUIRED-NESS REACHES THE PARAMETER. urlField.required reads the same
    `validate:"required"` the body schema does. Every query parameter was
    hardcoded optional, so a field the handler refuses to run without was
    described as optional and every generated client made the argument optional
    to match — a document describing a call that cannot succeed.
  - THE EXAMPLE SURVIVES. A bodyless op has no requestBody for the doc comment's
    Example to live in, so it rides the parameters that carry its values and
    CommandsFromSpec rebuilds the object from them. Before this, every GET and
    DELETE reached the published reference and the generated CLI with no example
    at all.

TestCLI_SpecAndRegistryAgree is now plain equality over every field of every
command for every method. The skip is gone, and so is the divergence helper that
replaced it one release ago: there is no divergence left to pin.

Also: a validation failure names the field the way the CALLER named it, through
the same jsonFieldName rule the decoder, the schema and the flag all read.
Someone who sent `reason` was told `field "Reason"` is required — a name that
appears nowhere in the document they were working from.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-28 17:07:47 -07:00
zeekayandHanzo Dev 72353f9d03 openapi: one type, one definition — and a cycle can no longer take the process down
schemaOfDoc took a `registry` map and never wrote to it. Two consequences, one
cosmetic and one fatal:

  - no $ref sharing. A type reached by ten ops was inlined ten times.
  - NO CYCLE GUARD. `type Node struct { Children []Node }` recursed forever, so
    building the document overflowed the stack — and so did listing the MCP
    tools. A Go stack overflow is not recoverable, so a service whose input had
    a self-reference could not start at all.

schemaRegistry makes the registry real: a named struct is described ONCE and
every use of it is a $ref. The entry is claimed BEFORE the type's fields are
walked, and that claim is the cycle guard — a type that contains itself finds
the definition already in flight. Two packages that both call a type Config no
longer overwrite each other either; the second is qualified by its package
instead of silently publishing one type's fields under the other's name.

The projection owns where its definitions live: #/components/schemas/ in a
document, #/$defs/ in a standalone schema. rootSchemaOf is the standalone form
an MCP inputSchema needs — the root inlined (a tool's schema has to BE the
object) with everything it reaches carried alongside, and no $defs at all when
nothing refers to anything, so a flat tool schema is byte-identical to before.

Three more things stop re-deriving what the registry already knows:

  - PATH PARAM TYPES. They were hardcoded "type":"string" while query params
    consulted the input type — the same value described two ways depending on
    which half of the URL carried it. bindURL is ONE binder over ONE set of
    fields, so urlFields is now one list and the document reads it for both.
  - FLAG KIND. cli.go's flagType re-derived the type vocabulary from reflect.
    It now asks schemaOf what the field IS and maps the answer with specType,
    the same rule the spec-derived CLI uses, so a flag spelled from a Go type
    and one spelled from that type's published schema are equal by construction.
  - COMMAND IDENTITY. commandName computed a different op identity from opName
    and ignored WithOperationID entirely: renaming an op renamed it in the
    document, the tool list and the call plane while the CLI carried on
    spelling itself from the route. One op had two names and no test related
    them. Command.OperationID now carries the one token every projection uses,
    CommandsFromSpec reads operationId off the document, and an explicit id
    renames the command with everything else.

TestCLI_SpecAndRegistryAgree stops hiding the hasBody drift. It used to
`continue` past GET/HEAD/DELETE while the fixture registered no DELETE — the
case was skipped twice over. It now registers one and pins the divergence
precisely: the document names the registry's scalar flags and only those,
declares them optional whatever `validate` says, and drops the example. None of
that is endorsed; closing any of it fails the test and sends you to LLM.md.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-28 16:37:57 -07:00
zeekayandHanzo Dev 170e572463 cli: the registry is the CLI, and the doc comment is what describes it
One registry, three projections. A typed op already produced a REST route, an
OpenAPI path and an MCP tool; it now also produces a command. cli.go derives
`<service> <operation>` from the op's own identity, so adding a route can never
rename an existing command, and LocalInvoke runs it through the op's own invoke
seam — a fused binary needs no client because the command IS the handler call.
clispec.go builds the same commands from a served spec, which is what lets the
CLI drive a service it was not linked against.

cmd/zipdoc + internal/zipdoc close the gap Go leaves: doc comments do not exist
at run time, so prose reaches the spec only via a build-time AST pass. Field
docs and examples are extracted from source and keyed "<Type>.<field>".

Two defects found by making the projections agree:

commandName treated "ends in a parameter" as the only way to address one thing,
so GET /v1/paas/apps/:app/deploy became apps-deploy-LIST — named for a
collection, returning one deploy. The route already says which it is past its
parameter: /apps/:app/deploy is singular, /nodes/:node/jobs is plural.

OpenAPI parameters carried no description while the registry had one, so the
same argument had help in-process and none from the spec. Both now read the
same key, and query parameters get it too.

internal/jsontag exists so the reflect view of the wire-name rule and the AST
view cannot drift; transport gains a dial-only https, because reaching a
published API is an ordinary Mount and terminating TLS stays the ingress's job.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 17:02:36 -07:00