D-0004 — ReqReply Workflow Simplification — design decisions¶
Status: Implemented — architectural foundation. All 5 phases (0-4:
api/reqreplycore types,adapters/mqtt5migration, theexamples/reqreply-apimini-project,adapters/zeromqmigration) are shipped and verified; Phase 5 (doc sync + doc promotion) is this promotion itself —Builder/NewBuilder/BuilderOptionare kept as DEPRECATED aliases (zero-cost, no behavioral duplication, matchingd-0002's own precedent for aliases).mqtt5/zeromq's lower-levelServe/Call/ServeRouter/CallDealer— RESOLVED (see "Addendum:reqreply-middleware.mdandzeromq-security.md" below for the final outcome). Phase 5 originally kept these permanently as documented escape hatches (mirroring REST'sServeOne/CallWithHandle); that analogy was found FLAWED during a later review (reqreply-middleware.md, now folded into the Addendum below), reopening the question of whether to retire them entirely. The FINAL resolution: NOT deleted — they kept their exact existing signatures (zero breaking change), with their bodies rewritten to construct aserverTransport/clientTransport(etc.) directly and delegate to it, eliminating the duplicate dispatch logic that motivated reopening the question in the first place, with zero caller/example migration required.docs/guides/mqtt5.md/docs/guides/zeromq.mdcurrently lead with theServer/Client+Attachworkflow and demote the lower-level functions to an explicit "escape hatch" section (still accurate today, pending the retirement work above), and.github/instructions/go-codex.instructions.md'sapi/reqreply/adapters/mqtt5/adapters/zeromqrows were brought current (they had fallen behind Phases 0/⅓, still describing the pre-Server/ClientAPI — a real doc-sync gap this promotion also closed). Establishes the patternd-0001(REST) andd-0002(pub/sub) already established —Server/Client+Attach,route.WithHandler(fn).Register(server)— extended to request-reply's genuinely asynchronous transport via the additiveCallAsync/Future[Resp]mechanism.Below is the original phase-by-phase implementation record (
api/reqreplycore types:Server/Client/ServerTransport/ClientTransport/Future/FutureFactory,Route.WithHandler+Register(*Server),RegisteredTopics/Topical,ServerEntryrename to resolve theServernaming collision between the asyncapi entry type and the new dispatch-owning type —Builder/NewBuilder/BuilderOptionkept as DEPRECATED aliases forServer/NewServer/ServerOption, zero breaking changes to existing callers, all pre-existing tests/examples pass unchanged;adapters/mqtt5'sAttachServer/AttachClientANDadapters/zeromq'sAttachServer/AttachClient/AttachRouterServer/AttachDealerClientreflection-shim transports (covering REQ/REP AND ROUTER/DEALER), plus a repo-wide mechanical migration ofports+ all example/test call sites off the deprecatedBuilderAPI to eliminate thestaticcheckSA1019 regression it caused; the consolidatedexamples/reqreply-apimini-project —routes/,handlers/,mqtt5server/,zeromqserver/,zeromqrouterserver/,client/packages plus 7 demo files andmain.go— replacing the deletedexamples/adapters-zeromq-reqrep/examples/adapters-zeromq-dealer- routerandexamples/adapters-mqtt5's stripped-out request-reply demos). Verified:go build ./...,go test ./...(incl.-race),staticcheck,gosec,gofmtall clean; new unit test suite (error taxonomy, dual-mode Call, Server/Builder unification, WithHandler/Register fluent dispatch, concurrent Serve w/ both blocking- and non-blocking-transport fakes, CallAsync/Future round trip, FutureFactory type-erasure) plus a new package-levelExample(); mqtt5's 3 and zeromq's 7 new reflection-shim tests all pass under-race; every one ofexamples/reqreply-api's 7 demos runs correctly viago run ./examples/reqreply-api(multiple repeat runs, no flakes, after fixing one genuine mqtt5-Serve-registration startup race with a 50ms sync sleep — mirrorsexamples/adapters-mqtt5's own established convention);for d in examples/*/; do go run ./$d; donere-verified with zero failures across all examples, including the retired-and- rebuiltexamples/adapters-mqtt5. Phase 5 (full doc sync + doc promotion) is ALSO now shipped — see this doc's top status header above and its own Phase 5 entry in the phased implementation plan below for the full record. Core mechanism CONFIRMED via 5 rounds of throwaway Go prototypes prior to this phase. Final confirmed shape, in brief (see Decisions 1-5 for full evidence):reqreply.ServerUNIFIES what a now-RETIRED, separateBuildertype did (spec accumulation,AddGlobalSecurity) with dispatch/transport — ONE type, mirroringrest.Server's own unification exactly. Server-side declaration is ONE fluent chain —route.WithHandler(fn).Register(server)— matching REST's REAL, dominant idiom byte-for-byte (an earlier round's separate freereqreply.Handle(server, handle, fn)function is superseded).Server.Serve(ctx)dispatches every registered route CONCURRENTLY (one goroutine per route, confirmed necessary — a sequential loop would starve everyzeromqroute after the first, sincezeromq. Serveblocks forever whilemqtt5.Servereturns immediately).Client.Callaccepts EITHER a raw, unregisteredRoute(REST-style, zeroServerneeded,GlobalSecurityinvisible — the SAME accepted limitation REST's ownClientHandle()has) OR an already-registered*RouteHandle(withGlobalSecurityenforced) — strictly MORE flexible than REST, not merely matching it. An ADDITIVE, reqreply-only asyncCallAsync/Future[Resp](Decision 5) exists alongside the blockingCall, needed because reqreply's transport (unlike REST's synchronous HTTP) is genuinely asynchronous underneath.zeromq.Attachtakes a topic→socket mapping (not a shared client value), validated at Attach time. A NEW, consolidatedexamples/reqreply-apimini-project (mirroringexamples/rest-api's layout) was planned here and has SINCE SHIPPED — see "Example mini-project" below — replacing 3 existing examples (adapters-zeromq-reqrep,adapters-zeromq-dealer-routerdeleted entirely;adapters-mqtt5's request-reply demos stripped out and rebuilt there) with 7 focused demos, INCLUDING a dedicatedCallAsync/Futureasync-call-and-promise demo. Remaining open items (below) are genuinely deferred design choices (mqtt v3/zeromq Fn shapes, migration checklist, Feature-declaration status for Response Topic/Correlation Data), not blocking gaps. Replaces the now-deleted "Events/ReqReply/Ports Workflow Simplification" doc'sapi/reqreply- scoped content (that doc's pub/sub-scoped content is superseded by Pub/Sub Workflow Simplification, now fully implemented). Spun out of a dedicated thin-adapter review (see Feature/Provider's status banner, then titled "Protocol-Native Feature Declarations") that confirmed REST and pub/sub already follow the codebase's guiding principle — adapters stay THIN (pure IO, attach-only, adapter-specific config/options); ALL workflow (middleware/handler attachment, calling client/server functions) lives in theapi/*declaration layer — butapi/reqreplydoes not. This doc designs the fix: areqreply.Client/reqreply.Server+Attacharchitecture mirroringrest.Client/rest.Server(the reference model), PLUS folds forward the still-relevant middleware/security decisions the deleted doc had already resolved forapi/reqreplyspecifically. ← Back to Roadmap
Why this exists¶
A dedicated review (triggered while reviewing Feature/Provider — then titled "Protocol-Native Feature Declarations" — against the codebase's guiding principle) confirmed, via direct code inspection:
- REST already fully follows the thin-adapter principle.
rest.Client/rest.ServerownAttach/Call(api/rest/builder.go);adapters/nethttp.Attach/AttachMux,adapters/chi.AttachRouterare thinClientTransport/ServerTransportbinders ONLY — no workflow logic lives in the adapter package. - Pub/sub (
api/events) already fully follows it too.events.ClientownsAttach/Publish/Subscribe/ServeSubscribers;mqtt5.Attach/mqtt.Attach/zeromq.Attachare thinTransportbinders. Decision 7 of Pub/Sub Workflow Simplification added a second, complementary spec-free path (events.PublishHandle/SubscribeHandle+ each adapter'sNewPublishTransport/NewSubscribeTransport) that keeps the SAME split: the adapter provides a thin per-Ttransport value; the workflow FUNCTION lives inapi/events. api/reqreplyis the one confirmed violation. It has NOClient/Server/Attachtype at all. The entire request-reply WORKFLOW — topic subscribe, correlation-ID generation/matching, reply-topic management, handler dispatch loop — lives directly insideadapters/mqtt5.Serve/.Callandadapters/zeromq.Serve/.Call/.ServeRouter. Users call ADAPTER functions directly today (mqtt5.Serve(ctx, client, router, handle, fn, opts),mqtt5.Call(ctx, client, router, handle, req, opts)) — the opposite of every other boundary's shipped design.- This is not a hypothetical concern: it is exactly where
d-0006-protocol-native-capabilities.md's own confirmed, already-shipped protocol-native capability — MQTT5's Response Topic + Correlation Data — is hardwired (adapters/mqtt5/reqreply.goreads/writesmsg.Properties.ResponseTopic/CorrelationDatadirectly insideServe/Call). Fixingapi/reqreply's architecture is a prerequisite for cleanly exposing that capability (and Shared Subscriptions, for reply-topic fan-out) as realFeaturedeclarations under that doc's now-generalized model.
The reference model — rest.Client/rest.Server¶
// api/rest/builder.go (existing, shipped)
type ServerTransport interface{ /* adapter-implemented IO binding */ }
type ClientTransport interface{ /* adapter-implemented IO binding */ }
type Server struct { /* accumulates registered routes/spec */ }
func (s *Server) Attach(t ServerTransport) error
func (s *Server) Serve(ctx context.Context) error // or equivalent wiring
type Client struct { /* accumulates registered routes/spec */ }
func (c *Client) Attach(t ClientTransport) error
func (c *Client) Call(ctx context.Context, route any, req any) (any, error)
adapters/nethttp.AttachMux(builder *rest.Server, mux *http.ServeMux, addr
string) error and adapters/nethttp.Attach(client *rest.Client, httpClient
*http.Client, baseURL string) error are THIN — they wire the adapter's
own IO primitive (an http.ServeMux/http.Client) to the rest.Server/
rest.Client value as its ServerTransport/ClientTransport. All
routing, middleware dispatch, security enforcement, and request/response
lifecycle logic lives in api/rest itself, shared identically by
nethttp and chi.
Proposed design — reqreply.Client/reqreply.Server¶
Decision 1 — introduce Client/Server + Attach, mirroring REST exactly¶
// api/reqreply (new)
// ServerTransport's shape is CONFIRMED (not a placeholder) — Serve is
// called ONCE PER REGISTERED ROUTE, any-typed/reflection-recovered by
// the adapter, exactly like ClientTransport.Call. See Decision 1's
// "concurrent dispatch" finding below for why Server.Serve calls this
// PER ROUTE, CONCURRENTLY — not once for the whole Server the way
// rest.ServerTransport.Serve(ctx) (no args) does.
type ServerTransport interface {
Serve(ctx context.Context, route any, fn any) error
}
type ClientTransport interface{ /* adapter-implemented IO binding */ }
type Server struct { /* accumulates registered routes/spec, mirrors rest.Server */ }
func (s *Server) Attach(t ServerTransport) error
func (s *Server) Serve(ctx context.Context) error // runs ALL registered routes CONCURRENTLY — see below
type Client struct { /* accumulates registered routes/spec, mirrors rest.Client */ }
func (c *Client) Attach(t ClientTransport) error
func (c *Client) Call(ctx context.Context, route any, req any) (any, error)
Server.Serve/Client.Call become the SOLE public entry points a
caller uses — mirroring rest.Server.Serve/rest.Client.Call and
events.Client.ServeSubscribers/.Publish exactly. The dispatch loop
that today lives inside adapters/mqtt5.Serve/adapters/zeromq.Serve
(reading handle.Security directly — confirmed
adapters/mqtt5/reqreply.go:266,516, decoding the request, running
the domain handler, encoding and publishing the reply, correlating
requests to replies) moves into api/reqreply itself, operating
generically against ServerTransport/ClientTransport — NOT against
*pahomqtt5.Publish/ZMQ frames directly.
Like REST's Call/Serve (which take any and recover concrete types
via reflection, since Client/Server are non-generic Go types and Go
forbids generic methods on non-generic types), reqreply.Client.Call/
reqreply.Server.Serve follow the SAME reflection-based shape — this is
a structural necessity, not a design choice, and mirrors
events.Transport's identical justification in
Pub/Sub Workflow Simplification's
Decision 5.
CONFIRMED, via a throwaway Go prototype: route any here means an
ALREADY-REGISTERED *RouteHandle, NOT REST's raw Route[Req,Resp] —
a DELIBERATE divergence from REST's literal mechanism, not an
oversight. A dedicated review pass caught that REST's real
Client.Call (confirmed adapters/nethttp/clienttransport.go:190)
takes the RAW, unregistered Route[Req,Resp] and calls .ClientHandle()
internally, FRESH, on every single call — and ClientHandle() "sources
no Builder" (confirmed via rest.RouteHandle's own field doc comment),
meaning REST's Client.Call can NEVER see GlobalSecurity, only
per-route Security. reqreply's OWN CURRENT, real mqtt5.Call
(ctx, client, router, handle, req, opts) takes an ALREADY-REGISTERED
*RouteHandle instead — Builder-sourced, WITH GlobalSecurity.
The prototype built both conventions side by side against a route with
NO route-level Security (relying entirely on GlobalSecurity) and
confirmed via a passing/failing assertion: REST's raw-Route+fresh-
ClientHandle() convention silently drops GlobalSecurity-gated
credential enforcement for such a route — CredentialFunc is never
even invoked, because handle.GlobalSecurity is unreachable from
ClientHandle(). Confirmed via adapters/mqtt5/reqreply.go:516-519
that reqreply's Call ACTIVELY relies on
secReqs := handle.Security; if secReqs == nil { secReqs =
handle.GlobalSecurity } to decide whether to invoke CredentialFunc
and validate credential format at all — this is load-bearing behavior
today, not a hypothetical.
SUPERSEDED by a later round's refinement — see immediately below.
The original resolution here locked Client.Call's route any to ONLY
accept an already-registered *RouteHandle, rejecting REST's raw-Route
convention outright. A follow-up round asked a sharper question: instead
of choosing ONE of the two conventions, could Client.Call support
BOTH, giving reqreply users a real choice rather than forcing a
trade-off REST doesn't even offer its own users?
CONFIRMED via a further throwaway Go prototype: Client.Call accepts
EITHER shape, dispatched via a type-switch inside the adapter — strictly
MORE flexible than REST, not merely matching it.
// adapters/mqtt5 (confirmed dual-mode dispatch inside ClientTransport.Call)
switch v := routeOrHandle.(type) {
case reqreply.Route[Req, Resp]:
// RAW, unregistered — REST-style: derive ClientHandle() fresh,
// zero Builder/Server needed. GlobalSecurity intentionally
// INVISIBLE here, same accepted limitation REST's own
// Route.ClientHandle() has — this is NOT a reqreply-specific gap.
case *reqreply.RouteHandle[Req, Resp]:
// Already-registered (via Route.Register(server), see below) —
// GlobalSecurity IS visible and enforced, same as reqreply's
// current, real Call behavior today.
}
Confirmed via 2 concrete test cases (in addition to the 5 from the
original Decision-1 prototype, all still passing unchanged): (1) a
raw, unregistered Route passed to Client.Call — round-trips
correctly, and CredentialFunc is confirmed NOT invoked (matching
REST's own accepted limitation, not a new one); (2) an already-
registered *RouteHandle passed to Client.Call — round-trips
correctly, and CredentialFunc IS invoked (preserving the original
finding above, unchanged).
Resolved: reqreply.Client.Call accepts BOTH conventions. A
reqreply CLIENT-ONLY application (no server, no GlobalSecurity need)
gets the SAME zero-ceremony, zero-Server-needed experience REST
offers via a raw Route value — closing what would otherwise have been
a real ergonomic gap relative to REST. An application that DOES need
GlobalSecurity registers via Server first (see below) and passes the
resulting *RouteHandle instead. Neither path is a compromise on the
other — this is a genuine capability REST's OWN Client.Call cannot
offer its users at all (REST has no way to see GlobalSecurity
client-side, full stop; reqreply now supports EITHER experience,
caller's choice).
CONFIRMED via a throwaway Go prototype: Server.Serve(ctx) MUST
dispatch every registered route's ServerTransport.Serve call
CONCURRENTLY (one goroutine per route), NOT sequentially — a critical
gap a critical pre-implementation review pass caught before any code
was written. REST's ServerTransport.Serve(ctx context.Context) error
takes NO per-route argument at all — the adapter (nethttp) wires EVERY
registered route into ONE shared http.ServeMux before Serve is ever
called, then Serve is a SINGLE blocking call
(http.Server.ListenAndServe). reqreply's ServerTransport.Serve(ctx,
route, fn) error is fundamentally different: it is called ONCE PER
ROUTE (confirmed necessary, since mqtt5/zeromq/mqtt have no
built-in equivalent of an HTTP mux to pre-wire many routes into one
call). This split behaves very differently depending on the transport,
confirmed via code:
adapters/mqtt5.Serve(confirmedadapters/mqtt5/reqreply.go:165-370) is non-blocking — it registers a router callback and subscribes, then returnsnilimmediately; actual per-message dispatch happens later, asynchronously, via the MQTT client library's own background connection goroutine.adapters/zeromq.Serve/ServeRouter(confirmedadapters/zeromq/adapter.go:817-849,:1200-1249) BLOCK forever in their ownfor { select { case <-ctx.Done(): return nil; default: }; sock.RecvFrames(); ... }poll loop untilctxis cancelled or a fatal socket error occurs.
A SEQUENTIAL loop — for _, r := range routes { t.Serve(ctx, r.route,
r.fn) }, which is what an earlier round's throwaway prototype for
Decision 1 actually built and confirmed compiling/running (that
prototype tested only ONE registered route, so this never surfaced) —
works fine for mqtt5 (each call returns fast) but permanently
starves every route after the first for zeromq: the first route's
t.Serve call never returns, so the loop never reaches the second
route's registration at all. A dedicated critical-review pass caught
this BEFORE implementation began, and a second throwaway prototype
confirmed the fix:
// api/reqreply — Server.Serve's confirmed, concurrent implementation
func (s *Server) Serve(ctx context.Context) error {
// ... for each registered route, launch:
// go func(r route) { errCh <- t.Serve(innerCtx, r.route, r.fn) }(r)
// then select between:
// - an error arriving on errCh: cancel innerCtx (stopping every
// OTHER still-running route), wait for all to unwind, return
// that error.
// - the caller's own ctx being Done(): wait for all routes'
// t.Serve calls to actually finish (they must respect ctx
// internally, same as they do today), then return nil.
}
Confirmed via 3 concrete test-scenario groups (all passed): (1) an
all-mqtt5-style registration (non-blocking transport, 3 routes) —
Server.Serve correctly does NOT return early just because every
route's t.Serve call finished instantly; it still blocks until the
caller's ctx is cancelled, preserving the "blocks until ctx cancelled"
contract REST/events both guarantee; (2) an all-zeromq-style
registration (blocking transport, 5 routes) — confirmed via an
active-route high-water-mark counter that all 5 routes ran
CONCURRENTLY (the sequential-loop design would have shown a
high-water mark of 1, proving starvation); (3) one route's t.Serve
returning a real error — confirmed it propagates PROMPTLY as
Server.Serve's own return value (without waiting for the caller to
cancel ctx), and correctly cancels every other still-running route
rather than leaving them dangling.
A second, related finding confirmed by the same prototype: zeromq's
Attach needs a topic→socket MAPPING, not a single shared transport
value, and this is possible because routes are registered BEFORE
Attach is called. Unlike pub/sub's zeromq.Attach(client, sock)
(one socket naturally multiplexes many pub/sub topics via ZeroMQ's own
SUB-socket topic filtering), zeromq.Serve/ServeRouter are each
dedicated to ONE sock FramedSocket for ONE Req/Resp pair — there is no
topic-based multiplexing on a single REQ/REP-style socket. Confirmed via
the prototype: because route registrations (route.WithHandler(fn).
Register(server) — see below) happen BEFORE zeromq.Attach(server,
...) is called (mirroring REST's real
Route.RegisterHandle(server)-before-nethttp.AttachMux(server, mux,
addr) ordering), a new Server.RegisteredTopics() []string method
(backed by a plain, non-generic Topical interface every
RouteHandle[Req,Resp] satisfies, mirroring Decision 5's FutureFactory
pattern) lets zeromq.Attach(server *reqreply.Server, socketsByTopic
map[string]FramedSocket) (*ServerTransport, error) validate FULL
topic/socket coverage at Attach time — confirmed via a passing case
(complete coverage) and a failing case (one route's topic has no
matching socket, correctly rejected with a typed MissingSocketError,
not silently ignored).
CONFIRMED via the same prototype round: reqreply.Server UNIFIES
what the existing, separate reqreply.Builder type does (spec
accumulation: AddGlobalSecurity, route bookkeeping) — mirroring
rest.Server's own unification exactly. A user-prompted re-comparison
against REST's REAL code surfaced a foundational question this doc had
never addressed in 4 prior rounds: REST has NO separate "Builder" type
at all — rest.Server (confirmed api/rest/builder.go:2473) is ONE
type handling BOTH spec accumulation AND dispatch/transport. reqreply,
by contrast, has a PRE-EXISTING, separate Builder type (spec-only,
AsyncAPI accumulation) alongside this doc's proposed NEW Server type
(dispatch-focused) — leaving it unclear which type a route registers
against, or whether both need to coexist.
Resolved: Server ABSORBS Builder's role — AddGlobalSecurity and
route bookkeeping move onto Server directly, and the standalone
Builder type is retired. Confirmed via the prototype: Route.
Register(s *Server) (*RouteHandle[Req, Resp], error) reads s's
accumulated GlobalSecurity directly (no intermediate Builder value
needed), exactly mirroring rest.Route.Register(b *Server)'s real
signature. This is a genuine simplification, not just parity for its
own sake — one type to construct, configure, register routes against,
attach a transport to, and serve, matching REST's own single-type
model exactly.
A further, related refinement — also confirmed via the same
prototype — replaces the earlier round's separate free reqreply.
Handle(server, handle, fn) function with a fluent Route.WithHandler
method, mirroring REST's REAL, commonly-used idiom exactly (NOT the
method this doc originally compared against). A closer look at
examples/rest-api's actual usage (not just RouteHandle.WithHandler,
the method an earlier round's comparison used) revealed REST's
dominant, real pattern is Route.WithHandler(fn) Route[Req, Resp]
(confirmed api/rest/middleware.go:207) — a FLUENT, PRE-registration
method attaching fn to the UNREGISTERED Route value itself, chained
with further options, THEN committed with ONE .Register(server) call:
// api/reqreply — CONFIRMED replacement for the prior round's free
// reqreply.Handle(server, handle, fn) function.
func (r Route[Req, Resp]) WithHandler(fn func(context.Context, Req) (Resp, error)) Route[Req, Resp]
// Route.Register reads the attached fn (if any) and records it into
// s's dispatch registry directly — no separate registration call.
func (r Route[Req, Resp]) Register(s *Server) (*RouteHandle[Req, Resp], error)
Confirmed via the prototype: route.WithHandler(fn).Register(server)
— ONE fluent chain — correctly dispatches fn when server.Serve(ctx)
runs, with ZERO separate registration step. This makes reqreply's
server-side declaration workflow IDENTICAL IN SHAPE to REST's real,
common idiom (route.WithHandler(fn).HandleMW(...).Register(server)),
not merely similar — resolving the ergonomic gap a fresh side-by-side
comparison surfaced: the earlier free-function design was reached by
comparing against the WRONG REST method (RouteHandle.WithHandler,
REST's less-common POST-registration variant), not REST's actual
dominant idiom. The free reqreply.Handle function from the prior round
is SUPERSEDED — Route.WithHandler+Register replaces it entirely.
Decision 2 — each adapter implements a THIN ServerTransport/ClientTransport¶
// adapters/mqtt5 (reworked)
func Attach(server *reqreply.Server, client MQTTClient, router MQTTRouter) error
func AttachClient(client *reqreply.Client, mqttClient MQTTClient, router MQTTRouter) error
The adapter's job shrinks to: (a) satisfy the ServerTransport/
ClientTransport interface by translating api/reqreply's generic
dispatch calls into MQTT5-specific IO (subscribe to the route's topic,
publish the reply with ResponseTopic/CorrelationData set, etc.),
and (b) carry adapter-specific CONFIG/OPTIONS only (QoS, retained,
ConnectOptions) — no request/reply WORKFLOW logic of its own. This is
the same shrink nethttp.Attach/mqtt5.Attach (pub/sub) already went
through relative to their own pre-Decision-5 designs.
adapters/mqtt (v3) and adapters/zeromq implement the SAME thin
interfaces, each translating the generic dispatch into their own native
IO — mqtt(v3) via plain pahomqtt.Client.Subscribe/.Publish (no
Response Topic/Correlation Data — see Decision 4 below); zeromq via
its own Serve/Call/ServeRouter's existing socket-frame handling,
relocated behind the transport interface instead of exposed as the
public entry point.
zeromq's Attach signature is NOT the same shape as mqtt5's —
confirmed via the prototype above, this is a genuine, necessary
divergence, not an inconsistency. Because zeromq.Serve/ServeRouter
each need their OWN dedicated socket per route/topic (no topic-based
multiplexing on one REQ/REP-style socket, unlike mqtt5's single
MQTTClient naturally handling many topics), zeromq.Attach takes a
topic→socket MAPPING instead of a single client value:
// adapters/zeromq (reworked) — DIFFERENT shape from mqtt5.Attach,
// confirmed necessary by Decision 1's socket-per-route finding above.
func Attach(server *reqreply.Server, socketsByTopic map[string]FramedSocket) error
Called AFTER every route is registered via route.WithHandler(fn).
Register(server) (mirroring REST's real registration-before-Attach
ordering), so Attach can validate full topic/socket coverage
immediately, returning a typed MissingSocketError for any registered
route with no matching socket, rather than discovering the gap later at
Serve time.
Decision 3 — fold forward the still-relevant middleware/security decisions¶
The deleted doc had already resolved 4 middleware/security decisions
for api/reqreply specifically (Decisions ⅓/4 of that doc). These
remain sound and are folded forward here, adjusted for the new
Client/Server shape:
reqreply.Routegains.Use()/.HandleMW()/.ClientMW(), mirroringrest.Route's equivalents exactly —reqreply.RouteOptis already the sameinterface{ applyRoute(*routeBuilder) }shaperest.RouteOptis, ready to extend with zero structural changes.RouteHandlegainsImplementations []middleware.ServerImplementation/ClientImplementations []middleware.ClientImplementationfields, populated byRoute.Register/Route.ClientHandleexactly likerest.RouteHandlealready populates its own.- No whole-builder
Serve/Callsplit is needed beyondServer.Serve/Client.Callthemselves. Unlike REST's old per-routeHandler+Registersplit (which needed consolidating),reqreplynever had that split —Serve/Callhave always taken one handle directly. The ONLY change from today is WHERE the security/credential function is read from: from a per-callOptions.SecurityFunc/CredentialFuncfield to the handle-attachedImplementations/ClientImplementationspopulated at declare time (previous bullet) —Server.Serve/Client.Callreadhandle.Implementations/ClientImplementationsautomatically, mirroringnethttp.Serve/Call's already-shipped read pattern. - Per-adapter Fn shapes stay adapter-specific, not universal — MQTT5,
MQTT3, and ZeroMQ have three incompatible native message envelope
types (
*pahomqtt5.Publish,pahomqtt.Message, raw ZMQ frames), so eachServerTransport/ClientTransportimplementation recognizes its OWN Fn shape internally, translating to/from the transport-neutralmiddleware.ServerImplementation/ClientImplementationcontractapi/reqreplyitself operates on.mqtt5's shape is the clearest translation target (confirmed viaadapters/mqtt5/adapter.go's existingmakeSubscribeMessageHandler/Publishpattern);mqtt(v3) andzeromq's shapes need a dedicated follow-up pass (see "Remaining open items" below — this was ALSO left open in the deleted doc and is not newly introduced by this rework). portsneeds no new capability. OnceRoute.Register(builder)populateshandle.Implementations/ClientImplementationsitself,ports.PluginReqReplyPatterngets this for free via the SAME.Register(builder)delegation it already performs for every other handle field — noports-specific changes required.- SUPERSEDED by Decision 1's later
Route.WithHandlerfinding — how the handlerfnattaches server-side is nowroute.WithHandler(fn). Register(server), NOT a free function. An earlier round resolved this gap (Server.Serve(ctx)takes no per-route arguments, sofnmust already be attached to something beforeServeruns) by proposing a freereqreply.Handle(server, handle, fn)function — reached by comparing againstRouteHandle.WithHandler(REST's less-common, POST-registration variant, confirmedapi/rest/builder.go:1182). A later round's closer look at REST's ACTUAL, dominant real-world usage (examples/rest-api) found REST's common idiom is insteadRoute.WithHandler(fn) Route[Req, Resp](confirmedapi/rest/middleware.go:207) — a FLUENT, PRE-registration method. Resolved:reqreply.Routegains the SAME fluentWithHandlermethod, and the freereqreply.Handlefunction is RETIRED — see Decision 1's "Server/Builder unification" finding above for the confirmed shape and prototype evidence. This is no longer treated as a "stylistic choice with no functional difference" — it is the confirmed, REST-matching design.
Decision 4 — mqtt (v3)'s permanent protocol limitation stays permanent¶
MQTT 3.1.1 has no Response Topic/Correlation Data property (MQTT5-only)
— confirmed via the protocol spec and adapters/mqtt's own existing
doc comments. adapters/mqtt therefore CANNOT implement a full
reqreply.ServerTransport/ClientTransport the way mqtt5 can; it
either (a) does not implement the reqreply transport interfaces at all
(reqreply over MQTT 3.1.1 remains unsupported, matching today's actual
state — api/reqreply has never been wired to adapters/mqtt), or (b)
implements a documented SUBSET using an application-level convention
(e.g. a well-known reply-topic-per-request-topic naming scheme instead
of a protocol-native Response Topic) — NOT decided here, flagged for
implementation time.
Decision 5 — an ADDITIVE async CallAsync/Future[Resp], alongside Call — CONFIRMED via a throwaway Go prototype¶
Motivation, grounded in existing code, not invented:
adapters/mqtt5/reqreply.go's CURRENT Call (confirmed
adapters/mqtt5/reqreply.go:385-618) already builds a promise
internally and never exposes it — it generates a correlation ID,
registers a reply handler that writes to replyCh := make(chan
*pahomqtt5.Publish, 1), publishes the request, then immediately blocks
on select { case <-ctx.Done(): ...; case <-timer.C: ...; case replyMsg
:= <-replyCh: ... }. replyCh is a future in Go's native form (a
single-slot channel); Call just awaits it immediately instead of
handing it back. Unlike REST — whose Call is blocking because HTTP
itself is a synchronous, one-connection protocol with no natural async
variant — reqreply's underlying transport (MQTT5/ZeroMQ correlation-based
reply matching) is GENUINELY asynchronous; the blocking Call this doc
already designed is a CHOSEN convenience shape over an inherently async
mechanism, not the only possible one. This is a real, common pattern in
async-messaging RPC clients (Akka's ask, gRPC async stubs,
hand-rolled Task<Response>-correlation-map clients over RabbitMQ/Kafka
all offer both a blocking and a future-based call) — confirmed via grep
that this codebase has NO existing Future/Promise precedent anywhere, so
this is genuinely new territory, not a mirror of prior art.
Proposed shape, confirmed via a throwaway prototype (compiled and run, then deleted):
// package reqreply
type Future[T any] struct{ /* single-slot channel, unexported */ }
// Wait blocks until the future resolves or ctx is cancelled.
func (f *Future[T]) Wait(ctx context.Context) (T, error)
// ClientTransport gains CallAsync alongside Call — any-typed, same
// reflection-recovered shape as Call, for the same structural reason.
type ClientTransport interface {
Call(ctx context.Context, route any, req any) (any, error)
CallAsync(ctx context.Context, route any, req any) (any, error) // returns *Future[Resp] as any
}
func (c *Client) CallAsync(ctx context.Context, route any, req any) (any, error)
The sharper, CONFIRMED structural finding — how an adapter recovers a
working *Future[Resp] without ever knowing Resp concretely: the
FIRST prototype attempt tried constructing Future[Resp] at the
adapter's CallAsync dispatch time via reflect on route any alone —
this does NOT work; Go's reflect package cannot instantiate a generic
type for a type argument known only at runtime (the same category of
limitation d-0006-protocol-native-capabilities.md's own rejected "type-erased
storage" candidate ran into). The confirmed, working fix: expose a
PLAIN, non-generic FutureFactory interface —
type FutureFactory interface {
NewFutureAny() (any, func(any, error)) // returns (*Future[Resp] as any, type-erased resolve fn)
}
// RouteHandle[Req,Resp] implements it — Resp is concretely known HERE
// (this method body is compiled once per Req/Resp instantiation), even
// though the interface method's OWN signature is fully any-typed.
func (h RouteHandle[Req, Resp]) NewFutureAny() (any, func(any, error))
— confirmed via the prototype that a plain interface type assertion
(route.(FutureFactory)) on the adapter's route any value recovers a
correctly-typed *Future[Resp] and a matching resolve closure with ZERO
reflection, because RouteHandle[Req,Resp] (for ANY Req/Resp pair)
automatically satisfies the non-generic interface — the type-erasure
boundary is crossed entirely at Go's own compile-time method dispatch,
not at runtime.
(Note: this prototype used a VALUE receiver — func (h
RouteHandle[Req, Resp]) NewFutureAny() — in a round PRIOR to Decision
1's later-confirmed finding that Client.Call/CallAsync's route
argument is a POINTER, *RouteHandle[Req, Resp]. This is not a
functional conflict — Go's method-set rules mean a value-receiver
method is automatically included in the POINTER's method set too, so
(*RouteHandle[Req,Resp]), passed as any, still satisfies
FutureFactory via the same type assertion. The two prototypes were
never re-run TOGETHER against the current, corrected convention,
though — flagged for a quick smoke-test at implementation time, not a
redesign.)
Confirmed via 4 concrete test cases (all passed): (1) CallAsync
returns immediately without blocking, confirmed by doing other work
before awaiting; (2) the returned future is awaited from a DIFFERENT
call site/goroutine than the one that issued CallAsync — the actual
"send here, resolve elsewhere" property this decision is about, not
just a renamed blocking call; (3) 5 concurrent CallAsync calls resolve
with their OWN correlated replies, zero cross-talk; (4) Future.Wait
against an already-expired context.Context returns a timeout error,
mirroring Call's existing CallError{Kind: KindTimeout} semantics.
Call itself is UNCHANGED — CallAsync is purely additive, same
discipline as every other confirmed mechanism in this doc/
d-0006-protocol-native-capabilities.md.
Confirmed: CallAsync preserves Decision 2's api-as-abstraction-layer/
adapter-as-thin-IO-mapper split — not just Decision ½'s Call/Serve
path. The prototype makes the boundary concrete, not just asserted:
- Lives in
api/reqreply(the abstraction layer):Future[T]itself (the single-slot-channel primitive),Wait's timeout/cancellation semantics, and — the key piece —FutureFactory/NewFutureAny's type-erasure crossing (RouteHandle[Req,Resp]knows how to build its OWN correctly-typedFuture[Resp]and a matching resolve closure; no adapter ever re-implements this).Client.CallAsyncitself is a thin, one-line delegation toc.transport.CallAsync(...)— identical in shape toClient.Call. - Stays adapter-specific (the thin IO mapper), by necessity, not
oversight: the confirmed prototype's
ClientTransport.CallAsyncimplementation does exactly two adapter-owned things — (1) callroute.(FutureFactory).NewFutureAny()to obtain the future/resolve pair (a one-line call INTO the API layer, not adapter-owned logic), and (2) launch whatever async delivery mechanism is NATIVE to that transport (subscribe to the MQTT5 reply topic, match the wire-levelCorrelationDatawhen a message arrives, THEN call the providedresolve(decodedResp, err)), callingresolveexactly once when the correlated reply arrives. This is the SAME shapemqtt5.Call's existingreplyCh-based wait already has today —CallAsyncdoes not introduce any NEW adapter-owned responsibility, it only stops the adapter from being forced to block on it internally. No encode/decode, no correlation-ID GENERATION policy, and noFutureconstruction logic live in the adapter — only the transport-native "detect the matching reply arrived, then call resolve" step does, which has no protocol-agnostic equivalent (mirrors Decision 2's own reasoning for whyCall/Serve's adapter-side stays thin but not empty).
Not designed further here (flagged for implementation time):
whether Future[Resp]'s single-slot-channel implementation needs a
richer API (e.g. a Done() <-chan struct{} for select-based
composition alongside other channels, mirroring context.Context's own
shape); whether Server-side dispatch needs any equivalent concept
(unlikely — a server handler already runs synchronously per request in
this design, with no analogous "fire and check back later" need); and
the exact relationship, if any, to
Protocol-Native Features's §8 Handler
Disposition — these are LIKELY orthogonal (Disposition is
server-side ack/nack/requeue outcome signaling; Future/CallAsync is
client-side response awaiting), but that has not been separately
verified and should not be assumed without a dedicated check if the two
mechanisms are ever implemented together.
Confirmed adapter capability matrix (carried forward, mostly unchanged)¶
Final update: all rows below are now SHIPPED/CLOSED for every adapter — corrected here, superseding the "STALE"/"not yet implemented" notes an earlier pass left in this table. See "Addendum:
reqreply-middleware.mdandzeromq-security.md" below for the full record (both source roadmap docs have since been deleted).
| Capability | mqtt (v3) |
mqtt5 |
zeromq |
|---|---|---|---|
Connection-level SecuredClient/ConnectSecurityScheme |
✅ | ✅ | ❌ permanently, by design (CURVE/ZAP is the caller's own concern — see the Addendum below's "Connection-level authentication — CLOSED" summary) |
Message-level subscribe-side SecurityFunc |
✅ | ✅ | ✅ (in-payload, via SubscribeOptions.SecurityFunc) |
Message-level publish-side CredentialFunc |
❌ (protocol limit — no per-message property channel) | ✅ | ✅ (in-payload, via PublishOptions.CredentialFunc) |
| Native Response Topic + Correlation Data (reqreply viability) | ❌ (protocol limit) | ✅ | n/a (own correlation mechanism) |
Scope-grant / middleware.CheckScopes integration (reqreply) |
n/a (no reqreply support) | ✅ SHIPPED (Phase 1) | n/a — zeromq reqreply's paired Fn shape uses a plain-error-returning shape instead (deliberately not REST's scope-grant model), SHIPPED across all 4 transports |
Handle-attached implementation (vs. per-call Options) (reqreply) |
n/a (no reqreply support) | ✅ SHIPPED (RouteHandle.Implementations/ClientImplementations, Phase 1) |
✅ SHIPPED (same fields, all 4 transports) |
The bottom two rows are what Decision 3 (above) closes — SHIPPED for
BOTH mqtt5 and zeromq (see the Addendum below for the full,
adapter-specific detail).
Relationship to d-0006-protocol-native-capabilities.md (now Feature)¶
MQTT5's Response Topic + Correlation Data — hardwired inside
adapters/mqtt5/reqreply.go/reqreply_transport.go — was originally
considered as a candidate for a real, sealed mqtt5.Capability.
DECIDED (now that reqreply.Server/Client/Attach have shipped and
this could actually be evaluated against a real Attach shape): it
stays an implicit, always-on characteristic of mqtt5's
ServerTransport/ClientTransport implementation, NOT a declared
Capability. EVERY mqtt5 reqreply route needs it, unconditionally —
there is nothing to "declare," since it is not optional the way Shared
Subscriptions are; a Capability's whole point is compile-time-safe
OPT-IN gating for something not every binding needs, which doesn't apply
here. Shared Subscriptions ($share/group/topic) for reply-topic
fan-out across multiple Server instances remain the genuine candidate
for a declared, sealed capability on a reqreply.Route from this same
feature cluster — mirroring the pub/sub use case in
Feature directly — tracked
independently THERE, not blocked by anything in this doc.
Escape hatches (carried forward from the deleted doc, still accurate)¶
Descriptor.Security/GlobalSecurityare never even read byadapters/zeromq's reqreplyCall/Servetoday — confirmed via grep. EVERY zeromq reqreply call is unconditionally unenforced, regardless of what the route declares. This rework does not automatically fix this —zeromq'sServerTransport/ClientTransportimplementation must actually read and enforceImplementations/ClientImplementationsonce Decision 3 lands.mqtt(v3) publish-side has NO credential mechanism, by protocol limitation — not a gap to close, must stay documented so no future design assumes parity withmqtt5is achievable here.SecurityScheme.Codecnil = "no format validation" — an explicit, documented escape hatch (same as REST): the security Fn receives the raw, unvalidated credential string whenCodecis nil.- Last-registered-wins on
WithSecuritySchemename collisions across routes sharing a builder — same policy as REST, silent (no error).
Phased implementation plan¶
Once the design was confirmed ready (5+ prototype rounds, all Decisions 1-5 locked), implementation is sequenced into 6 phases — each phase ends with its own build/test/lint pass, not deferred to the end, per this project's standard "verify before claiming success" discipline.
- Phase 0 —
api/reqreplycore types (isolated, no adapter changes). SHIPPED.Server/Client/ServerTransport/ClientTransport/Future/FutureFactory,Route.WithHandler+Register(*Server),Server.RegisteredTopics/Topical.Builder/NewBuilder/BuilderOptionkept as DEPRECATED aliases forServer/NewServer/ServerOption— zero breaking changes, every pre-existing test/example passes unchanged. A naming collision was found and fixed during this phase: the pre-existingreqreply.Servertype alias (forasyncapi.Server, an AsyncAPI server ENTRY like{URL: ..., Protocol: "mqtt5"}) collided with the NEW dispatch-owningServertype this phase introduces — resolved by renaming the entry alias toServerEntry(mirroringrest.ServerEntry/rest.Server's own, already-established naming split for the identical concept;api/eventsnever hit this becauseevents.Clienthandles both roles without a separate "Server" dispatch type). All 9 real call sites of the oldreqreply.Server{...}struct literal (7 inapi/reqreply's own doc-comments/tests, 2 in examples) were migrated toreqreply. ServerEntry{...}. New unit tests cover: the full error taxonomy (NoServerTransportAttachedError/NoClientTransportAttachedError/ServerTransportAlreadyAttachedError/ClientTransportAlreadyAttachedError/TransportTypeMismatchError); dual-modeClient.Call(rawRoutevs. registered*RouteHandle, confirmingGlobalSecurityvisibility differs correctly);Server/Builderunification (AddGlobalSecurityvisible on a registered handle with NO intermediateBuilder);WithHandler+Registerfluent dispatch;Server.Serve's concurrent dispatch against BOTH a non-blocking-transport fake (mqtt5-style — confirmsServestill blocks untilctxis cancelled) and a blocking-transport fake (zeromq-style — confirms an active-route high-water mark equal to the route count, the actual regression test for Finding B); prompt error propagation + cancellation of other routes;RegisteredTopics;CallAsync/Futureround trip (including "do other independent work, await from a different call site" and a cancelled-context timeout case);FutureFactory's type-erasure crossing. All tests pass under-race(3 repeated runs).staticcheck/gosec/gofmtall clean. A new package-levelExample()demonstrates the fullWithHandler+Register+Attach+Serve/Attach+Callworkflow against a minimal in-process transport stub. - Phase 1 —
adapters/mqtt5migration. SHIPPED. Newadapters/mqtt5/reqreply_transport.go:AttachServer(*reqreply.Server, MQTTClient, MQTTRouter, ...ServeOptions)/AttachClient(*reqreply. Client, MQTTClient, MQTTRouter, ...CallOptions)implementreqreply.ServerTransport/ClientTransportvia the SAME reflection-shim idiom already established byadapters/mqtt5/ transport.go'sevents.Transportimplementation (andadapters/nethttp/clienttransport.go'sCall) — not a new pattern. NamedAttachServer/AttachClientrather thanAttachbecausemqtt5.Attach(forevents.Client/pub-sub) already exists and Go has no function overloading.CallAsyncrecovers a*Future[Resp]viaFutureFactory.NewFutureAny()'s plain-interface type assertion (crossing the generic/reflection erasure boundary without needingreflectto instantiate a generic type). Documented v1-scope limitation, mirroring the existingevents.Transportshim's own documented limitation: per-callRequestFormats/Formatsoverrides,NewTopicParammerge-field topic-var merging, andErrorPatterntyped replies are NOT honored by this shim (routes needing them useServe/Calldirectly, unchanged). 3 new tests (TestAttachServer_ AttachClient_RoundTrip,TestAttachClient_DualMode_GlobalSecurity,TestAttachClient_CallAsync_RoundTrip) using awireBrokershelper simulating a shared broker across independent mock client/router pairs; all pass under-race(2 repeated runs). Additionally, migratedportspackage's realreqreply.Builderdependency (PortOptions.ReqReplyBuilder,RegisterReqReply,PluginReqReplyPatterninternals — confirmed via repo-wide grep as a REAL dependency, not previously called out in this doc's migration bullet before the phased-plan round) off the now-deprecatedBuilder/NewBuilder/BuilderOptionontoServer/NewServer/ServerOption: a repo-wide mechanical rename acrossports/*.go(7 files), the 3 example projects (adapters-mqtt5,adapters-zeromq-dealer-router,adapters-zeromq-reqrep),adapters/zeromq/adapter_test.go, 3adapters/mqtt5/*_test.gofiles, andapi/reqreply/route_test.go, plus manual updates to the 6 remaining godoc-example-only mentions (api/events/builder.go,api/llm/builder.go,api/reqreply/{builder,doc,route}.go) that still showedNewBuilder(...)as the "primary" usage pattern in prose — done to eliminate thestaticcheckSA1019 deprecation-warning regression this phase'sBuilderdeprecation annotation caused against 19 previously-untouched call sites (just checkmust stay clean; no//nolint/suppression was added). Verified:go build ./...,go vet ./...,gofmt -l .(clean),go test ./...(all packages pass, zero FAIL),staticcheck(zero findings across every touched package),gosec(zero NEW findings — the 5 pre-existingG304findings inports/file.goare unrelated), and all 3 migrated example projects re-run viago run(all exit 0). - Phase 2 —
examples/reqreply-api, mqtt5 slice. SHIPPED (built together with Phase 4 in one continuous effort — see Phase 4 below for the full mqtt5+zeromq mini-project writeup and verification evidence). - Phase 3 —
adapters/zeromqmigration. SHIPPED. Newadapters/zeromq/reqreply_transport.go: 4 Attach functions covering BOTH zeromq socket-pattern families —AttachServer/AttachClient(REQ/REP, mirrors [Serve]/[Call]) andAttachRouterServer/AttachDealerClient(ROUTER/DEALER, mirrors [ServeRouter]/ [CallDealer], including the identity-frame envelope: dispatches each request in its OWN goroutine, matchingServeRouter's own concurrency) — all 4 implementreqreply.ServerTransport/ClientTransportvia the SAME reflection-shim idiom asadapters/mqtt5's (duplicated, not shared — the two adapter packages don't import each other). A NEWMissingSocketError(witherrors.As/LogValue) is returned byAttachServer/AttachRouterServerat Attach time when a route registered on the*reqreply.Serverhas no corresponding entry in thetopic → socketmap they take (ZMQ REQ/REP and ROUTER/DEALER sockets are point-to-point, unlike MQTT5's single shared client — this is the confirmed reasonServer.RegisteredTopics/Topicalwere speced ahead of time in Phase 0 specifically for this check), and by the client-side transports at CALL time for a route with no socket entry (no upfront client-side check — the client may only need a subset of the server's routes).CallAsyncon both client transports reuses the SAMEFutureFactory/NewFutureAnymechanismadapters/mqtt5's does, unchanged. Same documented v1-scope limitation asadapters/ mqtt5's shim: route-declaredRequestFormats/Formatsoverrides andErrorPattern-typed error replies are NOT honored — callers needing those useServe/Call/ServeRouter/CallDealerdirectly, unaffected. 7 new tests (round trip +MissingSocketErrorcoverage for both socket-pattern families, plus aCallAsync/Futureround trip) using a channel-based in-memoryFramedSocketpair (chanSocketfor REQ/REP; adealerSocket/routerSocketpair that correctly models ZMQ's automatic identity-frame prepend/strip behavior for ROUTER/DEALER) — no real ZMQ library needed. All pass, including under-race(2 repeated runs). Verified:go build ./...,gofmt -l .(clean), fullgo test ./...(zero FAIL),staticcheck/gosec(zero findings on the new file). - Phase 4 —
examples/reqreply-api, zeromq slice + delete old examples. SHIPPED. Built the fullexamples/reqreply-apimini-project per the "Example mini-project" section below, covering BOTH mqtt5 (Phase 2) and zeromq (this phase) in one continuous effort:routes/(ComputeRoute/DoubleRoute/TripleRoute,SecuredComputeRoutewith its OWNRouteMeta.Security,GlobalOnlyComputeRoute— declaresWithSecuritySchemebut leavesRouteMeta.Securitynil so it inherits purely fromServer.AddGlobalSecurity, confirming these two declarations are genuinely orthogonal —RouterComputeRoute,MissingSocketRoute),handlers/(Add/Double/Triple),mqtt5server/(mock broker/router,AddGlobalSecurity(route. Require("bearerAuth")), exposesGlobalHandle/SecuredHandlefor callers dispatching directly against an already-registered*RouteHandle),zeromqserver/(3 REQ/REPchanSocketpairs, one per route — REQ/REP's point-to-point limitation confirmed in practice, not just in the design doc),zeromqrouterserver/(Build()'s working ROUTER/DEALER config plusBuildWithMissingSocket()'s deliberately incomplete config triggeringzeromq.MissingSocketErrorupfront, beforeServeever runs), andclient/(BuildMQTT5/BuildZeroMQ/BuildZeroMQDealer). 7 demo files +main.gowire everything together in narrative order: (1) basic call via a raw, unregisteredRoute; (2) dual-modeClient.CallcontrastingGlobalSecurity's invisibility on a rawRoutecall against a directly-dispatched, already-registered*RouteHandleviamqtt5adapter.Callwith aCredentialFunc— the server-side rejection in the raw-Route case surfaces as a generic wrapped error over the wire (NOT a re-hydrated typedSecurityCredentialError— that only happens for a CLIENT-side pre-check rejection, confirmed by running the demo, not just by reading the code); (3) route-level security viaSecuredComputeRoute, demonstrating a CLIENT-sideSecurityCredentialErrorfor a malformed credential (caught before publish); (4) concurrent multi-route dispatch against zeromq's 3-route REQ/REP server, the real-world demonstration ofServer.Serve's concurrent-dispatch fix a blocking transport motivated (Decision 1); (5)CallAsync/Future— two independent async calls issued back-to-back before either is awaited, both awaited from a different call site, plus aFuture.Waitagainst an already-cancelledctx; (6)Server.AsyncAPISpec()+MarshalYAMLprinting, derived entirely from the route declarations already made; (7) the zeromq ROUTER/DEALER variant plusBuildWithMissingSocket'sMissingSocketError. Deletedexamples/adapters-zeromq-reqrepandexamples/adapters-zeromq-dealer-routerentirely; strippedexamples/adapters-mqtt5/main.go'srunRequestReplyDemo/runSecurityDemofunctions and their now-unused supporting declarations (ComputeReq/ComputeResp/ComputeRoute/SecuredComputeRoute/bearerAuth/printSpecs), renumbering the remaining Connect-level-security demo from "Demo 4b" to "Demo 3" and updating the package doc comment to point atexamples/reqreply-apifor request-reply — its pub/sub, client-attach, error-channel, and connect-security demos are otherwise UNCHANGED. Fixed one genuine startup race discovered while verifying:examples/reqreply-api'smain.gostartedmqtt5Built.Server.Serve(ctx)in a goroutine and immediately issued the firstClient.Call— since mqtt5'sServerTransport.Serveregisters its router handler synchronously but only once ITS OWN dispatch goroutine (started byServer.Serve) actually runs, this raced and intermittently hung (a lost publish, no reply, noctxdeadline to time it out); fixed with the same 50ms-time.Sleepsynchronization convention already established inexamples/adapters-mqtt5's own demos — verified stable across multiple repeatedgo runinvocations after the fix. Also updateddocs/reference/project-structure.md(addedreqreply-api/layout entry, updatedadapters-mqtt5's description, removed the deleted zeromq example entries) anddocs/guides/{zeromq,mqtt5}.md(updated "See also" links and added pointers toexamples/reqreply-api). Verified:go build ./...,gofmt -l .,go vet ./...all clean;go test ./...zero FAIL;just check(staticcheck + gosec) zero findings; all 7examples/reqreply-apidemos plus the retired-and- rebuiltexamples/adapters-mqtt5demo re-verified via repeatedgo runinvocations with zero flakes;for d in examples/*/; do go run ./$d; donere-run across every example with zero failures. - Phase 5 — full doc sync + doc promotion to
docs/design/. SHIPPED.Decided AGAINST retiringREOPENED, REVERSED (see "Remaining open items" below) —Builder/mqtt5.Serve/.Call/zeromq.Serve/.Call/.ServeRouter(see "Remaining open items" above for the full reasoning — kept as documented escape hatches/deprecated aliases, a genuine ongoing need, not a stale leftover).Builder/NewBuilder/BuilderOptionstay kept, unaffected (zero-cost aliases, no duplicate logic);mqtt5.Serve/.Callandzeromq.Serve/.Call/.CallHandle/.ServeRouter/.CallDealerare now TARGETED for retirement instead — the "mirrors REST'sServeOne/CallWithHandle" justification below was found flawed: unlike REST's zero-duplication thin wrappers aroundServe/Callitself, these functions share NO code withAttachServer/AttachClient— a genuine duplicate dispatch implementation, not a thin convenience wrapper, conflicting with the "adapter stays a thin IO wrapper, user only touches api/*" principle. Retirement is tracked and sequenced indocs/roadmap/reqreply-middleware.md(new Phase 0/0b), not here. Full doc sync (still accurate as of THIS phase's shipping, pending the reopened item above): rewrotedocs/guides/mqtt5.md's "Request-Reply" section anddocs/guides/zeromq.md's "REQ/REP"/"DEALER/ROUTER" sections to lead with theServer/Client+Attachworkflow (concrete, runnable code samples, not just a pointer), demotingServe/Call/ServeRouter/CallDealerto an explicit "escape hatch" subsection each — also discovered and fixeddocs/guides/zeromq.md's "REQ/REP" section had gone FAR further stale than expected: it still documented a non-existentapi/zeromqpackage (zmqapi.NewBuilder/Register/ContractMeta) usingrest.NewRoutefor a ZMQ route, predatingapi/reqreply's very existence (confirmed vials api/— noapi/zeromqdirectory exists in the repo at all) — not merely behind Phases 0-4, but describing an API surface that had ALREADY been renamed away before this doc's own Decision 1 was ever written. Brought.github/instructions/go-codex.instructions.md'sapi/reqreply/adapters/mqtt5/adapters/zeromqrows current too — a real gap: they had NOT been updated during Phases 0/⅓ despite this skill's "every code change" mandatory-update rule, still describingRoute.Register(b *Builder)and a staleServer = asyncapi.Serveralias (renamed toServerEntryin Phase 0 — the instructions row never caught up); addedServer/Client/Attach/CallAsync/Future/the 5 adapterAttach*functions/MissingSocketErrorcoverage to all 3 rows. Promoted this doc fromdocs/roadmap/todocs/design/d-0004-reqreply-workflow-simplification.md(removed fromdocs/roadmap/index.md+zensical.toml's roadmap nav, added todocs/design/index.md+zensical.toml's[nav."Design Documents"]) — qualifies under the "establishes a pattern multiple packages follow" bar: extendsd-0001/d-0002'sServer/Client+Attachpattern to a third API boundary, confirming it generalizes to a genuinely asynchronous transport via the additiveCallAsync/Future[Resp]mechanism neither REST nor pub/sub needed. Verified:go build ./...,gofmt -l .clean (doc-only changes, no Go code touched).
mqtt(v3) is explicitly OUT of this phased plan — Decision 4 confirms
its publish-side credential limitation is permanent, and whether it
implements ANY documented subset transport remains a separately-tracked
open question (see "Remaining open items" below), not attempted in any
phase here.
Example mini-project — examples/reqreply-api (consolidates 3 existing examples)¶
SHIPPED (Phases 2 and 4) — this section's layout/demo proposal below is the AS-BUILT shape (file names, package names, and demo numbering all match exactly what's now in
examples/reqreply-api); see the phased implementation plan's Phase 4 entry above for the verification evidence.
Mirroring examples/rest-api's own layout (a routes/ package for pure
route declarations, a handlers/ package for handler functions +
security, per-adapter server subpackages, and a client/ subpackage —
each doing ONLY the route.WithHandler(fn).Register(server) + Attach +
Serve/Call wiring for its own transport), this doc proposes a NEW,
consolidated examples/reqreply-api mini-project once reqreply.Client/
Server+Attach ships, replacing 3 existing, separately-maintained
examples that all demonstrate the SAME underlying request-reply
mechanism today, each calling adapter functions directly (the exact
anti-pattern this doc fixes):
examples/adapters-zeromq-reqrep/main.go— deleted entirely (fully reqreply-scoped; REQ/REP socket variant).examples/adapters-zeromq-dealer-router/main.go— deleted entirely (fully reqreply-scoped; DEALER/ROUTER socket variant).examples/adapters-mqtt5/main.go— itsrunRequestReplyDemo/runSecurityDemofunctions are stripped out and rebuilt in the new project; its pub/sub, client-attach, error-channel, and connect-security demos are UNRELATED to reqreply and stay in place, untouched.
Proposed layout¶
examples/reqreply-api/
├── routes/
│ └── routes.go // ComputeRoute, SecuredComputeRoute — reqreply.NewRoute declarations only
├── handlers/
│ └── handlers.go // domain handler funcs + security (CredentialFunc, SecurityScheme wiring)
├── mqtt5server/
│ └── server.go // reqreply.Server + route.WithHandler(fn).Register(server) + mqtt5.Attach
├── zeromqserver/
│ └── server.go // same shape, zeromq.Attach (REQ/REP topic→socket mapping)
├── zeromqrouterserver/
│ └── server.go // same shape, zeromq DEALER/ROUTER Attach variant
├── client/
│ └── client.go // reqreply.Client + per-adapter ClientTransport Attach (mqtt5/zeromq)
└── main.go // wires demos together, mirrors rest-api's demo_*.go convention
Demos (mirroring rest-api's demo_*.go convention — one aspect each)¶
demo_basic_call_and_serve.go— the baseline round trip:route.WithHandler(fn).Register(server)→server.Attach(transport)→server.Serve(ctx), alongsideclient.Attach(transport)→client.Call(ctx, route, req)with a RAW, unregisteredRoute(noServerneeded client-side) — the simplest possible shape.demo_global_security_dual_mode_call.go— demonstratesClient.Call's CONFIRMED dual-mode acceptance side by side: the SAME route, relying ONLY onServer.AddGlobalSecurity(no per-routeSecurity), called once via a rawRoute(confirmsCredentialFuncis NOT invoked —GlobalSecurityinvisible, same accepted REST limitation) and once via an already-registered*RouteHandle(confirmsCredentialFuncIS invoked —GlobalSecurityenforced) — making the caller's choice and its consequence directly visible in one demo, not just described in prose.demo_route_level_security_credential_error.go— a route WITH its ownSecurity(not relying onGlobalSecurity), called with a deliberately malformed/missing credential — demonstratesreqreply.SecurityCredentialErrorsurfacing viaerrors.As, same shape asexamples/adapters-mqtt5's existingrunSecurityDemo.demo_concurrent_multi_route_dispatch.go— registers 3+ routes against ONEServer,Attaches azeromq-backed transport (the BLOCKING transport whose starvation risk motivated Decision 1's concurrent-dispatch fix), and demonstrates all routes actually answering calls concurrently — the real-world demonstration of the confirmed fix, not just its prototype's synthetic high-water-mark assertion.demo_call_async_future.go— the new async call + promise demo, directly demonstrating the "send here, resolve elsewhere" mechanism Decision 5 confirms:client.CallAsync(ctx, route, req)returns a*reqreply.Future[ComputeResp]IMMEDIATELY (the demo explicitly does other work — e.g. issues a SECOND, independentCallAsyncfor a different route — before awaiting the first), then awaits BOTH futures from a call site distinct from the one that issued them (mirroring the confirmed prototype's actual "different call site/goroutine" test, not a same-line call-then-immediately-await that would look identical to a blockingCallrenamed). A second scenario in the same demo showsFuture.Wait(ctx)against an already-cancelledctxreturning a timeout error, matchingCall's ownCallError{Kind: KindTimeout}semantics — establishing thatCallAsync/Futureis a genuine alternative shape over the SAME underlying mechanismCalluses, not a different transport-level guarantee.demo_spec_printing_asyncapi.go— replaces the existing examples' spec-onlyreqreply.NewBuilder+.Register(builder)usage; demonstrates printing the AsyncAPI document straight off the SAMEServervalue used for real dispatch (no separate throwaway builder needed, sinceServerabsorbsBuilder's role per Decision 1).demo_zeromq_dealer_router_variant.go— the DEALER/ROUTER socket-topology variant carried over fromexamples/adapters-zeromq-dealer-router, rebuilt against the newServer/Client+Attachshape, demonstratingzeromq.Attach's topic→socket coverage validation (Decision 2) with a DELIBERATE missing-socket case included, surfacing the typedMissingSocketErroratAttachtime rather than a later, harder-to-diagnose failure.
Not yet decided¶
- Whether
mqtt5server/zeromqserver/zeromqrouterservertruly need separate subpackages (mirroringrest-api'schiserver/nethttpserversplit) or whether, since each demo is single-route and much smaller thanrest-api's multi-route surface, a flattermain.go-only layout reads better — a styling call for whoever implements this, not a functional requirement either way. - This mini-project cannot be scaffolded with real, compiling code until
reqreply.Client/Server+Attachactually ships (this section is a DESIGN proposal for the example layout, not yet-runnable code) — it is recorded here now so the migration/example work is planned alongside the core implementation, not bolted on afterward as an unplanned extra step.
Remaining open items (deferred to implementation time)¶
Exact reflection-basedRESOLVED via a throwaway Go prototype (compiled and run, not merely sketched — deleted after this finding was extracted). Built a standaloneCall/Servesignatures forreqreply.Client/Serverreqreply.Client/Server+Attachpair, a thinmqtt5-style stub adapter, and confirmed via 5 concrete test cases: (1)Server.ServereturnsNoServerTransportAttachedErrorbeforeAttach; (2)Client.CallreturnsNoClientTransportAttachedErrorbeforeAttach; (3) a full round trip —Client.Call(ctx, route, req)→ adapter'sClientTransport.Call→ realreflect.Value.Calldispatch to the registered handler (mirroringadapters/nethttp/clienttransport.go's actual established reflection idiom, not a simplified stand-in) → decoded response recovered viarespVal.Interface().(Resp)— works end-to-end; (4)TransportTypeMismatchErrorfires correctly when a non-RouteHandlevalue is passed asroute; (5) a secondAttachcall correctly returnsClientTransportAlreadyAttachedError. Confirmed: this doc's proposed shape needs NO adjustment —reqreply.Client.Call/Server.Serve'sany-typed signatures andNoServerTransportAttachedError/NoClientTransportAttachedError/ClientTransportAlreadyAttachedError/TransportTypeMismatchErrorerror taxonomy are LOCKED as sketched above, verified identical in shape torest.Client/Server's real, shipped equivalents (api/rest/builder.go:2592-2789).RESOLVED (updated aftermqtt(v3) andzeromq's per-adapter Fn shapes forHandleMW/ClientMW(Decision 3) remain unresolved —zeromqin particular may need a NEW wire-level credential convention (an additional frame) before any Fn shape can be finalizeddocs/roadmap/reqreply-middleware.md/docs/roadmap/ zeromq-security.mdwere written, later in this same session — both Fn-shape DECISIONS are made; only their IMPLEMENTATION remains, tracked independently in those two roadmap docs, not a still-open DECISION of this doc's own):mqtt(v3) is N/A — permanently out of scope for reqreply entirely (confirmed zeroreqreplycode exists anywhere inadapters/mqtt; see Decision 4 below and the resolved bullet immediately below this one).zeromq's Fn-shape premise — that a NEW wire-level credential convention would be needed — turned out to be FALSE once actually investigated:docs/roadmap/zeromq-security.md's own "Implication forreqreply-middleware.md" section (added later, cross-checking this exact bullet against that doc's own already-resolved pub/sub finding) confirmed zeromq's REQ/REP frames have the EXACT SAME "no separate credential slot" shape pub/sub's[topic, payload]frames do, so the SAME in-payload*Reqmechanism (no new wire-level frame) applies — Fn shape DECIDED there (design-only, not yet implemented). mqtt5's ownHandleMW/ClientMWFn shapes are DECIDED indocs/roadmap/reqreply-middleware.md's own "API surface" section (also design-only, not yet implemented) — both are cross-referenced design decisions living in their own roadmap docs now, not still-open questions in this doc.WhetherRESOLVED:mqtt(v3) implements a documented subset of the reqreply transport (application-level reply-topic convention) or does not implement it at all (Decision 4) — not decidedmqtt(v3) implements NO reqreply transport subset AT ALL — confirmed via code (zeroreqreplyreferences anywhere inadapters/mqtt) during the session work that produceddocs/roadmap/reqreply-middleware.md's own status banner ("MQTT3 stays pub/sub-only, permanently excluded from reqreply entirely... nothing left to do for MQTT3"). Not a partial/ documented-subset situation — a clean, total exclusion.Migration path for existing callers ofadapters/mqtt5.Serve/.Callandadapters/zeromq.Serve/.Call/.ServeRouter(breaking change)RESOLVED, decided AGAINST removal. UnlikeREOPENED, DECISION REVERSED (found duringd-0002's pub/sub precedent (which DID delete its old call-time-competing primitives),Serve/Call/ServeRouter/CallDealerare DELIBERATELY KEPT as documented escape hatches, not retired — confirmed a genuine, ongoing need during Phase 4:examples/reqreply-api's own Demo ⅔ (dual-modeClient.Call, route-level security) callmqtt5adapter.Calldirectly against an already-registered*RouteHandlewith aCredentialFunc, a capability theAttach-basedClient.Call/CallAsyncv1 reflection shim does not (yet) expose (same documented v1-scope limitation asevents.Transport's own shim: no per-callRequestFormats/Formatsoverrides,NewTopicParammerge-field topic-var merging, orErrorPattern-typed error replies).docs/roadmap/reqreply-middleware.md's final review pass): the "mirrors REST'sServeOne/CallWithHandle" analogy above does not hold up against the real code. REST'sServeOne/CallWithHandleare confirmed (viadocs/design/ d-0001-rest-middleware-workflow-simplification.md) to be LITERALLY "build a scratch single-routeServer, callServe/Call" — the identical dispatch path, zero duplicate logic, a true thin wrapper.adapters/mqtt5/reqreply.go'sServe/Callandadapters/zeromq/ adapter.go'sServe/Call/CallHandle/ServeRouter/CallDealerare confirmed (via code — no calls between them andAttachServer/AttachClientin either direction, in either adapter package) to be a fully SEPARATE, duplicate protocol-dispatch implementation — not a thin wrapper at all. Keeping a second, independently-maintained dispatch path "because REST does" was the flawed premise; it conflicts with the "adapter stays a thin IO wrapper around IO/protocol implementation, the user only touchesapi/*" principle. Decision (initial): RETIRE these functions, mirroringd-0002's own precedent of fully deleting its old call-time-competing primitives — sequenced as new Phase 0 (close the ONE remaining real capability gap — merge-fields, per-call format overrides,ErrorPattern— the gap that was this bullet's own original justification) and Phase 0b (build genuine thin wrappers, migrate callers, delete the old functions) indocs/roadmap/ reqreply-middleware.md. UPDATE — SHIPPED, outcome BETTER than this initial plan. Phase 0 shipped as planned. Phase 0b, while implementing the planned new-named thin wrappers, foundreqreply.ServerTransport.Serve/ClientTransport.Callare ALREADY single-route/single-call scoped (confirmed via the interface definitions —Server.Serve's own multi-route CONCURRENCY happens ABOVE this, not insideServerTransport.Serve) — meaningserverTransport/clientTransport(the concrete typesAttachServer/AttachClientbuild) were ALREADY single-route/single-call dispatch primitives, noServer/Clientscratch-registration wrapper needed. SoServe/Call/CallHandlewere rewritten to DELEGATE toserverTransport/clientTransportdirectly — zero duplicate logic achieved WITHOUT deletion, WITHOUT a breaking change, and WITHOUT any caller/example migration (superseding the "retire"/"delete + rebuild + migrate" framing above entirely — the functions are KEPT, unchanged signatures, now genuinely thin). A real, additional gap (missingstats.TraceObserverspan support in the reflection-based dispatch) was found and closed along the way, via re-running the full pre-existing test suite.Builder/NewBuilder/BuilderOptionwere never affected either way (zero-cost aliases, no duplicate logic). Full detail lives indocs/roadmap/ reqreply-middleware.md's "Phase 0b" section, not duplicated here. zeromq's OWNServe/Call/CallHandle/ServeRouter/CallDealerreceived the SAME delegation fix in a follow-up pass, same session — ALSO SHIPPED (seedocs/roadmap/reqreply-middleware.md's "Phase 0b" section for the full detail, including a zeromq-specific regression found and fixed:BuildTopicfailure during observability-path derivation must be FATAL, not silently ignored).docs/guides/mqtt5.md/docs/guides/zeromq.mdneed NO update (the functions still exist, unchanged from the caller's perspective) — the EXAMPLE side of the EARLIER consolidation (distinct from this item) remains accurate: see "Example mini-project —examples/reqreply-api" above —examples/adapters-zeromq-reqrepandexamples/adapters-zeromq-dealer-routerare deleted entirely,examples/adapters-mqtt5's request-reply demos are stripped out and rebuilt, all consolidated into ONE newexamples/reqreply-apimini-project mirroringexamples/rest-api's layout.Whether Response Topic + Correlation Data should be a DECLAREDRESOLVED — DECIDED: stays IMPLICIT, NOT a declaredFeatureat all, or remain an implicit, always-on capability ofmqtt5's reqreply transport (see "Relationship tod-0006-protocol-native-capabilities.md" above) — not decided, deferred until theClient/Servershape exists to prototype againstCapability. ACapability(perdocs/design/d-0006-protocol-native-capabilities.md's own definition) exists to give a route/binding a compile-time-safe way to OPT INTO optional, protocol-specific behavior — sealed opt-in gating is the entire point of the mechanism. Response Topic + Correlation Data is NOT optional formqtt5reqreply: EVERY route registered againstmqtt5.AttachServer/AttachClientneeds it, unconditionally — it IS the wire mechanism by which reqreply-over-mqtt5 works at all (there is no way to route a reply back to the right caller without it). There is no opt-out scenario to gate: a route either uses mqtt5 reqreply (and gets Response Topic/Correlation Data automatically, with no choice involved) or doesn't use mqtt5 reqreply at all. Declaring something that is unconditionally present, with no real alternative, would be pure ceremony — it fails the actual test that motivatesCapabilityin the first place, mirroring how REST doesn't make callers "declare" that HTTP requests carry aContent-Lengthheader. Shared Subscriptions ($share/group/topic) remain the genuineCapabilitycandidate from this same MQTT5 feature cluster — a route CAN work with or without shared-subscription reply fan-out, and getting it wrong changes CORRECTNESS (competing vs. duplicating consumers), unlike Response Topic/Correlation Data's all-or-nothing nature — tracked independently indocs/design/d-0006-protocol-native-capabilities.md, not blocked by anything in this doc.
No implementation has started. A future session should pick the
lowest-risk starting point first — likely STALE, superseded by shipped work: this
sentence predates Phases 0-5, all of which have since shipped and been
verified (mqtt5... before
mqtt(v3)/zeromqmqtt5 first, per this exact recommendation, then zeromq) —
see this doc's own top status banner and Phase-by-phase implementation
record above for the actual, completed history. Kept here only to show
the original sequencing recommendation was followed, not as a live
instruction.
Update: one of the 6 items above was REOPENED and reversed (the
"migration path for Serve/Call/ServeRouter/CallDealer" bullet,
above) — found flawed during docs/roadmap/reqreply-middleware.md's
final review pass (the "mirrors REST's ServeOne/CallWithHandle"
premise doesn't hold against the real code: REST's versions are
zero-duplication thin wrappers, reqreply's old functions are a fully
separate duplicate dispatch path). Its resolution — RETIRE these
functions — is now tracked and sequenced entirely in
docs/roadmap/reqreply-middleware.md's new Phase 0/0b, not here. This
is the ONLY item reopened; the other items remain closed as below.
This doc (d-0004) otherwise has ZERO remaining open DECISIONS of its
own. Final update: ALL tracked implementation work below has SINCE
SHIPPED, for BOTH adapters. reqreply-middleware.md's Phase 0/0b
(escape-hatch delegation, BOTH mqtt5 AND zeromq) and Phase 1/1b (mqtt5's
HandleMW/ClientMW Fn shapes, the declare/implement split, plus the
User-Property param-as-middleware sub-phase) shipped first; zeromq's
OWN .Use/HandleMW/ClientMW Fn-shape work (zeromq-security.md)
has SINCE ALSO SHIPPED — the declare/implement split is now a
multi-adapter (mqtt5 AND zeromq) pattern. Both source roadmap docs have
since been deleted, per their own graduation policy; see "Addendum:
reqreply-middleware.md and zeromq-security.md" below for the full,
consolidated, durable record — promoted into this doc now that the
multi-adapter bar is met. Shared Subscriptions as a Capability
candidate (docs/design/d-0006-protocol-native-capabilities.md) remains
separately tracked, unrelated to the above. None of this represents an
unresolved question WITHIN d-0004's own scope (the
Server/Client/Attach rework, which is fully shipped and verified).
Test plan (executed during implementation — see the Phased¶
implementation plan above for the actual verification evidence per phase)
Mirroring D-0003's and Protocol-Native Features's own Test plan sections:
Client/Server+Attach+reflection dispatch (Decision ½, already confirmed via prototype) —NoServerTransportAttachedError/NoClientTransportAttachedErrorbeforeAttach; a full round trip via realreflect.Value.Calldispatch;TransportTypeMismatchErroron a malformedroute/handlevalue;ClientTransportAlreadyAttachedErroron double-Attach.Client.Call's DUAL-MODE route-vs-handle acceptance (Decision 1, CONFIRMED via prototype — supersedes an earlier round's single-mode lock): a RAW, unregisteredRoutepassed toCallround-trips correctly and confirmsCredentialFuncis NOT invoked (matching REST's own acceptedGlobalSecurity-invisible limitation, not a reqreply-specific gap); an already-registered*RouteHandlepassed toCallround-trips correctly and confirmsCredentialFuncIS invoked (preservingGlobalSecurityenforcement) — both cases exercised against the SAME route, confirming the caller's choice of value shape determines the behavior, not a global toggle.route.WithHandler(fn).Register(server)— ONE fluent chain (Decision 1's "Server/Builder unification" finding, CONFIRMED via prototype, supersedes the retiredreqreply.Handlefree function) — confirmsfnis dispatched correctly whenServer.Serve(ctx)runs with NO separate registration call and NO per-routeServearguments.Server.AddGlobalSecurity+Route.Register(server)(the confirmedServer/Builderunification) — confirmsGlobalSecurityset directly onServeris visible to a registered route's*RouteHandle.GlobalSecuritywith NO intermediateBuildervalue needed.Server.Serve's CONCURRENT per-route dispatch (Decision 1's "concurrent dispatch" finding, CONFIRMED via prototype — the critical case this Test plan must not skip): an all-non-blocking-transport registration (3+ routes, mqtt5-style — eacht.Servecall returns instantly) confirmsServer.Servestill blocks until the caller'sctxis cancelled, NOT returning early; an all-blocking-transport registration (5+ routes, zeromq-style — eacht.Servecall blocks internally) confirms ALL routes run CONCURRENTLY, via an active-route high-water-mark assertion (a regression to the sequential-loop design would show a high-water mark of 1, proving starvation — this is the actual test that catches Finding B if it regresses); one route'st.Servereturning a real error confirms it propagates PROMPTLY asServer.Serve's own return value (without waiting for the caller'sctxto be cancelled) and cancels every other still-running route.zeromq.Attach's topic→socket coverage validation (Decision 2's divergentAttachshape, CONFIRMED via prototype) — routes registered BEFOREAttachwith FULL topic/socket coverage succeeds; a route with NO matching socket is rejected with a typedMissingSocketErrorat Attach time, not discovered later atServetime.CallAsync/Future[Resp](Decision 5, already confirmed via prototype) — non-blocking return; await from a different call site/goroutine; concurrent calls with zero correlation cross-talk; timeout/cancellation parity withCall.FutureFactory/NewFutureAny's type-erasure crossing (Decision 5) — confirms a plain interface type assertion recovers a correctly-typed*Future[Resp]with zeroreflectuse.- Security/credential folding (Decision 3) —
.Use()/.HandleMW()/.ClientMW()registration mirroringrest.Route's own existing tests;handle.Implementations/ClientImplementationspopulated byRoute.Register/Route.ClientHandle, read automatically byServer.Serve/Client.Callwith NO per-callOptions.SecurityFunc/CredentialFuncfield remaining. mqtt(v3)'s absence from the reqreply transport interfaces (Decision 4) — confirmsmqttpackage does NOT implementServerTransport/ClientTransport(a compile-time absence, not a runtime check) unless/until a documented subset is designed.ports.PluginReqReplyPattern— confirms it continues to populateImplementations/ClientImplementationsfor free via its existing.Register(builder)delegation, with zeroports-specific changes.- Escape hatches (carried forward) — a regression test confirming
adapters/zeromq's reqreply transport, once Decision 3 lands, DOES read and enforceImplementations/ClientImplementations(closing today's confirmed "never even read" gap) — this is the ONE existing escape-hatch bullet this rework is expected to actually fix, not just document.
Addendum: reqreply-middleware.md and zeromq-security.md — the declare/implement split, for BOTH mqtt5 AND zeromq¶
Added after docs/roadmap/reqreply-middleware.md (mqtt5's declare/
implement split, Phases 0/0b/1/1b) and docs/roadmap/zeromq-security.md
(zeromq's own Fn-shape work, closing the last item the first doc
deferred) both shipped — recorded here so the lineage is discoverable
from this document, since the newer features' own roadmap docs did not
themselves narrate where their core mechanism came from. Both roadmap
docs have SINCE BEEN DELETED (per their own 3-way delete/keep/promote
graduation policy — single-feature roadmap docs, fully shipped, with no
lasting cross-cutting design value of their own beyond what is captured
here); this addendum is the durable record of the design lineage that
remains after that deletion.
Phase 0 / 0b — escape-hatch delegation (mqtt5 AND zeromq)¶
RouteHandlegained capability-parity methods mirroringrest/events:DecodeWithFormats,DecodeMergedWithFormats,EncodeRequestWithFormats,EncodeWithFormats,DecodeResponseWithFormats,EffectiveRequestFormats,EffectiveFormats,EncodeVars.reqreply.ClientCallOptions(mirrorsrest.ClientCallOptions) added as a variadic trailing parameter toClient.Call/CallAsync/ClientTransport.Call/CallAsync.- Final resolution for
Serve/Call/CallHandle/ServeRouter/CallDealer(see the corrected status-banner note above for the short version): these functions KEPT their exact existing signatures (zero breaking change) — their bodies were rewritten to construct aserverTransport/clientTransport(etc.) directly and delegate to its.Serve/.Callmethod, achieving zero duplicate dispatch logic with zero caller/example migration.CallHandleis now a pure alias forCall. Applied identically to both mqtt5 and zeromq (all 4 zeromq transport functions:Serve/Call/ServeRouter/CallDealer). A real, additional gap (missingstats.TraceObserverspan support in the reflection-based dispatch) was found and closed along the way.
Phase 1 / 1b — declare/implement split (mqtt5-only when shipped, now superseded by zeromq parity below)¶
Route[Req,Resp].Use(mws ...middleware.RouteMiddleware)/HandleMW(mw *middleware.Middleware, fn any)/ClientMW(mw *middleware.Middleware, fn any)—RouteHandlegainsImplementations []middleware.ServerImplementation/ClientImplementations []middleware.ClientImplementation.- mqtt5 Fn shapes: server-side PAIRED (security-verifying, REST's
scope-grant model:
func(ctx, msg *pahomqtt5.Publish, reqs) (map[string][]string, error)), server-side UNPAIRED (general decorator wrapping the raw*pahomqtt5.Publish), client-side PAIRED (credential-supplying, returns[]mqtt5.UserProperty), client-side UNPAIRED (general decorator wrapping the fullfunc(ctx, Req) (Resp, error)dispatch — no raw pre-decode form exists to wrap instead). - Breaking change (mirrors REST's own D-0001 precedent): mqtt5's OLD
ServeOptions.SecurityFunc/CallOptions.CredentialFuncfields were REMOVED entirely once.Use/HandleMW/ClientMW'sImplementations/ClientImplementationsbecame the sole credential mechanism — one declarative mechanism only, no permanent parallel imperative escape hatch (this REVERSES an earlier decision in this same doc that had followed events pub/sub's "keep both permanently" precedent instead). reqreply.WithSecurityScheme/SecuritySchemekept as deprecated aliases; new package-local error typesMissingSecurityMiddlewareError{Route, Scheme}/UnknownMiddlewareImplementationError{Route, Scheme}(mirrorrest/events' own equivalents).CheckCoverage-equivalent runs at adapter-Serve-time insidemqtt5.AttachServer'sServerTransport.Serve(mirrorsrest.CheckCoverage's own Serve-time-only-knowable-coverage precedent), not insideapi/reqreplyitself.- Phase 1b (request AND reply direction, mqtt5-only):
FromUserPropertyParam/FromResponseUserPropertyParam(adapters/mqtt5) attach User-Property params as spec-contributingmiddleware.Middlewarevalues;render/asyncapi/v3.Messagegained aHeaders schema.Schemafield;RouteHandlegainedRequestHeaderParams/ResponseHeaderParams. Reusedmqtt5.UserPropertyError/MissingUserPropertyErrorunchanged — no new error types needed.
zeromq's own Fn-shape work (zeromq-security.md) — CORRECTS a stale claim in the paragraphs above¶
The "Remaining open items" narrative above (written mid-session, before
zeromq-security.md shipped) still calls zeromq's CheckCoverage
wiring and Fn-shape support "idea only, no driver yet" and "not yet
implemented" — this is now stale and superseded. Confirmed via real
code: adapters/zeromq/reqreply_transport.go calls
reqreply.CheckCoverage at both serverTransport.Serve and
routerServerTransport.Serve construction time (mirroring mqtt5's Phase
1 exactly), and all 4 zeromq reqreply transports
(serverTransport/clientTransport/routerServerTransport/
dealerClientTransport) now dispatch .Use()/HandleMW/ClientMW
paired-security and general-purpose Fn shapes — SHIPPED, not deferred.
- Paired Fn shapes differ from mqtt5's by necessity: zeromq has no
raw-message-equivalent type to operate on (REQ/REP
[payload]frames decode immediately) — both server-side paired (func(ctx, req *Req, reqs) error, verifying) and client-side paired (func(ctx, req *Req, reqs) error, credential-writing) operate on the DECODED*Reqdirectly, using a NEW-to-this-package reflection mechanic: build a fresh addressablereflect.New(reqType), copy the decoded value in, call the Fn against its pointer, read back whatever it mutated. Deliberately NOT REST's scope-grant model — each transport adapter mirrors its own established precedent, an intentional per-adapter difference, not an inconsistency. - General-purpose (unpaired) Fn shape: one shape for both server AND
client (
func(next func(ctx, req Req) (Resp, error)) func(ctx, req Req) (Resp, error)) — zeromq has no raw pre-decode form on either side, unlike mqtt5's server-side raw-*Publish-wrapping decorator. - Implementation surface: sized ×2 relative to mqtt5's Phase 1 (4 transports — REQ/REP pair PLUS ROUTER/DEALER pair — vs. mqtt5's 2), duplicated rather than shared, consistent with how Phase 0's capability-parity work was also duplicated ×2 across the same 4 transports.
- Reused error types, no new ones needed:
reqreply.SecurityError/SecurityCredentialError(already transport-agnostic) wrap paired-Fn rejections identically to mqtt5's Phase 1; surfaced via the existingServeError/CallError{Kind: KindSecurity}wrapping (KindSecurityalready existed, no newErrorKindneeded). - A real bug found and fixed during this work, in BOTH adapters:
clientTransport.call/dealerClientTransport.call'sreflect.MakeFunc-builtinnerCallclosures ignoredargs[0](thectxactually passed by a general-purposeClientMWdecorator), silently discarding any context mutation a decorator made before dispatch. The SAME bug existed in mqtt5's own earlier Phase 1innerCall(inherited when this work mirrored mqtt5's technique) — fixed in both adapters together, verified via a dedicated regression test per adapter. examples/reqreply-api's Demo 9 (demo_cross_api_oauth2_sharing.go) demonstrates the SAMEmiddleware.SecuritySchemedeclaration shared across a zeromq reqreply route AND a locally-declared REST route, proving byte-identical scheme sharing acrossapi/restandapi/reqreply.- Full verification (
gofmt/go build/go vet/go test -race/just check/all examples) — all clean; all 12 planned unit tests pass.
RESOLVED: adapters/zeromq pub/sub's flat SecurityFunc/CredentialFunc — RETIRED¶
adapters/zeromq's PUB/SUB side (SubscribeOptions/PublishOptions)
already had its OWN declarative .Use/SubscribeMW/PublishMW
mechanism (per the D-0003 addendum in docs/design/
d-0003-codec-declared-middlewares.md), but its OLD flat
SecurityFunc/CredentialFunc fields permanently coexisted alongside
it — previously flagged here as a genuinely open question with no
decision recorded. Now resolved: RETIRED, mirroring mqtt5 reqreply's
own REST-precedent removal above — SubscribeOptions[T].SecurityFunc/
PublishOptions[T].CredentialFunc were removed entirely (BREAKING) from
ALL 3 pub/sub adapters (adapters/zeromq, adapters/mqtt5,
adapters/mqtt v3, not just zeromq) — see
D-0002's own "Addendum:
Observability Core Consolidation, SecurityFunc Retirement, and
examples/events-api" for the full evidence/decision record.