Architecture Overview
For contributors changing CoSec and integrators who want to know how it works inside. This page explains how the modules depend on each other, how a request travels through the coroutine pipeline, how the engine finds and evaluates policies, how policy snapshots reach every instance, how the control plane publishes them, and which invariants every change must keep. What a decision means (deny first, default deny, fail closed, break-glass) is in the security model; this page explains how the code keeps those promises.
Design goals
- One decision, many transports. WebFlux, WebMvc and Spring Cloud Gateway must answer the same request identically, so every decision and every status code is made once, in transport-independent code.
- A pure decision. The engine is a synchronous function over a request and an immutable snapshot: no I/O, no clock, no threads. Everything that needs I/O (credentials, memberships, attributes, rate limits, audit) happens around it, in the pipeline.
- Deny wins, errors deny. Adding a policy can never widen access past a matching deny, and data that cannot be read never permits.
- Fast on the event loop. A request whose data is already in memory finishes on the thread that received it, without a dispatch or a timer. A request that must wait for I/O suspends, and never blocks a thread.
- One writer. Policy documents change only through the control plane (or
cosec-migrate, which writes through the same store), as revisions that are checked, compiled and optionally shadowed before they go live. - Pay for what you use. An application pulls in only the integrations it asks for.
Module layering
Modules sit in group directories: api/ (annotations, api), core/ (model, engine, spi, runtime, control), backends/ (cocache, kafka), edge/ (webflux, webmvc, gateway, openapi), extensions/ (jwt, social, login, ip2region, micrometer, opentelemetry, cosid), host/ (spring-boot), migration/, test/, bom/ and platform/ (gateway-server). A Gradle project is named after its directory (:engine, :spring-boot); its artifact keeps the cosec- prefix.
Arrows point at what a module depends on; the dotted ones are modules the starter wires when the application adds them. The rules behind the picture:
cosec-modelandcosec-engine(directoriescore/model/andcore/engine/) are the pure core: ids, bindings, memberships, the condition AST, scopes and decisions in the model; action and resource patterns, the policy compiler and the flat engine in the engine. They depend on the Kotlin standard library only.EnginePurityTest(ArchUnit) fails on any reference to Reactor, Spring, logging, I/O, threads or clocks, andDependencyRulesTestfails on any other resolved dependency.ScopeConstructionTestchecks that onlyScopeResolverconstructs a resolvedScope.cosec-api(directoryapi/api/) holds the contracts applications compile against:CoSecPrincipal,CompositeToken,SecurityContextandAuditEvent. It depends on the Kotlin standard library alone. Changing it is a breaking change.cosec-annotations(directoryapi/annotations/) holds the stability markers (Stability levels).@InternalCoSecApiis an opt-in marker at error level: a declaration carrying it is public only so CoSec's own modules can reach it, and has no compatibility promise. Applications do not opt in;SourceRulesTestfails when an opt-in appears outside CoSec's own Kotlin sources.@ExperimentalCoSecApi(an opt-in at warning level) marks public API that a minor release may still change, and@CoSecSpithe interfaces applications implement.cosec-spi(directorycore/spi/) holds the ports a request or a store calls, with their values and in-memory implementations, underme.ahoo.cosec.spi.*: the snapshot store (SnapshotDocumentStore), memberships, token verification and revocation (TokenVerifier,RevocationReader,TokenStore,TokenRevoker), API keys, rate limiters, attribute providers, authenticators, the forwarded identity, the audit sink and the id generator (IdGenerator, with its defaultRandomIdGenerator). It depends oncosec-model,cosec-api,cosec-annotationsand kotlinx-coroutines only, so a third party implements a port against it alone. A port whose signature needs a layer's types stays with that layer:DecisionListener,RequestGate, the shadow window ports incosec-runtime;ControlStoreandChangeListenerincosec-control(Extending).cosec-runtime(directorycore/runtime/) is the data plane: the coroutinePipeline,StatusMapping,AuthorizationChecker, the snapshot documents and their codec, compiler and format descriptors (which generateschema/*.schema.json),SnapshotSource, route maps, token verification, the built-in authenticators and the logging audit sink. Its one Jackson mapper,CoSecJson, is internal (@InternalCoSecApi), and no Jackson type appears in a public signature ofapi/orcore/(JacksonSignatureTest). Coroutines stay in this module and the adapters, Reactor only at the adapter bridges:RuntimeBoundaryTest(ArchUnit) fails when code inruntime/,engine/ormodel/referencesreactor.*, when a data-plane SPI signature carries a Reactor type, whenruntime/references Spring, the Servlet API or a coroutine bridge (kotlinx.coroutines.reactor,runBlocking),GlobalScopeorDispatchers.IO, or when it callsrunCatching, which swallows cancellation.cosec-control(directorycore/control/) is the control plane: revisions, change requests, shadow windows, approvals, break-glass, simulation and analysis. It is a library that a host wires in.ControlBoundaryTest(ArchUnit) imports the module's own compiled classes and fails when one of them is outsideme.ahoo.cosec.control, references Spring,reactor.*, the coroutine or Reactive Streams bridges or a Redis client, or callsrunBlocking,Dispatchers.IOorGlobalScope.- Every other library module adapts exactly one technology: a web stack (
cosec-webflux,cosec-webmvc,cosec-gateway), Redis through CoCache (cosec-cocache: snapshot store, control store, revocation, rate limiter, shadow reports), Kafka (cosec-kafka), login and token issuing (cosec-login), JWT (cosec-jwt), OAuth (cosec-social), OpenAPI, CosId 5 ids (cosec-cosid), or an observer of decisions (cosec-opentelemetry,cosec-micrometer,cosec-ip2region). An adapter never decides; it translates. cosec-spring-boot(directoryhost/spring-boot/) wires everything. It bringscosec-runtime,cosec-loginandcosec-jwt; every other module iscompileOnlyinhost/spring-boot/build.gradle.kts, and its auto-configuration is guarded by@ConditionalOnClass, so it is active only when the application adds the module. A module that a turned-on feature needs is checked at startup (STA-1,CoSecModuleCheckAutoConfiguration): a forgotten adapter would otherwise leave a web host without a filter, so startup fails and names the artifact. The modules and what each needs are listed in the introduction.cosec-testkit(directorytest/testkit/) runs scenarios against an application's own documents in JUnit 5. It depends oncosec-runtimeandjunit-jupiter-api(both part of its API). It holds the engine level of the scenario format, whichconformanceruns too, so the suite and the applications' tests evaluate scenarios one way.cosec-migrateconverts 7.x data into 8.0 documents and publishes them; no application depends on it.cosec-gateway-serveris a ready-to-run gateway built from the starter; it is never published.conformance,architectureande2eare test-only modules and are never published either.
architecture (directory test/architecture/) holds the structural rules that scan several modules (RFC 0011 §5):
DependencyRulesTestreads the declared and resolved main dependencies of every project, which the root build'sarchitectureFactstask writes, and fails when a declared dependency points outwards (the reviewed exceptions, such ascosec-kafkaoncosec-runtimeforDecisionListener, are listed with their reason inDependencyRules), whencosec-annotations,cosec-api,cosec-modelorcosec-engineresolves anything but the Kotlin standard library (cosec-spiadds coroutines), whencosec-runtimeorcosec-controlresolves Reactor, Spring, the Servlet API or a Redis client, or when a project other thancosec-cosidand the starter declares or resolves CosId, or another build script names it (a BOM importing CosId included): CoSec makes its own ids (IdGenerator), so that an application can use any CosId version.SourceRulesTestscans the compiled classes: Reactor only in the edge and the host (andcosec-cocache, whose reactive Redis client the suspending ports await); no Spring, Servlet API or Redis client inapi/andcore/; the backends and extensions use only their own Spring technology; the backends reach intocosec-runtimeandcosec-controlonly through the listed ports andCoSecJson; onlycosec-cosid's and the starter's sources name CosId's packages; a package belongs to one module; opt-ins to the internal API only in CoSec's own sources.JacksonSignatureTest(no Jackson type in a public signature ofapi/orcore/, and no Reactor type in one ofcosec-cocache, which reads Redis reactively inside), the purity and boundary tests above, the one-decision-path and token rules, the project list (ProjectListTest) and the version catalog.ApiVariantTest: a library module's Gradle API variant (usagejava-api, what an application compiles against) carries the CoSec module whose type a public class extends or implements, and Jackson where a public signature names it. The module's own tests see its implementation classpath and cannot tell.SpiMarkerTest: every public interface ofcosec-spithat an application can implement, and every extension point Extending lists, carries@CoSecSpi, and the marker is on nothing else.
The public API itself is held by the build, not by review (RFC 0011 D4): every published library module but cosec-migrate (a command-line tool) keeps its ABI in a dump, api/<project>.api in its directory, and its check runs the Kotlin Gradle plugin's checkKotlinAbi, which fails when a public signature changed without the dump. @InternalCoSecApi declarations are not in the dumps. From 8.0 GA on, a removed or changed line of a dump is a breaking change.
Request path
Pipeline.evaluate in Pipeline.kt is one suspend function from the request's facts to an Outcome. The adapters (PipelineWebFilter, PipelineServletFilter, PipelineGatewayFilter) only read the request into HttpFacts, call it, and write what it returns. They depend on RequestGate, the interface Pipeline implements. The other RequestGate is IdentityGate, which a service behind a CoSec gateway uses instead (cosec.pipeline.mode=gateway): it reads the caller from the CoSec-Identity header the gateway forwarded, with no authenticator, snapshot, engine or listeners, and builds its outcomes through StatusMapping too, so both gates answer alike on every adapter. Why that needs a network boundary is in Trust boundaries.
- Read. The adapter reads the method, raw path, query, headers, cookies, client address (through the configured
X-Forwarded-Fordepth) and host once, and starts the pipeline (step 1). On WebFlux and Gateway the request starts on the event loop without a dispatch; after a real suspension it continues onDispatchers.Default, never on a client library's I/O thread. On WebMvc the bridge isrunBlockingon the servlet thread. - Route. A path that does not normalize, or an ambiguous
CoSec-*parameter, is a400(step 2). A route map turns a mapped request into aservice:verbaction on a typed resource; any other request ishttp:<METHOD>onhttp:path:<path>. - Authenticate. The
Authenticatorturns the token or API key into a principal (step 3). A rejected credential is evaluated as anonymous; an authenticator that fails or times out is a503. - Memberships and stage A. Stage-A attributes, those that decide what is loaded such as the blacklist, are fetched together with the memberships (steps 4 and 5). Memberships come from the token's claims, or from the
MembershipProviderfor an API key and for a token that carries the provider marker, narrowed to a key's cap. A principal the provider does not know or knows as deactivated refuses the credential, and the request is decided as anonymous. A membership source that fails, times out or answers the wrong kind of memberships is a503, never "no roles". - Scope.
ScopeResolverturns the credential's app and the selected space into a checked scope (step 6): the tenant is the principal's (the default tenant when anonymous); the app is the one the credential declares, and a service bound to an app (cosec.authorization.scope.app-id, or on a gateway a route'scosec-app-id) rejects a credential declaring another or none; a space other than the default needs a role in it. A rejected scope is a deny (trust boundaries). - Snapshot view. The snapshot source answers the view (platform, app and tenant documents) from memory, or loads what is missing (step 7). A blocked request loads only the platform document.
- Stage B. When a provider declares stage-B attributes, the engine's
plannames the external attributes this request could read, and only those are fetched, each provider in parallel with its own timeout (steps 8 and 9). The plan holds those of the candidate statements and their policies' conditions, of the rate limit rules, and of the boundary that holds the request's tenant (ATR-8). A provider that fails makes only its own attributes unavailable. A check (AuthorizationChecker) plans its own action and resource and fetches every planned attribute, from stage-A and stage-B providers alike. - Decide. The engine answers
Permit(with obligations),DenyorIndeterminate(step 10). - Obligations. A permit's rate limit obligations run in order (step 11). The first exhausted rule is a
429; a rule that cannot be counted is a503when it is strict and is skipped otherwise. - REQUIRED listeners. They see the outcome before it is returned (step 12); a failure turns only a permit into a
503. A permit through a break-glass binding needs at least one REQUIRED listener, or it is a503. - Status mapping.
StatusMappingis the only place that builds anOutcome: status, headers and theapplication/problem+jsonbody (step 13). At a gateway the outcome also names the headers to forward: the verifiedCoSec-Identityon a permit for an authenticated principal, and otherwise none, so the adapter removes any the client sent. The adapter writes it, or lets a permitted request continue with its security context (step 14). The status of each outcome and theCoSec-*parameters are documented in Web integration. - BEST_EFFORT listeners. The audit log, metrics, tracing, the shadow listener and, by default, Kafka get the final event through their own bounded queues, off the request path (step 15).
Every SPI call goes through one wrapper: it applies the call's timeout (cosec.pipeline.* and cosec.authorization.*, see Configuration; a snapshot load is bounded by cold-load-timeout instead), turns any failure into a value, and lets only an external cancellation through. The whole evaluation has a deadline too; past it the request is a 503. Timers are armed lazily: a request that never suspends arms none. An exception nothing modelled is a 500. A client that goes away cancels the coroutine, and nothing is written.
AuthorizationChecker reuses the second half of this path (view, attributes, engine) to check further actions in code as the principal of a permitted request. A check runs no obligation and no listener, so it is neither rate limited nor audited, and a permit that only break-glass gives is not a permit there.
The flat engine
The engine (FlatEngine.kt) evaluates one request against one SnapshotView: the compiled platform document, plus the tenant's document and, when the scope has roles in an app or the tenant's document binds one of the app's permission policies to Anyone or a principal (a set the compiled tenant document holds, so deciding it is one lookup), that app's document. It reads no clock (the request carries its time) and does no I/O (external attributes arrive with the request). The view must belong to the request's tenant and app; the engine refuses any other.
Candidate order
The engine first enumerates the policies that apply to the request, the candidates (CandidateOrder.kt):
| # | Candidates |
|---|---|
| 0 | The built-in blacklist rule cosec:blacklist, which denies a blocked request. |
| 1 | Platform bindings to anyone, by policy id. |
| 2 | Tenant bindings to anyone, by policy id. |
| 3 | For an authenticated principal: its attachments in claim order, then its principal bindings in the platform and the tenant document, by binding id. For a credential with a role cap, DENY candidates only: a cap removes ALLOWs, never DENYs. |
| 4 | Role bindings of the app document, then of the tenant document: by the scope's roles in claim order, and within a role in document order. |
| 5 | For an authenticated principal: its break-glass bindings, last. |
A binding is a candidate only in its app and space, and only before it expires. An attachment or binding that names a policy the snapshot does not have, or one that both the tenant and the platform define with different content, is recorded as missing or ambiguous; it does not silently disappear.
Evaluation
The decision depends on the set of candidates, never on their order; the order only decides which statement is reported when several match. At a high level:
- Every candidate's DENY statements are checked first. The first match is the decision.
- Without a matching DENY, anything that could hide one (a DENY that failed, a missing or ambiguous policy, a document or attribute that could not be read) makes the decision
Indeterminate. - Only then are the ALLOW statements checked; the first match is a
Permit. Nothing matched is aDenywith no statement (the implicit deny). - A permit for a tenant held by a platform boundary stays a permit only if a boundary statement also matches.
The exact rules, including break-glass and the boundary, are in the security model. A policy with many statements is indexed by action, resource and condition keys, so a candidate costs only the statements that could match. A policy that several bindings of one document reach is evaluated once per pass (ALG-9): a binding whose policy id others of its document name too gets a slot when the document compiles, and the evaluator marks the slots a pass has evaluated in a bitset it allocates only when it reaches one, so a document without such bindings costs nothing for it. Path patterns are bounded per document (DOC-4, path pattern bounds). Untraced evaluation records nothing and allocates almost nothing; the traced evaluator, used for decision events at cosec.pipeline.trace-level and for explain, is a separate class so the hot path carries no trace code.
Policy snapshots
Policies reach the engine as snapshot documents, one per key: the platform document, one document per app and one per tenant. They are stored as JSON (schema 2), compiled once per revision, and held in memory as immutable compiled documents. A view is a reference to three of them, so an evaluation always sees one revision of each document (SNP-1, guarded by SnapshotConsistencyPropertyTest).
A document may write roles and permissions in short form (Role permissions). It is stored that way, and RoleExpansion.effective writes it out as plain policies and bindings: the compiler, the grant analysis, the reference index and the diff all read that, so nothing about a decision depends on the short form (ROL-1, ROL-2).
SnapshotSource holds them:
- Hot path.
peekis a synchronous lookup in memory, with no future and no I/O, safe on an event loop. Only a miss goes toload. - Single flight. Concurrent loads of one key share one read and one compilation. Compilation runs on a small bounded pool (
compile-threads); a full queue fails the load closed. Installs of one key happen in the order their loads started (SNP-2,SnapshotInstallOrderPropertyTest). - Bounded memory. The platform document is loaded at startup and never evicted. App and tenant documents are loaded on demand and evicted least recently used beyond
max-appsandmax-tenants; selected ones can be prewarmed. Ids the store holds no document for are kept apart, at most 256 per family, and evict only each other (SNP-7). - Revalidation. Every installed document is revalidated every
revalidate-interval(10 s by default) with a conditional read of the store of record, which transfers no body when the digest is unchanged. It is the only bound on a lost change notification, so it cannot be turned off and is at most 5 minutes (SNP-6). Change notifications from the store trigger a reload sooner. A cold key is read once (possibly from a local copy) and then confirmed by a conditional read at once. - Fail closed. A read that fails while a document is installed keeps the installed revision, and the lag is reported. A document that is present but cannot be decoded or compiled is installed as failed. A cold read that fails or does not finish within
cold-load-timeoutis answered as failed. A key that once had a document and then reads as absent is a failure, not "no policies". A failed document is left out of the view as unresolved, and the decision isIndeterminateunless a DENY matches. With the starter, a missing platform document is unresolved too, so every request fails closed until one is published.
The store is an SPI, SnapshotDocumentStore. With cosec-cocache it is RedisSnapshotDocumentStore: Redis is the store of record, CoCache keeps a local copy for at most local-max-age, conditional reads bypass every local copy, and CoCache's invalidation channel carries the change notifications. Without it, the starter uses an in-memory store. Redis keys, the noeviction requirement and behavior when Redis is unavailable are in Storage; the cosec.authorization.snapshot.* properties are in Configuration.
Control plane
The control plane (cosec-control) is the only component that writes policy documents. Data-plane instances only read them. It runs inside a host application, behind the same pipeline as every other request, and exposes its API under cosec.control.http.base-path.
What makes it safe:
- Revisions. Every publish writes an immutable revision record first, then moves the document's head with one conditional write that succeeds only if the head is still the change's base. Two concurrent publishes cannot both win, and a reader sees the old head or the new one, never a mix. History, diff and rollback read the revision chain.
- Changes. A change request is a base revision plus entry-level operations, checked and compiled with the data plane's compiler. A moved head makes it
CONFLICTEDuntil it is rebased. Each document has one staging slot. - Authorization. Every control-plane action is authorized again as the operator, with
cosec:*actions on document resources, against a snapshot that is confirmed current from the store. The decisions of one change share one confirmation of the documents they read, and the documents are confirmed again at the end: if one moved meanwhile, the decisions are taken again, so a change of a thousand bindings reads them a handful of times, not two thousand (CTL-8). A grant the analysis cannot prove contained is an escalation and needs an approval by another person. - Shadow windows. Instances sample requests (
cosec.authorization.shadow.sample-rate), evaluate them on the staged candidate as well, and report how the outcomes differ (SAME,WIDENED,NARROWED, ...). An instance that cannot compile or evaluate the candidate blocks the window, and a blocked change never publishes. Documents compile against the platform's attribute declarations and the host'sAttributeProviders, so the control host and every instance must register the same providers: a candidate only the control host can compile blocks its window. Publishing requires the operator to acknowledge the numbers whenever anything changed or the evidence was insufficient. - Break-glass bindings are created only through the control plane, with a second approver, an expiry and a REQUIRED change listener.
- Maintenance. Every control-plane instance periodically reclaims old revision records and closes shadow windows (
ControlMaintenanceScheduler). TheControlStorelists what those passes visit (documents(),openChanges()), and a lease (acquireLease,releaseLease) lets one instance at a time do each piece of work (Revisions, history and rollback).
How to use changes, simulation, shadow windows and approvals is in Testing policies; the cosec.control.* properties are in Configuration.
Invariants
Each invariant exists for a reason, and each has tests guarding it. Never weaken a guard to make a change pass. The normative rules behind them are in test/conformance/src/test/resources/rules/, each with its scenarios or guard tests, and :conformance:mutationCheck breaks the rules on purpose (test/conformance/mutants/) to prove a test fails. AGENTS.md holds the same list for coding agents.
| Invariant | Why | Guard |
|---|---|---|
| Deny first. No ALLOW wins over a matched DENY, whatever document, binding or order it comes from. | Otherwise adding a policy can widen access. | FlatEnginePropertyTest (P1' adding a matching deny always denies, the decision is that of the pseudocode), ALG-1 scenarios |
Fail closed. Missing or unreadable data never permits; an error is a 503 or 500, never a 200. The documented exceptions are in the security model. | An outage must not open the gate. | FlatEnginePropertyTest (P9 missing and unavailable data never permit), PipelineTest (indeterminateDecisionIs503, unmodelledExceptionIs500, failingLateOrWrongMembershipsAre503, snapshotSourceFailureIs503) |
One decision path. Status codes and problem bodies are made only in StatusMapping; adapters never see a decision. | Three adapters must answer identically. | OneDecisionPathTest, WebAdapterParityTest |
| No blocking on the event loop. SPIs suspend instead of blocking; store reads and digests run off the caller's thread. | Blocking a Netty thread stalls every request on it. | SnapshotStoreBlockHoundTest, RedisSnapshotDocumentStoreTest (computesTheDigestOfALocalCopyOffTheCallersThread), PipelineBlockHoundTest, SuspendingRedisRateLimitingTest, RuntimeBoundaryTest |
| Authorization is a pure decision. The engine does no I/O and keeps no state; rate limits are obligations of a permit, so a denied request never spends a permit. | Keeps evaluation fast, predictable and explainable; denied requests never use up quotas. | EnginePurityTest, RateLimitObligationTest (engine and runtime) |
| Snapshot caches never expire and throw on failed reads, whatever a CoCache customizer or property says. | Redis is the store of record; expiry or a swallowed error would silently drop policies. | CacheTtlPolicyTest, StrictCacheFailureTest |
| One revision per document per evaluation, installed in load order. | A decision must never mix two revisions of one document. | SnapshotConsistencyPropertyTest, SnapshotInstallOrderPropertyTest |
| Hot-path changes come with a JMH before/after. | Authorization runs on every request. | FlatAuthorizationBenchmark, PipelineBenchmark (review) |
| Public signatures change only on purpose. Each published module's ABI is in its dump; a change that is not in the dump fails the build. | Applications compile against CoSec and implement its SPIs; a signature change nobody noticed breaks them. | checkKotlinAbi (part of check), SpiMarkerTest |
Performance
Two benchmarks measure the hot path:
FlatAuthorizationBenchmark(core/engine/src/jmh) measures the engine alone: three global policies, two attached principal policies and an app with 50 permissions granted to roles, in the scenariosglobal(a global ALLOW matches),role(a permission near the end of the app's list),none(nothing matches) androle-heavy, each with no platform boundary or a boundary of 1 or 8 statements.PipelineBenchmark(test/e2e/src/jmh) measuresPipeline.evaluateend to end, with the same data published to Redis and read through CoCache, a warm snapshot, and the scenariosglobal,role,none,anonymous,blacklist,blockedandlistener.
8.0 must be at least as fast as 7.x in every comparable scenario. That gate is the manual CI workflow jmh-v7-v8.yml: on one runner it alternates 7.x's SimpleAuthorizationBenchmark and SecurityPipelineBenchmark with 8.0's FlatAuthorizationBenchmark (no boundary) and PipelineBenchmark, and fails when any scenario's median 8.0/7.x speed ratio is below 100 %. The run that admitted the 8.0 path (40640046212) passed every scenario: the engine at 364 to 601 % of 7.x, the pipeline at 126 to 219 %.
On an Apple M4, one thread, a warm request through the whole pipeline takes under 2 µs and allocates 3 to 5 kB in the global, role, none and anonymous scenarios. The rounds, conditions and per-scenario numbers are in docs/perf/e2e-slice; other reference results (engine baselines, the platform boundary, the control plane, the gateway's AOT cache) are in docs/perf/.
Measuring
Run a benchmark through Gradle:
./gradlew :engine:jmh -PjmhIncludes=FlatAuthorizationBenchmark
./gradlew :e2e:jmh -PjmhIncludes=PipelineBenchmark # needs Redis on localhost:6379These runs use one fork with one warm-up and one measurement iteration (the defaults in the root build.gradle.kts): enough to see that a benchmark runs, not to compare versions. Gradle properties override the defaults: -PjmhWarmupIterations, -PjmhIterations, -PjmhFork, -PjmhProfilers (comma-separated, such as gc) and -PjmhJavaVersion, the JDK toolchain the benchmark JVMs run on (the code is still compiled for 21).
To compare versions, build the jar and run it with real settings:
./gradlew :e2e:jmhJar
java -jar test/e2e/build/libs/e2e-*-jmh.jar 'PipelineBenchmark.evaluate' \
-f 3 -wi 5 -i 10 -w 2s -r 2s -prof gc -rf json -rff jmh.json \
-jvmArgsAppend "-Dlogback.configurationFile=$PWD/config/logback-jmh.xml"Pass -jvmArgsAppend -Dlogback.configurationFile=… when you run a jar yourself. The jar contains Logback but no configuration, so without it every forked JVM logs at DEBUG and the results measure logging as much as authorization. The Gradle jmh task passes the file already.
For a routine change, jmh-ab.yml compares two refs on one CI runner as head/base ratios (see scripts/jmh-ab/README.md), and jmh-v7-v8.yml compares 8.0 with a 7.x tag.
How much to trust the numbers:
- Numbers taken on a busy machine are not reliable. Other processes (builds, IDEs, Docker, other benchmarks) steal CPU and change JIT and GC timing. Compare before and after on the same machine, one right after the other, and treat differences smaller than the error columns as noise. On a shared CI runner, only ratios from the same job mean anything.
- Compare with the same JDK on both sides: a different JDK changes JIT decisions more than most code changes do.
Next: add your own authenticators, attribute providers, listeners and stores, in Extending CoSec.