Vauban rests on a simple principle: anything that can be computed at compile time is. No hot reflection, no dynamic proxies, no external bytecode library. This page documents the implementation choices that uphold that discipline.

Code generation via Class-File API and APT

CDI 4.1 describes a richly reflective object model: Bean<T>, BeanManager, InjectionPoint, AnnotatedType. Vauban refuses to evaluate that model at runtime. It compiles it.

Two tools generate, at two different moments:

  1. vauban-processor — a javax.annotation.processing.Processor, running inside javac. It generates in the last annotation-processing round, mostly as Java source so the generated provider can instantiate its siblings in-module, and through the Class-File API (JEP 484) where source is not possible. It writes the bean index META-INF/vauban-beans.list for the module it compiles. No ASM, no Byte Buddy, no Gizmo.

  2. vauban-maven-plugin — the generate goal, bound to process-classes. It covers what never went through this compilation: dependency archives and modules built without the processor. It merges the bean index and emits the missing classes as bytecode, since it has no javac.

Both also ship what an in-package proxy needs, and it is worth naming the two files plainly:

  • the pre-generated proxy classes, written as resources under META-INF/vauban/placed/. A resource, not a class, so the build never writes into someone else’s package. At runtime a Vauban class loader defines them inside the produced type’s own package.

  • the list of produced types whose proxy must sit inside their own package, META-INF/vauban/required-opens.list, one binary name per line. The file is named after the problem it records: with a Vauban class loader the list is the loader’s authorisation to define the shipped proxy in that package; without one, the container reads the same list to know which packages it would otherwise have to open. The class loader unions the lists of every archive it manages.

The processor writes both for the module it compiles. Since it never sees an archive compiled elsewhere, vauban:generate writes them too, for the produced types only it can see, and merges into whatever the processor already wrote in the same output directory.

vauban-indexer underneath both is a library, not a tool: the class-file scanner, the bean model and the shared component collector. It walks nothing on its own and writes no file.

The processor generates:

Generated artefact Role

MyBean_Factory

One per bean. Implements io.vidocq.vauban.core.BeanFactory<MyBean>. No-arg constructor. Instantiates the bean via direct constructor invocation — no reflection.

MyBean_ClientProxy

Subclass generated for normal scopes. Forwards every public method to the active context. Where the proxy is generated depends on who owns the proxied type — see Client proxies — one strategy per case NEW.

MyBean$$Intercepted

Subclass generated for beans with interceptor bindings — routes the intercepted methods through the interceptor chain (see which methods it covers).

_VaubanComponents

One per package. Implements io.vidocq.vauban.api.VaubanComponentProvider: instantiates the package’s components in-module (new X(…)), injects fields, creates client proxies and invokes producers, observers, disposers and lifecycle callbacks — no reflection, no opens. NEW Its coverage() declares all of these, keyed as the container asks for them; VaubanContainer.codegenCoverage() reads it to tell, per bean, observer and interceptor, which generator covers each operation and which falls back to reflection — what the Vidocq dev console shows. For a scanned dependency jar, vidocq:generate asks the generator for the same providers (VaubanGenerator.Config#dependencyProviders) and moves them into an enriched copy of that jar, whose descriptor declares them. NEW It builds a bean through its @Inject constructor when it has one, and through a no-arg constructor only otherwise, the constructor the container picks (CDI 4.1 §3.1.1). It used to pick the no-arg one whenever it existed, so the container fell back to reflection, which a named module refuses without an opens.

Every generated class file carries the project’s Java release, never the release of the JDK that happens to run the build: the shared GeneratedClassFile.build pins them to Java 25, class file 69. A build on a newer JDK would otherwise write classes next to javac’s own output that the baseline runtime refuses to load, with UnsupportedClassVersionError at deployment and jlink refusing the image. Weaving is the mirror rule: it patches a class someone else compiled, so it hands back that class’s own version untouched.

NEW The output is also reproducible: the same input gives the same files from one build to the next. The processor and vauban:generate write the entries of each _VaubanComponents in a stable order (beans by binary name, a bean’s Intercepted` right after it, then the client proxies, the producers' proxies, the annotation types and the service file's provider names, each sorted), and the generators that read a class by reflection, the run-time `Intercepted and client proxy ones that vauban:generate also uses, list its methods in a stable order rather than in the order Class#getDeclaredMethods happens to return, which depends on what the JVM loaded before.

NEW A bean method may declare checked exceptions, as CDI allows for constructors, initializers, observers, producers and disposers. The generated source calls those methods directly, so it rethrows whatever they throw without declaring it: _VaubanComponents and MyBean_Factory route the call through a small generic sneaky helper, and the constructors of MyBean_ClientProxy and MyBean$$Intercepted declare throws Throwable. The exception reaches the container as thrown, unwrapped, which is what VaubanComponentProvider#invoke promises. The Class-File path needs nothing of the kind: bytecode has no checked exceptions (vauban#145).

Which methods the intercepted subclass covers NEW

MyBean$$Intercepted overrides every business method of the bean: the ones it declares, the public, protected and package-private superclass methods it inherits, and the interface default methods no class of the bean overrides. The processor’s subclass already covered the inherited protected and package-private methods; the subclass the container generates at run time and the one vauban:generate writes now cover them too, so a call the bean makes on itself to such a method is intercepted as well.

Three kinds of method are not business methods and are never overridden, by any of the generators, which read one list: @Inject initializers, the bean class’s own interceptor methods (@AroundInvoke, @AroundConstruct, @AroundTimeout), and its lifecycle callbacks (@PostConstruct, @PreDestroy, and the EJB @PostActivate and @PrePassivate). A lifecycle callback therefore reaches only the interceptors' own lifecycle methods, never an @AroundInvoke.

A method-level interceptor binding is read from the declaration a call runs. The binding of an inherited method, on a default method as on a superclass method, stops applying once a class of the bean overrides that method. The class-level bindings of an interface never apply. InvocationContext.getMethod() reports that declaration, not the generated $$super$ bridge, and getInterceptorBindings() includes its bindings. The client proxy of a normal-scoped bean forwards the same default methods to the contextual instance.

NEW Every client proxy the processor writes forwards these inherited methods, not only the source one. A bean with private constructors only gets a bytecode proxy built from its elements, which forwards the inherited and default methods as the source proxy does; the proxy built from the index alone, which forwards only what the bean class declares, is left for a bean the compiler cannot resolve. A top-level class whose own name contains $ (Dollar$Service) is named by its canonical name, so its intercepted subclass, its producers' proxies and its annotation artefacts compile, and a normal-scoped producer of a nested class or interface gets a build-time proxy like any other.

NEW An intercepted bean with an @Inject constructor takes its parameters' qualifiers from the bean’s descriptor, as a bean that is not intercepted does, so it is created under -Dvauban.annotations.reflection=forbid.

An inherited member may have a signature, as a member of the bean, that names a type the generated class’s package cannot name: a package-private type of another package, or a private nested type. Java source cannot override such a method; a class file can, by its descriptor. NEW The processor therefore emits that intercepted subclass or client proxy — a producer’s proxy included — as bytecode, with the same emitters the container and vauban:generate use, and only that class: everything else is still rendered as source. The module’s source _VaubanComponents reaches these classes through its own MethodHandles.lookup(), so the module still needs no opens. Such a build used to fail. Two cases remain where a method is left out:

  • a default method shadowed by a superclass method the bean does not inherit, when no interface carrying it can be named from the generated class’s package: the processor’s client proxy and intercepted subclass leave it out, and the processor prints a [Vauban] warning that says what a call does instead;

  • a method whose return type the intercepted subclass may not access: no class outside that type’s package can type the value the interceptor chain returns, so every generator leaves the method out of the subclass, and the processor warns. A call runs it as on a plain instance of the bean, not intercepted. The run-time subclass used to throw IllegalAccessError on every call instead.

Client proxies — one strategy per case NEW

A normal-scoped bean is never handed out directly: the container returns a _ClientProxy subclass that forwards every business method to the contextual instance. Where that proxy is generated, and whether it costs an opens, depends entirely on who owns the proxied type. Vauban resolves this in the order below; the first strategy that applies wins.

Case Strategy Cost

Managed bean — a bean class you compile

The APT emits <Bean>_ClientProxy as Java source in the bean’s own package, and the per-package _VaubanComponents provider instantiates it in-module (new …() and $$setDelegate). Same story for the $$Intercepted subclass.

Zero opens, zero exports, zero runtime class definition.

Producer of a fully-public class — @Produces returning a public, non-final class from another module, even one that knows nothing about CDI

The APT emits the proxy at build time in the producer’s package (never the produced type’s — that would be a split package), registered under the produced-type key the runtime looks up before any fallback. Eligibility requires the produced type to be fully public: public non-final non-sealed class, only public overridable methods, an accessible (public/protected) constructor. The check walks the whole superclass hierarchy (up to, but excluding, java.lang.Object), because a method inherited from a superclass is neither declared by the produced type nor overridden by the proxy: judging only declared methods would hand out a proxy that silently runs the superclass body against its own empty state. NEW

Zero opens, zero runtime class definition (#42, Stage 1).

Producer of an interface

The APT emits a build-time static class implements <Iface> in the producer’s package (InterfaceProxySourceRenderer), forwarding every method — abstract and default — to the contextual instance. No dynamic proxy, no reflection. On the class path (non-APT) a runtime java.lang.reflect.Proxy is the fallback.

Zero opens; zero reflection on the build-time path (a reflect.Proxy only as the class-path fallback).

Producer of a class whose proxy must live in its own package — a package-private or protected overridable member (declared or inherited), or no accessible constructor — when a Vauban loader defines the library NEW

The APT ships the co-located proxy as bytecode in the bean archive (META-INF/vauban/placed/…_ClientProxy.class) and lists the type in the placement manifest (META-INF/vauban/required-opens.list). When the library module is defined by a VaubanClassLoader — under the Java SE launcher or Vidocq.run, which re-layer every module they do not have to keep — that loader defines the proxy into the library’s package on a findClass miss: a loader may define classes in a package it owns, and opens governs reflective access across modules, not this. The proxy forwards non-public members too, inherited ones included. The cdi-proxifier weaves the (ProxyLink) entry constructor into the produced type at definition — and into the superclasses it defines that lack a usable no-arg constructor — then retargets the placed proxy onto it, so the third-party constructors do not run for the proxy (vauban#24). When a superclass the loader does not define blocks that chain, a WARNING says so.

Zero opens, no agent, no rewritten jar; the library is used as shipped.

Same shapes, when no Vauban loader defines the library — a bare boot layer, or a module the launcher or Vidocq.run keeps

Nothing can be placed. RuntimeClientProxyGenerator defines the proxy at runtime into the produced type’s own package, which requires that module to open the package to the container. The container refuses by default and names the remedies, the launcher first. Two levers keep this --add-opens-free (below).

An opens (applied by the container, opt-in) or a jar rewrite.

Producer of a class that is unproxyable outright — final or sealed class, a non-static final method that is not private, abstract class (CDI 4.1 §3.10)

No proxy can be built anywhere, co-located or not. A deployment error, as the specification requires; nothing is shipped.

Nothing helps — the type cannot be normal-scoped.

The two levers that remove the hand-written opens when no Vauban loader defines the library (when one does, placement makes both unnecessary):

  • The container opens the module itself (42, Stage 3b) — opt-in, and on borrowed time [.tag-new]#NEW. The annotation processor lists the produced types that fall to runtime generation in META-INF/vauban/required-opens.list; at boot OpensApplier loads each and — only when it is in a named module and not already open — calls Instrumentation.redefineModule to open its package to io.vidocq.vauban.core, reusing the load-time weaving agent (Client-proxy weaving tiers). No --add-opens, no jar rewrite, and a strict no-op when the list is absent (the class path, and the common module-path deployment).

    This lever is off unless you set -Dvauban.opens.auto=true. Opening another module’s package behind the user’s back is precisely what this project faults runtime CDI implementations for needing — the only difference would be who grants it; and silently acquiring a dependency on dynamic agent attachment is a defect that surfaces late, on a locked-down JVM in production. Left off, the container refuses and logs what is missing together with every way to avoid it. Treat it as a last resort: integrity by default is closing dynamic agent attachment, so prefer a fully-public produced type (nothing to open) or the build-time lever below.

  • Rewriting a copy of the dependency jar (#42, Stage 2, opt-in, last resort). Prefer the shipped in-package proxy above: it needs no copy and leaves the artefact alone. Keep this goal for the three cases placement cannot reach — the application never starts inside a Vauban layer, the library exports nothing so the container cannot instantiate the placed proxy, or the target is a GraalVM native image, where a class defined at runtime is not supported. The goal now says so itself: it warns, per type, when the class loader could have shipped the same proxy. The vauban:enhance-dependencies goal writes an enhanced copy of a third-party jar under target/vauban-enhanced-deps/ in which <Type>_ClientProxy is co-located in the type’s own package — so it can forward package-private and protected methods — and the module-info.class is rewritten to provides the generated component provider. Placed ahead of the original on the module path it resolves with zero opens and no agent — and it survives integrity by default, since nothing is opened, attached or defined at runtime.

    Because the copy is a modified redistribution of someone else’s artefact, it declares itself NEW: the enhanced jar drops the inherited signature files (META-INF/*.SF, .DSA, .RSA, .EC) rather than carrying a signature over content that no longer matches, drops the manifest’s per-entry digests, and records its origin in the manifest main attributes:

    Vauban-Enhanced-From: org.example:libwidget:1.0
    Vauban-Enhanced-Digest: sha256:<digest of the exact source jar>
    Vauban-Enhanced-By: vauban-maven-plugin/<version>

    Declare these copies to whatever SBOM, attestation or reproducible-build tooling your pipeline runs — they are not the artefact their coordinates name. That governance cost, not a technical limitation, is why the goal is opt-in.

A producer whose class type is not build-time proxyable is surfaced at compile time by -Avauban.producerProxy=error|warn|note (default note), naming the reason and the ways to keep zero opens.

When no Vauban loader defines the library, the reflex is MethodHandles.privateLookupIn. It does not help: it is not a way around opens, it is the API that consumes it — it throws IllegalAccessException unless the target’s module already opens the package to the caller. defineHiddenClass(…, NESTMATE) needs the same full-power Lookup; a Lookup donated by the library means modifying the library; and defining the proxy under the same package name in another class loader yields a different runtime package — (loader, name) — so package-private access fails with IllegalAccessError. A Lookup with PACKAGE access is obtainable only from inside the target module, or through opens. The one other door is not a Lookup at all: the class loader that defines a module can define further classes into its packages. That is what placement does, and why it needs the library to be defined by a Vauban loader — the JDK’s application class loader offers no such hook.

The class-typed front-ends build the same neutral ClientProxyShape: the APT source renderer (ClientProxySourceRenderer) and the shared bytecode emitter (ClientProxyEmitter, driven by both the runtime RuntimeClientProxyGenerator and the vauban-maven-plugin for scanned jars — bytecode parity for the producer case, #42 Stage 1.6). The interface case has its own source renderer (InterfaceProxySourceRenderer, implements rather than extends). The APT-source-first rule keeps them aligned; residual drift is tracked in BUG.md (VAU-PRX-002).

Case by case: from your source to a running proxy

The strategy table above says what is generated. This section walks the four situations you can actually be in, and shows what each one prints.

1. Your own code, compiled here

The processor renders the factory, the client proxy, the intercepted subclass and the per-package provider as Java source in the same compilation, and the auto-started javac plugin weaves the side-effect-free entry constructor into the compiled beans once javac has written them.

Nothing is logged. Silence is the success case: nothing reflects into your classes, no opens, no agent. What you can check is on disk, under target/classes and target/generated-sources/annotations.

2. A dependency the processor never saw

Ecosystem jars and modules built without the Vauban processor carry no generated code. At process-classes the vauban:generate goal scans them, merges their beans into META-INF/vauban-beans.list, weaves the entry constructor into what javac wrote, and — for a produced type whose proxy cannot live in the producer’s package — ships that proxy pre-generated and lists the type.

The build says so, and deliberately stops short of promising it will work:

[WARNING] Shipped an in-package client proxy for 1 produced type(s) that cannot be proxied from
the producer's package: [com.acme.lib.FraudScreen]. The Vauban class loader defines them inside the
produced type's own package, which requires the application to start through
io.vidocq.vauban.classloader.Launch or Vidocq.run, and that package to be exported. Started without
a Vauban layer, the container falls back to opening the package at runtime and says so at boot.

The annotation processor prints the same verdict for the module it compiles, naming the obstacle:

[Vauban] Producer of io.vidocq.vauban.example.cdi1015.lib.FraudScreen needs its client proxy inside
io.vidocq.vauban.example.cdi1015.lib (PACKAGE_PRIVATE_VIRTUALS). Its co-located proxy is shipped
under META-INF/vauban/placed/ and the Vauban class loader defines it there when the application runs
in a Vauban layer — zero opens, no agent: start through io.vidocq.vauban.classloader.Launch (or
Vidocq.run). On a bare module path the runtime generates it reflectively, which needs `opens
io.vidocq.vauban.example.cdi1015.lib to io.vidocq.vauban.core;`. […]

3. A third-party module, running inside a Vauban layer

Start through the launcher or Vidocq.run and the loader owns the library’s module, so it defines the shipped proxy into the library’s own package on the first lookup miss. One line tells you the layer exists, and which modules moved into it:

INFO io.vidocq.vauban.classloader.Launch run
Vauban layer ready: [io.vidocq.vauban.example.cdi1015.app, io.vidocq.vauban.example.cdi1015.lib]

The runnable example then prints where each proxy ended up. Two live in the application’s own package, because their produced types are fully public; two live inside the library, placed by the loader:

PaymentGateway proxy : …app.PaymentGateway$$8dad1edd_ClientProxy  (reflect.Proxy? false)
AuditLog proxy       : …app.AuditLog$$386ae28_ClientProxy          (reflect.Proxy? false)
FraudScreen proxy    : …lib.FraudScreen_ClientProxy     (module …cdi1015.lib, VaubanClassLoader)
ReceiptPrinter proxy : …lib.ReceiptPrinter_ClientProxy  (module …cdi1015.lib, VaubanClassLoader)

4. The same application, started without a layer

The bytes are still in the jar, but no loader owns the library’s package, so nothing can define them there. The container refuses to open the package behind your back and lists every way out:

WARNING Vauban needs package io.vidocq.vauban.example.cdi1015.lib of module
io.vidocq.vauban.example.cdi1015.lib opened to io.vidocq.vauban.core for a runtime producer proxy,
and will not do it on its own. Pick one: start the application through
io.vidocq.vauban.classloader.Launch (in a Vauban layer the loader defines the shipped proxy inside
the package itself — zero opens, no agent); make the produced type fully public (the proxy is then
built at compile time and nothing needs opening); run the vauban:enhance-dependencies goal (the
proxy moves inside the dependency, still nothing to open); add the matching `opens … to
io.vidocq.vauban.core;`; or, as a last resort, set -Dvauban.opens.auto=true to let the container
open it at boot through an agent — which integrity by default is closing off.

Ignore the warning and the boot fails at the injection point, with the whole chain named:

jakarta.enterprise.inject.spi.DeploymentException: Failed to create client proxy for normal-scoped
bean io.vidocq.vauban.example.cdi1015.app.Integrations
  Caused by: java.lang.RuntimeException: Failed to define proxy class for class …lib.FraudScreen
  Caused by: java.lang.RuntimeException: Cannot reflectively access …lib.FraudScreen on the module
      path. Either (preferred) provide a generated VaubanComponentProvider for its module […] or
      open the package: `opens io.vidocq.vauban.example.cdi1015.lib to io.vidocq.vauban.core;`
  Caused by: java.lang.IllegalAccessException: module …cdi1015.lib does not open …cdi1015.lib to
      module io.vidocq.vauban.core

5. Last resort: rewriting a copy of the dependency

When no layer is possible — a native image, a library that exports nothing, a launch you do not control — vauban:enhance-dependencies writes an enhanced copy of the jar with the proxy inside the type’s own package. It costs the jar’s signature, so the goal tells you when it was not needed:

[WARNING] enhance: com.acme.lib.FraudScreen only needs its client proxy inside its own package
(PACKAGE_PRIVATE_VIRTUALS). The Vauban class loader ships and defines that proxy without touching
the jar, while rewriting a copy here drops the jar's signature. Keep this goal for what placement
cannot do: no Vauban layer at launch, a package the library does not export, or a GraalVM native
image.

6. When no proxy is possible at all

Three shapes are unproxyable wherever the proxy sits, so nothing is shipped for them and the container refuses to start. The validation runs at build time through the annotation processor and again at boot, and both speak the same language.

CDI deployment validation failed:
  - Normal-scoped bean com.acme.Ledger cannot be a final class
  - Normal-scoped bean com.acme.Report has final method render
  - Normal-scoped bean com.acme.Session must have a non-private no-arg constructor, or declare a
    non-private constructor taking io.vidocq.vauban.api.ProxyLink as its client-proxy entry point
    (Vauban extension, Vidocq/vauban#24)

That block is a jakarta.enterprise.inject.spi.DeploymentException, thrown out of SeContainerInitializer.initialize() with no wrapping of its own: one line per problem, each prefixed with a dash. A DefinitionException carries the same shape under the header CDI definition validation failed:.

Wiring problems are reported the same way, and are worth recognising because they look alike but have nothing to do with proxies:

  - Unsatisfied dependency: field com.acme.Checkout#gateway of type com.acme.PaymentGateway with
    qualifiers [@Default]
  - Ambiguous dependency: field com.acme.Checkout#store of type com.acme.Store. Matching beans:
    [com.acme.SqlStore, com.acme.MemoryStore]

If a bean slips past the build — a class you did not compile, a scope decided by an extension — the runtime refuses at the moment the proxy is requested, with jakarta.enterprise.inject.UnproxyableResolutionException:

Normal scoped bean com.acme.Ledger is final
Normal scoped bean com.acme.Report has final method render
Normal scoped bean com.acme.Session has only a private no-arg constructor

Interception adds its own checks, because an intercepted bean is also a subclass. A managed bean that is not normal-scoped and carries a class-level interceptor binding is checked with the rest of the deployment, in the block above:

  - Intercepted bean com.acme.Audit cannot be final (interception requires subclassing)
  - Intercepted bean com.acme.Audit has final method write (interception requires subclassing)

NEW A bean intercepted any other way is checked when the container wraps it: through a method-level binding it declares or inherits, an interface default method included, a constructor binding, or an @AroundInvoke method of its own. The DeploymentException then says why the bean is intercepted, since its own source may carry no binding at all:

jakarta.enterprise.inject.spi.DeploymentException: Intercepted bean com.acme.Audit must not be
final. An intercepted bean must be proxyable (CDI 4.1 §3.10); com.acme.Audit is intercepted
because of the method-level interceptor binding @com.acme.Logged of com.acme.Journaled.write(),
an interface default method it inherits.

The same explanation follows has final method <name>. and has only private no-arg constructor (unproxyable). A final class used to fail there with a DefinitionException.

None of these has a workaround in Vauban, and that is deliberate: CDI 4.1 §3.10 rules them out, so the answer is to change the type — drop final, add a non-private constructor — or to produce an interface instead, which is proxied without any of these constraints.

7. An IDE run, where the build output was never woven

IntelliJ writes class files after javac has finished, which defeats the javac plugin. The container notices before loading a bean and tells you which tier took over. Inside a Vauban layer it simply weaves at definition. Outside one, it attaches the agent and says so:

WARNING Build output is not woven for 3 normal-scoped bean(s) [com.acme.Checkout, com.acme.Ledger,
com.acme.Audit] — attaching the Vauban load-time weaving agent (typical of IDE builds;
Maven/Gradle/javac builds weave at compile time). Disable with -Dvauban.weaving.loadtime=disabled.
Hint: a trampoline main that re-layers the application (Vidocq: @VidocqMain + Vidocq.run) avoids the
agent entirely

APT pipeline — sequence diagram

Diagram

The whole index, the whole injection grid, every proxy is written before javac finishes its job.

Client-proxy weaving tiers

A normal-scoped bean without a non-private no-arg constructor needs a side-effect-free client-proxy entry point: the synthetic protected <init>(ProxyLink) constructor (Usage — proxyable beans). Annotation processing cannot add members to an existing class (JSR 269 only creates new files), so the marker is woven into the compiled bytes by the Class-File API transformations of the vauban-weaver module — one canonical implementation, applied at three possible moments:

Tier When Covers

javac plugin

COMPILATION finished event, auto-started (Plugin.autoStart()) from the annotation processor jar; the processor publishes a weave plan, the plugin patches the class files javac just wrote

Maven, Gradle, plain javac, any javac-based build — no configuration

vauban-maven-plugin

process-classes

Belt-and-braces for Maven builds; ecosystem jars whose classes do not go through javac (enrichment, sjar packaging)

Load-time agent

Container boot — LoadTimeWeaving detects unwoven normal-scoped beans in the index before any bean class is loaded (resource bytes only), then dynamically attaches vauban-weaver-agent.jar (embedded in vauban-core) and hands it an explicit weaving plan; the ClassFileTransformer applies the same transformation at class definition

IDE builds — IntelliJ’s build system (JPS) flushes hand-written classes to disk after javac finishes, so both build-time tiers see stale files

Details worth knowing about the load-time tier:

  • Why an agent and not reflection — Vidocq application modules keep bean packages fully encapsulated (zero opens, zero exports). Any reflective instantiation trick (ReflectionFactory-style relaxed construction) is blocked by Java Modules; a ClassFileTransformer works below access control and produces bytecode identical to a woven build.

  • Self-attach through a child process — the JVM refuses VirtualMachine.attach() on itself; LoadTimeWeaving spawns java -cp vauban-weaver.jar io.vidocq.vauban.weaver.AttachBack <pid> <jar> <plan>, which attaches back to the parent and loads the agent. The IDE debugger session is unaffected. The JDK prints its standard dynamic-agent warning. The same agent (hence the same Instrumentation) is reused by OpensApplier to open a third-party module’s package when a producer’s type is not fully public (Client proxies — one strategy per case NEW).

  • Plan-driven, no heuristics — the agent only ever touches the classes named in the plan computed from the container’s own index: unwoven beans get the marker constructor, their _ClientProxy companions are retargeted to it.

  • Ordering constraint — adding a constructor is not a valid retransformation, so a class loaded before the attach can no longer be woven. The hook therefore runs inside VaubanContainerBuilder.build() right after the index is final and before bean discovery, and the detection itself never loads classes.

  • In a Vauban layer, no agent NEW — when the application’s classes are defined by a VaubanClassLoader (the Java SE launcher, Vidocq.run, a dist), the loader’s cdi-proxifier transformer applies the same weaving at class definition; LoadTimeWeaving detects the loader in the context class-loader chain and stands down. When the loader also defines a library whose proxies it places, the transformer weaves the listed produced types — which carry no scope annotation — and the superclasses they need, so a placed client proxy does not chain a third-party constructor.

  • Opt-out — -Dvauban.weaving.loadtime=disabled; unwoven beans then fail deployment validation with the regular vauban#24 diagnostic. Explicit -javaagent:vauban-weaver.jar=<plan> is also supported (Premain-Class).

Giving a dependency a module descriptor

A jar with no module-info is an automatic module: its name is guessed from its file name, it reads everything, and nothing can depend on it reliably. jlink refuses it outright. The fix is to give it a real descriptor — which used to mean delegating to ModiTect, a build-time dependency that generates module-info.java and runs a compiler over it.

ModuleDescriptorSynthesizer (in vauban-maven-plugin) does the same work with the JDK alone. There is no generated source and no compiler invocation: the Class-File API writes the descriptor bytes directly, and ModuleFinder answers the only question that matters — which module owns a given package.

Directive How it is derived

requires

Every class of the jar is read and every type name in its constant pool collected — descriptors, internal names, annotation types and generic signatures alike. Each referenced package is attributed to the module that exports it (a JDK module, or a module of the dependency closure; an automatic module owns everything it holds). Packages nobody owns are reported, never guessed at.

exports

Every package the jar contains. A library that used to sit on the class path had everything visible; narrowing that would break its users for no benefit.

opens

Nothing, for an open module — it opens everything already, and the JVM rejects a descriptor that adds an opens on top of that. A closed module spells each package out.

provides

Promoted from META-INF/services/*, minus providers the jar does not itself contain: a descriptor may only provide a class of its own module, and one that does not is unreadable.

uses

Supplied by the caller. Which services a jar looks up cannot be derived from its bytes without a call-graph analysis, and guessing wrong makes the module fail to resolve.

The collection errs towards completeness: a spurious requires costs nothing, a missing one breaks the application at run time. What no static analysis can see — Class.forName on a computed name — is invisible to jdeps too.

The patched jar is a copy, written under the original file name so downstream tooling can substitute it. Signature files are left behind on purpose: adding an entry invalidates them, and a jar whose signature no longer verifies fails to load at all.

Class loading

Vauban owns application class loading, as a single engine with two plugin natures chained before defineClass:

             ┌─ sources ─────────────┐   ┌─ transformers ───────────┐
archive ───▶ │ dir | jar | sjar | …  │ ─▶│ cdi-proxifier | (future) │ ─▶ defineClass
             └───────────────────────┘   └──────────────────────────┘
  • vauban-classloader-spi — the contracts: ByteSourcePlugin (where the bytes of an archive come from — the encrypted-jar support in vauban-sjar is one plugin) and ClassTransformerPlugin (what happens to the bytes before definition — experimental SPI). Both are ServiceLoader-discovered and priority-ordered.

  • vauban-classloader — the engine: VaubanClassLoader resolves each archive through the first source plugin that handles it (plain jars and exploded directories built in) and pipes every class through the transformer chain. The shipped cdi-proxifier transformer applies the vauban#24 weaving (ProxyLinkWeaver, same bytes as a build-time weave — idempotent) at definition; an unwoven bean inside an encrypted archive can be fixed nowhere else, the decrypt → weave composition falls out of the chaining for free. The container’s scanner defines sjar classes through this engine. Diagnostics: transformations are logged, -Dvauban.classloader.dump=<dir> writes transformed bytes, -Dvauban.classloader.transformers.disabled=<names> disables transformers by name, and a transformer failure fails the class load — nothing silent.

  • VaubanLayerFactory — the module-path story: application archives stay off the JVM module path and resolve into a child ModuleLayer whose single defining loader is the engine. Exports and opens keep being enforced inside the child layer; ServiceLoader provides/uses work across it. The Vidocq launcher activates this with -Dvidocq.app.path=<archives> (VidocqAppLayer, installed before anything can touch an application class), and every Vidocq launch vehicle uses it by default: packaged distributions ship the application jar in app/ (scripts boot the runtime, vidocq.package.layer=false opts out), vidocq dev hands target/classes over the same way (vidocq.dev.layer=false opts out), and in-process embedders (the CLI) get the layer from VidocqBootstrap.configure(). An application main is run through the layer via -Dvidocq.app.main — its package is exported to the runtime through the layer ModuleLayer.Controller, the same technique the JDK launcher uses. The load-time weaving agent then stands down — the loader does the weaving, without java.lang.instrument and without the JDK’s dynamic-agent warning. Implementation note for custom layer loaders: the JVM loads layer-module classes through findClass(String moduleName, String name), whose inherited default returns null — both name-based and module-based lookups are overridden. Known limitation: custom JUL log handlers declared in logging.properties must live on the module path — LogManager instantiates handlers through the system class loader and cannot see the layer.

  • The container itself never reflects into application packages: generated _VaubanComponents providers (ServiceLoader) instantiate beans and proxies in-module, which is what keeps opens-free deployments possible.

The layer is also what makes the dev-mode in-JVM hot reload possible (M3): after a successful recompile, vidocq dev signals the running JVM (reload file touch, vidocq.dev.hotReload=false restores the respawn cycle) — the runtime shuts the deployment down, discards the application layer and its loader, resolves a fresh layer over the recompiled classes and boots again. Same process, warm JIT, debugger and dev services untouched. Hosts embedding Cassini across such reloads must discard its static discovery caches (io.vidocq.cassini.runtime.CassiniMaintenance.resetDiscoveryCaches(), called by the Vidocq Cassini extension’s onStop): they are keyed by application Class objects, which a new layer replaces wholesale.

Design study and milestones: tasks/vauban-classloader-universal.md (M1 engine + sjar fold-in, M2 application layer, M2b launch vehicles and M3 in-JVM hot reload are implemented).

Runtime bootstrap sequence

Diagram

The ApplicationContext is built in memory, without opening any package.

Failures are reported, not swallowed NEW

  • A class named in a META-INF/vauban-beans.list that fails to load is still skipped, but with a warning that names it and the likely causes: a split package, or a missing module. It used to vanish without a word, and the only trace was a downstream failure with no cause.

  • A failed field injection reaches the caller. A RuntimeException keeps its type, so a caller catching UnsatisfiedResolutionException or IllegalProductException still sees it, and a checked exception is wrapped in a CreationException that names the field. The container used to log it, leave the field null and hand the bean out, and the failure came back later as a NullPointerException in application code. Bean#getInjectionPoints() likewise throws rather than reporting no injection points.

  • The build compatible extension phase resolves a bean class across the whole deployment: the classes the container holds, the loader set on the builder, then the build’s context loader — inside a Vauban layer, the layer’s loader, which sees the application and the libraries it re-layered. It used to take the loader of the first bean class, which reaches every archive only when the deployment has one loader: under vidocq:dev, where the application’s classes and its libraries are defined by two loaders, CDI 4.1 invokers fell back to reflection for every application bean. The run-time @Enhancement pass resolves classes the same way.

Threading model

Vauban uses virtual threads (JEP 444) in two places:

  1. Async observers — every @ObservesAsync is dispatched on an Executors.newVirtualThreadPerTaskExecutor() internal to the EventDispatcher. No platform-thread pool, no bounded queue.

  2. Request context — when Vauban is embedded in Cassini or Foy, the RequestContext is carried by a ScopedValue (JEP 487) rather than a ThreadLocal. That lets structured virtual threads propagate the context without leaks.

The ThreadLocal → ScopedValue move is documented in the Vauban repo’s CLAUDE.md as a guiding principle.

AOT compatibility

What the container relies on at run time:

  • Generated code for everything that builds a bean — _Factory classes, the per-package _VaubanComponents provider, _ClientProxy and $$Intercepted subclasses — called directly. No bytecode is generated at run time on this path.

  • MethodHandles, through VaubanLookup, for @PostConstruct, @PreDestroy, observer and interceptor methods.

  • LambdaMetafactory to lift the generated interceptor glue into the invocation chain.

What still reflects:

  • Class loading by name. The bean index is a list of names, and discovery turns each into a Class with Class.forName. That needs no opens — a public class of an exported package loads without one — but an ahead-of-time compiler cannot see a computed name.

  • Annotations. Matching no longer reflects: qualifiers and interceptor bindings are compared as AnnotationKey`s built from the index (member defaults applied, `@Nonbinding left out — the members a declaration marks and the ones an extension makes non-binding alike) — the outcome of vauban#70. Injection does not read them either: the qualifiers of a field or a parameter come from the descriptor the index built, not from the member the container holds.

    A module compiled with the Vauban APT also ships what its own annotation types declare — their members, their declared types, their defaults, which are @Nonbinding — together with a reader and a literal class per type, in that type’s own package. Bean#getQualifiers() then hands out the module’s own literal, so even a package-private qualifier costs no reflection and needs no opens.

    A qualifier from a dependency built without the processor is covered by the module that uses it, which carries a literal for it in its own package — provided the type is public, since no other package can implement a package-private interface. That last case, a non-public qualifier in a module compiled without the processor, is the one where a java.lang.reflect.Proxy over index data remains the fallback.

    What is left reflects only where nothing describes the site. A disposer’s non-@Disposes parameters are no longer among them: the descriptor now models them as injection points (Producers and @Disposes). What remains is the AnnotatedType a portable extension passes to getInjectionTargetFactory, which by construction comes from the caller; and a live annotation the index does not describe — an @Inherited one found on a superclass, a literal passed by a build compatible extension or a synthetic bean, the default of a member read from its declaration. The Annotated SPI facades are not in that list: the specification requires them to hand out the annotations themselves.

    -Dvauban.annotations.reflection=allow|warn|forbid (default allow) logs or refuses every one of those, so a deployment can prove it never needs them. forbid throws a type of its own that no catch (Exception) on the way out swallows. See Reference — system properties.

  • Fallbacks. A type no generated provider covers — the class path, a producer only known at run time — is instantiated reflectively, and the container says so at boot.

A GraalVM native image therefore needs reflection metadata for the above today, and Vauban does not generate it. Placed in-package proxies are defined at run time and are not available in a native image at all: vauban:enhance-dependencies is the ahead-of-time path for those (see Vauban in Java SE, Limits).

Project Leyden CDS and jlink images are unaffected: every byte is produced at build time, and the Vauban layer reads modules straight from a runtime image.

Module lookups for trusted extensions NEW

Some extensions must call a member the application never opened. MicroProfile Fault Tolerance is the first: @Fallback(fallbackMethod = "recover") may name a private method, and the implementation lives in another module. Without help it needs opens <package> to <extension>, which is exactly what Vauban removes everywhere else.

io.vidocq.vauban.core.access.ModuleLookups.lookupFor(Class<?>) returns an Optional<MethodHandles.Lookup>: a full-privilege lookup on that one class, so the extension can findSpecial / findVirtual the member and nothing else needs to change.

Where the lookup comes from

The generated _VaubanComponents of the class’s own package. It is written by the processor, or by vauban:generate / vauban:enhance-dependencies into the completed dependency’s package, so in both cases it belongs to the class’s module. Its grantModuleLookup(ModuleLookupGrant) does grant.accept(MethodHandles.lookup()). The container keeps that lookup only if its lookup class is the provider itself and it has full privilege. It then hands out privateLookupIn(managedClass, providerLookup), which is legal without any read edge or opens because both are in the same module. The application writes nothing and opens nothing: shipping the generated provider is the consent, as it already is for in-module instantiation and field injection.

What is handed out

A lookup only for a class the running container, or the container being built, manages, whose package has a generated provider granting one. Nothing for any other class, and nothing once that container is closed. A deployment is registered once its providers are loaded, so build compatible extensions can already ask during @Enhancement.

Who may ask

Only the modules named in io.vidocq.vauban.core’s qualified `exports io.vidocq.vauban.core.access to …. Today that is io.vidocq.heisenberg.cdi.vauban. A new consumer is added there, by name, in review. A provider hands its lookup over only through a ModuleLookupGrant, and only the container can create one: the factory sits in io.vidocq.vauban.api.access, exported to io.vidocq.vauban.core alone. Code that reaches a provider through ServiceLoader cannot make it give its lookup away. On the class path every module is unnamed and nothing of this is enforced, but nothing is encapsulated there anyway.

Providers generated before this method existed grant nothing; the extension keeps its own fallback (for Heisenberg, an opens to io.vidocq.heisenberg.core).

The container uses the same lookup itself to wire an intercepted bean: once the generated provider has created the Intercepted` subclass, its post-construction method `init is called through a method handle found with that lookup, so the bean’s package needs neither exports nor opens (BUG-20261010-02). Without a granting provider, the call falls back to reflection, which needs the package exported to io.vidocq.vauban.core.

Exported Java modules

Module Main exports

io.vidocq.vauban.api

io.vidocq.vauban.api (Vauban version marker, ProxyLink, VaubanComponentProvider)

io.vidocq.vauban.core

io.vidocq.vauban.core.container, io.vidocq.vauban.core.bean.model, io.vidocq.vauban.core.context, io.vidocq.vauban.core.event, io.vidocq.vauban.core.interceptor, io.vidocq.vauban.core.extensions; io.vidocq.vauban.core.access to the trusted extension modules only (module lookups)

io.vidocq.vauban.indexer

io.vidocq.vauban.indexer, io.vidocq.vauban.indexer.codegen, io.vidocq.vauban.indexer.model, io.vidocq.vauban.indexer.scanner

io.vidocq.vauban.processor

(internal, requires java.compiler)

io.vidocq.vauban.classloader.spi

io.vidocq.vauban.classloader.spi — ByteSourcePlugin SPI

io.vidocq.vauban.classloader

io.vidocq.vauban.classloader — universal loader engine (VaubanClassLoader, VaubanLayerFactory) and the Java SE launcher (Launch)

io.vidocq.vauban.weaver

io.vidocq.vauban.weaver — canonical vauban#24 weaving transforms, packaged as a load-time agent

io.vidocq.vauban.sjar

AES-256-GCM encrypted implementation of the SPI (optional)

See the reference for the exhaustive list.

Sources