7 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 478e06d1d4 Authorization can hold an op, not only allow or refuse it
The hook answered allow-or-error, so an operation that needs a person to
sign it off had nowhere to go but a refusal. A caller told 403 stops; a
caller told the request is held waits, and those are different futures.

An Authorizer now answers a Decision — Allow, Deny or Approve, with the
clause that reached it and the reason a person reads — resolved ONCE in
the run() contract that op.invoke and op.direct both funnel through. The
projections only render it, so all six agree without a branch in any of
them: REST answers 403 or 202, the call plane the same two over ZAP, MCP
carries the held body as content rather than an error because nothing
failed, the CLI prints it, the graph reports it beside the field it was
selected for, and a Go caller reads it off the error lane with HeldOf.
An Approval has no Out and never will have one — the handler did not run
— which is the whole reason it does not travel the value lane there.

Clause, not rule. This package already spells an Authorizer "the rule"
— App.rule, adopt, "two rules that disagree" — so the token naming which
part of it fired needs its own word, and a rule has clauses.

The error return survives for a genuine failure of the CHECK: an
authority that cannot be reached has not decided anything, and answering
503 rather than 403 is what lets a client retry.

The op carries the question rather than the app, because they differ. A
composed child answers to whatever included it, and a mounted op has no
local rule at all — so the document publishes the held body exactly where
the seam would produce one, and a gated service's OpenAPI reads as the
pair a generated client needs.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-04 00:16:37 -07:00
zeekayandHanzo Dev 158735fcf0 A path parameter arrives as the caller wrote it
A URL carries a segment percent-encoded — it has to: a space, a slash, a
percent or anything outside the unreserved set has no other spelling
between two slashes. The router matched on the raw text and handed that
same text on, so a handler addressed at /v1/secrets/café was given
"caf%C3%A9" and looked up a name nobody has.

Measured against a live listener before this: every path parameter
carrying such a character arrived encoded, in the typed binder and in
Ctx.Param alike. Every client encodes, because there is no other way to
write those characters, so no client could make such a value round-trip
and the address was unusable for anything but a bare identifier.

Text that is not valid encoding is passed through rather than refused: a
lone percent is a malformed URI, but the address it names is still the
address the router matched, and failing here would turn a lookup that
finds nothing into a 500.

Found by running a generated HTTP client against a real service.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-01 11:49:29 -07:00
zeekayandHanzo Dev 9118f9c2e4 WIP: plugin+MCP state still needs to move to the walk
The verb deletion is done and the library builds: Graft, Add, Service, the
Mount VERB and the claims ledger are gone; Load and Mount return definitions;
Plugins/Reload/Unload now find their plugin by walking.

NOT DONE — the same entanglement, one layer over: a plugin's MCP catalogue
(pluginTools/toolOwners/open) is still read off the host that loaded it, but a
plugin is now a definition, so the host reads an empty set and serves no door.
Four mcp_compose/mcp_open tests fail on exactly that. The fix is the same one
that worked for Plugins(): aggregate across App.hosts() instead of reading a
private map, and move the duplicate-tool-name refusal to the walk so two
plugins can actually see each other.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-02 12:34:35 -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 4b79de5cc4 feat(typed): a typed op reads the whole URL, not half of it
An op bound its input from the body and the path and stopped there, so the
query string — the other half of the URL — was unreachable. That made every
route carrying `?q=` inexpressible as a typed op, which is most of a read API:
it had to stay an untyped handler, and an untyped handler is absent from BOTH
projections. Measured on hanzoai/cloud: 1372 live routes, 0 MCP tools, because
not one route could be typed.

bindPath becomes bindURL — one binder for both URL sources, because a path
param and a query param are the same kind of value (a name and a string carried
by the URL) and two functions for one idea drift. It converts by the field's
kind (string, bool, sized ints/uints, floats) instead of binding strings only.

The three sources bind in increasing authority: body, then query, then path.
Query beats the body because it is part of the URL; path beats query because it
is the part the router MATCHED on. The authorizer still runs below all three on
the same decoded value, so the target it authorizes is the target the URL named.

A malformed scalar leaves its field zero rather than failing the call: `?limit=abc`
is a caller's typo about ONE field, and refusing the request would make a typed
GET brittler than the untyped handler it replaces. `validate:"required"` is how a
field becomes mandatory.

The document follows the binder: a bodyless op declares its non-path input
fields as query parameters, typed and optional. hasBody() is the single
predicate deciding "body or query" for both the binder and the doc, so the two
cannot disagree about what a request looks like.

MCP is untouched — a tools/call carries every argument in its JSON object, so
neither query nor path binds there.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 16:36:11 -07:00
zeekayandHanzo Dev b9e5c40257 feat(typed): bind URL path params into the typed input
A typed op decoded only the request body, so a route could not carry its
target in the URL — which is why every service that needed to address one
resource spelled it as POST /<resource>/get with the key in the body.
The dialect was a symptom of the framework, not a choice.

Path params now bind onto the decoded input by json tag, applied AFTER the
body so the URL wins. The URL is what routed the request, so it is the
addressing authority, and because the authorizer runs on this same decoded
value the target authorized is by construction the target the URL named — a
body cannot smuggle a different one past it.

Only top-level string fields bind: a path param is a string on the wire, and
a nested record is not a path target (an input that nests declares its target
explicitly instead of having it guessed out of a sub-struct the caller also
controls). Routes with no params are untouched, so no existing op changes.

OpenAPI now declares every templated segment, derived from the same route
pattern the router matches on, so the spec cannot describe a parameter the
route lacks.

MCP passes no path params: a tools/call carries every argument in its JSON
arguments object, so the body is the whole input.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 11:15:39 -07:00