Documentation
¶
Overview ¶
Wave 4b Task 3 — Accept-header content negotiation primitive.
Pure functions over header strings + a typed media-type lookup. No Gin coupling at this layer; the Gin glue lives in commons/cli/toon.go (W4b-T4) which calls NegotiateContentType + MarshalChosen + UnmarshalChosen below.
Policy (per W4b operator decisions 1–6, plan §"Operator-locked decisions" 2026-05-22):
- Accept: */* ⇒ server default (Herald default = TOON; operator may flip via the env var HERALD_DEFAULT_RESPONSE_CODEC=json)
- Accept: application/toon ⇒ TOON
- Accept: application/json ⇒ JSON
- Accept: both with no q ⇒ TOON wins (Herald-default tie-break)
- Accept: both with q params ⇒ higher q wins
- Accept: missing / empty ⇒ server default
- Content-Type: application/toon ⇒ TOON
- Content-Type: application/json or empty ⇒ JSON (backwards-compat: curl -d '...' usage)
§107 anti-bluff anchors enforced at this layer:
- MarshalChosen with ct="application/toon" goes through the REAL digital.vasic.toon (which wraps github.com/toon-format/toon-go since W4b-T2). The output never begins with '{' or '[' — the original 2026-05-17 PASS-bluff would have surfaced as JSON-syntax bytes here.
- UnmarshalChosen round-trips the same bytes back into the same Go struct; "no error" alone is insufficient evidence per Universal §11.4 / Herald §107. The paired test asserts reflect.DeepEqual.
- UnmarshalChosen with an unknown content-type returns an explicit error naming the unsupported CT — never a silent JSON fallback that would mask a misrouted body.
Constitutional anchors: Universal §11.4 (PASS-bluff prevention), Herald §107 (end-user-usability covenant), W4b operator decision 3 (HERALD_DEFAULT_RESPONSE_CODEC opt-out).
Package cli is the shared CLI scaffold for every Herald flavor binary. Per Universal §11.4.74 catalogue-check: vendored as Herald-internal (no-match against vasic-digital + HelixDevelopment).
Wave 4b Task 4 — Gin TOON encode/decode middleware.
This file is the consumer-facing seam between Herald's flavor handlers (which still write `c.JSON(status, obj)` like every other Gin app) and the content-negotiation primitive in commons/cli/contentnego.go. The middleware swaps Gin's ResponseWriter for a buffering wrapper so that, after the handler finishes, we can:
- Inspect Content-Type written by c.JSON (`application/json; charset=utf-8`).
- Compare against the negotiated response codec (driven by the request's Accept header + HERALD_DEFAULT_RESPONSE_CODEC env).
- If the client preferred TOON, JSON-decode the buffered body into `interface{}` and re-encode via MarshalChosen("application/toon").
- Rewrite the Content-Type header AND the body before flushing to the underlying ResponseWriter.
BindNegotiated is the symmetric request-side helper handlers can use instead of c.BindJSON when they need to honour an inbound Content-Type of application/toon.
§107 anti-bluff watershed (load-bearing):
- The middleware NEVER writes JSON bytes under an `application/toon` Content-Type. The transcode step is the structural guarantee: either MarshalChosen succeeds and we emit real TOON bytes (the paired test asserts byte-0 is not `{`) OR we keep the original JSON body AND restore the `application/json` Content-Type header. There is no third path. The 2026-05-17 PASS-bluff (JSON bytes wearing an application/toon CT) is structurally impossible here.
- The round-trip test (TestTOONMiddleware_RoundTrip_StructEqual) asserts reflect.DeepEqual against the original Receipt struct, not "no error". The size test (TestTOONMiddleware_RoundTrip_SizeSmaller) asserts TOON bytes are strictly smaller than JSON for the same struct — proving real codec benefit, not a header swap.
Constitutional anchors: Universal §11.4 (PASS-bluff prevention), Herald §107 (end-user-usability covenant), W4b plan Task 4.
Index ¶
- Constants
- Variables
- func AltSvcMiddleware(h3Port int) gin.HandlerFunc
- func BindNegotiated(c *gin.Context, v any) error
- func BindServeFlags(cmd *cobra.Command, port *int, tlsCert, tlsKey *string, noBrotli *bool)
- func BrotliMiddleware(quality int) gin.HandlerFunc
- func HealthzHandler(br commons.Branding) gin.HandlerFunc
- func MarshalChosen(v any, contentType string) ([]byte, error)
- func MetricsHandler(br commons.Branding) gin.HandlerFunc
- func NegotiateContentType(acceptHeader, contentTypeHeader string) (responseCT, requestCT string)
- func NewRootCmd(br commons.Branding, opts ...RootOpt) *cobra.Command
- func ReadyzHandler(br commons.Branding) gin.HandlerFunc
- func RunServe(ctx context.Context, opts ServeOpts, port int) error
- func ServeCmd(opts ServeOpts) *cobra.Command
- func StubCmd(name, hrd, description string) *cobra.Command
- func StubRouteHandler(route Route) gin.HandlerFunc
- func TOONMiddleware() gin.HandlerFunc
- func UnmarshalChosen(data []byte, v any, contentType string) error
- func VersionCmd(br commons.Branding) *cobra.Command
- type RootOpt
- type Route
- type ServeOpts
Constants ¶
const ( // MediaTypeTOON is the canonical Content-Type for TOON-encoded bodies. // Mirrors digital.vasic.toon/pkg/toon.ContentType (the smoke test in // toon_smoke_test.go pins value equality on the upstream constant). MediaTypeTOON = "application/toon" // MediaTypeJSON is the canonical Content-Type for JSON-encoded bodies. MediaTypeJSON = "application/json" )
Media-type constants. Kept as exported package-level values so callers (Gin handlers, tests) can reference them without re-typing the strings.
const EnvDefaultResponseCodec = "HERALD_DEFAULT_RESPONSE_CODEC"
EnvDefaultResponseCodec is the operator-facing environment-variable name controlling the *default* response codec when the client's Accept header is `*/*` or absent. Values: "toon" (default), "json". Anything else silently falls back to "toon" — the Herald default per W4b operator decision 1.
const HeraldBrotliDefaultLevel = 6
HeraldBrotliDefaultLevel is the operator-locked default Brotli quality used by all Herald flavors when no operator override is supplied. 6 is one notch above the andybalholm/brotli library default (5); balanced CPU/ratio for Herald's small responses (256B-10KiB).
The full quality range per RFC 7932 is 0 (no compression — store only) through 11 (maximum compression). Higher = slower encode, lower = faster encode + larger output. Decode cost is roughly constant.
Variables ¶
var ( BuildVersion = "dev" BuildCommit = "unknown" BuildTime = "unknown" )
Build-info variables. Set via -ldflags at build time; otherwise default to "dev" / unknown markers so a raw `go run` invocation is still operational. §107: an empty version field is a bluff — VersionCmd's JSON shape MUST populate every required key.
Functions ¶
func AltSvcMiddleware ¶
func AltSvcMiddleware(h3Port int) gin.HandlerFunc
AltSvcMiddleware returns a Gin handler that emits the Alt-Svc response header advertising HTTP/3 availability on the supplied UDP port. The emitted header is exactly:
Alt-Svc: h3=":<h3Port>"; ma=2592000
ma=2592000 is 30 days in seconds — RFC 7838 §3.1 recommends a long max-age so clients retain the upgrade hint across browser sessions.
Disabled mode: when h3Port <= 0 (HTTP/3 disabled via HERALD_DISABLE_HTTP3=1 or any configuration path that surfaces a non-positive port to serve.go), the middleware is a no-op and does NOT emit the header. Telling a client to upgrade to a port that isn't listening is a worse failure mode than letting them stay on HTTP/1.1+HTTP/2 — the client would burn UDP handshake attempts and silently fall back, polluting metrics.
The wrapped middleware is digital.vasic.middleware/pkg/altsvc.New bridged through digital.vasic.middleware/pkg/gin.Wrap so the Gin chain sees a native gin.HandlerFunc.
func BindNegotiated ¶
BindNegotiated reads the request body and decodes it into v using the codec implied by the request's Content-Type header. Handlers use this instead of c.BindJSON when they need to accept either JSON or TOON request bodies.
Behaviour:
- Content-Type: application/toon → TOON decode
- Content-Type: application/json → JSON decode
- Content-Type: empty / "*/*" → JSON decode (curl -d '...' compat per W4b operator decision 2)
- Content-Type: application/xml etc. → JSON decode (best-effort fallback; UnmarshalChosen will surface a clear error if the bytes are not JSON)
- empty body → returns nil, leaves v untouched (handler decides whether absence is OK)
- read or decode error → returns a wrapped error naming the codec for the caller to map to 4xx
func BindServeFlags ¶
BindServeFlags attaches the standard `serve` flag set (--http-port, --tls-cert, --tls-key, --no-brotli) to cmd, writing into the supplied destination variables. Exported so flavor binaries that compose a custom cobra command (pherald — needs lazy Runner construction before calling RunServe) get IDENTICAL flag UX to the shared ServeCmd path.
Per §107: divergent flag UX between flavors is a covenant violation — operators muscle-memorising --tls-cert on cherald and getting silent failure on pherald would be a paper-cut bluff. Centralising the flag definitions here keeps every binary lockstep.
func BrotliMiddleware ¶
func BrotliMiddleware(quality int) gin.HandlerFunc
BrotliMiddleware returns a Gin handler that compresses response bodies with Brotli (quality `quality`) when the client's Accept-Encoding includes "br" AND the response body is at least 256 bytes AND the Content-Type is in the upstream's compressible-types list (application/json + text/* + the standard upstream defaults).
Quality resolution:
- quality > 0: use that level directly (clamped to [0, 11] — out-of-range falls back to the operator-locked default).
- quality == 0: read HERALD_BROTLI_LEVEL env var; parse + clamp; if env unset OR unparseable OR out-of-range, default to 6.
Per §107: the test brotli_test.go MUST decode the response body via github.com/andybalholm/brotli.NewReader and byte-compare it against the source. An implementation that sets Content-Encoding: br but writes identity bytes (a real failure mode if a middleware bug short-circuits the encoder) would pass a header-only assertion but fail decode-and-compare.
func HealthzHandler ¶
func HealthzHandler(br commons.Branding) gin.HandlerFunc
HealthzHandler returns 200 with {status:"ok",flavor,build:{version,commit,go_version}}.
func MarshalChosen ¶
MarshalChosen serialises v using the codec identified by contentType. Returns the encoded bytes or an error.
Supported content-types:
- "application/toon" → digital.vasic.toon/pkg/toon.Marshal (the REAL upstream TOON wire-up via github.com/toon-format/toon-go; never JSON bytes wearing a TOON content-type — §107 anti-bluff).
- "application/json" → encoding/json.Marshal.
Any other content-type returns an explicit error naming the unsupported CT. The error message includes the bad value so observability captures it without the caller having to log separately.
§107 anti-bluff: callers MUST verify that the returned bytes match the claimed content-type — the paired test asserts the TOON bytes do not begin with '{' or '[' (the original 2026-05-17 PASS-bluff signature).
func MetricsHandler ¶
func MetricsHandler(br commons.Branding) gin.HandlerFunc
MetricsHandler returns Prometheus text with a <prefix>_build_info gauge. The gauge prefix follows the documented Prometheus convention for Herald flavors: BinaryName takes precedence (so pherald emits "pherald_build_info", sherald emits "sherald_build_info", …); when BinaryName is empty we fall back to Flavor (Wave 2 tests pass Flavor directly as the binary name). Wave 2 emits just the gauge; full Prometheus client wiring is a follow-up HRD (live observability per spec §17).
func NegotiateContentType ¶
NegotiateContentType inspects an inbound HTTP request's Accept + Content-Type headers and decides:
- responseCT: the Content-Type the server SHOULD set on the response body — determined by the Accept header per the policy ladder above.
- requestCT: the Content-Type the server SHOULD use to decode the request body — determined by the Content-Type header (defaulting to JSON when absent or `*/*`).
The function is pure (no Gin/net.http coupling) and reads ONLY the env var HERALD_DEFAULT_RESPONSE_CODEC for its `*/*` / empty-Accept fallback decision (operator opt-out per W4b decision 3).
Header parsing follows RFC 7231 §5.3.2 (media-range + optional `q` parameter). Wildcards beyond `*/*` (e.g., `application/*`) are treated as `*/*` because Herald only ever serves application/{toon,json}.
The function NEVER returns an empty responseCT/requestCT — a caller is always given a concrete content-type it can encode/decode against. The safety-fallback when the client asks for something we can't speak (e.g., `Accept: application/xml`) is JSON, NOT a lie: we return "application/json" so the eventual response header matches the bytes we actually emit. The original 2026-05-17 PASS-bluff revision served JSON bytes under `Content-Type: application/toon`; that is structurally impossible at this layer because the codec dispatch in MarshalChosen keys on the SAME string returned here.
func NewRootCmd ¶
NewRootCmd returns the top-level Cobra command for a flavor. Branding drives Use / Short / Long / Version output. Use is the binary name (e.g. "pherald") since Cobra renders it as the invocation command in --help; Flavor (single-letter key, e.g. "p") and Prefix (3-letter, e.g. "PHR") appear in the long description for §8.2 context.
Falls back to br.Flavor if BinaryName is unset so callers that only populate Flavor (older tests / synthetic Branding) still produce a usable Cobra command.
func ReadyzHandler ¶
func ReadyzHandler(br commons.Branding) gin.HandlerFunc
ReadyzHandler returns 200 with {status:"ready"}. Wave 2 doesn't probe real readiness — flavor implementations may extend later via custom Route entries that override this builtin.
func RunServe ¶
RunServe is the exported dual-listener serve loop. It builds the Gin engine, registers healthz/readyz/metrics + opts.Middleware + opts.Routes, resolves the TLS cert source, starts the TCP/H2 listener (and the UDP/H3 listener when not opts.DisableH3), and blocks until SIGINT/SIGTERM/ctx-cancel. On terminal signal it gracefully shuts down BOTH listeners in parallel (5s grace each).
Callable two ways:
- Via ServeCmd's RunE — the common case for flavors with eager dependency construction (cherald, sherald, bherald, …).
- Directly from a flavor's custom cobra command — for flavors that need lazy dependency construction (pherald builds its Runner and verifier inside its own RunE so `pherald version` / `pherald migrate` don't require PG + JWT env vars). The flavor binds the standard flags via BindServeFlags, builds opts.Routes + opts.Middleware lazily, then calls RunServe(ctx, opts, port).
The two paths produce IDENTICAL listener behavior (dual TCP+UDP when not disabled, single TCP when DisableH3). Env-driven auto-detection (HERALD_DISABLE_HTTP3=1, HERALD_AUTH_MODE=jwks) lives here so both callers get it for free.
Pass port = 0 to use opts.Branding.DefaultPort.
func ServeCmd ¶
ServeCmd is the `<flavor>herald serve` subcommand for serving flavors. Binds a Gin engine with healthz/readyz/metrics + every Route in opts.Routes (nil-Handler+non-empty-HRD routes get StubRouteHandler).
Listener model (Wave 4a Task 6):
- DisableH3 == false (default): binds BOTH a TCP/HTTPS+H2 listener AND a UDP/H3 listener on --http-port. Both serve the same Gin engine. Auto-injects AltSvcMiddleware + BrotliMiddleware + TOONMiddleware (Wave 4b T6) into the chain so TCP clients see Alt-Svc upgrade hints + Accept-Encoding: br responses get compressed + Accept: application/toon responses get TOON-encoded. Requires a TLS cert (operator flag, env, or dev-autogen depending on ProdMode).
- DisableH3 == true: binds ONLY the TCP listener (HERALD_DISABLE_HTTP3=1 escape hatch). If a cert is supplied, the listener runs TLS; if not, it falls back to plaintext HTTP/1.1+H2c, matching pre-T6 behavior for dev workflows that don't want TLS.
Graceful shutdown on SIGTERM/SIGINT/ctx-cancel: BOTH listeners get up to 5s grace each. Returns nil on clean shutdown, error on bind failure.
Per §107: a "serve started" PASS without observing a healthz round- trip on BOTH listeners is a bluff. The integration tests start the server and verify both listeners actually accept traffic before declaring success; post-shutdown, the TCP port returns connection- refused and the UDP port is re-bindable by an unrelated listener (proof the QUIC stack released the socket).
Wave 4a Task 7.5 (pherald unification): the dual-listener body is now in RunServe(ctx, opts). ServeCmd is just flag-binding + a thin RunE shim. Flavor binaries that need lazy dependency construction (e.g. pherald, which builds its Runner against PG only at serve-time) can call BindServeFlags + RunServe directly, bypassing the cobra wrapper while still getting the exact same dual-listener engine.
func StubCmd ¶
StubCmd returns a Cobra subcommand that always fails with a 501-style error citing the HRD that will implement it. Used by every flavor's internal/stubs/ to register §43 commands not yet implemented.
Per Herald §107: the error message MUST contain the HRD pointer + the command name + the literal "not yet implemented" — these are the three substrings the e2e_bluff_hunt E31 invariant asserts on.
func StubRouteHandler ¶
func StubRouteHandler(route Route) gin.HandlerFunc
StubRouteHandler returns 501 with a JSON body citing the HRD that will implement the route. Used for Wave 2 placeholder routes.
func TOONMiddleware ¶
func TOONMiddleware() gin.HandlerFunc
TOONMiddleware returns a Gin handler that intercepts response bodies emitted via c.JSON (or c.Render with render.JSON) and transcodes them to TOON when the client's Accept header (or the Herald-default codec env) prefers application/toon.
Flow:
- Negotiate response codec from Accept + HERALD_DEFAULT_RESPONSE_CODEC.
- Wrap c.Writer in toonResponseWriter; handlers see no difference — they still call c.JSON(status, obj).
- After c.Next(), inspect the buffered body's Content-Type: - If the writer recorded application/json AND the negotiated codec is application/toon, JSON-decode the body into a generic interface{}, then MarshalChosen → TOON bytes. - Otherwise, pass through unchanged.
- Flush to the underlying ResponseWriter with the correct Content-Type + status + bytes.
Why this design over a Respond(c, status, obj) helper?
- It is a drop-in: every existing handler that calls c.JSON keeps working. The W4b plan's original sketch (Respond helper) requires editing every handler; the middleware approach narrows the blast radius of W4b T5/T6 to "add the middleware to the chain".
- The transcode is bounded by JSON round-trip semantics (numbers become float64, etc.) — for Herald's payloads (Receipts, Compliance decisions, Safety states) those round-trip losslessly. If a flavor ever ships a payload that doesn't round-trip cleanly, the per- handler escape hatch is to call MarshalChosen directly + c.Data.
Non-goals (deliberate):
- This middleware does NOT compress (Brotli is a separate layer).
- It does NOT touch responses that were never set as JSON (e.g., MetricsHandler emits text/plain; the middleware passes those through unchanged).
- It does NOT short-circuit when negotiation returns JSON — the pass-through path is identical, just no transcode.
func UnmarshalChosen ¶
UnmarshalChosen decodes data into v using the codec identified by contentType. v MUST be a non-nil pointer.
Supported content-types: same as MarshalChosen — application/toon (digital.vasic.toon/pkg/toon.Unmarshal) and application/json (encoding/json.Unmarshal).
Any other content-type returns an explicit error naming the unsupported CT (per the §107 anti-bluff rule: never silently fall back to JSON when the caller named a CT we can't handle — that would mask a misrouted body).
func VersionCmd ¶
VersionCmd is the `<flavor>herald version` subcommand. Prints human- readable build info by default; `--json` returns the canonical JSON shape used by e2e_bluff_hunt E2/E19-E24 (required fields: binary, flavor, version, go_version, os, arch — none may be empty).
Types ¶
type Route ¶
type Route struct {
Method string // "GET", "POST", ...
Path string // "/v1/compliance"
Handler gin.HandlerFunc // if nil + HRD non-empty, StubRouteHandler is used
Description string // operator-readable summary
HRD string // "HRD-028" for 501 stubs; "" for live routes
}
Route declares one HTTP route. Wave 2 stubs return 501 with the HRD pointer body when Handler is nil + HRD is non-empty.
type ServeOpts ¶
type ServeOpts struct {
Branding commons.Branding
Routes []Route
Middleware []gin.HandlerFunc
TLSCertPath string
TLSKeyPath string
ProdMode bool
DisableH3 bool
DisableBrotli bool
}
ServeOpts configures ServeCmd.
Required:
Branding — drives the default port + healthz/metrics gauge name.
Optional flavor-specific:
Routes — flavor-specific routes appended after healthz/readyz/metrics.
Middleware — flavor-specific Gin middleware chain registered immediately
after gin.Recovery() + the auto-wired Brotli/Alt-Svc layers
+ before any handler. Typical use is request-ID
propagation, tenant extraction, OTel tracing.
Wave 4a TLS / HTTP/3 controls (zero-value defaults preserve pre-T6 behavior so existing flavor binaries are not broken by the dual- listener refactor):
TLSCertPath — operator-supplied --tls-cert path (forwarded to
commons_tls.ResolveCertSource).
TLSKeyPath — operator-supplied --tls-key path.
ProdMode — true ⇒ ResolveCertSource refuses dev-autogen. The
caller's convention: set this to
(os.Getenv("HERALD_AUTH_MODE") == "jwks").
DisableH3 — true ⇒ HTTP/3 listener is NOT started, the Alt-Svc
middleware becomes a no-op, and the TCP listener
falls back to plaintext (or TLS if a cert was
supplied explicitly). Driven by
HERALD_DISABLE_HTTP3=1 in the caller.
DisableBrotli — true ⇒ Brotli middleware is NOT auto-wired. Useful
for routes that stream + cannot be buffered.