release: promote develop to main (OpenAPI contract completion) - #1228
Merged
Conversation
chore: sync main → develop after v0.16.1
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Promotes
developtomainto 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:
{}.toSchemacalledzod-to-json-schema@3, which only understands zod 3 internals and returns an empty object for a zod 4 schema without throwing.GET /skill-searchadvertisedparameters: []and every request and response body wasschema: {}. Now on zod 4's built-inz.toJSONSchema; the dependency is removed.application/jsonwrapping the legacy{data, error}envelope, where the API has emitted RFC 7807application/problem+jsonat 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-apionly. Noornn-websource file changed. Changesets runs in fixed mode, soornn-web's version is bumped in lockstep and its CHANGELOG gets a bare heading — same as0.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 withETag+Cache-Control: public, max-age=300and 304 support.Release notes
.github/release-notes-20260807.mdis included and curated.