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
.apidump beside each module is the binary contract.apiCheckfails when a published signature changes, so a break cannot reach a release without someone editing the dump in the same commit and saying why. StillCompilesTestis 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.
-
1.0 ships one backend, one codec module and one client transport: Pekko HTTP, Jackson and
pelican-client-pekko. Twelve modules leavemain: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-javaandpelican-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-backendbranch (since removed; the same code is commita6c20b2) — cut frommainimmediately 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 onmainwith 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; onmain, read them as describing the Pekko stack. -
Params.asMap()isinternal. 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 islensInputs, insidepelican-core; no interpreter onmaincalled it, and none on themulti-backendbranch (commita6c20b2) did either. One line leavespelican-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-codegenname helpers areinternal.asWritten,branchName,isIdentifierandisWritableare gone from that module's dump, which is four lines shorter. All four are machinery the generator calls on itself —KotlinTypes.ktandUnions.ktare the callers — rather than anything a consumer was meant to hold, and no caller outside the module exists in this repository or on themulti-backendbranch. 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_LENGTHandRouteIndex.INITIAL_CAPTURESare gone from the dumps. Aconst valin 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.
-
A synchronous handler runs on a virtual thread of its own, not on the dispatcher (spec 0058).
handledNow,handledOrFail,handledWithandhandledOneOfused 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,toRouteandrouteWithDocstakehandlers, defaulting toHandlers.onVirtualThreads;Handlers.onDispatcheris the old behaviour andHandlers.on(executor)a service's own.…Byhandlers and streams are not moved. The signatures from before stay in the bytecode, hidden, so a caller compiled against RC2 still links; in source,handlerssits beforeroute, so aroutepassed 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-pekkogained ascala3Testsource set: this module's compiled output, which is_2.13-compiled, on a classpath holding Pekko_3and no_2.13artifact at all. It describes an endpoint, interprets it and answers a request, so a Pekko class that moved between the cross-builds fails./gradlew buildrather than somebody's service. It runs oncheck. -
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-mcpandpelican-test-pekkodeclare PekkocompileOnly, so noorg.apache.pekkoartifact is published in any Pelican POM or module metadata. A build that addedpelican-pekkoalone now fails to resolveorg.apache.pekko.http.javadsl.server.Route; addpekko-bom,pekko-actor-typed,pekko-streamandpekko-httpbeside it. reference.md has the block to paste.Pelican pinned the Scala 2.13 cross-build.
pekko-actor_2.13andpekko-actor_3are different Maven modules carrying identical class names, so no resolver reconciles them andpekko-bom_3never constrained the_2.13set: 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 againstjavadsland 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")andhandlers.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 themulti-backendbranch and to generate forpekkoor restore the module.Backend.HTTP4KandBackend.KTORkeep their places in the enum: those interpreters return after 1.0, and deleting an enum value is the larger break. -
FallibleOutputisDeclaredResponses. The type names what an endpoint declared, andFalliblenamed only the half of it that can go wrong. The file holding it isOutcomes.kt, so the top-levelok,of,orandorFailmove fromFallibleKttoOutcomesKtfor a Java caller;pelican-test's two@JvmNames follow,callFallibleandcollectFalliblebecomingcallOutcomeandcollectOutcome.Outcome,or,orFailanderrorJsonkeep the names they had, so Kotlin source that names none of the three moved names is unaffected. -
spi.renderErrortakes theApi, not a boolean.renderError(t, api.exposeInternalErrors)becomesrenderError(t, api, endpoint), and theRenderedErrorit returns carries the body already written, underbody.bytesandbody.mediaType. There is no default forapi: 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 writerendered.bodyand take only the status fromrendered.error; encodingerroritself 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,declaredInputCountandCorsHeadersmoved 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
ApiSpecis built byapiSpec(endpoints, schemas) { }, and its constructor is internal, for the same reason asApibelow: 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 = risrefusals(r), the spellingapi { }already uses.pelican-importgenerates the builder form, so regenerate imported sources when picking this up. -
An
Apiis built byapi(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, andfilters = listOf(a, b)isfilter(a)andfilter(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 { }andimportOptions(packageName, name) { }replace their constructors.ClientRequestkeeps a constructor — a client builds one per call — and gainswithTimeout(...)andwithHeader(...)for what is no longer a parameter. -
startisstart(port, host = "127.0.0.1", ...)on all three backends. Pekko readstart(host, port)and Ktor readstart(port, host), so the same two positional arguments meant opposite things; http4k had no host at all. Port is first everywhere now,hostis second onstartandstartWithDocs, and http4k'sconfigmoves one place right —StreamingSunHttp(port, host, ...)likewise. A call using named arguments is unaffected; a positionalstart("0.0.0.0", 8080)on Pekko no longer compiles. -
Ktor binds loopback by default, where it bound every interface. A bare
start()was0.0.0.0on Ktor and127.0.0.1on 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 saysstart(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.hostreports what was bound. -
One server handle on all three backends. Pekko's
PelicanServergainsAutoCloseable,block()and astop()returning nothing; theCompletionStageit used to return is nowstopAsync(), which http4k and Ktor gain too.server.stop().toCompletableFuture().join()becomesserver.stop().block()means the same thing everywhere — parked untilstop()— which on http4k is a change:Http4kServer.block()isThread.currentThread().join(), released by nothing, and Pelican no longer delegates to it. -
Ansiis internal, and theDefaultImplsclasses 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 implementedJsonValue,PlainCodecorSecuritySchemeby callingDefaultImplsis 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'sNON_NULLand jsoniter's encoder skips a null property, rather than one library writing"detail": nulland 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 orJsonto write them back. -
A streamed call no longer inherits the client's deadline.
ndjson,sse,jsonArrayandbytescalls 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.mdnow 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
ApiCallFailedlike 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 itscause. Regenerate to pick it up.
-
The CI matrix tests the three JDKs it names.
jvmToolchain(21)sets the Java toolchain as well as the Kotlin one, so aTesttask 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.TestRuntimeIsTheBuildRuntimeTestfails if that drifts back. -
PelicanServer.stop()no longer fails on an interrupt the actor system took during its own shutdown. Pekko runsstopScheduler()as a termination callback, and on JDK 21 a plainForkJoinPool.shutdown()interrupts the worker it runs on; Scala boxes thatInterruptedExceptioninto the future's result, so the stagestop()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, andtools/flake-0049the kit that found it. -
stop()waits with a deadline instead of forever. Ajoin()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, andstopAsync().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.
onRefusalwas told_unmatchedfor one, sohttp.server.refusalscounted it against nothing: a capture is decoded insideRouteIndex.match, which knows the endpoint it matched and throws to a caller that does not. The newspi.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, aRuntimeException, soRetryPolicy's default — retry anIOException— never fired for a connection failure throughPekkoHttpTransport. It now crosses as anIOExceptionwith 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
PekkoHttpTransportcall 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 byrepeated()orcommaSeparated()carried a scalar default into a slot typedList<T>— aClassCastExceptionin the handler, far from the declaration; the working order isrepeated().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 namingpathParamand the/spelling. AnderrorResponseskipped 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 withURLDecoder;/tags/c++now reaches a handler asc++. Literals are matched decoded too, so/users/%61dminfinds 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%2Fbis one captured value and never two segments and a different route. -
?qon 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 setsexplicitNulls = false. Without it, a nullable property with no default wasrequiredto the decoder and optional in the published schema, so a caller following the document got a 400 fromKotlinxCodecsand 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.
FormShaperead only the string spelling oftype, so the["integer","null"]OpenAPI 3.1 publishes for anInt?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 —
UndeclaredResponsewhere 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 isParams.(String) -> User, neverFunction1<String, User>, and a PekkoSourcebuilt 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 inAGENTS.md, as the build has, andFunctionalStyleTestjudges fourteen modules rather than the eleven the reference claimed.
-
Health checks (spec 0068).
pelican-health:health { live(...); ready(...) }gives/health/liveand/health/readyas ordinary endpoints, public, taggedhealthand in the document. Checks run in parallel, each under its own timeout, and the body isapplication/health+json: 200 forpassorwarn, 503 forfail. A check markedcritical = falsethat fails is awarn, and a check'soutputreaches the body only withdetail = true. Built in, on the JDK alone:jdbc(dataSource), which borrows a connection and asks the driverisValid,diskSpace(path, minFreeBytes),heapHeadroom(minFreeBytes)andnoDeadlockedThreads(). -
+jsonmedia types are JSON (spec 0068).media<T>("application/problem+json"), or any other+jsontype, is written by the API's JSON codec with nocodec(type, mediaType)override, as RFC 6839 says it is. The check also ignores case now, soApplication/JSONis 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 a404carries 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:writeToleaves 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-wiremocknow takespelican-test, for the request builder the export reuses rather than copies; it is core-only itself, andDecouplingTestallows it anchored to its version sopelican-test-pekkois 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 aoneOfof the tagged schemas and adiscriminatormapping 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.ErrorOutputandErrorSpecgainedtaganddiscriminator; 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 atdocsPath, 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:optionsis passed toRedoc.initas it is (theme,expandResponsesand the rest),logo(url, altText, backgroundColor)setsx-logoon the page's copy of the document while/openapi.jsonstays as it was, andstylesheet(url)links the fonts a theme names.redoc { }gives the same look to the page atdocsPathwhenui = DocsUi.Redoc. A reference on the docs or document path is refused, and so is aredoc { }look with Swagger UI as the page.redocHtmltakes aRedocLook; 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, asmediadoes for a success; a health probe's 503 isapplication/health+json. The server sends it with that Content-Type, the document lists it under that media type, andApiClient.outcomereads it back as the declared failure through the codec for that media type, which answers any+jsontype as JSON.ErrorOutputandErrorSpecgainedmediaType,application/jsonunless declared otherwise. A tagged failure must be JSON or a+jsontype, 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 theHealthReport, where it was a second success: a client reads a failing probe back asErr(report)instead of throwing, and the document describes the 503 as a check failing rather than as "Success.". The probes areEndpoint<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 withorFail errorJson<T>(…), orerrorMediafor 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
constlisted 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 aoneOf, 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. AoneOfthat became one schema is compared the other way round. Between twooneOfs oranyOfs, 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 astubFileexports 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 andWWW-Authenticatebefore the handler runs, requires the scheme in the document, and hands the handlerthis[caller].optional()for endpoints open to anyone. What verifies a scheme is bound on the API withauthenticate(scheme, by), and an unbound one fails at startup.pelican-testaddsTestCallers,Api.inMemory()andApiClient.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 admitsnoneor HMAC. Groups from a claim. An unreachable provider is a 503. -
Signing in to pages (spec 0061).
oidc(...).signIn(clientId, callbackUrl, sessionKey)gives/login,/callbackand/logout(authorization code with PKCE, state and nonce), an encryptedHttpOnlysession cookie, anauthenticatortaking a bearer token or that session, and aguardfor 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 sentCache-Control: no-store. -
Acting as someone else (spec 0061).
pelican-oidcreads RFC 8693'sactclaim intoIdentity.actor, andsignIn.actAs(credentials, subject, until)/stopActing(credentials)give theSet-Cookiefor a page session acting as someone, which lapses back to the person signed in. -
pelican-arrow: anEitherwith several declared failures (spec 0060).either.toOutcome { left -> declared(...) }names the declaration eachLeftbecomes, and aRightisok. The lambda can only answer a failure. It replaces thefoldthat spec 0036 had every such handler write. -
Pages served beside an API (spec 0059).
pages = pages("ui")inapi's block serves a classpath directory:/isindex.html,/xisx,x.htmlorx/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),verifyandcalls. 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.PelicanWireMockExtensionis the JUnit 5 form. See spec 0056. -
pelican-streams, a streaming body as a stream that names its failure.rows.toStream()reads aStreamIn<T>as aStream<Nothing, T>fromio.github.matthewjones372:lark-stream, where a row that cannot be understood is a declaredEin the type rather than a thrown exception a materialised stage reports later.toOutcome(failure)is the way back: it takes theExit<E, A>that lark'srun(system)completes with and answers theOutcomea handler returns —Doneis the value,Failedis the endpoint's own declared error, which is what fixes the status, andDiedis rethrown, because a throwable nobody declared is what this library throws rather than returns. The element bound isT : 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-streampinspekko-stream_2.13as anapidependency, so taking it normally would republish a Scala suffix through the back door and undo the decisionpelican-pekkohad already made. A consumer declareslark-streambeside the Pekko it already declares, andDependenciesTestasserts 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 samedocsPath, with the same content type and CORS headers. The page isredocHtml(title, specPath, spec)inpelican-openapi, besideswaggerUiHtmland taking the same two forms: fetch the document fromopenApiPath, 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 setui.ui = DocsUi.Redocwithoauthis refused where it is written rather than ignored — Redoc sends no requests, sodocsOAuthwould configure a flow nothing reads — and the refusal names both. The bundle comes fromcdn.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. -
filteredByputs a filter over some of the endpoints rather than all of them.filter(...)on theApiruns for everything in it, so a service with a public group and an authenticated one had to write a membership predicate intoonlyWhen, split into twoApivalues 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 oneApi. The API's filters stay outermost. It is folded byhandlerFor, 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,onlyWhenreadingp.endpoint?.securityis 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)andok(json<Order>())both type-check — a declaration is a value — and then fail several lines later asOutcome<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.DoesNotCompileTestpins the message. -
err(value), the failure-sideok. The single declared failure, carryingvalue, for the endpoint that declares one failure and would rather not name it on every return. With several declared failures a bareerris anUndeclaredResponsewhere 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 bareokalready was.Outcome.Err.declaredbecomes nullable to carry the unnamed form; source and binaries built against the old shape are unaffected. Deliberately noterror: that is the standard library's throw, and taking the name would silently turn everyerror("message")in a handler into an unused value. -
pelican-arrow. Arrow'sEitherinto Pelican'sOutcomeand back:either.toOutcome()reads aRightasokand aLeftaserr,toOutcome(failure)names the declaration for an endpoint declaring several, andoutcome.toEither()is the way back on the client side. Core plusarrow-coreand nothing else, asserted by the usual dependency test. -
The names the
.apidumps cannot see are pinned by compiling them.StillCompilesTestcompiles 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. Renamingjsonacross this repository leaves every.apifile byte-identical and every other test and gate green. -
The refusals a request metric cannot see, reported.
onRefusal(...)insideapi { }takes aRefusalObserver, told about each request refused before the filter chain was entered: a parameter or body that would not decode, aContent-Typenothing declared, anAccepttaking nothing the endpoint sends, a body overmaxBodyBytes.http.server.requestscounts 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 fromspi.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 aRefusalReason, 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, inpelican-metricsandpelican-metrics-otelunder the same name.onRefusal(refusalCounter(registry))besidefilter(metrics(registry)), orrefusalCounter(sdk)besideopenTelemetry(sdk). Taggedreason(unmatched,decode,body_limit,content_type,accept),status, andpath— the refusing route's template, or_unmatchedwhere nothing matched; the OpenTelemetry attributes arepelican.refusal_reason,http.response.status_codeandhttp.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.mdlists the three things it deliberately does not count. -
UnroutedandUnsupportedMediaType, the two refusals Pelican used to raise as anApiException. 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 catchingApiExceptionfor 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 newmaxFrameBytesis a 413. Both are classified refusals, so they go out in the service's own envelope and reachonRefusalunderdecodeandbody_limit. The document publishes one frame's schema underapplication/x-ndjson—itemSchemaunder 3.2,schemaunder 3.1 — and an import reads both spellings back. A generated client takes aSequence<T>and streams it overClientRequest.Body.Streaming; the typed test client sendsframes(...)buffered, andInMemoryClientTransportrefuses such a call by name for the reason it refuses abytes(...)body. -
maxFrameBytesinapi { }: the largest single frame of a streamed request body, takingmaxBodyByteswhere 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 ofrenderError, 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)insideapi { }writes the built-in refusals — a 400 nothing could decode, a 406, a 413, a 500 nobody described — as RFC 9457application/problem+jsoninstead of{"status":…,"error":…,"detail":…}. The default,ApiErrorEnvelope, is the envelope that was always there, byte for byte. ARefusalRenderersees 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 andinstancecannot echo anything a caller sent. One renderer serves all three backends and the in-memory transport;RefusalsAcrossBackendsTestandThrowingHandlerTestrun 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
defaultresponse — 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 oneApiErrorcomponent. An endpoint declaring its owndefaultJson<E>(...)keeps it. -
An event stream that says where a caller left off.
sse<T>(id = { it.sequence.toString() }, retry = 15.seconds)puts anid:on every frame and opens the stream with aretry:directive of its own — once, ahead of the first event, because a frame carrying nodata:dispatches nothing.idis 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: ansse<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 anidproperty, required exactly when the output sends one;retryis 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()onParams, theLast-Event-IDa 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 onParamsrather 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'sidcarries the contract in words. -
Clients that can pick a stream up again. A generated method answering
text/event-streamtakes a trailinglastEventId: String? = nulland sends it as the header, and theStreamed<T>it returns reportslastEventId— the id of the last event handed over, so it is read after the sequence rather than off the response.pelican-test'scollect(...)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 aReport; 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. NoAccept, 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 samenegotiated(...), and the generated client grows one method per rendering (exportReportAsJson,exportReportAsCsv), each sending theAcceptthat asks for the one it decodes. -
media<T>("text/csv"), a response written as a media type that is not JSON, andCodecFactory.codec(type, mediaType), which is where the writer for it comes from. The default answersapplication/jsonwith 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 theCodecsanApiis given;example/securedhas one. -
pelican-mcp-server, and the tools served.pelican-mcpderived 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, revision2025-11-25:initialize,notifications/initialized,tools/list,tools/call,ping— over stdio withmcpServe(api), andpelican-pekko-mcp,pelican-http4k-mcpandpelican-ktor-mcpmount the request/response half of Streamable HTTP on/mcpbeside the endpoints, exactly as the-docsmodules mount the document. Both drive the sameMcpDispatch, 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 areRouteextensions, andkotlin-sdk-servercompiles againstktor-server-coreeven for stdio — so adopting it would put a Ktor server behindmcpServeon a service running Pekko, and leave the HTTP half mountable on one backend of three. The revision is pinned to that SDK'sLATEST_PROTOCOL_VERSION. Deferred and said out loud: the server-initiated event stream and the session id that goes with it — a GET is answered405— and resources, prompts and sampling.example.mcpis 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 —
mcpDispatchrunning the sameServerEndpointas a tool — castnullto anApplicationCalland 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 themulti-backendbranch when 1.0 narrowed to one client transport; it returns with the branch, as described below. There is nojava.net.httpon Android, so a generated client could only get there throughKtorHttpTransport(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 andokhttp, no Android plugin and no AndroidX — andDependenciesTestasserts 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'scallTimeout, which is the whole exchange and would end everysseresponse that outlived the deadline its client was built with. It raises theInterruptedIOExceptionOkHttp itself raises on a call timeout, so a caller catching a timeout by type catches it either way, andRetryPolicytreats it as theIOExceptionit is. -
Four claims the parity suite made and never asked. A handler that throws answers the same
ApiErroron 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:
watchTerminationon aSource,onCompletionon aFlow, and thefinallyof aSequencewhose walking thread is interrupted. It fails against aStreamingSunHttpthat 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.mdstates it, andBinderNameParityTestnames 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
Jsonsetting the schema derivation cannot see is refused atKotlinxCodecs(...), naming the setting:classDiscriminator,ignoreUnknownKeys,encodeDefaultsandexplicitNullsare what it reads. And a success carrying a payload its response never declared is now anUndeclaredResponsenaming both types, the check a failure already had. -
Every compiler line the manual quotes is now a fixture.
DoesNotCompileTestcompiles 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 —
errorJsonis 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), inpelican-core: aClientTransportthat answers a generated client's requests by calling theApiin memory — the same routing, decoding, filters, handlers and response building a bound server runs, with no port and no mock. Abytes(...)request body and a streamed response that is not aSequenceare 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.
endpoint { }now means no inputs, not the lens form.endpoint(a)is one input andendpoint(a, b)is two, so no arguments should be none; the lens isendpoint(lensInputs) { }, which is a name rather than an absence, andnoInputsis gone. Every affected call site is a compile error — a handler's parameter moves fromParamstoUnit— so nothing silently keeps working and means something else.Fallible<E, T>is gone, replaced byOutcome<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 readsexpected 'Outcome<Problem, Item>'.
- 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-metricsandpelican-metrics-otel. Micrometer meters and OpenTelemetry spans, dimensioned from the descriptions rather than by hand.- A client transport SPI —
ClientTransportin core, with adapters over the JDK'sHttpClient, Pekko's and Ktor's — plus asuspendcall 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
descriptionon a declared success, so two of them stop both saying "Success.". UndeclaredResponse, so a handler returning a response its endpoint never declared is distinguishable atonServerErrorfrom a broken codec.
maxBodyBytescounted 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 nokindon 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
operationIdare refused where the endpoint is described, instead of producing an invalid document and a client that will not compile. ApiSpecsilently dropped one of two endpoints on a route.Apirefused 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.
- A handler can return a failure its endpoint never declared.
Eis pinned to the failure's payload type, not to theErrorOutputthat declared it, so any failure carrying the same type fits — andApiErroris the payload of most of them. It is anUndeclaredResponsewhen 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.
- 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.
orFailputs 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, withshouldBuildfor pinning the URL a caller holds.