Skip to content

feat(api): publish the deployment's OpenAPI schema - #358

Merged
fazpu merged 1 commit into
mainfrom
feat/publish-openapi-schema
Sep 3, 2026
Merged

feat(api): publish the deployment's OpenAPI schema#358
fazpu merged 1 commit into
mainfrom
feat/publish-openapi-schema

Conversation

@fazpu

@fazpu fazpu commented Sep 3, 2026

Copy link
Copy Markdown
Member

Closes the gap the cloud UI's data-plane types name in their own docstring:

Hand-written rather than generated […] can drift from it silently. Generating them from the engine's OpenAPI is the correct fix and needs a pipeline that does not exist yet.

This is that pipeline. scripts/export_openapi.py builds the app in-process and asks FastAPI for the document — no server, no port. The app keeps openapi_url=None: refusing to serve the schema and refusing to publish it are different choices, and only the first is correct. The schema route sits outside the auth perimeter.

Four ways the document was wrong before it was right

All found by @codex.

wrong consequence fix
too few routes/deployment registered by the profile after build_api 10 routes missing composed via BuildInfoPort
too many routes — export composed connectors, which the profile never passes 4 operations that 404 on every deployment the shipped profile builds export composes only what the profile names
no credential generated client compiles, then fails on contact HTTPBearer(auto_error=False) — declares, never raises
no version (0.1.0) published assets indistinguishable stamped with the package version

Routes: 19 → 29 → 25. The middle number is the point — more is not better when the extras don't exist.

The guard got smaller, and that was the fix

Five instruments, each defeated:

  1. Naming four routes I cared about — passed while the export omitted ten routes: the two operations endpoints, all seven /query/*, and /deployment. A missing route is invisible to a test that only asks about routes it remembered.
  2. Freezing the whole route set — froze the phantom /connectors routes as readily as the real ones.
  3. Comparing the two call sites by AST — caught connectors, but compared keyword spelling, which is not composition: auth=resolve_selfhost_api_auth(...) returns None for the open quickstart.
  4. Enumerating evasions**kwargs, second call, qualified call, aliased import, rebinding. codex produced two more that walked through.
  5. Forbidding the class — every mention of build_api must be the callee. codex produced a sixth: import build_api as other plus a parameter default build_api=other, so the counted call is a decoy.

Static analysis cannot soundly answer "is there exactly one way to call this." That machinery is gone.

What remains is a frozen route set that is reviewed, not derived: the published surface cannot change without a person editing the list and saying why. That is what would have caught the phantom routes in the first place — not the machine, but the edit being visible in a diff.

Verified to fail:

injected caught
stale committed openapi.json sync test
surface=None, open_query=None frozen surface (9 of the 10)
build_info=None frozen surface (/deployment)
auth=None security-scheme test
unclassified new optional param on build_api signature check
a deleted test count assertion

Two tests I deleted without noticing

Mid-development, edits that rewrote a block of the test file removed a test each time. Nothing failed — a deleted test cannot fail — and the claim each supported stayed in my commit message describing a guarantee that no longer existed. codex caught the second.

There's now a count assertion. It's crude, and that's the point. It caught a miscount while this very change was being made.

A vacuous test I had to rewrite

My first auth test asserted 401 on a gated route — and passed with auto_error=True, because the perimeter raises first. The real risk was GET /healthz, which is perimeter-exempt: auto_error=True would have refused the container's liveness probe while every other route kept working.

Claims corrected along the way

Each of these was false when written, and each was caught in review rather than by me: that the compositions "cannot drift apart"; that the profile composes spend_lease; that HTTPBearer returns 403 (the pinned FastAPI returns 401); that /connectors would 404 on every deployment (a programmatic one can compose it); that this module is the "single place the HTTP surface is defined" (/healthz is still added by the profile, deliberately, and excluded from the schema); and that the docs page's every route is in the schema.

Docs (D66)

/docs/reference/api gains The machine-readable schema — where to get it, why the deployment doesn't serve it, how to generate a client, and the distinction review surfaced: capabilities nobody ships are absent, while capabilities you haven't configured are still listed, because no check on a published document can know your settings. /docs/project-status gains one bullet.

Site builds; 22 pages indexed.

The dotfile naming, recorded where the mistake was made

.env.example is published by GitHub as default.env.example — the API prefixes dotfile assets with default. Root cause of the fleet pin defect fixed in umc #419: four pins recorded the source name because whoever wrote the first read this workflow's file list rather than a release's asset list.

Checks

ruff, ruff format, pyright (0 errors, repo-wide); 430 surfaces tests pass; test inventory OK.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UxXnThvjreGrY7c7CKM6TC

Contributor agreement

Signing on behalf of a legal entity (leave blank if accepting individually):

@fazpu
fazpu force-pushed the feat/publish-openapi-schema branch 8 times, most recently from a4f555b to e6781e3 Compare September 3, 2026 03:58
The HTTP surface is the contract every client codes against, but it was only
discoverable by reading `http_api.py`, so consumers restated it by hand. The
cloud UI's data-plane types say so in their own docstring: hand-written, able
to drift silently, with generating them named as the correct fix and no
pipeline to do it. This adds the pipeline.

`scripts/export_openapi.py` builds the app in-process and asks FastAPI for the
document offline — no server, no port. The app keeps `openapi_url=None`,
because that route is not covered by the auth perimeter and serving it would
hand the surface to unauthenticated callers. Refusing to serve the schema and
refusing to publish it are different choices; this repo makes only the first,
and the document ships as a release asset.

Four ways the document was wrong before it was right:

- **Too few routes.** `/deployment` was registered on the app by the self-host
  profile after `build_api` returned, so a reader of `http_api.py` saw an
  incomplete surface. It is now composed through a `BuildInfoPort`, so every
  documented route is declared in one place. `/healthz` is still added by the
  profile, deliberately: it is the container's liveness probe rather than part
  of the query API, and carries `include_in_schema=False`.
- **Too many routes.** The export composed a `connectors` port the profile
  never passes, publishing four `/connectors` operations that answer 404 on
  every deployment the shipped profile builds.
- **No credential.** A guarded deployment rejects unauthenticated calls, so a
  client generated from a document that never mentions credentials compiles and
  then fails on first contact. `HTTPBearer(auto_error=False)` declares the
  scheme without ever raising, leaving `_perimeter` the sole enforcement point.
- **No version.** FastAPI defaults to 0.1.0, which would make every published
  asset indistinguishable.

The guard against the second kind went through five instruments before the
right answer turned out to be fewer, not more. Naming four routes missed the
ten it did not name. Freezing the whole route set froze phantom routes as
readily as real ones. Comparing the two `build_api` call sites by AST caught
`connectors` but compared keyword spelling rather than composition — `auth`
resolves to None for the open quickstart, so the profile names capabilities a
deployment may not compose — and was then defeated six ways in review, each
time by a construction the previous fix had not imagined.

Static analysis cannot soundly answer "is there exactly one way to call this",
so that machinery is gone. What remains is a frozen route set that is
**reviewed rather than derived**: the published surface cannot change without a
person editing the list and saying why. That is what would have caught the
phantom routes — not the machine, but the edit being visible.

Two tests were deleted mid-development by edits that rewrote the surrounding
block. Nothing failed, because a deleted test cannot fail, and the claim each
supported stayed in the commit message describing a guarantee that no longer
existed. A count assertion now notices, and caught a miscount while this change
was being made.

Verified to fail: a stale committed schema; an export dropping the operations
and open-query ports; one dropping build info; one dropping auth; an
unclassified new optional parameter on `build_api`; and a deleted test.

The auth test exercises `GET /healthz` as well as a gated route, because only
the exempt route can tell the difference: on a gated route the perimeter
answers first and an `auto_error=True` scheme looks harmless, while on the
liveness probe it would refuse the container's health check.

Also records, in the release workflow where the mistake was made, that GitHub
prefixes a dotfile asset with "default" — `.env.example` is published as
`default.env.example` and by no other name. Four fleet engine pins recorded the
source name, copied forward from the first, because they were written from that
file list rather than from a release.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UxXnThvjreGrY7c7CKM6TC
@fazpu
fazpu force-pushed the feat/publish-openapi-schema branch from e6781e3 to 011ab23 Compare September 3, 2026 04:11
@fazpu
fazpu merged commit 68ab611 into main Sep 3, 2026
12 of 13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant