Grimm refuses per-request classpath scanning: all the work happens once, at CDI container startup, and the result is cached. This page describes the exact pipeline sequence, the role of the Vauban BCE, and the threading invariants.

grimm-core / grimm-cdi-vauban separation

grimm-core is plain Java. It knows nothing about CDI or JAX-RS runtime — only the spec types (@Path is read as an annotation, not as a handler). It is usable outside any container, for example by a Maven tool that produces an OpenAPI document at build time.

grimm-cdi-vauban is the integration layer: it plugs grimm-core into the Vauban Build Compatible Extension, exposes the /openapi resource, and supplies the Supplier<OpenAPI> consumed by downstream tools.

Build pipeline

Six steps, executed once when the container activates (ModelBuilder in grimm-core), with the result stored in GrimmModelCache:

Diagram
  1. StaticFileReader — searches for META-INF/openapi.yaml, .yml, then .json. Read through the current classloader. Returns Optional<OpenAPI>.

  2. ModelReaderInvoker — if mp.openapi.model.reader is set, instantiates the class (CDI lookup preferred, no-arg constructor as fallback) and calls buildModel().

  3. Annotation source — compile-time $$GrimmModel fragments contributed by grimm-processor are folded in first (ContributionRegistry + FragmentMerger); AnnotationScanner then walks the remaining application classes recorded by the BCE — @Path resources as well as @Schema POJOs and the Application subclass — and builds a partial OpenAPI. The output is tagged by AnnotationSource (sealed sub-type of ModelSource).

  4. ModelMerger — merges the three ModelSource instances (sealed: StaticFileSource, ReaderSource, AnnotationSource) into a single OpenAPI, with the spec priority annotations > reader > static file.

  5. ConfigApplier — applies the configuration overrides on the merged document: mp.openapi.servers, mp.openapi.servers.path.<path>, mp.openapi.servers.operation.<operationId>, and the mp.openapi.schema.<FQCN> schema overrides (registered before the scan so that references resolve, applied here).

  6. FilterInvoker — if mp.openapi.filter is set, loads the OASFilter (CDI lookup or no-arg constructor) and walks it across the tree. The filter is dispatched recursively via a typed visitor that `switch`es on the sealed model types.

The final OpenAPI is then stored in GrimmModelCache: a one-time initialisation guarded by a ReentrantLock, published through a volatile field.

Vauban BCE: GrimmExtension

The class discovery lives in io.vidocq.grimm.cdi.GrimmExtension, a Build Compatible Extension that uses exactly two CDI 4.1 Lite hooks — no @Synthesis, no synthetic bean:

@Discovery
public void resetForNewDeployment(ScannedClasses classes) {
    // One container boot = one deployment: drop everything collected
    // by a previous boot in the same JVM before this deployment's
    // @Enhancement phase runs.
}

@Enhancement(types = Object.class, withSubtypes = true)
public void registerApplicationClass(ClassConfig classConfig) {
    // Records every application class found by the CDI scanner —
    // spec §4.4: the annotation scan covers all application classes,
    // not only @Path resources. Grimm's own classes (io.vidocq.grimm.*)
    // are skipped so the /openapi endpoint never documents itself.
}

The recorded class names are handed to the pipeline through plain CDI producers: GrimmConfigProducer @Produces the GrimmConfig (read from MicroProfile Config) and the ScannedTypes holder built from GrimmExtension.discoveredTypes(). GrimmModelCache is a regular @ApplicationScoped bean — no synthesis involved — whose @PostConstruct runs the pipeline once; getDocument() simply returns the cached reference.

Threading model

One-time locking, volatile reads

GrimmModelCache stores the document in a volatile field, initialised once in @PostConstruct behind a ReentrantLock (double-checked: initialize() is idempotent and subsequent calls are no-ops). After that build, getDocument() is a single volatile read on the fast path — the lock is only re-taken to lazily create an empty skeleton if the cache was never initialised. No synchronized anywhere, so a virtual thread never pins its carrier. The BCE guards its discovered-names set the same way, with a ReentrantLock held only during the container build phase.

Virtual threads for potentially blocking operations

Invoking an application-supplied OASModelReader or OASFilter may, in theory, trigger blocking calls (file or database reads). When the host runtime (Cassini + Chappe) runs on the default virtual executor (Executors.newVirtualThreadPerTaskExecutor()), these invocations inherit the virtual context — Grimm creates no platform-thread pool of its own.

No ThreadLocal

Per the module’s CLAUDE.md contract, production code uses no ThreadLocal and no synchronized. When shared state is needed, ConcurrentHashMap, ScopedValue or ReentrantLock are preferred.

YAML and JSON serialization

grimm-core ships two hand-written serializers (no SnakeYAML, no Jackson):

  • JsonSerializer — emits raw JSON 2020-12, dependency-free.

  • YamlSerializer — emits OpenAPI 3.1-compatible YAML 1.2 with two-space indentation and block scalars for multi-line strings.

Symmetrically, JsonDeserializer and YamlDeserializer consume the static files.

OpenApiModelMapper and OpenApiValueMapper translate between the serialised tree and the internal OpenAPI model.

/openapi endpoint

OpenApiResource is an @ApplicationScoped JAX-RS bean in grimm-cdi-vauban. Its constructor injects GrimmModelCache. The getOpenApi(format, accept) method:

  1. Resolves the format via ?format= (priority, spec §2.3) then Accept (spec §2.2, YAML default).

  2. Reads the document from the cache.

  3. Serialises it to YAML or JSON depending on the resolved format.

  4. Returns a Response with the matching Content-Type.

No lock is taken; serialisation itself can parallelise safely.

Further reading

  • Concepts — sources, merge, configuration.

  • TCK — execution and status.

  • Reference — annotations and MP Config keys.