vidocq-runtime-spi exposes the interfaces an extension implements to contribute to the runtime. It is the only module a user-side extension must declare in its Java Modules requires.

Coordinates

Artefact

io.vidocq.runtime:vidocq-runtime-spi:0.4.0-SNAPSHOT

Java module

io.vidocq.runtime.spi

Source

vidocq-runtime-spi/src/main/java/io/vidocq/runtime/spi/

Public surface

List indexed from the existing sources:

Type Role

VidocqExtension

The extension point — lifecycle interface with name(), priority() (lower runs first, default 1000), configure(VidocqConfiguration), beforeStart(VaubanContainerBuilder), onStart(ExtensionContext), onStop() and configKeys(). Discovered via ServiceLoader.

ExtensionContext

Handed to onStart — container() (Vauban), beanManager(), configuration() (legacy string API), config() (typed API), launchMode() NEW (Reading the launch mode NEW), startupReport() NEW (Reading the startup report NEW).

VidocqConfiguration

String-property view passed to configure — property(key) returns Optional<String>, plus property(key, default) and portFor(extensionName, defaultPort).

config.VidocqConfig

Typed configuration API aligned with MicroProfile Config concepts — getValue(key, type) returns Optional<T>, getValue(key, type, default), getValues(key, elementType).

config.ConfigSource / config.ConfigSourceProvider

Pluggable configuration sources (analogous to MicroProfile), also discovered via ServiceLoader.

config.Converter<T>

Typed conversion of configuration values.

VidocqApp / @VidocqMain

Application-side entry points — the class run inside the Vauban application layer by Vidocq.run(Class, args), and the zero-config IDE trampoline annotation.

ApplicationLayer NEW

The application’s module layer, when Vidocq boots it in one of its own — current() — and the files of a directory of its modules — list(layer, directory) (Listing the application’s files NEW).

report.StartupReportContributor NEW

Adds a section to the startup report — id(), title(), order(), contribute(context, section). Implemented by an extension, or declared as a service (Contributing to the startup report NEW).

report.StartupReportContext NEW

What a contributor reads — verbosity(), launchMode(), hasBeanOfType(String), lookup(Class), routeUrls(String).

report.StartupReportSection NEW

Where a contributor writes — summary, row, list, secret, listener, route, anomaly.

report.Verbosity / report.LaunchMode NEW

The level of the report (OFF, SUMMARY, DETAILED) and the launch mode (DEV, TEST, PROD, with label() and parse(String)).

report.StartupReportView NEW

The startup report of the running boot, read-only (Reading the startup report NEW).

report.ReportAnomaly, report.ReportSection, report.ReportLine NEW

The text-only records of that view.

The dev console panel SPI, DevConsolePanel and the types it writes, is a module of its own, vidocq-runtime-devconsole-spi NEW (Dev console panels NEW).

Writing an extension — skeleton

An extension overrides only the hooks it needs; every lifecycle method has an empty default implementation.

package com.example.greeting;

import io.vidocq.runtime.spi.VidocqExtension;
import io.vidocq.runtime.spi.VidocqConfiguration;
import io.vidocq.runtime.spi.ExtensionContext;

public class GreetingExtension implements VidocqExtension {

    private String prefix;

    @Override
    public String name() {
        return "greeting";
    }

    @Override
    public void configure(VidocqConfiguration config) {
        // called before CDI boot — read your keys
        this.prefix = config.property("vidocq.greeting.prefix", "Hello");
    }

    @Override
    public void onStart(ExtensionContext ctx) {
        // the CDI container is up — resolve beans, mount handlers
        Greeter greeter = ctx.container().select(Greeter.class);
        greeter.setPrefix(prefix);
    }

    @Override
    public java.util.Set<String> configKeys() {
        return java.util.Set.of("vidocq.greeting.prefix");
    }
}

Java Modules declaration in module-info.java:

module com.example.greeting {
    requires io.vidocq.runtime.spi;

    provides io.vidocq.runtime.spi.VidocqExtension
        with com.example.greeting.GreetingExtension;
}

That is all. At startup the runtime’s ExtensionLoader discovers the provides directive through java.util.ServiceLoader, sorts all extensions by ascending priority(), and VidocqBootstrap drives the lifecycle: configure → beforeStart → onStart, then onStop in reverse order at shutdown.

Ordering with priority()

Priorities express real ordering constraints between extensions. The built-in HTTP stack illustrates the idiom: the Chappe engine prepares the server at priority 100, contributors such as the Cassini REST extension mount their handlers around priority 500, and the listener bootstrap opens the sockets last at priority 10 000. Pick a value between the extensions you must run after and before; unrelated extensions can keep the default 1000.

Configuration keys audit

Declaring your keys in configKeys() is optional but recommended: the runtime’s ConfigKeyAudit compares every configured vidocq. key against the union of declared keys and warns at startup about keys nobody consumes — the typical typo that otherwise silently keeps the default. The warning carries the code VIDOCQ-CFG-003 NEW. Entries ending in declare a prefix ("vidocq.greeting.*").

Listing the application’s files NEW

Under vidocq:dev, vidocq:run, the launcher vidocq:package writes by default, and after Vidocq.run re-layers a trampoline’s application, Vidocq boots the application in a module layer of its own. Its archives are then off the JVM’s class and module paths: the thread context class loader serves each of their files by name, but no class loader lists a directory of them, and getResources("db/migration") finds nothing. An extension that scans a directory of the application lists it from the layer, then reads each file by name:

Optional<ModuleLayer> layer = ApplicationLayer.current();
if (layer.isPresent()) {
    ClassLoader loader = Thread.currentThread().getContextClassLoader();
    for (String name : ApplicationLayer.list(layer.get(), "db/migration")) {
        try (InputStream in = loader.getResourceAsStream(name)) {
            // name is a resource name, such as db/migration/V1__init.sql
        }
    }
} else {
    // no layer of its own (the application module in the boot layer, a class-path launch, a unit test):
    // the class loader lists the directory itself
}

current() reads the layer at each call, so a dev reload’s new layer is the one it returns. It is empty when the application has no layer of its own, and when no Vidocq runtime is on the path, as in an extension’s unit tests. list walks the modules of that layer only, directories and jars alike, and returns the files under the directory, at any depth, sorted. The Flyway and Liquibase backends list their classpath: locations this way (Scripts in the application layer).

Reading the launch mode NEW

ExtensionContext.launchMode() returns the launch mode Vidocq resolved for this boot: LaunchMode.DEV, TEST or PROD, from the package io.vidocq.runtime.spi.report. An extension reads it to offer what only makes sense while developing, rather than reading vidocq.profile itself:

@Override
public void onStart(ExtensionContext ctx) {
    if (ctx.launchMode() == LaunchMode.DEV) {
        // what only makes sense while developing
    }
}

It is an observation, and the same for every extension of a boot: the reloads of the dev loop read it again. launchMode() is a default method that returns PROD, so an ExtensionContext written by hand, such as a test fake, compiles unchanged.

Contributing to the startup report NEW

The startup report that ends every boot has a section for every library that contributes one: what it found, what it serves, what is wrong with it. The contract is the package io.vidocq.runtime.spi.report of this module, so a contributor needs requires io.vidocq.runtime.spi, like an extension, and nothing else: never vidocq-runtime-core, which implements it.

A contributor is normally a Vidocq extension. An extension that also implements StartupReportContributor is found with no second declaration, and its section comes first, in the order the extensions start:

public class GreetingExtension implements VidocqExtension, StartupReportContributor {
    // name(), onStart(...) as above, then id() and contribute(...)
}

A library that is not an extension declares its contributor as a service. Its section comes after those of the extensions, by ascending order() (1000 by default), then by id().

The contract

Method Contract

StartupReportContributor.id()

A stable lowercase id, such as translate: the name of the section, and what keeps it from appearing twice. null or blank skips the contributor (VIDOCQ-RPT-001); an id already taken by another class, or by a section of the core, skips the second one (VIDOCQ-RPT-002). startup, config, cdi, logs, tests and jvm are taken too NEW: they are the dev console’s own panels.

title(), order()

The title printed after the id in the detailed report, the id by default; the position among the contributors declared as services.

contribute(context, section)

Writes the section. Called once per boot, on the booting thread, after every onStart and before Vidocq - Started in, at every report level, OFF included, so that an anomaly is never lost. While the dev console is on, its level is DETAILED NEW, whatever the report prints, so that the console gets every row.

StartupReportContext.verbosity(), launchMode()

The level of this report, and the launch mode of this boot. The header already gives the reason of the mode: a section never repeats it.

hasBeanOfType(String)

Whether an enabled bean has a bean type of this binary name, its class or one of its interfaces. It compares names and never creates the bean.

lookup(Class)

The bean of this type with the default qualifier; empty when there is none, when several resolve, or when creating it fails. A @Dependent instance it creates is destroyed when contribute returns.

routeUrls(String)

The absolute URLs of the routes of a handler class, as the sections written so far declare them with route and listener: the contributors called last see every route.

StartupReportSection.summary(text)

The one line that stands for the section, printed at SUMMARY and DETAILED; the last call wins.

row(key, value), list(key, items)

A row, a list: DETAILED only.

secret(key, configured)

configured or not configured, DETAILED only. The value never reaches the report.

listener(name, boundBaseUri), route(listener, method, path, handler)

For HTTP servers and routing bricks: Vidocq prints each route with the base URI of its listener, whichever section declared it. handler is the binary name of the class, then # and the method.

anomaly(code, message, hint)

Something wrong that does not stop the boot, under a code of your own, such as TRANSLATE-001. Logged at once, at every level, as one WARNING record [CODE] message hint on io.vidocq.startup.anomaly, its control characters replaced with ? so that it stays one line, never cut; recalled in the anomalies section. hint may be null.

A minimal contributor

A translation library, which is not an extension, says how many languages its Translator bean knows, and warns when the bean is missing:

src/main/java/com/example/translate/report/TranslateReport.java
package com.example.translate.report;

import com.example.translate.Translator;
import io.vidocq.runtime.spi.report.StartupReportContext;
import io.vidocq.runtime.spi.report.StartupReportContributor;
import io.vidocq.runtime.spi.report.StartupReportSection;

public final class TranslateReport implements StartupReportContributor {

    @Override
    public String id() {
        return "translate";
    }

    @Override
    public String title() {
        return "Translation library";
    }

    @Override
    public void contribute(StartupReportContext context, StartupReportSection section) {
        Translator translator = context.lookup(Translator.class).orElse(null);
        if (translator == null) {
            section.anomaly("TRANSLATE-001", "No Translator bean is deployed, so /translate answers 404.",
                    "Add translate-cdi to the application.");
            return;
        }
        section.summary(translator.languages().size() + " languages, default " + translator.defaultLanguage())
                .row("default", translator.defaultLanguage())
                .list("languages", translator.languages())
                .secret("apiKey", translator.hasApiKey());
    }
}

It is declared twice, because the JDK reads one declaration or the other depending on where the jar is: provides in module-info.java on the module path, where the JDK ignores META-INF/services for the classes of a named module, and a META-INF/services file on the class path.

src/main/java/module-info.java
module com.example.translate {
    requires io.vidocq.runtime.spi;
    // ... the library's own requires and exports

    provides io.vidocq.runtime.spi.report.StartupReportContributor
        with com.example.translate.report.TranslateReport;
}
src/main/resources/META-INF/services/io.vidocq.runtime.spi.report.StartupReportContributor
com.example.translate.report.TranslateReport

The summary report gets its summary line:

  extensions  chappe-engine, rest-cassini, chappe-mount-config, chappe-bootstrap
  translate   2 languages, default en
  anomalies   none

The detailed report gets its title and the time it took to write, then everything it wrote, aligned by Vidocq:

translate     Translation library | 0 ms
  2 languages, default en
  default     en
  languages   en, fr
  apiKey      not configured
anomalies     none

Without the bean, the anomaly is logged the moment it is reported, and recalled at the end of the report:

[WARN ][2026-09-18 14:37:31.305][main           ][i.v.r.c.r.StartupAnomalies#warn              ] : [TRANSLATE-001] No Translator bean is deployed, so /translate answers 404. Add translate-cdi to the application.
...
translate     Translation library | 0 ms
anomalies     1 (logged above)
  TRANSLATE-001  No Translator bean is deployed, so /translate answers 404.

Rules

  • Read memory only. No I/O, no network, no blocking call, no bean you do not need: contribute runs on the booting thread, before Vidocq - Started in. Its time is printed in the detailed report, followed by slow beyond 50 ms.

  • Pass plain values. Never format, pad, truncate or log: Vidocq aligns the rows, replaces control characters with ?, cuts a value at 200 characters and a list at 50 items.

  • Keep the summary line short and public. It is printed by default in production: counts and names, no absolute path, no configured value. A secret goes through secret(key, configured), which never takes its value.

  • Skip what will not be printed. Rows and lists only appear at DETAILED; a contributor that has to compute them can test context.verbosity() first.

  • A failure costs the section, never the boot. A RuntimeException or a LinkageError thrown by the contributor, or while it is loaded or created (a ServiceConfigurationError included), drops its section with VIDOCQ-RPT-001 and its stack trace; the anomalies it already reported stay. Any other Error is not caught.

Layer twins

Vidocq.run loads the application’s modules a second time, in the Vauban application layer, so the service loader can find the same contributor class twice: once in that layer, once in the boot layer, the application layer’s first. Vidocq keeps one contributor per class name — the first found — before creating either, so each section appears once. For the same reason, an extension that is also declared as a contributor service is called once, as an extension.

What the rule means for a contributor:

  • Two contributor classes must not share an id: only the same class found twice is a twin, and a different class with the same id is skipped with VIDOCQ-RPT-002.

  • The two copies of a class are two different Class objects. To test whether a bean exists, hasBeanOfType(String) compares binary names, so it answers the same whichever copy asks.

Reading the startup report NEW

ExtensionContext.startupReport() gives an extension the startup report of its boot, read-only, as a StartupReportView of the package io.vidocq.runtime.spi.report. It is what the dev console shows; any development tool may read it the same way. Exporting the core’s report classes would have let any extension add an anomaly or a section to a report that is written: the view holds text, and nothing in it can be changed.

The report is written after every onStart, so the view is not there yet when onStart runs. startupReport() returns a supplier: keep it, and ask it when the report is needed, on a request for instance, from any thread.

private volatile Supplier<Optional<StartupReportView>> report = () -> Optional.empty();

@Override
public void onStart(ExtensionContext ctx) {
    report = ctx.startupReport();
}

// later, on a request
report.get().ifPresent(view -> render(view.sections(), view.anomalies()));
Method Returns

launchMode(), launchReason()

The launch mode of the boot, and why, as the header prints it: vidocq.launch.mode, auto: IntelliJ agent.

anomalies()

Every anomaly of the boot, the core’s and the contributors', in the order they were logged, as ReportAnomaly(code, message, hint, source). The detailed report recalls the first 50; this list has them all.

sections()

The sections in the order of the detailed report — the lines of its header (launch, vidocq, phases) as sections of their own, then the core’s, then the contributors' — as ReportSection(id, headline, summary, lines). A ReportLine(key, values) with a null key is a row of a table or a line of text. The anomalies are not a section here.

detailedText()

The whole report as the log prints it at DETAILED, whatever level this boot logged it at: the text a user copies into a bug report.

contributors()

The contributors this boot called, the very instances, in the order of their sections. A reader may look for another interface they implement, as the console looks for DevConsolePanel, and never calls contribute: the report is written.

The supplier is empty until the report is written, and again once Vidocq stops, which withdraws the view first. A view belongs to one boot and never changes: a dev reload publishes a view of its own. Its values are text written by the application and its libraries: a reader escapes them for where it shows them, and never renders them as markup. startupReport() is a default method that answers empty, so an ExtensionContext written by hand, such as a test fake, compiles unchanged.

Dev console panels NEW

A startup report contributor that the dev console also shows live implements DevConsolePanel. The panel SPI is a module of its own, so that only the extensions that have a panel depend on it:

Artefact

io.vidocq.runtime:vidocq-runtime-devconsole-spi:0.4.0-SNAPSHOT

Java module

io.vidocq.runtime.spi.devconsole, one exported package of the same name, requires transitive io.vidocq.runtime.spi

Source

vidocq-runtime-devconsole-spi/src/main/java/io/vidocq/runtime/spi/devconsole/

The module name falls under the io.vidocq.runtime.spi prefix, which keeps a module in the boot layer across dev reloads: the console and every panel see the same DevConsolePanel type after any number of reloads.

Type Role

DevConsolePanel NEW

Extends StartupReportContributor. contribute writes the boot facts once per boot, for the report and the console alike; sample(PanelSample) writes the live values on every poll, from memory only; charts() names the values the page plots, none by default; actions() NEW, what the page may ask it to do, called in a dev launch only, none by default. Found as any contributor is: nothing new to declare.

PanelSample NEW

Where sample writes, every method returning a scope so that calls chain: gauge(key, value, unit), gauge(key, value, max, unit), counter(key, total, unit), duration(key, value), text(key, value), absent(key, reason), table(key, columns, rows), and group(name), one level deep. requireKey(key) checks the rule of every key, [a-z][a-z0-9.-]{0,39}.

Unit NEW

What a number measures: COUNT, BYTES, NANOS, RATIO (0 to 1, shown as a percentage).

Chart NEW

A chart the page draws from successive samples: an id, a title, and its series in drawing order, checked when it is built. Repeated once per group that holds one of its keys.

Series, Series.Style NEW

A value a chart plots and how: AREA, STACKED, LINE and CEILING for a gauge, RATE for a counter. Series.area(key), stacked, line, rate, ceiling.

PanelAction, PanelAction.Argument NEW

Something a panel offers to do from the page, in a dev launch only: an id, a label, an optional confirmation, its string arguments, each checked by a list of values (Argument.oneOf) or a regular expression (Argument.matching), and a Function<Map<String, String>, String> that does the work and returns one short line. See Actions.

Where a panel lives, how to write one, its rules, a worked example and how to test it: Writing a dev console panel.

Next steps