Skip to content

Latest commit

 

History

History
936 lines (886 loc) · 61.9 KB

File metadata and controls

936 lines (886 loc) · 61.9 KB

Changelog

Notable changes, newest first, in the format of Keep a Changelog.

From 1.0, the public API of the shipped modules is stable, and a breaking change waits for a major release. Two artefacts in this repository say what that covers, and they are checked on every build rather than promised here:

  • The .api dump beside each module is the binary contract. apiCheck fails when a published signature changes, so a break cannot reach a release without someone editing the dump in the same commit and saying why.
  • StillCompilesTest is the contract for the half of the DSL a dump cannot see. json<T>(), pathParam<T>(), errorJson<T>() and the rest are reified inline functions, so a caller compiles against source; the suite compiles pinned call sites against the published modules and fails when one stops compiling.

What is not covered: anything internal, the emitted OpenAPI document's byte-for-byte shape (pelican-test-golden is how you pin the part your callers hold), and the modules taken off main before 1.0, which are in the history at commit a6c20b2 and are outside the promise until they return to main.

Breaks are still recorded here, and this file is the only place they are.

A version is cut by tagging — git tag v1.0.0 && git push --tags. The build reads the nearest v tag, so an untagged commit is a -SNAPSHOT of the next one.

1.0.0 — Unreleased

1.0.0-RC1 is the first candidate cut from this section. The surface below is what it ships, and the stability promise above takes effect at 1.0.0 rather than here: a candidate exists so that the things only real use finds can still change. Breaks between a candidate and 1.0.0 are recorded here like any other.

1.0.0-RC2 is the second, cut 2026-09-24. RC1 is 157 commits behind this section: pelican-streams, the Redoc renderer, the measured decode path and the two test-harness fixes all landed after it, so a reader evaluating the library against RC1 was reading a surface a month out of date. Five declarations left the dumps between the two candidates — Params.asMap() and four pelican-codegen helpers — and both changes are under Removed, which is what the paragraph above promises.

1.0.0-RC3 is the third, cut 2026-09-25. RC2 was tagged the day before pelican-test-wiremock landed, so the one thing between the two candidates is that module. Nothing left the dumps: every line RC2's .api files carried is still there, and the only new ones are the module's own.

Removed — read this one first

  • 1.0 ships one backend, one codec module and one client transport: Pekko HTTP, Jackson and pelican-client-pekko. Twelve modules leave main: pelican-http4k, pelican-http4k-docs, pelican-http4k-mcp, pelican-test-http4k, pelican-ktor, pelican-ktor-docs, pelican-ktor-mcp, pelican-client-ktor, pelican-jsoniter, pelican-kotlinx, pelican-client-java and pelican-client-okhttp. A build depending on any of them has no artifact to resolve at the next release.

    They are not abandoned and they are not deleted. All twelve live, complete and green, on the multi-backend branch (since removed; the same code is commit a6c20b2) — cut from main immediately before this change, built by the same specs and passing the same parity suites — and each returns after 1.0 as a restore rather than a rewrite. Nothing about the interpreter, the codec seam or the client SPI changed to make this possible: the point of a first release's stability promise is that it covers what the maintainer can stand behind, and this is that narrowing and nothing else.

    allBackends, the parity suites and the codec agreement matrix keep their shapes on main with a single entry each, so a returning module plugs into a live socket. The entries below that describe an http4k, Ktor, JDK-transport or OkHttp module — or say "all three backends" — still describe the branch accurately; on main, read them as describing the Pekko stack.

  • Params.asMap() is internal. It was public, and its own doc offered it to "interpreters that need to walk it rather than read known keys" — an audience that never arrived. Its only caller in the tree is lensInputs, inside pelican-core; no interpreter on main called it, and none on the multi-backend branch (commit a6c20b2) did either. One line leaves pelican-core's dump. An interpreter that wants the bag can ask for it back with a caller to point at; freezing it at 1.0 for nobody is the promise this release is trying not to make. See spec 0010.

  • Four pelican-codegen name helpers are internal. asWritten, branchName, isIdentifier and isWritable are gone from that module's dump, which is four lines shorter. All four are machinery the generator calls on itself — KotlinTypes.kt and Unions.kt are the callers — rather than anything a consumer was meant to hold, and no caller outside the module exists in this repository or on the multi-backend branch. A generator that needs one of them back can ask, with the caller that wants it. See spec 0044.

  • Three static fields nothing was meant to read. PekkoHttpTransport.CONTENT_TYPE, PekkoHttpTransport.CONTENT_LENGTH and RouteIndex.INITIAL_CAPTURES are gone from the dumps. A const val in a private companion is still a public static field on the class, so two header spellings and a list-sizing hint were about to be frozen as contract. No Kotlin caller could have named them.

Changed — read this one

  • A synchronous handler runs on a virtual thread of its own, not on the dispatcher (spec 0058). handledNow, handledOrFail, handledWith and handledOneOf used to run where the route matched — one of Pekko's few dispatcher threads — so a handler blocking on JDBC or an ask held a thread every other request was parsed and written on. start, startWithDocs, toRoute and routeWithDocs take handlers, defaulting to Handlers.onVirtualThreads; Handlers.onDispatcher is the old behaviour and Handlers.on(executor) a service's own. …By handlers and streams are not moved. The signatures from before stay in the bytecode, hidden, so a caller compiled against RC2 still links; in source, handlers sits before route, so a route passed by position rather than as a trailing lambda or by name has to move.

  • Either Scala cross-build of Pekko is now a gate, not an argument. pelican-pekko gained a scala3Test source set: this module's compiled output, which is _2.13-compiled, on a classpath holding Pekko _3 and no _2.13 artifact at all. It describes an endpoint, interprets it and answers a request, so a Pekko class that moved between the cross-builds fails ./gradlew build rather than somebody's service. It runs on check.

  • Pekko is no longer a dependency of the modules that speak it; a service declares its own. pelican-pekko, pelican-client-pekko, pelican-pekko-docs, pelican-pekko-mcp and pelican-test-pekko declare Pekko compileOnly, so no org.apache.pekko artifact is published in any Pelican POM or module metadata. A build that added pelican-pekko alone now fails to resolve org.apache.pekko.http.javadsl.server.Route; add pekko-bom, pekko-actor-typed, pekko-stream and pekko-http beside it. reference.md has the block to paste.

    Pelican pinned the Scala 2.13 cross-build. pekko-actor_2.13 and pekko-actor_3 are different Maven modules carrying identical class names, so no resolver reconciles them and pekko-bom_3 never constrained the _2.13 set: an application already on Scala 3 that added Pelican got both cross-builds on one classpath and Pekko's own version check refused to start the service. Pelican compiles against javadsl and four names identical in both cross-builds, so the suffix was never ours to choose. Either one works now; the build and every suite still run at _2.13, Pekko 1.2.1 and Pekko HTTP 1.3.0.

  • The importer refuses to write handler stubs for a backend 1.0 does not ship. handlers.set("ktor") and handlers.set("http4k") used to emit a file whose first import named …pelican.ktor.*, which nothing on the classpath provides — generated source that cannot compile. Both now fail the generation task by name, saying the interpreter is on the multi-backend branch and to generate for pekko or restore the module. Backend.HTTP4K and Backend.KTOR keep their places in the enum: those interpreters return after 1.0, and deleting an enum value is the larger break.

  • FallibleOutput is DeclaredResponses. The type names what an endpoint declared, and Fallible named only the half of it that can go wrong. The file holding it is Outcomes.kt, so the top-level ok, of, or and orFail move from FallibleKt to OutcomesKt for a Java caller; pelican-test's two @JvmNames follow, callFallible and collectFallible becoming callOutcome and collectOutcome. Outcome, or, orFail and errorJson keep the names they had, so Kotlin source that names none of the three moved names is unaffected.

  • spi.renderError takes the Api, not a boolean. renderError(t, api.exposeInternalErrors) becomes renderError(t, api, endpoint), and the RenderedError it returns carries the body already written, under body.bytes and body.mediaType. There is no default for api: a call site that forgot one would answer in an envelope nobody configured, silently, and only for the refusals that reach it — which is how three of them came to bypass the setting during this change. An interpreter should write rendered.body and take only the status from rendered.error; encoding error itself is a fourth dialect.

  • The interpreter SPI lives in io.github.matthewjones372.pelican.spi. handlerFor, routeIndex/RouteIndex, renderError/RenderedError, readStrictBody, requestBodyCodec/RequestBodyCodecs, successNamedBy, failureNamedBy, decodeList, acceptable, declaredInputCount and CorsHeaders moved out of the root package, with their signatures unchanged and no forwarders left behind. Nothing in the DSL moved. A service written against Pelican compiles as it was; a backend or transport written against core adds one import per file. The fence goes up before 1.0 because after it the same move would break every backend at once.

  • An ApiSpec is built by apiSpec(endpoints, schemas) { }, and its constructor is internal, for the same reason as Api below: a document will want more settings after 1.0 — a contact, a license — and a nine-parameter constructor with defaults freezes twice over. Settings that were named arguments are assignments inside the block; refusals = r is refusals(r), the spelling api { } already uses. pelican-import generates the builder form, so regenerate imported sources when picking this up.

  • An Api is built by api(routes, codecs = ...) { }, and its constructor is internal. A fifteen-parameter constructor with defaults freezes twice over — the descriptor and the synthetic constructor carrying the defaults both change when a parameter is added — so after 1.0 every new server setting would break every caller compiled against the last release. Settings that were named arguments are assignments inside the block, and filters = listOf(a, b) is filter(a) and filter(b). Every call site is a compile error, not a silent change of meaning.

  • The same for the other settings bundles: retryPolicy { }, docs { }, docsOAuth(clientId) { }, mcpOptions { } and importOptions(packageName, name) { } replace their constructors. ClientRequest keeps a constructor — a client builds one per call — and gains withTimeout(...) and withHeader(...) for what is no longer a parameter.

  • start is start(port, host = "127.0.0.1", ...) on all three backends. Pekko read start(host, port) and Ktor read start(port, host), so the same two positional arguments meant opposite things; http4k had no host at all. Port is first everywhere now, host is second on start and startWithDocs, and http4k's config moves one place right — StreamingSunHttp(port, host, ...) likewise. A call using named arguments is unaffected; a positional start("0.0.0.0", 8080) on Pekko no longer compiles.

  • Ktor binds loopback by default, where it bound every interface. A bare start() was 0.0.0.0 on Ktor and 127.0.0.1 on Pekko, which is a deployment decision inherited from an engine rather than made by a service. This one is silent — nothing fails to compile — so a Ktor service that meant to be reachable off the machine now says start(port, "0.0.0.0"). http4k, which had no host parameter and bound every interface through the JDK's server, changes the same way. PelicanServer.host reports what was bound.

  • One server handle on all three backends. Pekko's PelicanServer gains AutoCloseable, block() and a stop() returning nothing; the CompletionStage it used to return is now stopAsync(), which http4k and Ktor gain too. server.stop().toCompletableFuture().join() becomes server.stop(). block() means the same thing everywhere — parked until stop() — which on http4k is a change: Http4kServer.block() is Thread.currentThread().join(), released by nothing, and Pelican no longer delegates to it.

  • Ansi is internal, and the DefaultImpls classes are gone from the published surface: the modules compile with -jvm-default=no-compatibility, and the interfaces that had them already emitted real JVM default methods. Kotlin callers see no difference; a Java class that implemented JsonValue, PlainCodec or SecurityScheme by calling DefaultImpls is the one break.

  • Ktor dispatches through RouteIndex, as the other two backends already did, rather than installing one Ktor route per endpoint. Decoding, precedence and the trailing-slash rule are one answer on all three. Where a request lands is still Ktor's: a constant segment scores above the tailcard these routes install, so routes written by hand beside them keep the paths they describe.

  • All three codecs now leave a null property out. The flag that lets kotlinx.serialization read an absent nullable property governs writing too, so defaultMapper() moved to Jackson's NON_NULL and jsoniter's encoder skips a null property, rather than one library writing "detail": null and another omitting it. The schema already marks a nullable property optional and all three read an absent one back as null, so this is one spelling of a fact that had two. A null inside a list or a map is unaffected — it is a value there, and all three still write it. A response body carrying nullable fields is shorter and no longer carries their names; pass your own mapper or Json to write them back.

  • A streamed call no longer inherits the client's deadline. ndjson, sse, jsonArray and bytes calls are built with no timeout, because the three transports do not bound the same thing: Ktor's request timeout ends the whole exchange and the other two are done when the response head arrives, so one client's SSE subscription died at 30 seconds on one transport and ran on the other two. Everything read whole is bounded as before. Regenerate to pick it up. docs/generated-client.md now has the per-transport table, and the bearer-token recipe that was missing beside it.

  • A generated client refuses a body it cannot read, naming the call. A declared status arriving with something the codec cannot decode — a proxy's HTML 404, a gateway's plain-text 502 — used to let a bare Jackson or kotlinx exception escape with no status, path or body attached. It is now an ApiCallFailed like any undeclared status, carrying the status, the method, the path template, the body capped at 8 KiB with a marker where it was cut, and the codec's failure as its cause. Regenerate to pick it up.

Fixed

  • The CI matrix tests the three JDKs it names. jvmToolchain(21) sets the Java toolchain as well as the Kotlin one, so a Test task with no launcher of its own took it: the 21, 23 and 25 jobs all compiled to 21 and all ran on 21, and nothing was exercising 23 or 25 at runtime. Compilation still targets 21 — that is the bytecode a release has to keep working — and the suites now run on whichever JDK started the build. TestRuntimeIsTheBuildRuntimeTest fails if that drifts back.

  • PelicanServer.stop() no longer fails on an interrupt the actor system took during its own shutdown. Pekko runs stopScheduler() as a termination callback, and on JDK 21 a plain ForkJoinPool.shutdown() interrupts the worker it runs on; Scala boxes that InterruptedException into the future's result, so the stage stop() was joining failed although the system had terminated. It surfaced three times as a whole suite going red on one trace and green on a re-run. stop() now treats a boxed interrupt as termination, because what threw is housekeeping that runs after the guardian is already dead, and rethrows anything else. Spec 0049 has the trace, and tools/flake-0049 the kit that found it.

  • stop() waits with a deadline instead of forever. A join() on a stage Pekko never completes parks the calling thread with nothing to say. In this suite JUnit's 60s default timeout would have ended it; in an application nothing would. It now fails after 30 seconds either way, and stopAsync().awaitTerminated(duration) is the spelling for a different deadline. InMemoryTransport.close() shares the same wait.

  • Pelican no longer force-upgrades an app's Pekko. The modules built against Pekko 1.7.0, so adding Pelican to an app holding any Pekko artifact at an older version ended at startup with Pekko's own mixed-version refusal — "You are using version 1.7.0 of Apache Pekko, but it appears you (perhaps indirectly) also depend on older versions of related artifacts." The build now pins the floor it actually needs — Pekko 1.2.1 and Pekko HTTP 1.3.0 — and every suite runs at that floor, so an app anywhere at or above it keeps the Pekko it has and Gradle resolves upwards where it wants newer. The reference manual's Versions section carries the mixed-version error and its fix.

  • A path parameter that will not decode is refused under its own route. onRefusal was told _unmatched for one, so http.server.refusals counted it against nothing: a capture is decoded inside RouteIndex.match, which knows the endpoint it matched and throws to a caller that does not. The new spi.routeFor(method, path) looks the route up again on that path alone — a request being refused anyway — and both the interpreter and the in-memory transport now name it. A query, header or cookie is decoded after the route is in hand and always carried the template.

  • A dead node is retried on the shipped transport. Pekko raises a connection refused or reset as StreamTcpException, a RuntimeException, so RetryPolicy's default — retry an IOException — never fired for a connection failure through PekkoHttpTransport. It now crosses as an IOException with Pekko's own as its cause, so a policy that retries a dead node on one transport retries it on this one.

  • A cancelled call no longer parks a connection. Cancelling the stage a PekkoHttpTransport call returned left the late-arriving response holding its connection until the pool's subscription timeout salvaged it. The response is discarded the moment it arrives cancelled, and the transport stays usable.

  • Three declarations that silently misrouted or miscast are refused where they are written. default(v) followed by repeated() or commaSeparated() carried a scalar default into a slot typed List<T> — a ClassCastException in the handler, far from the declaration; the working order is repeated().default(listOf(...)), and the refusal says so. A brace in a path literal — get("users/{id}"), the spelling every annotation framework taught — routed the literal text %7Bid%7D; refused naming pathParam and the / spelling. And errorResponse skipped the status check every other response gets, while a documentation-only error declared under a live status silently replaced that response in the published document; both are construction failures now.

  • A path segment is decoded as a path, not as a form. A + in a captured segment arrived as a space, because captures were decoded with URLDecoder; /tags/c++ now reaches a handler as c++. Literals are matched decoded too, so /users/%61dmin finds the route declared /users/admin. A malformed escape is a 400 naming the segment rather than a 500 out of an interpreter's last-resort catch.

  • An encoded slash stays inside the segment that carried it. The path is split on / before anything is decoded, so /items/a%2Fb is one captured value and never two segments and a different route.

  • ?q on http4k is a present, empty value, as it already was on Pekko and Ktor, rather than indistinguishable from a parameter nobody sent.

  • A kotlinx-backed server reads what its own document says it accepts. defaultJson() now sets explicitNulls = false. Without it, a nullable property with no default was required to the decoder and optional in the published schema, so a caller following the document got a 400 from KotlinxCodecs and a 200 from the other two. The agreement harness gained the decode direction that catches the whole class: for every shape it covers, the smallest payload each codec's own schema accepts must decode through that codec.

  • A nullable scalar in a form body keeps its type. FormShape read only the string spelling of type, so the ["integer","null"] OpenAPI 3.1 publishes for an Int? fell through to a string and the value reached the codec quoted — a 400 on jsoniter and a silent coercion on the other two.

  • Documentation that promised more than the library does. The reference said a response an endpoint never declared "does not compile"; both halves are false, and what actually happens — UndeclaredResponse where the response is written, a 500 with a reference and the whole story in the log — is now what it says. Two of the compiler lines it and the README quote were invented: a handler is Params.(String) -> User, never Function1<String, User>, and a Pekko Source built from a Java constant arrives with platform-type marks. Every quoted line is now the compiler's own, asserted verbatim. The Kover floor reads 90 in AGENTS.md, as the build has, and FunctionalStyleTest judges fourteen modules rather than the eleven the reference claimed.

Added

  • Health checks (spec 0068). pelican-health: health { live(...); ready(...) } gives /health/live and /health/ready as ordinary endpoints, public, tagged health and in the document. Checks run in parallel, each under its own timeout, and the body is application/health+json: 200 for pass or warn, 503 for fail. A check marked critical = false that fails is a warn, and a check's output reaches the body only with detail = true. Built in, on the JDK alone: jdbc(dataSource), which borrows a connection and asks the driver isValid, diskSpace(path, minFreeBytes), heapHeadroom(minFreeBytes) and noDeadlockedThreads().

  • +json media types are JSON (spec 0068). media<T>("application/problem+json"), or any other +json type, is written by the API's JSON codec with no codec(type, mediaType) override, as RFC 6839 says it is. The check also ignores case now, so Application/JSON is JSON too.

  • Stub files that keep the contract (spec 0062). stubFile(codecs) { stub(endpoint, input) answers outcome }.writeTo(dir) exports the stubs a test is already written in as WireMock mapping files, so a demo that stands WireMock up from JSON is not an answer written a second time by hand. Each is rendered by the code a server answers with, as a live stub's is, so a 404 carries the body its contract declares with the content type the endpoint says. Files are grouped by the endpoint's first literal path segment, and they are golden: writeTo leaves an unchanged file alone and fails naming the file when the contract no longer agrees, rewriting on -Dpelican.golden.update=true. Stubs that answer from their input are the spec's second entry and are not exported yet. pelican-test-wiremock now takes pelican-test, for the request builder the export reuses rather than copies; it is core-only itself, and DecouplingTest allows it anchored to its version so pelican-test-pekko is still refused.

  • Two failures under one status (spec 0063). errorJson<T>(503, "...").tagged("not_recorded") says which of several failures under one status this is. Two may now share a status when every one under it is tagged with its own tag; the tag is written into the body under "kind", settable per declaration, and the document declares that status once with a oneOf of the tagged schemas and a discriminator mapping each tag to one, so a client outside Pelican can branch on it. A handler still names the declaration it answers. Untagged failures sharing a status are still refused, and so are two with the same tag, two that disagree on the field their tag is written under, a success sharing a status, and a tag on a payload that is not a JSON object. Nothing changes for a failure that is the only one under its status. ErrorOutput and ErrorSpec gained tag and discriminator; the constructors they had in RC3 are kept hidden, so a service compiled against RC3 still links.

  • A reference page beside the console, with its own look (spec 0071). docs { reference("/reference") { ... } } serves a Redoc reference beside the page at docsPath, reading the same document, so a service can offer Swagger's "Try it out" and a reference to read. Its look is Redoc's own: options is passed to Redoc.init as it is (theme, expandResponses and the rest), logo(url, altText, backgroundColor) sets x-logo on the page's copy of the document while /openapi.json stays as it was, and stylesheet(url) links the fonts a theme names. redoc { } gives the same look to the page at docsPath when ui = DocsUi.Redoc. A reference on the docs or document path is refused, and so is a redoc { } look with Swagger UI as the page. redocHtml takes a RedocLook; without one it serves the page it always has.

  • A failure in a media type of its own (spec 0070). errorMedia<T>(mediaType, status, description) declares a failure written as something other than JSON, as media does for a success; a health probe's 503 is application/health+json. The server sends it with that Content-Type, the document lists it under that media type, and ApiClient.outcome reads it back as the declared failure through the codec for that media type, which answers any +json type as JSON. ErrorOutput and ErrorSpec gained mediaType, application/json unless declared otherwise. A tagged failure must be JSON or a +json type, since its tag is written into a JSON body. pelican-health's ready and live probes now declare their 503 that way, as a failure carrying the HealthReport, where it was a second success: a client reads a failing probe back as Err(report) instead of throwing, and the document describes the 503 as a check failing rather than as "Success.". The probes are Endpoint<Unit, Outcome<HealthReport, HealthReport>>. Breaking: a success declared at 400 or above is now refused where it is declared, since every client reads a 4xx or 5xx as a failure whatever the document says; the message says to declare it with orFail errorJson<T>(…), or errorMedia for a body that is not JSON. 3xx successes are unchanged.

  • Stub files templated from the request body (spec 0067). stub(endpoint, example = input) { input -> ... } exports an endpoint with a JSON request body as one templated mapping. The example's string and number fields are varied like path parameters, and one the answer copies becomes {{jsonPath request.body '$.field'}}. Without an example, an endpoint with a body is still refused, and the message now says to give one. A body field whose varied value the endpoint refuses, by its codec or with another status from the handler, keeps the example's value rather than failing the export. An answer computed from a body field, rather than copied, is refused with the field named.

  • A tagged failure's schema declares its tag (spec 0066). The tag a tagged failure writes into its body is now in its component too, as a required const listed first, so the discriminator names a property every schema it maps to declares. A type tagged in one place and used plainly in another, or under two tags, is refused when the document is built, since its one component cannot describe both; so is a payload that already has a field of the tag's name. The status's description lists each tag with its failure's description, as a Markdown list, where it was every description joined with ; , and each tagged schema carries its own failure's description unless the type gives it one.

  • Golden checks read through oneOf (spec 0065). A schema that became a oneOf, as a status does when its failure is split into tagged ones, is compared with each of its shapes, and a break is named with the shape it is in. The split itself is reported as compatible, so splitting one failure into two that both keep every field no longer reads as every field removed. A request breaks only when no shape takes the old payload. A oneOf that became one schema is compared the other way round. Between two oneOfs or anyOfs, shapes are paired by tag, then by component, then by position. A shape added to a response is a break (a client switching on the old tags has never heard of it) and one removed from a request is a break; their mirror images are not. A renamed tag field is a break both ways.

  • Stubs that answer from their input, as response templates (spec 0062). stub(endpoint) { input -> outcome } in a stubFile exports as one mapping on the endpoint's path pattern, with a {{request.pathSegments.[n]}} template filling the path parameters the answer uses, so one file answers every pet with its own chip. A fixed input is written at a higher priority and still wins the request it names. Since a lambda cannot be read, the template is derived by calling the endpoint twice with values built from each parameter's declared type; an answer that moved for any other reason — a query parameter, a header, the request body, or a status that changes with the input — is refused where it is declared, naming the endpoint. An endpoint that refuses the probe value itself is reported as that, not as an answer that cannot be templated.

  • Who is calling (spec 0061). authenticated(scheme) { identity -> Caller(...) } declares a caller; authenticatedBy(caller) on an endpoint refuses a request without a verified one with 401 and WWW-Authenticate before the handler runs, requires the scheme in the document, and hands the handler this[caller]. optional() for endpoints open to anyone. What verifies a scheme is bound on the API with authenticate(scheme, by), and an unbound one fails at startup. pelican-test adds TestCallers, Api.inMemory() and ApiClient.signedInAs(subject).

  • pelican-oidc (spec 0061). oidc(issuer, audience) verifies a provider's bearer tokens: signature against its published keys (fetched on first use, refetched on an unknown key id at most once a minute), issuer, audience, expiry with leeway, and an algorithm allow-list that never admits none or HMAC. Groups from a claim. An unreachable provider is a 503.

  • Signing in to pages (spec 0061). oidc(...).signIn(clientId, callbackUrl, sessionKey) gives /login, /callback and /logout (authorization code with PKCE, state and nonce), an encrypted HttpOnly session cookie, an authenticator taking a bearer token or that session, and a guard for pages. pages(...).guardedBy(guard) is new in core: a page asked for without the guard's say-so is a 302 to where it points, and guarded pages are sent Cache-Control: no-store.

  • Acting as someone else (spec 0061). pelican-oidc reads RFC 8693's act claim into Identity.actor, and signIn.actAs(credentials, subject, until) / stopActing(credentials) give the Set-Cookie for a page session acting as someone, which lapses back to the person signed in.

  • pelican-arrow: an Either with several declared failures (spec 0060). either.toOutcome { left -> declared(...) } names the declaration each Left becomes, and a Right is ok. The lambda can only answer a failure. It replaces the fold that spec 0036 had every such handler write.

  • Pages served beside an API (spec 0059). pages = pages("ui") in api's block serves a classpath directory: / is index.html, /x is x, x.html or x/index.html, a directory without its slash is redirected to it, and no path leaves the directory. An endpoint at the same path always wins. Pages carry the API's CORS headers; filters, which wrap a handler, do not run for them.

  • pelican-test-wiremock, a stub written in endpoints. A WireMock server stubbed and verified in the endpoint values of the API a service calls: stub(getUser, 1L) answers ok(user), fails(Fault), breaksWith(502), answers(..., after = 5.seconds), verify and calls. A request matches by what Pelican decodes it to, so a client that is not Pelican's matches too; an answer is rendered by Pelican's own response code. PelicanWireMockExtension is the JUnit 5 form. See spec 0056.

  • pelican-streams, a streaming body as a stream that names its failure. rows.toStream() reads a StreamIn<T> as a Stream<Nothing, T> from io.github.matthewjones372:lark-stream, where a row that cannot be understood is a declared E in the type rather than a thrown exception a materialised stage reports later. toOutcome(failure) is the way back: it takes the Exit<E, A> that lark's run(system) completes with and answers the Outcome a handler returns — Done is the value, Failed is the endpoint's own declared error, which is what fixes the status, and Died is rethrown, because a throwable nobody declared is what this library throws rather than returns. The element bound is T : Any: an operator handed a null cannot promise that a missing value and a failure are different things.

    Two functions, and the whole published surface of the module. Pelican converts; every operator is lark's and every endpoint description is Pelican's, so a service that never streams never sees the library and the library never learns what an endpoint is. Both sides are compileOnly: lark-stream pins pekko-stream_2.13 as an api dependency, so taking it normally would republish a Scala suffix through the back door and undo the decision pelican-pekko had already made. A consumer declares lark-stream beside the Pekko it already declares, and DependenciesTest asserts the published runtime classpath holds neither. See spec 0037.

  • A second way to read the document: docs { ui = DocsUi.Redoc }. Swagger UI is a console — one operation expanded at a time, the request form at the centre, Try it out sending real calls — which is the wrong tool for a surface people read rather than poke. Redoc is a read-only three-panel reference over the same document, at the same docsPath, with the same content type and CORS headers. The page is redocHtml(title, specPath, spec) in pelican-openapi, beside swaggerUiHtml and taking the same two forms: fetch the document from openApiPath, so a reader can curl the same URL, or embed it when there is none. Swagger UI stays the default and nothing changes for a service that does not set ui.

    ui = DocsUi.Redoc with oauth is refused where it is written rather than ignored — Redoc sends no requests, so docsOAuth would configure a flow nothing reads — and the refusal names both. The bundle comes from cdn.jsdelivr.net/npm/redoc@2, the same kind of CDN link the Swagger UI page has always used, with the same exposure: a browser that cannot reach it gets an empty page. 2.x rather than the 3.0 candidate for a measured reason rather than a cautious one: spec 0055 rendered both and found the RC's element form reports to a third party with no attribute that turns it off. See specs 0054 and 0055.

  • filteredBy puts a filter over some of the endpoints rather than all of them. filter(...) on the Api runs for everything in it, so a service with a public group and an authenticated one had to write a membership predicate into onlyWhen, split into two Api values and lose one document, or check in each handler. A list of bound endpoints now carries its own filters: secured.filteredBy(authenticate, rateLimit), concatenated with the open ones into one Api. The API's filters stay outermost. It is folded by handlerFor, so the Pekko interpreter, the in-memory transport behind the typed test client and MCP dispatch all see it. Where the description carries the distinction, onlyWhen reading p.endpoint?.security is still the better tool — it cannot drift from the document.

  • Passing a declaration where a payload belongs is refused where it is written. err(notFound) and ok(json<Order>()) both type-check — a declaration is a value — and then fail several lines later as Outcome<ErrorOutput<E>, T>, a type the author never wrote. Both are now a compile error naming the fix: call the declaration, or pass the payload. DoesNotCompileTest pins the message.

  • err(value), the failure-side ok. The single declared failure, carrying value, for the endpoint that declares one failure and would rather not name it on every return. With several declared failures a bare err is an UndeclaredResponse where the response is written, naming the choices — the declaration is what fixes the status — and a failure that promised a required header is refused the way a bare ok already was. Outcome.Err.declared becomes nullable to carry the unnamed form; source and binaries built against the old shape are unaffected. Deliberately not error: that is the standard library's throw, and taking the name would silently turn every error("message") in a handler into an unused value.

  • pelican-arrow. Arrow's Either into Pelican's Outcome and back: either.toOutcome() reads a Right as ok and a Left as err, toOutcome(failure) names the declaration for an endpoint declaring several, and outcome.toEither() is the way back on the client side. Core plus arrow-core and nothing else, asserted by the usual dependency test.

  • The names the .api dumps cannot see are pinned by compiling them. StillCompilesTest compiles nine fixtures of ordinary call-site source — endpoints, inputs, outputs, failures, construction, binders, clients, the test DSL and the codecs — against the published modules, and fails naming the fixture and the compiler's own error when one stops compiling. Binary compatibility validation reads bytecode, and the most-typed half of this DSL never reaches it: json<T>(), jsonBody<T>(), pathParam<T>(), sse<T>(), errorJson<T>() and the rest are reified inline functions, so what a caller compiles against is source and the only thing left in the jar is a synthetic copy no dump lists. Renaming json across this repository leaves every .api file byte-identical and every other test and gate green.

  • The refusals a request metric cannot see, reported. onRefusal(...) inside api { } takes a RefusalObserver, told about each request refused before the filter chain was entered: a parameter or body that would not decode, a Content-Type nothing declared, an Accept taking nothing the endpoint sends, a body over maxBodyBytes. http.server.requests counts what reached the chain and by construction cannot count what did not, which is why its 4xx rate goes quiet exactly during an attack or a broken-client rollout. The observer is called from spi.renderError — the one function that turns a refusal into a response — so a refusal cannot be answered and left uncounted, and all three backends plus the in-memory transport report identically because they report through the same function rather than three of their own. It is handed a RefusalReason, the status, and the path template of the route that refused; never the request, the throwable or the detail, so it has nothing high-cardinality to be tagged with.

  • http.server.refusals, a counter, in pelican-metrics and pelican-metrics-otel under the same name. onRefusal(refusalCounter(registry)) beside filter(metrics(registry)), or refusalCounter(sdk) beside openTelemetry(sdk). Tagged reason (unmatched, decode, body_limit, content_type, accept), status, and path — the refusing route's template, or _unmatched where nothing matched; the OpenTelemetry attributes are pelican.refusal_reason, http.response.status_code and http.route. Every existing meter, timer, span and histogram is untouched, so no dashboard changes meaning, and the two counters are disjoint: this one counts only what the request counter structurally cannot. docs/reference.md lists the three things it deliberately does not count.

  • Unrouted and UnsupportedMediaType, the two refusals Pelican used to raise as an ApiException. Same status, same sentence, same body: what changes is that a 404 the router raised can now be told from one a handler chose to throw, which is what keeps the refusal counter disjoint from the request counter. Catch them by name if a filter was catching ApiException for either.

  • Typed frames in the other direction. ndjsonIn<T>() declares a request body as newline-delimited JSON: one document per line, decoded by the configured codec, handed to the handler as its own backend's stream — toSource() on Pekko, toSequence() on http4k, toFlow() on Ktor. It occupies the body slot and composes with path, query, header and cookie parameters; a second body beside it is refused where it is declared. Core owns the splitting and the per-frame decode, so an upload and a download agree on where a frame ends by construction, and nothing is held but the frame being read: the first frame reaches the handler before the last is sent, on all three backends, over a socket. A frame that will not decode is a 400 naming the line it was on; one longer than the new maxFrameBytes is a 413. Both are classified refusals, so they go out in the service's own envelope and reach onRefusal under decode and body_limit. The document publishes one frame's schema under application/x-ndjson — itemSchema under 3.2, schema under 3.1 — and an import reads both spellings back. A generated client takes a Sequence<T> and streams it over ClientRequest.Body.Streaming; the typed test client sends frames(...) buffered, and InMemoryClientTransport refuses such a call by name for the reason it refuses a bytes(...) body.

  • maxFrameBytes in api { }: the largest single frame of a streamed request body, taking maxBodyBytes where it is not set. A limit of its own because a stream has no total length to bound, and one frame that never ends is the way to run a service out of memory with a single upload.

  • spi.classifyError(t, api), the classification without the body — the status, the sentence and the reference — for a caller answering in an envelope the protocol fixes rather than one the service chose. The MCP server uses it instead of renderError, whose body it always discarded: a refused tool call goes out as a 200 carrying a JSON-RPC result, so counting it as a refused request would put a status on a meter no caller was ever sent.

  • Refusals in the caller's dialect. refusals(ProblemDetails) inside api { } writes the built-in refusals — a 400 nothing could decode, a 406, a 413, a 500 nobody described — as RFC 9457 application/problem+json instead of {"status":…,"error":…,"detail":…}. The default, ApiErrorEnvelope, is the envelope that was always there, byte for byte. A RefusalRenderer sees the classified refusal — status, sentence, detail, reference, and the path template where a route matched — and never the request or the throwable, so the 500 path cannot leak internals by construction and instance cannot echo anything a caller sent. One renderer serves all three backends and the in-memory transport; RefusalsAcrossBackendsTest and ThrowingHandlerTest run once per backend and once per shipped renderer. Declared failures are untouched — errorJson<E> already carries whatever type was promised — and an MCP tool call is unaffected, because JSON-RPC's envelope is the protocol's and not the service's.

  • The document says what a refusal comes back as. Every operation now publishes a default response — OpenAPI's word for the statuses not enumerated, which is what a refusal is — pointing at one component carrying the configured envelope's schema. New emission: the document said nothing about refusals before, so every golden gains one response per operation and one ApiError component. An endpoint declaring its own defaultJson<E>(...) keeps it.

  • An event stream that says where a caller left off. sse<T>(id = { it.sequence.toString() }, retry = 15.seconds) puts an id: on every frame and opens the stream with a retry: directive of its own — once, ahead of the first event, because a frame carrying no data: dispatches nothing. id is a projection of the event rather than a counter the interpreter keeps, so there is one source of truth for the resume point. Both are optional and additive: an sse<T>() that declares neither writes the frames it always wrote, byte for byte, on all three backends. Under OpenAPI 3.2 the described event gains an id property, required exactly when the output sends one; retry is said in the response's description, being a directive about the stream rather than a field of any event. Neither travels through the OpenAPI importer — an id extractor is a function of the event value and no document holds a function — so an imported event stream declares its payload and the service adds the frame fields back.

  • lastEventId() on Params, the Last-Event-ID a reconnecting caller sent, and null on a fresh connect. An extension rather than a declared input: resume is optional by nature and reading it does not change an endpoint's input arity, so a handler that ignores it is unchanged. The header is read only where the output declares an event stream (Endpoint.resumable, decided in core), and the value rides on Params rather than through an attribute nothing stops a filter forging. What is done with it stays the service's: Pelican frames and delivers, and retention is not its business. Under OpenAPI 3.2 the described event's id carries the contract in words.

  • Clients that can pick a stream up again. A generated method answering text/event-stream takes a trailing lastEventId: String? = null and sends it as the header, and the Streamed<T> it returns reports lastEventId — the id of the last event handed over, so it is read after the sequence rather than off the response. pelican-test's collect(...) takes the same argument. The parameter is generated for every event stream rather than only those declaring an extractor, so a client generated from a description and one generated from the document it publishes stay identical. Reconnecting itself is deliberately not done for you: no loop, no backoff, no decision about how much of a gap is worth replaying — that is a policy, like retrying, and what is here is the seam for writing it.

  • One response, several renderings, chosen by the caller's Accept: negotiated(json<Report>(200), media<Report>("text/csv", 200)) is one declared response written two ways. The handler goes on returning a Report; which rendering goes out is the interpreter's, from the RFC 9110 scoring the 406 already used, and all three backends read it through one function in core. No Accept, or */*, takes the first alternative in declaration order. Nothing acceptable is the 406 it always was. The document says one status with one entry per media type — the shape a negotiated request body already published — the importer reads that shape back as the same negotiated(...), and the generated client grows one method per rendering (exportReportAsJson, exportReportAsCsv), each sending the Accept that asks for the one it decodes.

  • media<T>("text/csv"), a response written as a media type that is not JSON, and CodecFactory.codec(type, mediaType), which is where the writer for it comes from. The default answers application/json with the codec it already had and refuses anything else by name, so a media type nothing can write is a startup failure rather than a 500. Implementing it is one override on the Codecs an Api is given; example/secured has one.

  • pelican-mcp-server, and the tools served. pelican-mcp derived the tools and ran a call; nothing carried either to a client, and the docs said so. This module speaks the protocol — JSON-RPC 2.0, revision 2025-11-25: initialize, notifications/initialized, tools/list, tools/call, ping — over stdio with mcpServe(api), and pelican-pekko-mcp, pelican-http4k-mcp and pelican-ktor-mcp mount the request/response half of Streamable HTTP on /mcp beside the endpoints, exactly as the -docs modules mount the document. Both drive the same McpDispatch, so a tool call is the execution path an HTTP request takes: the same codecs, refinements, filters and refusals, into the handler the route already has. No MCP SDK, and the dependency test says so: the official Kotlin SDK's server half is Ktor's — its transports are Route extensions, and kotlin-sdk-server compiles against ktor-server-core even for stdio — so adopting it would put a Ktor server behind mcpServe on a service running Pekko, and leave the HTTP half mountable on one backend of three. The revision is pinned to that SDK's LATEST_PROTOCOL_VERSION. Deferred and said out loud: the server-initiated event stream and the session id that goes with it — a GET is answered 405 — and resources, prompts and sampling. example.mcp is the Orders service with both halves on one port.

  • A Ktor-bound handler runs when it is invoked without a request. Ktor's binders launch the handler as a child coroutine of the call, so anything invoking a binding without one — mcpDispatch running the same ServerEndpoint as a tool — cast null to an ApplicationCall and answered an internal error. Where there is no call it runs on the default dispatcher instead; nothing is cancelling that work and no socket is waiting on it.

  • pelican-client-okhttp, the fourth transport — and the one Android can run. Landed here, then moved to the multi-backend branch when 1.0 narrowed to one client transport; it returns with the branch, as described below. There is no java.net.http on Android, so a generated client could only get there through KtorHttpTransport(HttpClient(OkHttp)), which drags the Ktor client machinery into an app that already ships an HTTP stack. OkHttpTransport(okHttpClient) takes the client the application already built, with its interceptors, its cache and its connection pool. The module is plain JVM — core and okhttp, no Android plugin and no AndroidX — and DependenciesTest asserts that list rather than promising it: running on Android is a consequence of depending on nothing that does not. OkHttp 4.x is the floor; Gradle resolves upwards, so a build on OkHttp 5 keeps it, and a fleet pinned to OkHttp 3 keeps the Ktor-engine route, which stays documented. The per-request deadline bounds the arrival of the response head, as it does on the JDK and Pekko adapters — deliberately not OkHttp's callTimeout, which is the whole exchange and would end every sse response that outlived the deadline its client was built with. It raises the InterruptedIOException OkHttp itself raises on a call timeout, so a caller catching a timeout by type catches it either way, and RetryPolicy treats it as the IOException it is.

  • Four claims the parity suite made and never asked. A handler that throws answers the same ApiError on all three backends — a 500 whose detail is a reference to grep the log for, nothing of the throwable, and the header a filter stamped still on it. A declared HEAD endpoint answers on all three; a HEAD aimed at a GET endpoint is pinned per backend as the difference it is (Pekko 404, http4k 405, Ktor 404). A chunked body over the limit is refused on http4k and Ktor, as it already was on Pekko. And a list header split over two field lines decodes as two values, not one.

  • A consumer that reads one frame and hangs up closes the handler's stream, on all three backends, within a bounded time — asserted through each one's own signal: watchTermination on a Source, onCompletion on a Flow, and the finally of a Sequence whose walking thread is interrupted. It fails against a StreamingSunHttp that does not close the body it was writing, which is the leak it exists to stop.

  • A definition for handledNow, which had none: handled in place, on the request, where in place is a thread on Pekko and http4k and a coroutine on Ktor. No code changes — docs/reference.md states it, and BinderNameParityTest names the binders each backend publishes so the difference stays documented rather than discovered.

  • Three refusals where something was published as what it is not. Two types wanting one component name are refused where the schema is built, naming both — all three sources named a component after the simple name and let the second take the first's schema. A Json setting the schema derivation cannot see is refused at KotlinxCodecs(...), naming the setting: classDiscriminator, ignoreUnknownKeys, encodeDefaults and explicitNulls are what it reads. And a success carrying a payload its response never declared is now an UndeclaredResponse naming both types, the check a failure already had.

  • Every compiler line the manual quotes is now a fixture. DoesNotCompileTest compiles the wrong input type, the wrong stream element, a value where a stream was declared, a bare value where a failure was declared, a failure from outside the declared type, and a seventh input with no overload to land on — asserting the compiler's line, not a paraphrase of it. Two more fixtures pin the holes the manual admits open, so a page describing a trade-off fails if the trade-off quietly closes: a failure of the same payload type compiles whichever endpoint declared it, and a lens handler reading a key nothing declared compiles and throws on the request instead.

  • Three truths the manual left unwritten. A declared failure is always JSON — errorJson is the only spelling, on all three backends and in the document. A client that disconnects cancels a Ktor handler and does not cancel a Pekko or http4k one, which both run to completion and answer a connection that is gone; a stream closes on all three. And the refusal of two responses under one status says why in terms that leave room for several renderings of one response, which would be that one response declaring both media types.

  • InMemoryClientTransport(api), in pelican-core: a ClientTransport that answers a generated client's requests by calling the Api in memory — the same routing, decoding, filters, handlers and response building a bound server runs, with no port and no mock. A bytes(...) request body and a streamed response that is not a Sequence are refused by name, because the values behind those belong to a backend rather than to core.

0.2.0 — 2026-08-25

Ninety-seven commits since 0.1.0. One breaking change, named below.

Changed — read this one

  • endpoint { } now means no inputs, not the lens form. endpoint(a) is one input and endpoint(a, b) is two, so no arguments should be none; the lens is endpoint(lensInputs) { }, which is a name rather than an absence, and noInputs is gone. Every affected call site is a compile error — a handler's parameter moves from Params to Unit — so nothing silently keeps working and means something else.
  • Fallible<E, T> is gone, replaced by Outcome<E, T> in the same position. It was a phantom with a private constructor whose only appearance was in the compiler error you got for using the wrong binder, where it named a type you had never written. That error now reads expected 'Outcome<Problem, Item>'.

Added

  • Routing that does not depend on how many endpoints you have. Descriptions go into a trie once instead of a list each router walks in turn. At two hundred endpoints a request went from about 150µs to 223ns on http4k and 645ns on Pekko — and both are now faster than the same routes registered by hand at every size, including one. See what it costs.
  • pelican-mcp. The endpoints as MCP tool descriptions and a dispatch that runs them through the handler the route already has.
  • pelican-schema. A derived JSON Schema that resolves on its own, for anything holding one without your OpenAPI document around it.
  • pelican-metrics and pelican-metrics-otel. Micrometer meters and OpenTelemetry spans, dimensioned from the descriptions rather than by hand.
  • A client transport SPI — ClientTransport in core, with adapters over the JDK's HttpClient, Pekko's and Ktor's — plus a suspend call surface and a retry policy for generated clients.
  • OpenAPI 3.2.0, written as well as 3.1.0.
  • bytesOrFail, so a byte stream that may 404 before its first byte is bindable and not merely describable.
  • A description on a declared success, so two of them stop both saying "Success.".
  • UndeclaredResponse, so a handler returning a response its endpoint never declared is distinguishable at onServerError from a broken codec.

Fixed

  • maxBodyBytes counted characters on http4k and Ktor, so an 8MB limit admitted roughly 24MB of CJK — and the body was already whole in memory before there was a length to check. Both now count bytes as they read.
  • Jackson dropped the discriminator on a collection of a union, so json<List<PaymentMethod>>() went out with no kind on any member and could not be read back.
  • kotlinx described a value class as an object while writing it as a number.
  • A slow request body is a 408 on Pekko rather than an undescribed 500.
  • Two parameters under one name and two endpoints sharing an operationId are refused where the endpoint is described, instead of producing an invalid document and a client that will not compile.
  • ApiSpec silently dropped one of two endpoints on a route. Api refused it; a documentation-only build did not.
  • Parameter defaults and emits(...) headers on failures now reach the document, which the server was already applying and sending.

Known

  • A handler can return a failure its endpoint never declared. E is pinned to the failure's payload type, not to the ErrorOutput that declared it, so any failure carrying the same type fits — and ApiError is the payload of most of them. It is an UndeclaredResponse when the response is written: a 500 with a reference, the whole story in the log. See declared failures.

0.1.0 — 2026-08-24

First published release, on Maven Central.

Added

  • Descriptions as values. An endpoint is an ordinary Kotlin value; the route, the OpenAPI 3.1 document, the test client and the generated client are derived from it.
  • Three backends — pelican-pekko, pelican-http4k, pelican-ktor — one import apart, with parity asserted by a suite that runs against all three.
  • Three JSON libraries — pelican-jackson, pelican-kotlinx, pelican-jsoniter.
  • Typechecked endpoints. endpoint(a, b, …) up to six inputs fixes the handler's signature; a lens form past that.
  • Declared failures. orFail puts the promised error into the endpoint's type, so a handler produces it rather than throwing it.
  • More than one success, with handledOneOf.
  • Streaming, backend-agnostic in the description: NDJSON, SSE, and a chunked JSON array.
  • Refined inputs that carry their constraint into the document.
  • Form bodies, multipart uploads, cookies, response headers, CORS, security schemes, webhooks, filters with attributes.
  • pelican-openapi, read back by an independent parser in the build.
  • pelican-codegen — a Kotlin client, as source.
  • pelican-import — an existing OpenAPI document, read back as descriptions.
  • pelican-gradle-plugin — every generator as Gradle tasks, with a remote reference lock.
  • pelican-test — the descriptions interpreted a third way, as a typed client, with shouldBuild for pinning the URL a caller holds.