Skip to content

Publish the contract the application actually answers: 262 paths to 410 - #823

Merged
MBombeck merged 19 commits into
mainfrom
docs/openapi-contract
Aug 22, 2026
Merged

Publish the contract the application actually answers: 262 paths to 410#823
MBombeck merged 19 commits into
mainfrom
docs/openapi-contract

Conversation

@MBombeck

Copy link
Copy Markdown
Owner

The published API document described 262 paths. The application answers on 410.

pnpm openapi:check compares the registry against docs/api/openapi.yaml and fails on drift between them. It has never compared the routes on disk against the registry, so a route that was never registered produced no drift and no failure. The document stayed internally consistent while going quietly incomplete, and the check ran green on every commit for months. Forty-three of the missing paths are called by the native client.

What is in the contract now

Every route under src/app is either published or named in an exemption list with a reason. There is no third state. Seventy-three routes are deliberately outside: the admin console (requireAdmin() is cookie-only by construction, so no API client can reach it however wide its token), OAuth callbacks the provider drives, browser handoffs that end at a provider's authorise page, webhooks authenticated by a shared secret, the MCP transport and its OAuth dance, the two clinician share links, three discovery documents whose format belongs to somebody else, and seven internal operations that serve the deployment rather than any client.

The reasons are kinds rather than free text. These fall into a small number of genuinely different shapes, and writing the same sentence seventy-three times would hide the entry whose sentence is different.

And how a caller authenticates

components.securitySchemes defined bearerAuth and cookieAuth and nothing referenced them. No document-level security, none on any operation. A client generated from the contract wired neither the Authorization header nor the session cookie and read all of it as an open API. The schemes being present is what hid it, because the obvious check passes.

The document declares them as alternatives now, and the ten operations reachable before a credential exists opt out explicitly. That list is derived from what each handler accepts rather than from the proxy's public-path allowlist: /api/ingest/medication sits on that allowlist and resolves a Bearer token itself, so reading the allowlist as the answer would publish an authenticated route as open.

Fifteen defects fixed on the way

Documenting a route means reading it, and reading four hundred of them turned up work that no brief asked for.

Turning the API off used to lock people out of their own tokens: the operator switch was checked on the read as well as the revoke, so a user could neither see nor clean up tokens that were still live. A preference update read its body with no size limit at all where its neighbours cap at one kilobyte. Four routes threw away the detail of why they refused a value, so an empty name and an over-long one came back as the same sentence. A GLP-1 read refused a delegate whose own write to the same medication was permitted. Withings credential teardown neither audited nor parked its ledger, so a client reading the state afterwards saw a stale connected. Two sync routes had no rate limiter. Every sync body parse swallowed everything, so a client typo read as a successful incremental run. A failed freshness query and an empty result were the same answer. Two query parameters widened a read on a typo instead of refusing it. The medication ingest bucket was keyed on the client IP, so several bridges behind one NAT starved each other.

Each fix has a test that fails without it, and each was broken again afterwards to confirm the test goes red.

Two guards, so the gap cannot reopen

openapi-route-coverage-guard walks src/app, reads the verbs each module exports, and requires every one to be published or exempted. openapi-security-declaration-guard requires every operation to name a scheme or opt out on the record.

Both earn their keep already. The security guard caught its own author: the opt-out list was first written from the proxy allowlist and put /api/auth/oidc/callback on it, a path that is not published at all.

Notes for review

Two component-id collisions surfaced only at integration, both from the same cause: two paths that are two doors onto one action, documented independently. DELETE /api/devices/{id} and DELETE /api/auth/me/devices/{id} both call revokeDeviceCascade; the shared response moved to shared.ts so neither module owns it.

A dozen findings are documented rather than changed, because each is a behaviour change or a product decision rather than a fix. The sharpest: GET /api/withings/status refreshes and re-persists a token during a read; GET /api/analytics writes a score row on every cache miss; PUT /api/auth/profile reports partial success inside a 200; DELETE /api/cycle/symptoms/custom/{key}?purge=true is an unconfirmed cascading hard delete; and sixty-four .meta() calls across the registry are silent no-ops in Zod 4, so the component ids they name never reach the document.

Sixteen routes the native client already calls were absent from the OpenAPI
registry, so the published spec said nothing about them. `openapi:check`
compares the registry against the YAML and never the routes against the
registry, which is why the gap survived.

Nightscout status / test / disconnect, the WHOOP credential and status reads,
and the Withings status / disconnect pair land in the integrations module. The
three per-channel configs under `/api/settings/*` join the settings module; the
notification preference and delivery-health surfaces get their own module, as do
the MCP connector connections and tokens.

These are credential-bearing surfaces, so the contract states what each response
actually carries. Every channel GET reduces its secret to a boolean, and the
descriptions say so, along with the consequence: a client cannot read the value
back, so an empty value on the way in preserves the stored one rather than
clearing it. `POST /api/mcp/tokens` is the one operation that returns a usable
Bearer, and its description says outright that this is the only copy.

Two inline request schemas move to the validations modules they belong in, so
the registry documents the same object the handler parses rather than a
restatement of it.
…er had

`pnpm openapi:check` compares the registry against the committed YAML and
never the routes against the registry, so a route that was simply never
registered stayed invisible to the gate. Sixteen of them are called by the
native client and describe nothing: sign-out, the three per-user preference
scalars, the two pre-session discovery reads, passkey login options, the
passkey rename and delete, registration status, the consent latest-receipt
reader and its revoke, account deletion, the instance service switches, the
API-token list and revoke, the threshold overrides, the version read, and the
assistant feature matrix.

Each contract is derived from the handler rather than from its docblock, so
the published statuses are the ones the code can actually produce — the 413 on
a 1 KB body cap, the 400 the consent family answers where the rest of the API
answers 422, the 403 an instance-wide API switch produces on a token READ, the
last-admin and last-Guardian refusals on account deletion, and the fresh-factor
arm that a Bearer transport cannot clear at any scope.

Four request bodies moved to the validation modules the handlers now parse
with, so the published wire cannot drift from the runtime one: the three
`/api/auth/me` preference scalars into a new `user-prefs` module, and the
passkey rename beside the login schemas. Behaviour is unchanged.

The generated YAML is left out; it is regenerated once the remaining groups
land.
Eleven routes the native client calls have never been in the registry, so
`docs/api/openapi.yaml` does not describe them. Nothing caught it:
`openapi:check` compares the registry against the YAML and never compares
the route tree against the registry, so a route registered nowhere drifts
with every gate green.

  GET       /api/analytics
  GET,POST  /api/cycle/symptoms/custom
  GET       /api/dashboard/summary
  GET       /api/environment
  GET       /api/export
  GET,PUT   /api/insights/settings
  GET       /api/insights/targets
  GET,POST  /api/medications/intake
  GET,POST  /api/medications/{id}/glp1
  GET       /api/mood/insights
  GET       /api/personal-records

Each contract is derived from the handler rather than from its comments:
the methods it exports, the parameters it reads with their real defaults
and bounds, the statuses it can actually produce, whether it resolves the
record fence, and which module gate it sits behind. Where the answer is
awkward it is written down rather than smoothed over — `/api/export`
answers a bare `data` key and not the standard envelope; `/api/analytics`
serves two unrelated shapes behind one query parameter and records the
health score it computes; `GET /api/medications/intake?scope=today`
projects rows before it reads them; the GLP-1 GET is not delegable while
the POST on the same path is; and `highlightInsight` on the dashboard
summary is pinned null by the handler and has never carried anything.

The intake aggregator's query and body schemas move from inside the
handler to `@/lib/validations/medication`, so the contract generates from
the same object the route parses with instead of a second copy of it. The
environmental overview gets its own route module because nothing owns that
surface. `docs/api/openapi.yaml` is regenerated separately.
Four defects the registry work surfaced, each with a check that fails without
the fix.

`GET /api/tokens` no longer consults the instance-wide API switch. That switch
gates the surfaces a token is FOR — external ingest, the MCP bridge — and not a
token's ability to authenticate: nothing on the `requireAuth` path reads it, so
a token minted before an operator flipped it stays live while it is off, and
refusing the list hid exactly the credentials their owner most needed to see,
their own signed-in phone among them. The revoke keeps the gate; the asymmetry
is stated in both route comments and pinned by the test, because widening it is
a decision about what the switch means rather than a fix to the read.

`PATCH /api/auth/me/unit-preference` reads its body through `safeJson` with a
1 KB cap, like the scalar beside it. It used a bare `request.json()` and was the
one endpoint in the family with no body-size limit at all — the new test proves
it by sending 2 KB and watching the old handler answer 200. Malformed JSON now
answers 400 like every sibling instead of 422; the only caller checks `res.ok`
and never the status, so nothing depends on the old code. The existing
malformed-JSON assertion was changed deliberately: it pinned the inconsistency,
not a contract.

`PATCH /api/auth/passkeys/{id}` returns the multi-issue envelope. It answered a
flat "Invalid request", so an empty name and a 200-character name produced
byte-identical bodies and the form had nothing to show beside the field.

`GET /api/user/thresholds` has a header that describes the handler. It promised
`{ defaults, overrides, effective }` and has always returned
`{ effective, overrides }`. The guard compares the two, so the comment cannot
drift again in either direction.

The two published operations whose contracts moved are updated with them.
Thirty-seven more paths the clients already call and the published spec did not
describe: the six OAuth providers' credential, status, disconnect, sync and
probe endpoints, the cross-provider surfaces under `/api/integrations/*`, the
managed record's configuration tree, and the native device-revoke path.

Where providers share a verb name they do not always share a contract, and the
descriptions say so instead of averaging it out. Three divergences are stated
per path. A disconnect refuses a repeat with 404 on six providers and answers an
idempotent 200 on two. `/api/<provider>/test` answers a bare probe result while
the same-named `/api/integrations/<provider>/test` adds the connection's last
sync, and the Google Health one accepts a body that replaces the response shape
outright. `GET /api/withings/status` refreshes and re-persists the OAuth token
while it reads; the other six status endpoints write nothing, and each now says
which it is.

Two further facts a client cannot infer. The Withings credential DELETE writes
no audit row and leaves the ledger at its last state, unlike every sibling, so
its `state` is not proof of a live connection afterwards. The WHOOP connect
ticket is a credential in a response body, returned once and stored only as a
hash.

The Google Health credential schema moves to the validations module; the managed
record's six strict patch schemas are exported from where they already lived, so
the contract publishes the objects the handler parses rather than a restatement.
`openapi:check` compares the registry against the committed YAML, so a route
that was never registered produced no drift and no failure. These nineteen
had shipped without a contract: the health probe, the dashboard analytics
aggregate, the profile summary, personal records, the badge grid, the whole
environmental-context module, both onboarding writes, the Apple Health import
pair, the external medication ingest, the self-context adopt, the AI-provider
update and the AI connection probe.

Each entry is read off the route rather than inferred from a neighbour, so it
carries what a client cannot guess: that `/api/health` answers a bare object
and not the envelope, that `/api/analytics` records a score row and mints
pattern ids on a GET, that `/api/ai/test` reports a provider failure as 200
with `ok: false` because a 5xx comes back as somebody's HTML error page, that
the ingest route replays with 200 and creates with 201, and that a home
location re-stamps its effective-from instant on every write.

Three route-local request schemas move to `src/lib/validations/` so the
published shape and the runtime parse are one object rather than two copies —
a route module may only export handlers, which is why they could not be
imported where they were. The AI-provider PATCH has no schema to move: it
hand-rolls its parse field by field, so its entry says so and spells out that
a wrongly-typed key is skipped in silence.

The generated YAML is deliberately not in this commit.
…aces

Twenty-eight more verbs that exist, are called, and said nothing in the
contract: registration, password rotation, the profile patch, the SSO entry
point, passkey enrolment, the Codex device-auth flow, the device list and its
revocation cascade, four per-user preference scalars, the email channel and the
four channel self-tests, the record wipe, the privacy facts, the update check,
and the Web Push transport.

Four of them needed more care than the rest.

`GET /api/auth/oidc/login` is documented as the two-armed route it is. The
browser arm redirects to the provider; the native arm takes `client=native`
plus a mandatory S256 challenge and returns to a compile-time scheme with a
one-time code — or an MFA ticket when the account has a second factor. The two
PKCE exchanges in play are named apart, because confusing them is the way to
get this wrong.

`POST /api/auth/register` is the only genuinely anonymous surface in the set,
and its refusals are stated exactly: the SSO-only block, the 5-per-15-minutes
bucket that collapses rather than falls open, the invite that is consumed
whenever it is sent, the zero-user bootstrap that mints the first admin under a
lock, and the one 409 that covers a taken email and a taken username so it
cannot be used to enumerate accounts. `POST /api/auth/password` is a change
behind authentication, not a reset, so it is described as one.

`DELETE /api/settings/data` names what it takes and what it leaves: ninety-nine
models hard-deleted, the account's own personal columns reset, credentials and
interface preferences kept, one transaction, and a confirmation string that is
deliberately not the one account deletion wants.

Two collisions surfaced while wiring this up and are resolved rather than
worked around. `PUT /api/auth/profile` and `PATCH /api/user/profile` are two
doors onto one handler, so they now share one published request component
instead of two descriptions of one validator. Their RESPONSES differ, though —
one carries id and role, the other the display preferences, and neither is a
superset — so they are named apart and the older description no longer claims
to cover both.

The four Web Push and APNs surfaces land in their own route module rather than
beside the notification preferences: that module answers what gets sent and
whether it arrived, this one the plumbing a client sets up and then verifies.
Both self-tests report failure inside a 200, which the contract says plainly,
because a client reading only the status will tell someone their notifications
work when they do not.

The generated YAML is left out; it is regenerated once the remaining groups
land.
`GET /api/medications/{id}/glp1` resolved the caller as themselves while
the POST beside it has resolved the record since v1.37.0. A manager
inside a shared record could therefore add a titration step to a
medication whose history the same path refused them: may write, may not
read. No reading of the sharing model produces that shape, and the
sibling reads on the same medication — `/inventory`, `/side-effects`,
`/cadence` — have all resolved the record at READ level for releases.

The read joins them at that level rather than at the POST's MANAGE,
because it is strictly narrower than the write already admitted. The
module was already on both reviewed allowlists, so nothing is newly
delegable; its reason line said the module was reached through the
MANAGE arm alone, which is no longer true and now says so.

The gap survived this long because a route that refuses every switched
caller is indistinguishable from a route nobody classified. Three legs
close that: a delegate scoped to medications reads the owner's titration
history and still cannot reach their own medication, a delegate scoped
elsewhere is refused, and a caller who never switched is unchanged. Both
delegate legs assert the refusal CODE rather than the status, because the
pre-fix answer was also a 403 — verified by reverting the handler, at
which point the first leg reads 403 for 200 and the second reads
`sharing.not_permitted` for `sharing.access.denied`.
Each of these made the server report something that was not so, and each now
has a test that fails without the fix.

The Withings credential DELETE wrote no audit row and left the ledger at
whatever state it last held. Fitbit, Google Health and WHOOP all park theirs,
and three of them say in their own comments that they do it for parity with a
list that never included Withings. A client reading `state` after the teardown
was told `connected` about a connection that no longer existed.

Oura's and Strava's credential DELETEs audited only when an access token
happened to be present, so removing a stored pair that had never completed
OAuth left no trail at all. Polar's twin has always done both unconditionally;
they match it now. The old Oura test asserted the skip, which is why the gap
survived a rewrite — that expectation is inverted here, with the reason next to
it.

The Withings and WHOOP manual syncs had no rate limiter, while `fullSync` walks
full history against a per-user provider budget. The Fitbit route's own comment
records that it was the outlier until it got one; these two were never brought
along. Both now carry the same 5/60s baseline and 1/hour full-sync buckets as
their siblings.

Every sync route parsed its body inside a catch commented "no body provided",
which was true for one of the three ways to reach it. An absent body still
means an incremental run — that is the documented shape and the web cards send
it. A body that is present and unparseable is now 400, and one that fails the
schema is 422: `{"fullSync": "true"}` used to be read as false and answered 200,
so a client that asked for full history and mistyped the value was told the run
it never requested had succeeded. Unknown keys are still ignored. The four
copies collapse into one helper.

The consolidated integration status swallowed a failed freshness query into an
empty array, which is exactly what a record with nothing in it also produces —
so the signal built to say "this metric has gone quiet" answered "nothing has
gone quiet" when it had failed to look. `metricFreshnessDegraded` separates the
two, as one flag rather than one per entry, because it is a single query for all
eight providers.

The contract follows the behaviour in the same commit.
`POST` and `DELETE /api/notifications/web-push` both answered a flat
`apiError("Invalid data", 422)` and discarded the Zod issues. A non-HTTPS
endpoint, an endpoint aimed at an internal host, an over-long one and a missing
subscription key produced byte-identical bodies, so a browser whose
subscription was refused could not tell a fixable client bug from a policy this
instance will never satisfy. Both now answer the multi-issue envelope every
other body-parsing route in the tree uses.

Echoing the issues is safe here and the route says why, because it is the kind
of thing that gets re-derived wrongly later: the endpoint is a subscription's
routing secret and `keys` is its crypto material, but `sanitiseZodIssues` emits
`path`, `code` and `message` only — the rejected value lives in `issue.params`
and stays server-side — and every message this schema can produce is a fixed
string or a length/format default rather than an interpolation. A test pins
that none of the four secrets reaches the wire, so a future schema change that
adds an interpolating message fails here instead of leaking quietly.

The tests assert on content rather than status: a status-only check passes
against the flat error it replaces, which is how this survived. Two of them
compare whole bodies between two different refusals and require them to differ.

The two `/test` routes were checked in the same pass and need nothing. Neither
accepts a body — they take no parameters at all — so neither runs a Zod parse
to discard. Their 422s already carry `not_configured`, `vapid_not_configured`
and `rate_limited_self` as machine-readable codes.
The eighth teardown endpoint, missed when the seven siblings were split across
the earlier batches. It has no per-user unsubscribe to make — WHOOP webhook
subscriptions are registered once per developer app rather than per category
the way Withings does it — and it leaves the BYO-key credentials in place so a
reconnect does not force a re-paste.

Like every other `POST /api/<provider>/disconnect`, a repeat call answers 404
rather than an idempotent 200, and the description says so. That completes the
tally: all seven disconnect endpoints refuse a repeat, all six credential
DELETEs accept one.
The drift check compares the registry to the emitted YAML. It has never
compared the routes on disk to the registry, so a route that was never
registered produced no drift and no failure: the document stayed
internally consistent while going quietly incomplete. Two hundred and
twenty-two paths reached that state.

The coverage guard walks src/app, reads the verbs each module exports,
and requires every verb to be either published or named with a reason.
The reasons are kinds rather than free text, because these fall into a
small number of genuinely different shapes and writing one sentence
seventy-three times would hide the entry whose sentence is different.

The security guard is the same idea one level down. The document defined
bearerAuth and cookieAuth and referenced neither, so a generated client
wired no credential and read the whole surface as open. The schemes
being present is what hid it. The document now declares them as
alternatives, and the guard checks the reference rather than the
declaration.

Both are red until the routes are documented, which is the point.
Twenty-five path entries across nine domains, all of them served and none
of them described. Same cause as the first batch: `openapi:check`
compares the registry against the YAML and never the route tree against
the registry, so a route registered nowhere drifts with every gate green.

Four groups where the contract had to say more than the shape does.

The full backup is the download that carries a whole record in plain
text, so what it contains is a safety question. It has two modes and this
route reaches only one: called with no options, it always emits the
PORTABLE form — decrypted columns, no tombstones, no row ids — while the
disaster-recovery form belongs to the worker and the admin path. Three
things it leaves out say so in the payload's own manifest: document
bytes, workout GPS and per-sample series, and the sensitive pair, where
the screener answers include the PHQ-9 self-harm item and a consent
receipt restored elsewhere would assert an agreement that operator never
obtained. The undeclared gap is named too, since roughly thirty models
the plan marks as backed up still have no reader or restore branch.

The four intake mutations differ in ways a caller has to know before
firing one. The by-id delete, the bulk delete and the purge all
tombstone, so the sync feed carries each removal; only the purge's
compliance rollups are dropped outright, and those recompute. The first
two refund the inventory stamp because not taking a dose puts the units
back; the purge deliberately does not, because refunding a whole history
would inflate the current container count instead. The bulk delete drops
unmatched ids silently, so a partial match succeeds with a lower count
than the ids sent.

The two provider-configuration surfaces are documented by what they do
NOT return. The chain answers provider types and boolean state, never a
credential. The per-medication ingest endpoint returns a raw token
exactly once, on the PUT that mints it, and a re-enable answers a null
token with `created: false` rather than re-issuing — the stored value is
an HMAC and there is no path back.

Insight generation costs money, so both gates are published with the
refusals they produce: the hourly per-user limit, and the day's token
ceiling, which is refused at reservation time before any provider is
contacted and carries its own code so a client can tell "too often" from
"the day's spend is gone". A 200 there does not mean a provider ran.

Two things the code said that were no longer true. The seeded-question
route's docblock declared it deliberately unregistered; it is registered
now, and a comment contradicting the registry is worse than none. And
the idempotency guard went red on `/api/insights/feedback`, whose
exemption said the path could not be published — which is exactly what
that leg exists to catch, so the entry is gone rather than the assertion.

`docs/api/openapi.yaml` is regenerated separately.
Four surfaces answered a bad request by quietly substituting a good one. Each
fix comes with the test that catches it, and each test was confirmed red
against the old behaviour before the fix went back in.

`PATCH /api/user/ai-provider` inspected its body key by key with `typeof`
guards, so a numeric `model` or a boolean `baseUrl` failed every guard,
produced no entry in the update, and the write went ahead without it — a 200
that says "saved" for a field that was discarded. A body of nothing but such
keys came back as "No valid fields" naming none of them. The real schema now
lives in `src/lib/validations/ai-provider.ts`, the route runs one `safeParse`,
and a mismatch is the multi-issue 422 every sibling answers with. The accepted
field set is unchanged, and the object is deliberately not strict: an unknown
key has always been ignored here and still is. The one thing the schema has to
carry that no other does is that this body holds plaintext credentials, so no
rule may interpolate the value it rejected — noted where the next `.refine()`
would go.

`?slice=` on `/api/analytics` was compared against one literal and fell
through to the DEFAULT body on anything else, so `?slice=summary` answered 200
after running the heaviest chain on the surface. Invisible and expensive at
once. Closed enum, 422 on anything outside it.

`?metricType=` and `?limit=` on `/api/personal-records` were parsed
defensively, which read as robustness and behaved as silence — and in one
direction it was worse than silence: dropping an unrecognised `metricType`
WIDENS the read to every metric, so a typo returned more of the record than
was asked for. Both refuse now, and the 500 ceiling refuses rather than
clamps, because serving a different number of rows than were requested is the
same quiet substitution. No caller in the tree sends a value the new rules
reject.

`POST /api/ingest/medication` bucketed on the client address, so a household
running a chat bridge, a button and a home-automation rule — the normal
deployment for this route — put all three in one 60/min cell and let the
chattiest starve the rest. It already resolves a per-caller identity two lines
further down and threw it away; the bucket is that token now. A request that
resolves no token keeps the per-IP bucket it always had, through the shared
anonymous helper, so a flood is still capped and a broken proxy chain
collapses into the tight global cell rather than a shared "unknown" one. Its
429 goes through `apiError` and carries `meta` like every other refusal.

Two older tests pinned the defects by name — "drops an unknown metricType
silently" and "falls back to default on garbage ?limit" — and one pinned a
throw where the rest of the surface returns an envelope. All three are
rewritten with the reason beside them.

The generated YAML is deliberately not in this commit.
Two component-id collisions, both from the same cause: two paths that
are two doors onto one action, documented independently.

DELETE /api/devices/{id} and DELETE /api/auth/me/devices/{id} both call
revokeDeviceCascade and answer with the same shape. Each grew its own
DeviceRevokeResponse, which the emitter refuses outright. The response
moves to shared.ts so neither module owns it and the next door onto the
same call finds it; the two envelopes keep separate names because an
envelope names an operation.

The settings module took both halves by hand rather than by keep-both:
one side added nine paths, the other three, with no overlap but a shared
import block and a shared reminder-thresholds entry that a mechanical
union duplicated.
Two groups created environment.ts independently; one carried all six
paths, the other only the overview read, so the fuller side stands and
the barrel keeps one import.
The document defined bearerAuth and cookieAuth and referenced neither,
so the schemes were decoration: a client generated from the contract
wires no credential and reads the whole surface as open.

The default now offers the two as alternatives, and the ten operations
reachable before a credential exists opt out with an empty array. The
opt-out list is derived from what each handler accepts, not from the
proxy's public-path list — /api/ingest/medication is on that list and
requires a Bearer token, so reading it as the answer would publish an
authenticated route as open. Writing the list from the proxy is exactly
the mistake the guard caught: /api/auth/oidc/callback went on it and is
not published at all.
@MBombeck
MBombeck merged commit 4d53241 into main Aug 22, 2026
23 checks passed
@MBombeck
MBombeck deleted the docs/openapi-contract branch August 29, 2026 16:36
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