# Core concepts

Source: https://gortexa.sheng.page/en/concepts/

Gortexa's design rests on four ideas: Protobuf as the single source of truth, one h2c port multiplexing three protocols, a fixed eight-stage interceptor chain, and one error-mapping table shared by gRPC, HTTP and MCP. Each is covered below.

## Contract-first

Protobuf is the single source of truth. You write two things: proto contracts and logic implementations. gRPC stubs, the HTTP/JSON gateway, the OpenAPI spec and MCP tool schemas are all produced by `gortexa regen`, which always runs buf lint → buf breaking → buf generate.

- Everything under `gen/` is generated: committed (CI checks it for drift), never hand-edited.
- The breaking-change gate is built into the flow: proto edits that break compatibility are rejected; additive edits pass.

## Single h2c port multiplexing

One cleartext HTTP/2 port, dispatched by Content-Type and path:

| Request | Destination |
| --- | --- |
| `Content-Type: application/grpc` | gRPC (grpc-go's `ServeHTTP` mode) |
| Path `/mcp` | MCP bridge (Streamable HTTP) |
| Everything else | grpc-gateway (HTTP/JSON) and built-in routes (`/healthz`, `/openapi.json`) |

h2c uses Go's native `http.Server.Protocols`, not the deprecated `x/net/h2c`. Cleartext means TLS must be terminated in front of the service in production — see [Deployment](https://gortexa.sheng.page/en/deployment/).

## The interceptor chain

Eight stages, fixed order:

```
recover → request-id → logger → load-shed → rate-limit → circuit-breaker → auth → validation
```

An incomplete chain fails startup (fail-loud) rather than silently dropping a stage, and an **absent** one is refused outright: `kernel.New` requires exactly one of `WithInterceptors` or `WithoutInterceptors`, so a server with no recover, auth, rate limit or validation can no longer happen by omission. OTel attaches as a gRPC `StatsHandler`, covering unary and streaming. A panic recovered by the recover stage is logged with its `request_id` (client-supplied or minted by the chain, matching the `X-Request-Id` response header) — the only record a panicking RPC leaves.

The order is fixed, but the auth stage is pluggable: the default is HS256 JWT (`interceptor.Config.Verifier`), yet the stage actually runs an `auth.Authenticator` — a non-JWT scheme (static bearer, mTLS, API key) just sets `Config.Authenticator`, leaving the eight-stage order intact. To add your own interceptor, use `kernel.WithServerOptions(...)` (gRPC chains it after the framework chain, before the handler, still within recover) rather than editing the eight stages. Interceptor and `StatsHandler` options work as described, but **transport-level** options — `KeepaliveParams`, `KeepaliveEnforcementPolicy`, `MaxStreams`, `InitialWindowSize` — are silently ignored on the shared h2c port, because external gRPC there runs in grpc-go's `ServeHTTP` handler mode, which builds its transport from the `http.Request`. They do still apply to the in-process loopback. If you need keepalive enforcement on your public gRPC surface, split gRPC onto its own listener.

The defaults for the three governance stages are defined in `cmd/server/main.go` (not in config.yaml — to change them, edit the `interceptor.NewSet` parameters and recompile):

| Stage | Defaults | On limit |
| --- | --- | --- |
| rate-limit | 200 RPS per peer, burst 100, tracking TTL 10 minutes | `RESOURCE_EXHAUSTED` (HTTP 429) |
| circuit-breaker | opens after 5 consecutive failures for 10s, half-open admits 2 probes | `UNAVAILABLE` (HTTP 503) |
| load-shed | in-flight cap 1,024 (gRPC health checks exempt; so is `/grpc.reflection.` whenever reflection is enabled) | `RESOURCE_EXHAUSTED` (HTTP 429) |

The circuit breaker counts only server-side failures: `Unavailable`, `Internal`, and a handler-produced `DeadlineExceeded` while the request context is still live. Client-caused outcomes — a cancelled or expired request context, and `Canceled` / `InvalidArgument` / `Unauthenticated` / `PermissionDenied` (which auth and validation return *inside* the breaker) — are **neutral**: they neither trip it nor reset the counter. A half-open episode whose probes have not reported back within `OpenInterval` is abandoned and the next caller opens a fresh one, so a long-lived stream cannot wedge a method shut. For a slow dependency to be visible, keep dependency timeouts below the client deadline.

Measured numbers (benchmarks in the framework README, Apple M1 / go1.26.5): an **authenticated** unary pass through all eight stages is 80 allocs (`ChainUnaryJWT`) — that is the figure a capacity plan needs; the auth-skipped health/reflection path is about 1,552 ns and 27 allocs (`ChainUnaryAuthSkipped`), the difference being HS256 verification, the metadata lookup and JWT parsing. The rate-limiter `Allow` hot path is 0 allocs (allocs are machine-independent).

## One error model

A single mapping table decides how the same error appears on all three protocols: gRPC status, HTTP status, MCP error envelope. The rule is that internal causes never leak — only invalid-argument and unauthenticated forward their messages to the caller; every other category returns a safe message from the registry. When a handler wraps a status with `%w`, only the innermost status's own message is forwarded — the wrapper's context (host, DSN, SQL) and grpc's `rpc error: code = …` boilerplate are replaced by the registry's safe message.

The error-resolution hot path is about 70 ns and 2 allocs (using Go 1.26's `errors.AsType`).

## Batteries

| Component | Notes |
| --- | --- |
| Config | Layered loading, fail-loud, masked secrets. See [Configuration](https://gortexa.sheng.page/en/configuration/) |
| Health | Three-state health mapped to gRPC Health; `app.Health().Register(...)` wires real dependencies |
| Logging/Observability | Structured logs via slog + OTLP export |
| Database | PgBouncer-safe pgx pool + sqlc |
| Cache | Process-local in-memory by default; `cache.driver: redis` switches to distributed (zero-dependency RESP client) |
| MQ | NATS (core or JetStream); empty `mq.group_id` = fanout, non-empty = load-balance |

**Before relying on a battery**
- Cache: the default in-memory backend is process-local — not shared across replicas, with no size cap or eviction. Fine for a single instance or local development; switch to `cache.driver: redis` when replicas must share a cache. See [Configuration](https://gortexa.sheng.page/en/configuration/).
- Config: the placeholder `jwt_secret` is refused at startup, and secret fields are masked in logs and error output. Real secrets come in via environment variables.
- MQ: `group_id` sets the delivery mode — empty is fanout (every subscriber receives each message), non-empty is load-balance (one handler per message).
