# Clearing API Surface (/docs/reference/api-surface)



Ductor's API is defined once and served three ways. This page is the map of the
transport surface: how a request reaches a handler, what runs on the way in, and
where to find things. For the human-browsable method list, see the
[API reference](/docs/api-reference); for error shapes, see
[Errors & status codes](/docs/reference/errors).

## Tri-protocol serving [#tri-protocol-serving]

Every method is declared **once** in protobuf under `api/proto`. From that single
definition, each method is served as:

* **ConnectRPC** and **gRPC** — the binary/streaming RPC surface.
* **RESTful HTTP/JSON** — generated from `google.api.http` annotations on the RPC.

```mermaid
flowchart TD
  P["Proto definition"] --> C["ConnectRPC handler"]
  P --> G["gRPC handler"]
  P --> R["REST / JSON route"]
```

One RPC definition (plus its `google.api.http` annotation) in
`api/proto/api/*.proto` fans out to:

* **ConnectRPC handler** — `POST /ductor.api.<Service>/<Method>`
* **gRPC handler** — the same service/method over HTTP/2
* **REST/JSON route** — e.g. `GET /api/v2/…`, `POST /api/v2/…`

REST paths live largely under `/api/v2`, with a number of legacy `/api` paths
still served for compatibility. Because JSON routes are generated from the proto
annotations, the REST surface never drifts from the RPC surface.

<Callout type="warn" title="OpenAPI is generated and CI-gated">
  The OpenAPI spec and API docs (`api/openapi/openapi.yaml`,
  `transport/gateway/openapi/openapi.yaml`, `docs/api/*`) are **committed
  artifacts** regenerated from the proto. To change them, edit the proto
  comments/annotations and run `make openapi` — never hand-edit the generated
  files. `make openapi-check` fails CI if the committed artifacts are stale.
</Callout>

## Transport planes [#transport-planes]

The listener surface is split into two planes, each with its own interceptor
chain, method registry filter, and listen address, sharing one handler set:

| Plane        | Audience           | Auth                          | Rate limited | `internal_only` methods                 |
| ------------ | ------------------ | ----------------------------- | ------------ | --------------------------------------- |
| **External** | Public clients     | OIDC + API key                | yes          | filtered out — never registered         |
| **Internal** | Service-to-service | Service token (HS256) or mTLS | no           | admitted (only place they're reachable) |

Methods marked `(authz).internal_only = true` in the proto are filtered out of
the external plane's registry, so the public listener cannot serve them at all.

<Callout type="warn" title="Internal plane defaults to loopback">
  The internal plane refuses to start when bound to a **non-loopback** interface
  unless mTLS is required. The default internal address is non-loopback, so
  operators must either bind it to `127.0.0.1` / `[::1]` or set
  `server.internal_require_mtls=true`. This is a fail-closed guard against
  accidentally exposing internal RPCs.
</Callout>

## Interceptor chain [#interceptor-chain]

Both the Connect and gRPC chains run the same interceptors in the same order,
outermost (first on the way in) to innermost (closest to the handler):

```text
Recovery          panic recovery → log stack → return Internal
Telemetry         RPC metrics
Request-ID        inject request_id (from header or generated UUID)
API-Version       negotiate Api-Version, SDK check, deprecation headers
AuthN             token validation, Principal injection
AuthZ             policy evaluation against the method registry
Tenant            extract tenant from Principal claims into context
Deprecated-RPC    observational warn/counter for deprecated methods
ProtoValidation   validate request via buf/protovalidate annotations
Concurrency       global + per-tenant in-flight caps (if configured)
RateLimit         per-method token bucket (if a limiter is wired)
ErrorMasking      mask Internal/Unknown before they leave
Status            map domain errors → Connect/gRPC codes
SlowRequest       log full request duration
```

The `ErrorMasking → Status` ordering matters: on the return path, Status converts
domain errors to proper codes first, then ErrorMasking sees only coded errors and
hides `Internal`/`Unknown` details. Concurrency and RateLimit are only added when
configured — an unconfigured limiter is simply absent from the chain.

## Discovery endpoints [#discovery-endpoints]

Ductor is self-describing. These endpoints (all behind the standard tenant-auth +
authz chain) let clients introspect what the server offers:

| Endpoint                                                | Returns                                                    |
| ------------------------------------------------------- | ---------------------------------------------------------- |
| `GET /api/_meta/services`                               | The service inventory — which services this build serves   |
| `GET /api/capabilities`                                 | The pre-computed capabilities report                       |
| `GET /api/v2/capabilities`, `/api/v2/capabilities/{id}` | The proto-served capabilities surface                      |
| `GET /api/config/schema`                                | Structural metadata for the dynamic-config surface         |
| `GET /api/config/export`                                | The writable dynamic-config values for the caller's tenant |

`/api/config/export` returns `503` when the dynamic-config module is off (its
default) — a deliberate "fail visibly" rather than pretending an empty surface.

## API versioning [#api-versioning]

Version is negotiated with a date-string header:

* Clients send `Api-Version: 2024-01-01`. If omitted, the server assumes the
  current version.
* **Current = Min = `2024-01-01`.** A requested version **older than the minimum
  is rejected** with `400` / `INVALID_ARGUMENT`.
* The negotiated version is echoed back in the `Api-Version` response header.

### Deprecation signaling [#deprecation-signaling]

When a called method is deprecated, the response carries standard signal headers:

| Header        | Value                                                                 |
| ------------- | --------------------------------------------------------------------- |
| `Deprecation` | the literal `true`, always emitted for a deprecated method            |
| `Sunset`      | the removal date — **only** when a sunset date is configured          |
| `Link`        | `<successor>; rel="successor-version"` — only when a successor is set |

The sunset date is operator-configured, so the planner never bakes a removal date
into the build.

## Rate limiting [#rate-limiting]

Rate limiting is a **per-method token bucket**. Each method has a token cost
(`TokenCosts`); cheaper methods drain fewer tokens. When a caller is throttled the
response is `429` (`RATE_LIMITED`) with:

* `X-RateLimit-Limit`
* `X-RateLimit-Remaining`
* `Retry-After` (seconds)

<Callout title="Rate limiting is optional">
  The limiter is only installed when one is wired (it is Redis-backed). When no
  limiter is present — e.g. Redis is absent — the interceptor is a **pass-through**
  and no throttling occurs. Don't rely on rate limiting being on in a deployment
  without Redis.
</Callout>

## Pagination [#pagination]

There are **three** pagination conventions on the surface — don't conflate them:

| Convention                     | Shape                                             | Where                                 |
| ------------------------------ | ------------------------------------------------- | ------------------------------------- |
| Offset/cursor `PaginationMeta` | `{ next_cursor, total, limit, offset, has_more }` | REST list endpoints (`domain/shared`) |
| Proto `next_page_token`        | opaque `next_page_token` string                   | proto-defined list RPCs               |
| Marketplace `APIMeta`          | `{ total, page, per_page }`                       | the marketplace REST API              |

For the offset/cursor convention, `limit` defaults to **50** and is clamped to a
maximum of **1000**; a non-positive limit falls back to the default and a negative
offset resets to `0`.

## Health & ops endpoints [#health--ops-endpoints]

These are unauthenticated and mounted on the gateway directly:

| Endpoint          | Kind      | Behavior                                                                                     |
| ----------------- | --------- | -------------------------------------------------------------------------------------------- |
| `/health`         | Liveness  | Static `{"status":"ok"}` — the process is up                                                 |
| `/ready`          | Readiness | Boot flag **plus** live Postgres/Redis/queue checks, bounded to **1s**; `503` when not ready |
| `/metrics`        | Metrics   | Prometheus exposition                                                                        |
| `/openapi.yaml`   | Spec      | The committed OpenAPI document                                                               |
| `/docs`, `/docs/` | Docs UI   | Scalar-rendered interactive API reference                                                    |

Liveness never touches dependencies (so a slow database can't flap pods);
readiness runs the same live dependency checks the Connect `Ready` RPC uses, but
only a *hard-unhealthy* aggregate fails the probe — a degraded component keeps the
instance in rotation. These paths also have registered `-z` aliases — see
[Health checks](/docs/operations/health-checks).

## Gotchas [#gotchas]

<Callout type="warn" title="Authz fails OPEN by default">
  When no authorizer is configured, authorization **fails open** (allow) to
  preserve backward-compatible behavior. This is deliberate at the wiring seam,
  but it means an unconfigured deployment is not enforcing policy. Set
  `authz.allow_when_unconfigured=false` (which maps to the chain's
  deny-when-unconfigured posture) to fail closed. See
  [Configuration](/docs/reference/configuration).
</Callout>

* **Internal errors are masked.** `Internal`/`Unknown` errors are replaced on the
  wire with `Internal error (ref: <8-hex>)`, and the real error is logged
  server-side under that ref. See [Errors & status codes](/docs/reference/errors).
* **Never edit generated OpenAPI/proto stubs.** Edit the proto, run `make
  openapi`, and commit the regenerated artifacts.
