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. ModelReaderInvoker — if mp.openapi.model.reader is set, instantiates the class (CDI lookup preferred, no-arg constructor as fallback) and calls buildModel(): the starting model.

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

  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). NEW The processor leaves to the runtime scan any class with a jakarta.validation.constraints. or javax.validation.constraints. annotation on an operation parameter, as it does for the other constructs outside its subset, so the constraints the scan derives (minimum, pattern, …) reach the document.

  4. ModelMerger — merges the three ModelSource instances (sealed: StaticFileSource, ReaderSource, AnnotationSource) into a single OpenAPI, with the spec priority annotations > static file > reader (the static file overrides the conflicting elements of the reader model, the annotations those of both). NEW The reader is invoked before the static file is read, and the merge order follows (it was static file, then reader, up to 0.3.0); see How the sources combine.

  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.
}

NEW The recorded class names belong to the extension instance, so to one container. Its @Synthesis phase registers a synthetic ScannedTypes bean whose parameter lists them, and ScannedTypesCreator resolves them in the container, through the application’s class loader. The creator also adds every resource class grimm-processor described in the application’s META-INF/services/io.vidocq.grimm.spi.gen.OpenApiContribution: in annotated discovery mode a @Path class with no bean-defining annotation is not a bean, so the extension never sees it, and its generated description is what puts it in the document. Up to 0.3.0, the names lived in a static set, shared by every container of the class loader (grimm#22).

GrimmConfigProducer @Produces the GrimmConfig (read from MicroProfile Config). GrimmModelCache is a regular @ApplicationScoped bean, which takes the ScannedTypes through an Instance (the synthetic bean only exists once the extension ran) and 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.