Capability Matrix — what benzene-go does, deliberately doesn't, and how to fill the gap
Benzene is honest about its boundaries. This page is the single place that states, for each package or area of this port, what it provides (with the code that provides it), what it deliberately does not do (and why), and how to solve the rest outside Benzene. A gap that is a design decision is stated with its reasoning; a gap that is simply unbuilt says "not implemented" plainly — the two are never conflated here.
The one idea behind every row
Benzene abstracts at the business-logic boundary — you write a message handler once and host it anywhere — and never at the transport or storage boundary. If you wrap SQS behind a generic queue interface, you lose the SQS-specific capabilities that were the reason to choose SQS. So Benzene's answer to "does it abstract X?" is usually a deliberate no: every binding exposes the native message/event, and rolling your own middleware is a first-class, supported path, not an escape hatch.
Two corollaries specific to this port:
- The root module is zero-dependency. Every third-party dependency lives in its own Go module
(
awssqs,kafka,grpcbinding, …) so importing the core never pulls an SDK you didn't ask for. A "needs an SDK" boundary below is that policy, not a missing feature. - A database is not a transport. Benzene delivers events in; persisting state is your handler's own code with its own SDK/driver.
Core
| Capability | What benzene-go provides | What it deliberately does NOT do (and why) | How to solve the rest |
|---|---|---|---|
| Pipeline & routing | Topic, Status, Result[T], Registry, Middleware/Pipeline, RouterMiddleware, three-phase App lifecycle (root module: app.go, router.go, registry.go, middleware.go) |
Reflection/attribute-scanning handler discovery — this port is explicit-registration by design (core-concepts.md §8's "explicit registry" idiom for Go) |
Register handlers explicitly on the Registry; the compiler is your discovery mechanism |
| DI | DI-lite Container/Scope + ScopeFromContext (scope.go) |
Ship or integrate a reflection-based DI container — Go has no DI-container culture to bridge to | Constructor wiring in main; the Container covers per-invocation scoping |
| Wire envelope & reserved names | Transport-neutral envelope, ResolveMetadataTopic, ReservedNames — one injectable value for the configurable topic/correlation/version keys of wire-contracts.md §2 (wire/envelope.go, wire/metadata.go) |
Impose the envelope on transports that already carry routing (EventBridge detail-type) — the native key is the routing key there; Kafka and RabbitMQ read the topic header first and fall back to their native key (Kafka topic, routing key) |
Envelope-less transports use the Benzene envelope or a preset topic; otherwise route on the native key |
| Versioning | Version selection per core-concepts.md §2 (router.go, registry.go, router_version_test.go): the version comes from the one benzene-version header (wire.DefaultVersionKey; renamed in one place via ReservedNames.VersionKey, which UseDefaultPipeline passes as WithVersionKey) or an HTTP {version} route segment (sugar for the header). A versioned message runs the exact (id, version) handler or is not-found naming topic and version; an unversioned one runs the topic's default handler — the unversioned handler, else the one registered with benzene.AsDefault(). A topic with only versioned handlers and no declared default is logged as a warning when the router is built (Registry.TopicsWithoutDefault). A version/x-version header is an ordinary header |
Transparent payload up/down-casting (versioning.md §4 Mechanism B) — not implemented: spec-optional, and the .NET implementation leans on reflection this zero-dependency port avoids; so the spec's cast-reachable selection step never applies. No "highest available" selector, even as an opt-in. Outbound clients do not write benzene-version from Topic.Version — not implemented |
Register a handler per supported version and declare the oldest one default; set the benzene-version header yourself on an outbound send; do payload up-casting in a small middleware if you need it |
| Validation | Validated(validator, handler) typed handler wrapper + Validator[T]/Combine (validation/); validation.Pointer builds the RFC 6901 JSON Pointer each errors[].field carries (wire-contracts.md §1.3), from the request's JSON names (json tags) — runs the conformance:validate case |
A struct-tag/reflection validation DSL — the pipeline is type-erased until dispatch, so validation is a typed wrapper, not a pipeline middleware; and the wrapper cannot check a validator's Field is a pointer (the validator writes it) |
Plug any validation library inside your ValidatorFunc, converting its property paths with validation.Pointer |
| In-process dispatch | inprocess — an in-process client.Sender over named pipelines (PipelineSet), for the modular-monolith pattern |
A container-wide outbound routing table (the .NET/TypeScript shape) — this port has no process-wide registry to hook into; a Sender is constructed and used directly like any other client |
Construct the in-process Sender in main and pass it where a transport client would go |
| Outbound error fidelity | Every client that can receive a problem document rebuilds the Result's errors from it with message, field and code intact (core-concepts §12): httpclient.Client — from the failure envelope (envelope.ToResult, errors else detail as one opaque error) and, when the response is not an envelope, from an application/problem+json (or JSON) problem body, the status then from the problem's benzeneStatus (wire-contracts §4.1, so an application-defined status survives the hop; a success-class benzeneStatus on a failure is ignored) and from the reverse HTTP table only when it carries none — an envelope's own statusCode is that status already; awslambdaclient.Client — from the failure envelope (envelope.ToResult); inprocess.Sender — no wire, the handler's errors are passed through whole. Run end to end against problem-details-cases.json's envelopeCases by conformance/client_fidelity_conformance_test.go (httpclient over the envelope endpoint and over a route-mapped problem+json response, inprocess) and awslambdaclient/problem_conformance_test.go. A call that fails before any reply (no connection, refused or reset, the caller's deadline) is a failed Result, never a Go error, on every reply-receiving client — timeout when the deadline elapsed (the context's deadline, an error wrapping context.DeadlineExceeded, or a Timeout() bool error such as http.Client.Timeout's), else service-unavailable (transport-bindings §1; the shared rule is client.CallFailure): httpclient.Client, awslambdaclient.Client, inprocess.Sender (a context already done is not dispatched; a pipeline's Go error is mapped the same way) and grpcbinding.Client (grpc-go's own Unavailable/DeadlineExceeded through the reverse table; a conn's non-status error through client.CallFailure). The one-way clients (awssqs, awssns, awseventbridge, gcppubsubclient, azureservicebus, azureeventhub, azureeventgrid, azurequeuestorage, rabbitmq, kafka, awsstepfunctions) receive no reply and answer accepted: there is no request-reply queue client in this port. A one-way send that fails in the transport (the SDK or broker call errors before the broker accepts the message) is also a failed Result, never a Go error, through the same client.CallFailure: timeout when the deadline elapsed, else service-unavailable (each binding's TestClient_DeadlineExceededIsTimeout, Pub/Sub's table case). grpcbinding.Client reads the gRPC error model of wire-contracts §4.2 rather than a problem document: field survives (from BadRequest.FieldViolation.field, turned back into a pointer) and so does code (from FieldViolation.reason, §4.2) |
Attach the received problem document verbatim (the MAY of §12) — an application's own problem type reaches the caller only as the status and errors |
Read an application-defined problem type at the call site from the raw response, or carry it in an error code |
| Testing | benzenetest in-process test host (Invoke[TReq, TRes]) |
— | — |
Transports — HTTP, gRPC, brokers
| Area | What benzene-go provides | What it deliberately does NOT do (and why) | How to solve the rest |
|---|---|---|---|
| HTTP inbound | Native REST-style routing + envelope-over-HTTP (httpbinding/, shared dispatch in envelope/); portable CORS middleware (cors/). The envelope endpoint (EnvelopeHandler, /benzene/invoke) answers 200 for every processed envelope — not-found, deleted, a pipeline unauthorized alike — and 400 only for a body that is not an envelope; the inner problem omits status; WithTopicFilter restricts the topics it reaches (a restricted topic is not-found). A route mapped to a health topic answers 503 when the report says isHealthy: false (httpstatus.Response, shared with awslambda.HTTPHandler/azurefunctions.Handler) |
Own the HTTP server — the binding is an http.Handler you mount; host-enforced 401/403 before the envelope is read is your net/http middleware's job |
Mount on any net/http-compatible server/mux; wrap EnvelopeHandler in your auth middleware |
| HTTP outbound | httpclient.Client (one Send), satisfying client.Sender; decorators WithCorrelationID/WithRetry (client/). Reads the response envelope whatever the HTTP status, through envelope.ToResult (isSuccessful decides; a missing one is a failure), and falls back to the reverse HTTP table only when the body is not an envelope — then rebuilding the errors, field and code intact, from a problem-document body (application/problem+json or JSON), and taking the status from its benzeneStatus when it carries one. A call that fails before any reply is timeout (deadline elapsed) or service-unavailable |
Manage connection pooling/auth for you | Supply your own configured *http.Client |
| gRPC | grpcbinding (own module): UnaryServerInterceptor claiming only routed methods, proto3-JSON bridging, benzene-status trailer, outbound Client; status tables in grpcstatus/; errors[].field pointers become dotted FieldViolation.field paths (/items/0/sku → items[0].sku) and the client turns them back into pointers; errors[].code is written to FieldViolation.reason and read back (wire-contracts §4.2) |
Streaming RPCs (client/server/duplex) — not implemented, a documented gap; and no codegen of service stubs — the binding claims routes, it doesn't own the server | Register real protoc-generated services; unmatched methods fall through untouched |
| Kafka (self-hosted) | kafka (own module, segmentio/kafka-go): consumer-group Consumer (one invocation + scope per record, explicit commits) + outbound Client; topic from the record's topic header (ReservedNames, inherited from the builder), falling back to the Kafka topic it was consumed from (transport-bindings' Kafka entry); other headers and the value verbatim; the Client produces to a Kafka topic named after the Benzene topic. Settlement: skip-and-continue by default (kafka.DefaultDelivery; a failed message goes to OnFailure and is committed past), or envelope.AtLeastOnce to re-dispatch it in place until it succeeds |
Broker-side redelivery/DLQ — Kafka has none | Wire OnFailure to a dead-letter topic publish or log, or choose at-least-once and accept that a poison message stalls its partition |
| RabbitMQ | rabbitmq (own module, amqp091-go): self-hosted Consumer + outbound Client |
— | — |
| CloudEvents | cloudevents/ — envelope↔CloudEvents 1.0 mapping + inbound HTTP handler (both content modes), zero-dep |
Ship per-broker CloudEvents transports — the mapping is the bridge; delivery is whatever CloudEvents-shaped system you point at it | Point Event Grid, Knative, EventBridge, etc. at the handler |
AWS
Full parity with the .NET reference on cloud integrations (see PARITY.md).
| Area | What benzene-go provides | What it deliberately does NOT do (and why) | How to solve the rest |
|---|---|---|---|
| Lambda runtime + HTTP | awslambda/ — hand-rolled Runtime API bootstrap; HTTP adapter for Function URL / API Gateway v2, REST/v1, ALB (shape-detected per invocation), plus two envelope entry points: EnvelopeHandler for synchronous invokes (the caller reads the envelope) and AsyncEnvelopeHandler for Event invokes (a failure fails the invocation so Lambda retries) — the Runtime API does not reveal the invocation type, so the application picks |
The API Gateway custom-authorizer sub-feature is not implemented | Attach an authorizer at the gateway; claims arrive as request context you can read in middleware |
| SQS | awssqs (own module): inbound Lambda Handler (per-message batch-item failure) + outbound Client + self-hosted Consumer poller |
Hide SQS specifics — the native record is exposed | Use SQS features (delay, FIFO, attributes) via the SDK directly |
| SNS / EventBridge / S3 | awssns, awseventbridge (own modules — inbound handlers + outbound clients), awss3 (root module, inbound) |
Batch/partial-failure reporting on these — the platform offers none for direct async invokes; a failure returns a Go error so AWS's async-invoke retry applies (the same default as .NET's 1.0 settlement contract; per-binding detail in the settlement breakdown below) | Handlers must be idempotent (at-least-once) |
| DynamoDB Streams / Kinesis / MSK Kafka | awsdynamodb/, awskinesis/, awskafka/ (root module, zero-dep) — ordered CDC/stream handlers: sequential per partition, stop at first failure, report the resume position via batch-item failures |
Concurrent fan-out on ordered streams — ordering is the point of a stream; and no outbound side (writing the table/stream is the publish) | Configure ReportBatchItemFailures on the event source mapping |
| Outbound invoke / orchestration | awslambdaclient (Lambda invoke, RequestResponse/Event), awsstepfunctions (StartExecution, idempotent name) — own modules |
Run/resume workflows — starting an execution is Benzene's job; the workflow is Step Functions' (see Saga row) | Model durable workflows in Step Functions itself |
| X-Ray | Covered via diagnostics (OTel → X-Ray via ADOT/OTLP) |
A direct X-Ray-SDK port of .Aws.Lambda.XRay — not implemented; the OTel path is the intended route |
Export OTLP to ADOT |
Azure
Custom-handler model: the Functions host POSTs a Data/Metadata JSON envelope; parity turns on
whether a trigger reduces to that envelope (see PARITY.md §Azure).
| Area | What benzene-go provides | What it deliberately does NOT do (and why) | How to solve the rest |
|---|---|---|---|
| Functions triggers | azurefunctions/ (zero-dep): HTTP Handler, QueueHandler (Storage Queue + Service Bus, topic resolution + outer-500 redelivery), CosmosHandler (fan-in change-feed batches), TimerHandler, EventGridHandler (Event Grid + CloudEvents schema), EventHubHandler (batch cardinality, per-event routing) |
Blob trigger: deferred by design — it is SDK-typed (BlobClient) in .NET, not a JSON the custom handler forwards; a faithful port must own the lease via azblob, and this repo does not fabricate an unverifiable custom-handler shape. Functions Kafka trigger: not implemented — zero-dep-achievable, but the exact custom-handler payload must first be pinned against a live host (same no-fabrication rule). Service Bus trigger settle is outer-status only (≈ AutoComplete) — explicit per-message settle needs the SDK |
For blobs, react to the Event Grid BlobCreated event via EventGridHandler and read the blob with azblob in your handler; for explicit Service Bus settle, use the self-hosted worker below |
| Self-hosted workers | azureservicebus.Worker (explicit complete/abandon/dead-letter), azureeventhub.Consumer (caller-owned checkpoint), azurecosmos.Worker (caller-owned lease, fan-in) — own modules |
Own the Event Hub checkpoint store / Cosmos lease for you — checkpoint strategy is an application decision the worker surfaces rather than hides | Wire the SDK's checkpoint/lease store of your choice |
| Outbound clients | azureservicebus.Client, azureeventhub.Client, azureeventgrid.Client, azurequeuestorage.Client — own modules |
— | — |
GCP
| Area | What benzene-go provides | What it deliberately does NOT do (and why) | How to solve the rest |
|---|---|---|---|
| Cloud Functions Gen2 | gcpfunctions (own module): RegisterHTTP + RegisterCloudEvent (functions-framework) |
— | The zero-dep Cloud Run path needs no package at all — mount httpbinding |
| Pub/Sub | gcppubsub/ — zero-dep push-subscription http.Handler (ack/nack via status code); gcppubsubclient (own module) — outbound publish |
Pull subscriptions on the zero-dep path — pull needs the SDK's streaming client; push is the zero-dep shape | Use gcpfunctions.RegisterCloudEvent or run the SDK's pull client feeding the pipeline |
Mesh, health, spec surface
| Area | What benzene-go provides | What it deliberately does NOT do (and why) | How to solve the rest |
|---|---|---|---|
| Mesh service-side | mesh/ — the service registers its Contract Document, the same contract.Derive document benzene:spec serves, through mesh.Announcer (announce.go): Registration fills a detected placement (DetectPlacement) when the document carries none; runtime ("go"), profile (cloudservice.New's self-check) and degraded (events and sends when no OutboundRegistry was supplied) come from the derivation; no instanceId in the body. Heartbeats carry serviceVersion and the registered registrationHash, with the aggregate of healthcheck.Run over the service's checks; Run retries registration while the collector comes up and before each heartbeat until it lands. TraceMiddleware + outbound WithTraceContext, LogExporter/PushExporter trace feeds, issue emitter (IssueMiddleware/PushIssueExporter; each failing result's field/code error pairs — never the message — counted per issue and bounded to the ten most frequent, mesh.md §4.1) — every feed independent and optional at Core level. Meshed service (mesh.md §7.1): a Cloud Service (below) plus M1 — Announcer (register + heartbeats) and TraceMiddleware with a PushExporter (traces); the issue feed is outside M1 — and M2 — TraceMiddleware joins inbound traceparent and adopts its tracestate (dropped with a malformed one), and WithTraceContext forwards this span's traceparent with the tracestate unchanged on every outbound message: a send with a reply, a one-way send (every binding's Client is a client.Sender) and a published event (responseevents.NewSenderPublisher publishes through a Sender), replacing any forwarded copy. The app wires both; cloudservice.New reports them as not wired. There is no benzene:mesh topic, descriptor or descriptorHash (spec-0.4.0) |
Capture a language-native exceptionType on issues — the Go router converts a panic to a service-unavailable result before middleware sees it (the field is optional in mesh.md §4.1 for exactly this). Detect a region outside AWS Lambda — Azure Functions and Cloud Run document none in the environment, so none is guessed |
Status-based classification carries the signal; set contract.Info.Placement to name a region yourself |
| Mesh collector | meshd/ — register/heartbeat/traces/issues ingest + benzene:mesh:query:* read models over an in-memory store with a bounded trace ring (meshd/meshd.go, store.go). A register body is a Contract Document read loosely: only service is required (no dialect check, schemas never interpreted); requests are the consumer claims, events ∪ sends the provider claims, benzene: entries ignored; a member that is absent or named in degraded is unknown — no edges, and no undeclared-edge contract-drift against it; a trace of a reserved benzene: topic (contract.IsReserved — benzene:spec, benzene:healthcheck, …) is never an undeclared edge, against the handler or the caller, since no document lists one (mesh.md §4.2). Each declared edge reports its providerActivity/consumerActivity entry, with lastObservedAt absent (not the zero time) when no trace has exercised it. Likewise a topic row's lastSeen is absent (not the zero time) for a declared topic with no traffic. The carried registrationHash is trusted (never recomputed, never a reason to reject); a heartbeat's hash is compared with the service's one registration (hashMatches). An issue's errors (field/code pairs) merge by identity under the ten-entry bound (mesh.MergeIssueErrors); a malformed entry is skipped, never failing the issue. A fleet flow carries its status — its first failure's, else its first event's. The fleet row's topics counts requests, produces counts events and sends, and it carries serviceVersion, runtime, transports and placement; missingFeeds' descriptor means "not registered"; the service view's descriptor is the registered document as sent. Passes mesh-collector-cases.json and mesh-issue-cases.json |
Durable storage — the collector is a diagnostic surface, not a system of record. Service Versions is not implemented: the catalog keys by service alone, so two side-by-side releases replace each other's registration, and mesh-service-version-cases.json/mesh-version-order-cases.json are listed opt-outs. The produced-vs-consumed version-skew read model is not implemented. Old topics/produces register bodies are not read |
Any port's collector can host this port's services over the shared benzene:mesh:* contract; use the .NET collector for side-by-side versions |
| Mesh View (UI) | meshd.ViewHandler (meshd/view.go) — serves a Go-native embedded page (view.html, no JS framework, no external assets) at /benzene/fleet-ui, polling the collector's fleet query through the envelope endpoint. It does not embed or serve a canonical cross-language mesh-ui.html — no such asset is referenced in this repo; the page is this port's own rendering of the shared query contract |
A rich SPA — zero-dependency stance extends to the page | The wire contract is the interop point; any UI can poll benzene:mesh:query:* |
| Health checks | healthcheck/ — reserved-topic interception middleware + TCPCheck, HTTPPingCheck, DiskSpaceCheck (zero-dep; error categories, not raw messages, so internal hostnames/credentials don't leak). The result is ok whenever the checks ran; isHealthy carries the verdict (a warning does not flip it), and GET /benzene/health answers 503 when it is false. RouterMiddleware answers the reserved benzene:ping probe ok with no payload, so queue-borne probes are acknowledged. No benzene:liveness/benzene:readiness topics or routes are served |
Ship dependency-specific checks (database ping, broker liveness) — a check is a one-function interface | Implement healthcheck.Check calling your dependency's SDK |
| Client-side contract check | clienthealthcheck.ServiceCheck — asks a provider's benzene:spec for its Contract Document via an outbound Sender and compares the served contractHash with the one supplied by WithExpectedContractHash (a generated client's ContractHash), reporting the contract relationship (drift = warning, not a health flip). No provider-side or private hash: the check computes nothing itself |
Couple the check to the provider's transient health — benzene:spec is served health-independently on purpose, so contract drift and downtime stay distinguishable |
Use benzene:healthcheck for liveness; this check is a contracts diagnostic |
| Spec endpoint & profile | Default paths per transport-bindings.md §1: /benzene/invoke, /benzene/spec, /benzene/health (httpbinding.EnvelopePath/SpecPath/HealthPath) and /benzene/fleet-ui (meshd.ViewPath), each configurable by mounting elsewhere; this port serves no spec-ui, mesh-ui or MCP surface. Self-description (R5): the contract package derives the Contract Document (contract-document.md, spec-0.4.0 shape: schemaDialect: "openapi-3.0", service/serviceVersion/versionScheme/description, the deployment-side runtime/placement/profile/degraded outside both hashes, requests/events/sends, no benzene: entries, an absent response for a handler returning struct{}, author examples only — none synthesized) at start-up with contract.Derive, which hoists every named struct into components (OpenAPI 3.0 dialect: nullable pointer to a named struct is the {"type":"object","nullable":true,"allOf":[…]} wrapper; format emitted for ints, floats, times and bytes), names it by BenzeneSchemaName() or its Go name, and fails before the first message on a component-name collision (*contract.NameCollisionError, naming both types, the topics and the remedy); undeclared generic instances are inlined. contract.Middleware answers benzene:spec ({type?, format?}; anything but benzene/json is not-implemented; WithDocumentType adds port extensions such as AsyncAPI), and httpbinding.SpecHandler serves GET /benzene/spec through the pipeline, copying the type/format query parameters. Both hashes — contractHash and registrationHash — are computed in the root module (contract.HashDocument) over the §6.2 projection, canonicalized by the hand-written RFC 8785 internal/jcs (differentially fuzzed against gowebpki/jcs in codegen/jcsfuzz). Events and sends are declared with contract.RegisterEvent/contract.RegisterSend (WithResponse for a reply); a (topic, version) in both is a start-up error. Cloud Service Profile (R1–R5, R7): cloudservice.New — one-call builder wiring the whole profile (R5: benzene:spec intercepted in the pipeline, answered wherever the transport carries a reply, and GET /benzene/spec), returning its derivation error or an honest ProfileReport: Requirements is the R1–R5/R7 checklist (Satisfied()/Unsatisfied()), which the document carries as its profile ({"name":"cloud-service"}, missing listing only R1–R5/R7 ids and omitted when none), and Mesh reports the meshed-service claim's M1/M2 apart (Meshed() requires the profile too); no Go consumer reads profile.missing, so a stale R6/R8 from an older service is ignored. cloudserviceprobe.Run — external black-box probe with tri-state verdicts for R1–R5/R7 (R5 needs both GET /benzene/spec and benzene:spec over the envelope endpoint), with Report.Mesh carrying M1/M2 as always Inconclusive (not observable from one service), noting any profile requirement observed unmet. A bodiless request on any Route mapped to benzene:spec — through httpbinding.Handler, awslambda.HTTPHandler or azurefunctions.Handler — carries its type/format query parameters as the request, as httpbinding.SpecHandler does |
cloudservice.New does not wire the meshed-service claim — M1's three feeds (registration, heartbeats, traces) or M2 (trace propagation) — they need a collector and exporter lifecycle the app owns; the report's Mesh section says so rather than overclaiming. The probe keeps its own path constants — it must audit any port's service, so it deliberately duplicates rather than imports |
Report.Mesh.Unsatisfied() is the exact to-do list to become meshed; run a mesh.Announcer over Service.Contract, wire mesh.TraceMiddleware and the push exporters, and wrap every outbound Sender (the event publisher's included) in mesh.WithTraceContext yourself |
| Contract docs & codegen | openapi/ — OpenAPI 3.0 generation from the Contract Document (its components and $refs carried over as they are); asyncapi/ — AsyncAPI 3.0 generation from the Contract Document (requests → receive, with a reply only when a response is declared; events and sends → send), each with a GET Handler; codegen (own module: contractdoc + gengo + cmd/benzene-codegen) — typed-client generation from a Contract Document: rejects an unknown schemaDialect, a missing one included (missing-schema-dialect-is-rejected), ignores benzene: entries, include-list and topic-scoped projections recompute both hashes with the root module's contract.HashDocument, an absent response generates Result[struct{}], and the nullable wrapper becomes a pointer field. Passes every parseCases/topicScopeCases/schemaClosureCases case and every contract-hash-cases.json case, projections included |
Infer events or sends from call sites — they come only from RegisterEvent/RegisterSend declarations. Deriving responseevents mappings into event declarations is not implemented. Typed enums: Go cannot list a named string type's values, so enums derive as their base type |
Declare each event with contract.RegisterEvent; see docs/codegen-client.md for the client pipeline |
Cross-cutting building blocks
| Area | What benzene-go provides | What it deliberately does NOT do (and why) | How to solve the rest |
|---|---|---|---|
| Idempotency | idempotency/ — atomic-claim middleware over a pluggable Store; InMemoryStore (single-process, TTL); store outage fails open; a failure is never permanently suppressed |
Cross-instance de-duplication — independent processes can't coordinate without external shared state, and shared state can relocate the race, not remove it | An external store with an atomic conditional write (DynamoDB conditional put, Redis SET NX) behind the Store interface, plus naturally idempotent handlers |
| Settlement (one-way bindings) | Every one-way binding settles through envelope.Settlement (envelope/settlement.go), taken as WithSettlement by the handler constructors and as a Settlement field by the self-hosted workers. Its zero value is the spec default (transport-bindings §1, obligation 8): a message is acknowledged only when its invocation's isSuccessful is true (ignored included); a failure result, no result (unexpected-error), an unmatched topic (not-found), a middleware's Go error and a recovered handler panic are all redelivered — except the self-hosted kafka.Consumer, a stream that documents skip-and-continue as its default. Every binding offers the at-most-once override (envelope.AtMostOnce) and unmatched-topic-as-ignored (IgnoreUnmatchedTopics, keyed on benzene.IsUnmatchedTopic). Asserted against settlementDefaults by conformance/settlement_conformance_test.go. See the per-binding breakdown |
A single cross-transport acknowledgement abstraction — each binding settles with its transport's own signal (batch-item failure, Go error, non-2xx, nack, abandon, withheld delete/commit/checkpoint) | Make handlers idempotent (idempotency/). Bound retries with the transport's redrive/DLQ settings |
| Outbox | Not implemented. Nothing ships; the only trace is a seam — responseevents.Publisher is documented as implementable by "an outbox relay" (responseevents/responseevents.go). This is an unbuilt gap, not a design decision: the .NET reference ships Benzene.Outbox, and this port's ROADMAP records no "no" for it |
— | Store-and-forward in your handler's own transaction against your store, relaying via a client.Sender; pair the consuming side with idempotency |
| Oversized payloads (claim check) | Not implemented. No package, no seam. An unbuilt gap, not a design decision — the .NET reference ships Benzene.ClaimCheck |
— | Offload to blob storage in a small middleware pair (offload before send, hydrate before deserialize), carrying the reference in a header |
| Resilience | resilience/ (zero-dep): retry (full-jitter backoff, WithRetryOnError/WithRetryOnResult), cooperative Timeout, Bulkhead, Fallback; circuitbreaker (own module, sony/gobreaker/v2) with WithTripOnResult |
Forcibly stop a ctx-ignoring handler — impossible in Go; Timeout bounds the wait cooperatively. Hedging is not implemented — racing attempts needs per-attempt InvocationContext isolation the shared-ic pipeline contract doesn't provide, a core-concepts question deliberately not forced package-locally |
Honor ctx in handlers; for hedging, race at the caller (your own goroutines) until the contract question settles |
| Rate limiting | ratelimiting/ — per-instance middleware over a Limiter interface + TokenBucket default; rejection = too-many-requests |
Fleet-wide (authoritative) limiting — N instances admit up to N× the rate; authoritative limiting belongs at the gateway | Limit at the gateway/load balancer, or plug a shared-store Limiter |
| Caching | cache/ — pluggable Store + read-through GetOrLoad[T]; InMemoryStore default; degrades safely (read error = miss, write error ignored) |
Ship a distributed cache — a shared store is its own module/dependency decision | Implement Store over Redis/Memcached |
| Sagas | saga/ — in-process staged orchestrator with LIFO compensation, SagaContext result threading, observability StateStore, clean-rollback-only RetryPolicy |
Durable crash-resume — steps are closures that can't be rehydrated; the state store records progress for observability, not recovery (matching Benzene.Saga's own boundary) |
Use a durable orchestrator (Step Functions via awsstepfunctions, Durable Functions, Temporal) for crash-durable workflows |
| Response events | responseevents/ — post-handler republish of the response as a follow-up event (Map, CrudConvention, fan-out mappings; FailMessage default so a lost publish is never silently swallowed) |
The .NET package's build-time unmapped-response diagnostic and mapping-derived AsyncAPI send side — not implemented (the asyncapi generator exists; deriving its send side from these mappings is the open, unbuilt piece) |
Declare the published events with contract.RegisterEvent (the AsyncAPI generator reads them from the Contract Document); use LogAndContinue + OnPublishError where publish loss is acceptable |
| AuthN / AuthZ | auth/ (zero-dep): Principal, BasicAuth (RFC 7617), BearerAuth — stdlib JWT validation with a strict algorithm allowlist (RFC 8725), per-family typed keys, iss/aud/exp/nbf/iat + skew; keys from StaticKeys or a caching JWKSResolver with OIDC discovery (auth/jwks.go: NewJWKSFromAuthority); Authorize/RequireRole/RequireScope |
Ship a policy-engine (OPA/Cedar) adapter; leak validation failure detail to callers (the real reason reaches only WithOnError) |
An authorization middleware calling your policy engine, built on Principal |
| Observability | diagnostics (own module, OTel API only): one server span per invocation, W3C traceparent join, count/duration metrics, outbound WithTraceContext; logging/ — zero-dep log/slog per-invocation line |
Own the OTel SDK/exporter or ship vendor packages (Datadog, Zipkin) — the application owns the SDK; standard OTLP export covers vendors | Configure the OTel SDK + OTLP exporter in main |
| Database / state access | (nothing — by design) | Any database/state-store abstraction — a database is not a transport; wrapping one hides its capabilities (the core anti-pattern) | Your handler uses its own driver/SDK directly |
| Conformance | conformance/ — runs this port against the vendored language-neutral fixtures from the main repo's docs/specification/conformance/ (conformance/testdata/, with its README.md). conformance/SPEC_VERSION pins the spec release the snapshot was taken from; the conformance-drift-check workflow fails when the snapshot is not byte-identical to that release and warns when a newer spec-* release exists. Contract-document producer (R5, and the register body): every derivationCases case (with its serviceInfo identity and placement), namingCases, specTopicCases and specHttpCases case and every contract-hash-cases.json case without a projection, through the runtime contract package; meshed service: mesh-trace-cases.json; collector: mesh-collector-cases.json and mesh-issue-cases.json against meshd (mesh-service-version-cases.json and mesh-version-order-cases.json are listed opt-outs — Service Versions is not implemented); client generator: codegen/conformance runs the client-generator groups and every hash case. Transport metadata (transport-metadata-cases.json): topicSources — every fixture binding is claimed in topicSourceClaims, and the non-metadata sources are driven (DynamoDB Streams' derived {tableName}:{eventName}, httpbinding's route, awskafka's Kafka-topic fallback here; EventBridge's detail-type, gRPC's method, RabbitMQ's routing-key fallback and kafka.Consumer's Kafka-topic fallback in their own modules); metadataCases and overrideCases (the consumed topic key absent from the headers, the case-insensitive read, the version selecting the handler through the real router) run through wire.ResolveMetadataTopic and through the real entry point of every native-metadata inbound binding, configured with UseReservedNames as a service would — gcppubsub.Handler, azurefunctions.QueueHandler (Service Bus) and EventHubHandler and awskafka.Handler here, awssqs.Handler/Consumer, awssns.Handler, azureservicebus.Worker, azureeventhub.Consumer, rabbitmq.Consumer and kafka.Consumer in their modules (shared harness internal/transportmetadata); settlementDefaults by settlement_conformance_test.go. The outbound clients' use of an overridden key is asserted by each client's own tests. Kafka (kafka.Consumer, awskafka.Handler) runs metadataCases/overrideCases too, and topicSources' Kafka-topic fallback is driven through both; the snapshot is pinned to spec-1.2.0 (conformance/SPEC_VERSION) |
Edit a fixture to make this port pass — the fixture is the neutral truth | A mismatch is a bug here or a deliberate spec change there, never a fixture patch |
Settlement — the per-binding breakdown
Re-audited against the source on 2026-10-11, after the spec-0.2.0 settlement work. "Failure" means
the invocation ended with isSuccessful: false (validation-error, not-found, …), with no result
(reported as unexpected-error), or with a middleware's Go error (service-unavailable). Every row
inherits these facts:
- Every one-way binding settles through
envelope.Settlement(envelope/settlement.go), whoseOutcome.Acknowledgeis true for a successful invocation, for any outcome underenvelope.AtMostOnce, and for an unmatched topic underIgnoreUnmatchedTopics. So the no result and unmatched topic columns always match the failure column unless a knob says otherwise. RouterMiddleware(router.go) always sets a result: an unmatched topic isnot-found(marked sobenzene.IsUnmatchedTopiccan tell it from a handler's ownnot-foundor an unknown version), an empty topic isvalidation-error, a handler panic is recovered asservice-unavailable, and the reservedbenzene:pingprobe isok— so queue-borne health probes are acknowledged.Pipeline.Run(middleware.go) does not recover a panic in a middleware outside the router. The uncaught panic column records where that panic lands; no knob applies to it.redelivermeans the binding withholds acknowledgement, so the transport's own redelivery applies. Retry counts and dead-lettering are the transport's redrive configuration, not Benzene's.- Knobs on every row:
envelope.AtMostOnce(acknowledge whatever the outcome) andIgnoreUnmatchedTopics; the failure hook, where a binding has one, still sees every failure.
| Binding | Package / file | Shape (fixture id) | Default on failure / no result / unmatched topic | Uncaught middleware panic | Unit of settlement |
|---|---|---|---|---|---|
| Lambda SQS trigger | awssqs — sqs.go Handler |
queue (sqs) |
redeliver (batchItemFailures) |
redeliver — the runtime recovers it (awslambda/runtime.go invoke) and fails the invocation, so the whole batch is retried |
per message (messageId; needs ReportBatchItemFailures on the mapping) |
| SQS self-hosted poller | awssqs — consumer.go Consumer |
queue (sqs) |
redeliver (not deleted; visibility timeout) | redeliver — panics out of Run; nothing in the batch is deleted |
per message (DeleteMessageBatch of the acknowledged) |
| RabbitMQ consumer | rabbitmq — consumer.go Consumer |
queue (rabbitmq) |
redeliver once (Nack with requeue), then nack without requeue (dead-letter exchange, or dropped); NoRequeue nacks without requeue on the first failure |
redeliver — panics out of Run; the broker requeues the unacked delivery |
per delivery |
| Pub/Sub push | gcppubsub — gcppubsub.go Handler |
queue (pubsub) |
redeliver (HTTP 500) | redeliver — no response (net/http recovers and closes the connection) |
per message |
| Functions Storage Queue / Service Bus trigger | azurefunctions — queue.go QueueHandler |
queue (queue-storage, servicebus) |
redeliver (outer 500) | redeliver — no response | per message (invocation) |
| Service Bus self-hosted worker | azureservicebus — worker.go Worker |
queue (servicebus) |
redeliver (AbandonMessage), or quarantine with AckModeDeadLetter |
redeliver — panics out of Run; the lock expires |
per message |
| Lambda SNS | awssns — sns.go Handler |
one-shot (sns) |
redeliver (Go error → async-invoke retry) | redeliver — invocation fails | per invocation (one notification) |
| Lambda EventBridge | awseventbridge — eventbridge.go Handler |
one-shot (eventbridge) |
redeliver (Go error) | redeliver — invocation fails | per event |
| Lambda S3 | awss3 — s3.go Handler |
one-shot (s3) |
redeliver (Go error at the first failed record; under at-most-once it carries on) | redeliver — invocation fails | per invocation (the whole event is re-run) |
| Functions Event Grid trigger | azurefunctions — eventgrid.go EventGridHandler |
one-shot (event-grid) |
redeliver (outer 500) | redeliver — no response | per event |
| Functions Timer | azurefunctions — timer.go TimerHandler |
one-shot (timer) |
invocation fails (outer 500) — the function's retry policy applies where one is configured; a tick is not queued | invocation fails — no response | per tick |
| Lambda async envelope | awslambda — envelope.go AsyncEnvelopeHandler |
one-shot (envelope-async) |
redeliver (Go error → async-invoke retry) | redeliver — invocation fails | per invocation (one envelope) |
| Lambda DynamoDB Streams | awsdynamodb — dynamodb.go Handler |
stream (dynamodb-streams) |
redeliver from the failed record (a poison record too); at-most-once skips and continues | redeliver — invocation fails (whole batch) | per shard position (SequenceNumber) |
| Lambda Kinesis | awskinesis — kinesis.go Handler |
stream (kinesis) |
redeliver from the failed record (a poison record too); at-most-once skips and continues | redeliver — invocation fails (whole batch) | per shard position (SequenceNumber) |
| Lambda MSK / self-managed Kafka | awskafka — kafka.go Handler |
stream (kafka) |
redeliver from the failed offset, per partition; at-most-once skips and continues | redeliver — invocation fails (whole batch) | per partition position ({partition, offset}) |
| Kafka self-hosted consumer | kafka — kafka.go Consumer |
stream (kafka) |
skip-and-continue (documented default, kafka.DefaultDelivery): OnFailure, then committed; envelope.AtLeastOnce re-dispatches in place after ErrorBackoff until it succeeds |
redeliver on restart — panics out of Run; offset uncommitted |
per message, in partition order |
| Functions Event Hub trigger | azurefunctions — eventhub.go EventHubHandler |
stream (eventhub) |
redeliver² (outer 500 at the first failed event) | redeliver² — no response | per batch (invocation) |
| Event Hub self-hosted consumer | azureeventhub — consumer.go Consumer |
stream (eventhub) |
redeliver in place: the failed event and the rest of its batch are held and re-dispatched after ErrorBackoff before anything new is received; never checkpointed past |
redeliver on restart — panics out of Run; not checkpointed |
per event, in partition order |
| Functions Cosmos DB change feed | azurefunctions — cosmos.go CosmosHandler |
stream (cosmosdb-changefeed) |
redeliver (outer 500, whole batch) | redeliver — no response | per batch (fan-in: one invocation) |
| Cosmos DB self-hosted worker | azurecosmos — worker.go Worker |
stream (cosmosdb-changefeed) |
redeliver (token not advanced; re-read after ErrorBackoff) |
redeliver on restart — panics out of Run |
per page (continuation token; fan-in) |
| Cloud Functions Gen2 CloudEvent | gcpfunctions — gcpfunctions.go RegisterCloudEvent |
cloudevents (one-shot) |
redeliver² (returns an error) | redeliver² — functions-framework v1.9.2 re-panics; cloudevents sdk-go v2.16.2 recovers it into an error result | per event |
| CloudEvents over HTTP | cloudevents — http.go Handler |
cloudevents (one-shot) |
redeliver (HTTP 500) | redeliver — no response | per event (batched mode answers 415) |
² The binding fails the invocation. Whether the platform then redelivers depends on the trigger's retry configuration, which this repo cannot verify against a live host. In particular, how the Functions host checkpoints an Event Hub batch after a failed invocation is unverified.
Request-response bindings settle by responding (fixture shape request-response): httpbinding,
awslambda.HTTPHandler, azurefunctions.Handler, grpcbinding, gcpfunctions.RegisterHTTP, and
the envelope over a synchronous carrier (httpbinding.EnvelopeHandler,
awslambda.EnvelopeHandler). The Functions Kafka and Blob triggers (blob-storage in the fixture)
and Pub/Sub pull are not implemented (see the rows above). cloudevents.Handler and
gcpfunctions.RegisterCloudEvent have no settlementDefaults id; the conformance runner reports
them rather than inventing one.
Divergences from the .NET settlement contract
- Unmatched topic on
awskafka(MSK) and the Functions Event Hub trigger. .NET acknowledges an unrouted (no-result) outcome on these. Here the router always setsnot-found, so they redeliver by default —IgnoreUnmatchedTopicsis the opt-in for .NET's behaviour. - Self-hosted Event Hub (
azureeventhub.Consumer). .NET defaults to at-most-once (catch-and-continue). This consumer defaults to at-least-once and redelivers a failed event in place;envelope.AtMostOncematches .NET. - Cosmos DB change feed (
CosmosHandler,azurecosmos.Worker). In .NET a returned result does not apply (fan-in, with no result; only a throw redelivers). Here the batch is one result-bearing invocation, so a failure result redelivers the whole batch;envelope.AtMostOncematches .NET'sCatchHandlerExceptions = true. - Kinesis (
awskinesis). .NET is fan-in with handler-driven checkpoints. Here each record is routed by stream name and processing stops at the first failure. Both redeliver; the unit differs. - Middleware panic on a self-hosted worker. .NET's Service Bus and RabbitMQ workers abandon or
nack a throwing message and carry on. Here the panic escapes
Run; the in-flight message is redelivered by the broker, or from the uncommitted position after a restart.
Deliberately out of scope (a "no", not a gap)
.NET-ecosystem idioms with no Go analogue to port (see ROADMAP.md for the full reasoning):
alternate DI containers (Container/Scope is the Go idiom), alternate loggers (log/slog is
standard), alternate serializers (encoding/json is idiomatic; Avro/MessagePack would be new
dependency decisions, not ports), vendor-specific observability packages (OTLP covers them), and
ASP.NET-on-Lambda/Functions hosting bridges (net/http handlers are the pipeline here).
Why "we don't do that" is a feature
Every deliberate "no" above buys you something: you keep the full power of the tool you chose,
you're never blocked by a leaky abstraction, and the zero-dependency core stays small and stable.
When you need something benzene-go doesn't ship, the extension model — a custom Middleware, a
custom Check, a Store/Limiter/Sender implementation — is the supported, documented path.
See docs/middleware.md.