Skip to content

release: promote develop to main (OpenAPI contract completion) - #1228

Merged
chronoai-shining merged 24 commits into
mainfrom
develop
Aug 7, 2026
Merged

release: promote develop to main (OpenAPI contract completion)#1228
chronoai-shining merged 24 commits into
mainfrom
develop

Conversation

@chronoai-shining

Copy link
Copy Markdown
Collaborator

Promotes develop to main to cut the next release.

Carries one feature branch plus its changeset and the dated release-notes file.

What is in this release

Completing and correcting the OpenAPI contract at GET /api/v1/openapi.json (#1214, #1226).

The published spec had two defects that made it unusable for client generation:

  • Every Zod-derived schema serialized to {}. toSchema called zod-to-json-schema@3, which only understands zod 3 internals and returns an empty object for a zod 4 schema without throwing. GET /skill-search advertised parameters: [] and every request and response body was schema: {}. Now on zod 4's built-in z.toJSONSchema; the dependency is removed.
  • Every error response described the wrong wire shapeapplication/json wrapping the legacy {data, error} envelope, where the API has emitted RFC 7807 application/problem+json at the body root since [Bug] Error responses violate RFC 7807 — implementation uses { data, error } envelope instead of problem+json #456.

Coverage went from 13 documented operations to all 134, with contract tests now enforcing both directions against the booted router (no documented endpoint the API does not serve, and no served endpoint the spec does not document) with no allowlist.

Deployment

ornn-api only. No ornn-web source file changed. Changesets runs in fixed mode, so ornn-web's version is bumped in lockstep and its CHANGELOG gets a bare heading — same as 0.16.1 — but its bundle is functionally unchanged.

The single runtime behaviour change is GET /api/v1/openapi.json: the document grew from ~18 KB to ~1 MB, so it is now serialized once at boot and served with ETag + Cache-Control: public, max-age=300 and 304 support.

Release notes

.github/release-notes-20260807.md is included and curated.

chronoai-shining and others added 24 commits August 6, 2026 16:04
Shared building blocks every per-domain path module will compose from, so
the whole document speaks one dialect instead of each operation inventing
its own response shape.

Two things here are load-bearing corrections, not conveniences:

`toSchema` uses zod 4's built-in `z.toJSONSchema`. The existing builder
called `zod-to-json-schema@3`, which only understands zod 3 internals and
returns `{}` for a zod 4 schema *without throwing*. The published spec was
consequently structurally valid while describing nothing at all —
`GET /skill-search` advertised `parameters: []`, and every request and
response body was `schema: {}`. The codebase already knew this: the
SKILL.md manifest endpoint works around it in
`domains/skills/format/routes.ts`, but the spec builder was never migrated.

`problemResponses` is the only sanctioned way to declare a non-2xx. Errors
have been RFC 7807 `application/problem+json` with fields at the body root
since #456, while the spec still described them as `application/json`
wrapping the legacy `{ data, error }` envelope — so a generated client read
`error.message` and got `undefined`.

Request and response schemas are generated in opposite directions: a field
carrying a `.default()` is optional on the way in and guaranteed on the way
out, so `jsonBody`/`queryParams` use `io: "input"` and `jsonResponse` uses
`io: "output"`. Output schemas are stripped of `additionalProperties: false`
so adding a field server-side does not break clients validating against a
cached spec.

Deliberately NOT modelled: an `errors[]` array on the problem body.
`ProblemJsonBody` declares the field but nothing populates it —
`buildProblemJsonBody` never sets it and `validateBody` folds the zod issues
into `detail`. Documenting a field the server never emits is what sent
integrators looking for it.
…1214)

21 operations: publish, pull, refresh, source rebind, package JSON, version
list/diff/download, closure, dist-tags, deprecation, permissions, ownership
transfer, NyxID service binding, and the deletes.

Schemas come from the domain's Zod definitions where they are exported; the
nine request bodies whose schemas are module-private consts inside
`crud/routes.ts` are transcribed with every bound copied verbatim, and the
response shapes are mirrored from the TypeScript interfaces the handlers
project inline.

Documents several behaviours the previous spec got wrong or omitted:

- A dist-tag in a path segment needs the `@` prefix. `resolveDistTag` only
  treats a value as a tag when it starts with `@`, so `/versions/latest/
  download` 400s — it must be `/versions/@latest/download`. The old inline
  `skillDownloadPath` said "dist-tag (e.g. 'latest')" and would have sent
  integrators straight into a 400.
- `POST /skills` returns 201, not the 200 the old spec declared.
- Both deletes answer 200 `{success:true}` rather than 204.
- `skip_validation` is snake_case on the query string, and
  `POST /skills/{id}/refresh` accepts both spellings in its body.

Where the handler diverges from CONVENTIONS.md the spec documents what the
code does, not what the convention says — a spec that describes the intent
rather than the behaviour is the problem being fixed here.
9 operations: create, search, read, closure resolution, version list,
publish, plugin export, delete, and ownership transfer. The whole domain was
absent from the document.

Request bodies are generated from the domain's Zod schemas. Response shapes
are hand-written from the projected TypeScript interfaces and each notes the
interface it mirrors.

Records two contract facts a caller cannot guess: `DELETE` answers 200 with
`{success:true}` rather than 204, and `{idOrName}` and `{id}` are genuinely
separate path keys because the router registers them separately — reads
accept either form, writes take the GUID only.
…1214)

8 operations: skill search, the three facet endpoints, skill counts, the
format rulebook, the SKILL.md JSON Schema, and package validation.

`GET /skill-search` is the endpoint that made the underlying bug visible: it
published `parameters: []`, so the registry's primary discovery endpoint
documented none of its 15 query parameters. All of them are now described
with their types, bounds, defaults, and enum members.

Keeps the four operationIds the previous spec already published
(`searchSkills`, `getFormatRules`, `validateFormat`, `getFormatSchema`) so
generated SDK method names do not churn; only the four facet/count routes
get new ids.

`GET /skill-manifest-schema.json` is documented with `rawJsonResponse` —
it deliberately serves its schema document at the body root under
`application/schema+json` rather than in the `{ data, error }` envelope,
because that is what schema-store tooling consumes.
5 SSE operations: skill generation from a prompt, from a source repository,
and from an OpenAPI document, plus the playground and assistant chats.

None of these return the `{ data, error }` envelope — they stream, and the
useful contract is the event vocabulary and the quota reconciliation rule,
both of which are now written down per operation.

The frame layout is not uniform and the spec says so: generation and
playground write bare `data:` frames with no `event:` line, so a client must
dispatch on the JSON body's own `type` field, while the assistant writes
both. A client built against a single documented layout would silently
receive nothing from four of the five streams.

Also records that once a stream has opened with 200, later failures arrive
in-band as an error event rather than as an HTTP status.
7 operations covering the two skill-scoped read surfaces: LLM safety audits
(verdict, dimension scores, history, and the two trigger endpoints) and
usage aggregates (execution summary, pull time series).

Both surfaces are visibility-scoped exactly like `GET /skills/{idOrName}`,
so a private skill the caller cannot read answers 404 rather than 403 and
the document states that per operation.

Two things a caller would otherwise get wrong:

- `GET /audit` returns the newest audit row and only when its status is
  `completed`. It does not filter to completed rows, so a newer running or
  failed run masks an older verdict and the endpoint 404s while a re-audit
  is in flight. `summary-by-version` is the one that genuinely selects the
  latest completed row.
- No execution hook is wired in this build. `recordExecution` has no
  non-test caller, so the execution aggregate is all-zero for every skill
  regardless of real usage — a zero there means "no telemetry", not "no
  usage". The pull series is the log that is actually written, and only for
  authenticated callers.
13 operations: profile, organisations, bound NyxID services, grant and
share summaries, activity pings, launch promo, quota, the model picker, and
redemption code redeem/history.

Several reads fail soft — when NyxID is unreachable they answer `[]` rather
than 5xx — which is called out per operation, because an agent must not read
an empty list as "the caller definitively has none".

The redeem body is generated from `redeemSchema`, the schema the route's
`validateBody` actually runs, so its bounds cannot drift from the validator.
That schema ends in a `.transform()`; only the input side of a transform has
a JSON Schema representation, which is exactly the direction `jsonBody`
uses.

`POST /me/redemption-codes/redeem` documents the `Idempotency-Key` header.
The idempotency middleware is mounted on the whole versioned app, so the
operation is safely retryable with a key even though it is not idempotent
without one.
14 operations across three collaborating surfaces that were entirely absent
from the spec.

The important structural fact for an integrator is that `GET /notifications`
is a *merged* feed: per-user notifications interleaved with the
admin-authored broadcasts the caller is a recipient of. Rows are a
discriminated union on `source` and the two variants do not share a title
field (`title` vs `titleI18n`), so branching on `source` is mandatory.

The two bilingual encodings also differ and both are documented: announcements
flatten locales into sibling fields with ZH optional, broadcasts nest them
and require both.

Public announcement reads declare `security: []` — they have no auth
middleware at all. Creates return 201 with a `Location` header; deletes
return 200 with the deleted id, not 204.
13 operations: skill moderation, the AgentSeal rescan, user listing, the
dashboard, platform settings read/patch, settings export/import, the GitHub
mirror reconcile and status, and the launch-promo award and feed.

Every operation requires `ornn:admin:skill` and documents both 401 and 403.

Records constraints an operator would otherwise discover the hard way:
`GET /admin/users` reports a `total` capped at the 5000-row pool the service
loads for in-application sorting, with no truncation flag in the payload;
its `q` filter is an email *prefix* OR a displayName *substring*, so
`q="@example.com"` matches no addresses; and `POST /admin/settings/import`
ORs the body's `dryRun` with the query parameter rather than ranking them,
so `dryRun: false` cannot force a real write past `?dryRun=1`.

Three handlers in this surface still emit the pre-#456 legacy error envelope
under `application/json` instead of RFC 7807. They are documented with
`problemResponses` per the house rule, each carrying an explicit body-shape
warning in its description until the handlers are fixed.
9 operator-only operations: month-to-date usage listing, per-user lifetime
history, single and bulk grants, the grant audit trail, and minting,
listing, reading, and invalidating redemption codes.

The bucket model these manipulate is documented once at module level:
buckets are per (userId, surface, monthMarker), calendar-month, UTC, no
carry-over — so a grant applied on the 30th evaporates a day later, and
there is no API for pre-funding a future month.

The grantable surface list and the admin permission string are imported from
`domains/quota/types` rather than transcribed, so the published enum cannot
drift from the runtime one. The mint body is generated from `mintCodeSchema`
and then overlaid with prose, keeping the bounds owned by the validator.

Notes that bulk grant reports per-recipient failures as `ok: false` rows
inside a 200 rather than as an error status.
4 operations. `/users/search` and `/users/resolve` are the only bridge
between the two identifiers a human and this API disagree about — every
access-control endpoint speaks NyxID `user_id`, every human speaks email —
so they are a prerequisite for using permissions, ownership transfer, and
quota administration at all.

Both share one rate-limit bucket, and the RFC 9239 `RateLimit-*` headers
that bucket emits on every response are documented as response headers.

`POST /github/repo` carries the domain's sharp edges: an empty string is a
real value meaning "clear this field", `appPrivateKey` uses a bullet
character as a preserve-the-stored-key sentinel so an admin form can
round-trip the masked display value, and changing owner or repo is refused
with 409 until `confirmAbandonOldRepo` is set. The four field patterns
accept the empty string, matching the handler, which applies its regexes
only to non-empty values.
4 operations, and the only ones in the document that are not under
`/api/v1`: the probes are registered on the root app on purpose, so an API
version bump never moves a liveness URL out from under a running deployment.

They are also the only operations whose bodies are not wrapped in the
`{ data, error }` envelope, and `/readyz` is the single endpoint whose
failure response is plain `application/json` — it returns its 503 directly
rather than raising through the global RFC 7807 handler. Documented as it
behaves, with `UNVERSIONED_SYSTEM_PATHS` exported so the contract test can
assert the prefix rule while knowing about the exceptions.

`/health` is documented as a deprecated alias for `/livez`, and the
liveness/readiness distinction is spelled out: `/livez` performs no
dependency checks and stays green while MongoDB is down, so it answers
"should this pod be restarted", not "should it receive traffic".
Replaces the hand-written path table with composition over the eleven
`paths/` modules, and rewrites the contract tests in the same commit because
the wire shape and the assertions that pin it change together — splitting
them would leave a red tree at the intermediate commit.

The document gains a real preamble. An integrator now learns the success
envelope, the RFC 7807 error shape with a worked example, the auth model,
the 404-not-403 visibility rule, request correlation, and the SSE frame
layouts before reading a single operation. Tags are declared with
descriptions so generated clients get named namespaces instead of one
unlabelled bucket.

Both directions of coverage are now enforced against the booted router:

  documented => registered   no phantom endpoints (already enforced, #1213)
  registered => documented   no undocumented endpoints (new)

with no allowlist. Only the first was enforced before, which is how the
document decayed to describing 13 of 104 routes while CI stayed green.
Adding a route without documenting it now fails.

`openapi.test.ts` additionally requires per operation: a summary, a
description of real substance, a unique operationId, declared tags, an
explicit security declaration, every templated path parameter declared,
descriptions and non-empty schemas on every parameter, a described 2xx with
content, and every 4xx/5xx typed as `application/problem+json` with fields
at the body root.

The sharpest of those is the "no empty schema" sweep. The zod 3/zod 4
converter mismatch was invisible precisely because nothing asserted on
schema *content* — the document stayed valid, it just said nothing. Two
targeted regression tests pin the symptoms that were actually shipping:
`GET /skill-search` publishing its query parameters, and a representative
response carrying properties.
Nothing imports it any more — every path module derives its schemas from
the domain Zod definitions the handlers actually validate against.

The file was a parallel, hand-maintained copy of those shapes, and it had
drifted exactly as that arrangement guarantees: `generateJsonBodySchema`
still said `model` where the handler reads `modelId`, the playground body
was missing `modelId` entirely, `searchQuerySchema` was missing nine of the
query parameters the endpoint accepts and had a `scope` enum short of two
members, and `skillSearchItemSchema` lacked every enrichment field
`enrichItem` emits.

Keeping a second description of a shape next to the first is what produced
the drift; deleting it is the fix, not tidying it up.
Dead since `toSchema` moved to zod 4's built-in `z.toJSONSchema`.

Worth removing rather than leaving unused: against zod 4 this package
returns `{}` for every schema *without throwing*, so a future caller who
reaches for the familiar name gets silently empty output rather than an
error. That failure mode is the entire reason this issue exists.

The lockfile entry is removed alongside the manifest entry; CI installs with
--frozen-lockfile, so the two must move together.
§10 promised "every handler in code appears in the spec with complete
metadata" while only the reverse direction was enforced. Documents what is
now actually true and what a contributor has to do.

Adds three things: where the spec lives and the rule that a new route is
documented in the same change (there is no allowlist to opt out of); the
two derive-don't-transcribe rules, including an explicit do-not-reintroduce
note on zod-to-json-schema and why request and response schemas are
generated in opposite directions; and a table of the invariants CI enforces
in both directions, with the per-operation metadata bar spelled out.

Also corrects the endpoint path, which was written as `/v1/openapi.json`
rather than `/api/v1/openapi.json`, and renumbers the manifest-schema
subsection accordingly.
…1214)

Completing the spec took `GET /api/v1/openapi.json` from ~18 KB to ~1 MB,
which turns two pre-existing shortcuts into real costs.

`c.json(spec)` re-ran `JSON.stringify` over the whole document on every
request. The document is static for the lifetime of the process, so it is
now serialized once at boot and the string is served directly.

The endpoint also set no cache headers, despite CONVENTIONS.md §8 naming it
as public and cacheable. It now carries a strong `ETag` over the serialized
body plus `Cache-Control: public, max-age=300`, and answers a matching
`If-None-Match` with a bodyless 304 — a hash comparison instead of a
megabyte of transfer for the common "has anything changed?" poll. The ETag
doubles as a cheap deployment-change signal, since it moves only when the
API does.

The spec's own description of the endpoint is updated in the same commit —
it documents the size, the inline-schema tradeoff behind it, both response
headers, and the 304. A spec that misdescribes the endpoint serving it would
be a poor advertisement for the rest of this work.
…1214)

27 operations that every previous sweep missed, because their paths are
computed rather than written as literals: `domains/settings/routes.ts`
mounts a GET+PUT pair per entry of the `sections` registry, and
`llmProviders/routes.ts` builds its seven paths off a `base` const. A
source scan sees no string to match. The router reflection test in CI is
what surfaced them — which is the argument for that test existing.

The section paths are GENERATED from the same `sections` registry the
router iterates, not transcribed. Adding an eleventh section therefore
documents itself and cannot break the reflection test; per-section payload
schemas come from each entry's own Zod `schema`, so they cannot drift
either. Only the prose — what a section controls, what writing it does to
a running deployment — is hand-written, keyed by section id, with a
fallback that says plainly when a section has no hand-written
documentation rather than inventing one.

Documents the two things that make this surface confusing: secret fields
are mid-masked on read and a masked value posted back means "keep the
stored secret", and the section PUT response carries a third top-level
`meta.changedFields` key alongside the usual `data` / `error`.

Coverage is now 134 of 134 registered routes.
RT-HARDCODE-SWEEP-API exists to force every URL, port, model id, and
timeout in `ornn-api/src/` through env or admin settings. `openapi/` is a
documentation surface, not a configuration one: its literals are
`description` and `examples` values written to be read — an example LLM
gateway URL, an example model id, a GitHub URL showing the shape of a
`repoUrl` field. Nothing there is connected to, parsed, or used to
configure the server; these modules return a JSON document and have no
other runtime behaviour. Same rubric line as a test fixture
(Architecture §8) — exempt by file path, alongside `regression/` and
`infra/config.ts`.

The one genuinely deployment-shaped value the spec carries is NOT
hardcoded and stays covered: `buildSpec` takes the advertised server URL
as `options.serverUrl` from `config.ornnApiBaseUrl`.

Removing the examples instead would have been the wrong trade — concrete,
realistic examples are most of what makes the document usable, and they
are exactly what integrators said was missing.
Curated from the single changeset on develop; per the template's rules,
product-level facts only, no version numbers or issue refs.
Found by an adversarial pass over the new module, each verified against
the handler before being changed.

The load-bearing one is model resolution on the playground, skill-gen, and
assistant sections. The docs claimed the section's `defaultProviderId` /
`defaultModelId` pin "outranks" the per-model `defaultFor<Surface>` flag.
It does not — no execute path reads the section at all. A request that
omits `modelId` resolves through the per-model flag and falls back to the
first enabled row by display name; the section pin is only ever read by
`GET /me/models` and the skill-search default resolver. An operator who
believed the old text would set the pin, watch runs use a different model,
and have nothing in the document to explain it. Now documented as an
advertised-vs-executed split, with the instruction to keep the two in
agreement.

Also corrected: the per-model surface flags are writable through `POST`
and `PUT .../llm-providers/{id}` as well as the per-model `PATCH` (the
PATCH is merely the only path enforcing at-most-one-default-per-surface);
the mirror `owner` limit is 39 characters, not 40; the basic-auth password
example used an unreachable 2+2 mask where `midMaskSecret` always produces
4+4; the `sourceSync` example listed a preserved secret in
`changedFields`, which the service's diff cannot report; and the provider
`PUT` 404 note claimed precedence over body parsing that the middleware
order contradicts.
fix(api): make the OpenAPI spec a complete, accurate contract
@chronoai-shining
chronoai-shining merged commit 5992a1a into main Aug 7, 2026
21 of 22 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