Executive Talents · API Gateway — Wire Contract

Rendered from this repo's docs/gateway/CONTRACT.md.

API Gateway Contract (contract_version 3)

This is the authoritative wire contract between the edge (cmd/gateway) and the control plane (cmd/controlplane), and between the control plane and any application that registers with it. Both binaries live in this repo; this document exists anyway because they are still two independently deployable processes that must agree on wire shape without sharing Go types across a process boundary (they share Go types only where genuinely safe to — internal/verify.Claims/JWK/JWKS, used by both the edge's Verify and the control plane's Sign).

0. History

1. Actors and authentication

Actor Authenticates via Verified by
A registered application (machine client) POST /oauth/token → EdDSA JWT The edge, entirely offline, against the JWKS in its config snapshot
Browser / console session traffic Whatever the destination application's own session mechanism is The destination application itself — the edge does not authenticate passthrough traffic
The edge, pulling config/pushing usage Static bearer GATEWAY_INTERNAL_TOKEN The control plane
Admin tooling / developers, registering/granting/minting Static bearer CONTROLPLANE_ADMIN_TOKEN, or a per-developer/console token minted via controlplane token create (§13) The control plane

The control plane has no user/session system of its own by design — admin tooling is trusted to report who is acting via each mutating request's optional actor field, recorded as a free-text label (created_by/ granted_by), not a foreign key to any user table.

2. Application manifest

Every application that wants to plug into the gateway exposes:

GET /.well-known/gateway-manifest
{
  "appId": "hr-service",
  "name": "HR Service",
  "description": "Employee management system",
  "version": "1.0.0",
  "baseUrl": "https://hr.internal",
  "health": "https://hr.internal/.well-known/health",
  "documentation": "https://hr.internal/.well-known/openapi.json",
  "owner": "HR Team",
  "contact": "hr@company.com",
  "scopes": [
    { "key": "employee.read", "description": "Read employee records" }
  ],
  "routes": [
    { "path": "/employees", "method": "GET", "scope": "employee.read" },
    { "path": "/status" }
  ],
  "allowedOrigins": ["https://console.etdevops.io"]
}

The control plane never invents a scope, route, or allowed origin: everything it stores about an application comes from what that application declared here.

3. Registration, grants, credentials

4. Token issuance

POST /oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&client_id=...&client_secret=...

Returns {"access_token": "...", "token_type": "Bearer", "expires_in": 900}. The JWT's scopes claim is exactly the set of <providerSlug>:<scopeKey> strings the consumer application currently holds via gateway_scope_grants — computed fresh at issuance, not cached. sub is app:<applicationId>, iss/aud are etg-gateway-control/etg-gateway — opaque internal claim identifiers, not a reference to any specific business application.

GET /.well-known/jwks.json — public, unauthenticated, includes active and retiring signing keys (retiring keys stay published so tokens they already signed keep verifying until they expire).

5. Config snapshot (edge ⇄ control plane)

GET /api/v1/gateway/internal/config
Authorization: Bearer <GATEWAY_INTERNAL_TOKEN>
If-None-Match: <previous config_etag>

304 Not Modified on an unchanged etag; otherwise 200 with:

{
  "contract_version": 3,
  "generated_at": "2026-07-28T00:00:00Z",
  "config_etag": "<sha256 hex>",
  "default_route_class": "passthrough",
  "routes": [
    {
      "prefix": "/hr-service/employees",
      "class": "blocked",
      "required_scope": "",
      "service": "hr-service",
      "upstream_url": "https://hr.internal",
      "strip_prefix": "/hr-service",
      "allowed_origins": ["https://console.etdevops.io"],
      "methods": {
        "GET": { "class": "machine", "required_scope": "hr-service:employee.read" }
      }
    }
  ],
  "rate_limits": { "per_client_rpm": 600, "per_tenant_rpm": 1200, "passthrough_ip_rpm": 1200 },
  "jwks": { "keys": [ { "kty": "OKP", "crv": "Ed25519", "kid": "...", "x": "...", "use": "sig", "alg": "EdDSA" } ] }
}

5.1. Namespacing (ADR-GW-2)

Every route is published under /<owner-slug><raw-path> — e.g. hr-service's manifest declares /employees; the public path is /hr-service/employees — regardless of class. The edge strips strip_prefix (/hr-service) from the request path before forwarding, since the application itself only knows /employees.

This is what lets two different applications each declare /employees without colliding: they land at different public paths by construction, whether either one is machine-class or not. Before ADR-GW-2 (v2, §0), only machine-touched routes were namespaced this way — a pure passthrough/blocked route (typically one application's entire browser-facing surface) stayed at its raw, unnamespaced path, uniqueness enforced across applications at registration time. That meant two applications could never both register if they shared any overlapping raw passthrough surface — the common case for a monolith mid-split, where a newly-split-out service still carries most of the original app's routes. Universal namespacing removes that ceiling entirely: raw-path collision across applications is now structurally impossible, so there's nothing left to reject at registration time.

The cost: a caller that used to reach an application directly at its own raw path (e.g. https://api.appa.com/api/customers) now reaches it at https://<gateway-host>/appa/api/customers once that application is registered and the caller's base URL is repointed at the gateway — the path itself is unchanged, but every caller (human session or machine) now goes through the same namespaced URL, no raw alias. See APP_INTEGRATION_GUIDE.md's ADR-GW-2 section for what this means in practice for a frontend that used to call its backend directly.

5.2. Per-request auth dispatch (ADR-GW-2, extended by ADR-GW-3)

Because every route lives at one URL for every kind of caller now, the class declared in the manifest no longer picks which URL a request has to hit to be treated as machine vs. passthrough — it's decided per request, by whether the presented credential actually verifies. ADR-GW-3 removes the one place that per-request dispatch didn't yet reach: previously, a route with required_scope empty (class: "passthrough") never even attempted verification, so a genuine machine caller hitting a route nobody had declared a specific scope for was silently treated as anonymous. That's gone — verification is attempted on every non-blocked route now, regardless of required_scope:

One consequence worth naming explicitly (carried over from ADR-GW-2, still true under ADR-GW-3): a route with required_scope set can express "must be authorized to reach this on a machine's behalf" but never "must ONLY ever be reached by a machine, reject an ordinary unauthenticated request outright" — there is no edge-level way to say that for any route class. A route that declares required_scope is always machine-preferred-with-passthrough- fallback, never machine-only; a route with required_scope empty is always machine-accepted-with-passthrough-fallback, never machine-required. An application that genuinely needs "reject unless X-Gateway-Client-Id is present" enforcement for a specific route has to do that check itself, downstream — the same "compose, don't replace" pattern every application already needs for a route that serves both a human session and a machine caller (APP_INTEGRATION_GUIDE.md).

CORS note (ADR-GW-3): Access-Control-Allow-Credentials eligibility now follows the actual per-request outcome, not the route's static class — a required_scope-declared route that falls back to passthrough for a given request (no verifying bearer presented) is genuinely being served as passthrough for that request, and gets the same declared-origin credential grant a passthrough-classed route would. Previously this was gated on the static class alone, so a real browser session hitting a scoped route never got credentialed CORS treatment even though it was, in fact, being served as passthrough underneath.

Observability note: because a failed machine-auth attempt now falls through to passthrough rather than being rejected, it's logged as an ordinary passthrough request (§6) with whatever status the destination app happened to return — not as a distinct machine-auth-failure event the way v2's GW1001/GW1002 responses were. GW1001 and GW1002 are retired as of v3: the edge no longer emits either (§8).

5.3. Why (ADR-GW-2)

v2's design assumed passthrough was rare and narrow — "one application's whole browser-facing surface, typically a catch-all /." In practice, an application mid-split from a shared monolith (the common real case, not a hypothetical) carries most of its sibling's routes for a long transitional period, and every one of those overlapping routes is genuinely passthrough (each app's own frontend calls its own copy directly) until the split finishes. Under v2, the second such application could never register at all — not "some of its routes conflict," all registration failed outright the moment its manifest declared one path collision (CP1004), since POST /applications validates the whole manifest transactionally. The only v2-compatible fixes were: don't register the second app at all (defeats the purpose), or mark the shared routes machine-class to dodge the raw-path collision (actively wrong — see api-gateway.md rule 2: machine-class is for another application calling on its own behalf, and marking a frontend's own session traffic machine-class gets it rejected at the edge with GW1002 instead, the single most common integration bug this contract warns about). Universal namespacing (§5.1) removes the underlying constraint that forced that choice; dynamic per-request dispatch (§5.2) is what makes namespacing-everything actually workable without regressing the "human vs. machine" auth story a static per-route class used to provide.

6. Usage ingest

POST /api/v1/gateway/internal/usage
Authorization: Bearer <GATEWAY_INTERNAL_TOKEN>
{"events": [{"request_id": "...", "client_id": null, "tenant_id": null,
             "route_prefix": "/employees", "method": "GET", "status": 200,
             "duration_ms": 12, "route_class": "machine", "service": "hr-service"}]}

202 Accepted. Idempotent on request_id (ON CONFLICT DO NOTHING) — the edge's usage sink is fire-and-forget, at-least-once delivery; this is aggregate metering, not a billing-of-record system.

service is the owning application's slug (Route.Service, §5) — empty/ absent when no route matched at all (a true GW1006). Optional on the wire so an edge binary predating this field still ingests cleanly. Unlike client_id (only ever known for machine-class, caller-authenticated requests), service is set for every route class the edge actually matched, including passthrough — it's what makes §14's per-app request log group passthrough (human/browser) traffic by app too, not just machine calls.

7. Forwarded headers (edge → upstream)

The edge strips any inbound X-Gateway-* header before setting its own (clients cannot forge trusted identity), then stamps:

For a request that falls through to passthrough — whether because the route is passthrough-class, or because it's machine-class but the presented credential didn't verify (§5.2) — the client's own Authorization header is forwarded unchanged, and no X-Gateway-Client-Id/X-Gateway-Scopes are set. The destination application remains its own authenticator for that request.

The correct application-side check, composing with (not replacing) your own session auth: if X-Gateway-Client-Id is absent, run your normal session/Bearer check, unmodified. If it's present, verify X-Gateway-Auth against your configured GATEWAY_SHARED_HEADER_SECRET with a constant-time compare before trusting it — a present-but-mismatched value is always a reject, never a fall-through to session auth (this is the one case where a direct caller, bypassing the gateway entirely, could try to forge X-Gateway-Client-Id themselves — the shared-secret check is what makes that fail).

8. Error envelope

{"error": {"code": "GW1003", "message": "...", "request_id": "..."}}
Code Status Meaning
GW1000 503 / 502 No config loaded, or invalid/unreachable upstream for a matched route
GW1001 401 Retired as of v3 (ADR-GW-2) — a missing/malformed/expired bearer token on a machine-class route no longer rejects; it falls through to passthrough (§5.2). Reserved, never emitted by the edge.
GW1002 401 Retired as of v3 (ADR-GW-2) — same fallback as GW1001 for an unknown kid/signature failure. Reserved, never emitted by the edge.
GW1003 403 Valid, verified machine token, insufficient scope — still a hard reject, unchanged from v2
GW1004 403 Route explicitly blocked
GW1005 429 Rate limit exceeded (Retry-After set)
GW1006 404 No application registered a route for this path

The control plane uses its own CP1xxx codes for its admin/public API (internal/controlplane/api.go) — a separate namespace, since it's a different HTTP surface with different failure modes (validation, conflict, not-found) than the edge's hot-path codes above.

9. Lifeline floor (edge-side, independent of served config)

Two paths are always forced to passthrough, regardless of what the config snapshot says, so an operator can never be locked out of fixing a bad routing rule by that same rule:

This is deliberately narrow and lives only in internal/gwconfig/config.go — it does not depend on the control plane refusing to create a bad rule (a migration, a restore, or a future control-plane bug bypasses that guard entirely; see the 2026-07-25 incident documented in that file). A third prefix, /api/v1/platform/gateway (the admin API), was part of this floor under v1 — it's gone under v2 because that API no longer flows through the edge's route table at all; it's reached directly, on its own address, and so can no longer be locked out by anything in this table.

10. Rate limiting

In-memory, single-instance (internal/ratelimit), token bucket per client:<id> (machine) or ip:<addr> (passthrough). ratelimit.Limiter is the seam for a shared (e.g. Redis) implementation if the edge is ever horizontally scaled — not built yet because it isn't, today.

11. Developer portal (public, unauthenticated)

Matches ETOPS Integration Standard §8 ("the Gateway never writes API documentation. Instead it indexes it"):

None of this is reverse-proxied through the edge (like the rest of the admin surface, §9) — reached directly, on the control plane's own loopback port in development (docs/gateway/DEV_ROLLOUT.md) or however staging/ production choose to route it.

12. One public hostname, split by path (api.*)

Superseded design (v2, until now): a dedicated docs.* host proxied every request straight to the control plane, with a short-path alias (/{slug} → /apps/{slug}/docs) that only fired on that host. That's gone.

Current design: the control plane and the edge now share the same public hostname (api.gateway.etdevops.io / gateway.etdevops.io) — there is no docs.* host anymore. A reverse proxy in front of both (real nginx in production, tools/devproxy locally) splits every request by path, not Host:

Path Routes to
/, /docs, /openapi.json, /apps, /apps/{slug}/..., /developer-portal, /oauth/token, /applications/..., /credentials/..., /grants/..., /.well-known/jwks.json, /getting-started, /contract, /claude/... The control plane
Everything else — i.e. /<app-slug>/<path> The edge (proxying to that application, §5)

This works because reservedAppSlugs (internal/controlplane/manifest.go) already forbids an application from registering under any of the control plane's own top-level path names — Manifest.Validate() rejects appId values in docs, apps, developer-portal, oauth, applications, credentials, grants, healthz, api, getting-started, contract, claude at registration time. So the path split above is exhaustive and never ambiguous: a first path segment is either one of those reserved names (→ control plane) or it's a real, registered application's slug (→ edge), never both.

There is no shorter alias for an application's docs page anymore — a bare /{slug} now always means "call that application's real API" (via the edge), matching the same shape every other proxied call already uses (/hr-service/employees, §5). An application's docs stay reachable at the existing long form, /apps/{slug}/docs / /apps/{slug}/openapi.json, on the same hostname as everything else.

/api/v1/gateway/internal/* (§5's edge⇄control-plane contract) is deliberately not part of the public split above — it stays reachable only over the shared Docker network / loopback, authenticated separately by GATEWAY_INTERNAL_TOKEN, exactly as before (docs/gateway/DEV_ROLLOUT.md "What's deliberately NOT exposed").

Unlike the old docs.*-only posture, the admin API (/applications, /credentials, /grants, and friends — §3) is now reachable on this same public hostname, not loopback-only. See §13 for the credential model that makes that safe to expose, and internal/controlplane/api.go's withAdminIPRateLimit / withSecurityHeaders for the accompanying hardening (per-IP rate limiting, standard security response headers) that came with making a mutating admin surface public.

13. Admin API tokens (terminal-only)

Every admin-API request (§1, §3) authenticates with a bearer token that is either:

controlplane token create -name "alice@company.com" [-actor "who ran this"] [-ttl 720h]
controlplane token list
controlplane token revoke [-reason "..."] [-actor "..."] <id>
controlplane token rm <id>

14. Request log / observability API

Every request the edge actually classified (i.e. everything except a request that arrived while no config snapshot was loaded at all) reaches gateway_usage_log via §6, grouped by owning application via service. Two admin-authed read endpoints expose it — same bearer as every other admin-API call (§13), no new auth mechanism:

Neither endpoint is a substitute for the org-wide structured logging/error- tracking conventions in .claude/rules/observability.md — it's a gateway-specific, per-app status view, scoped to what the edge itself saw (status code, method, timing, route class), not a general log search or error-tracking tool.