Extending Data Prism: writing a reviewed adapter
If your source is a JSON REST API whose response is either flat or nests
objects at most one level deep, you almost certainly do not need this guide.
Read docs/protect-your-own-api.md instead: a
YAML-only walkthrough that protects such a source with no Java class, no
pom.xml, and no META-INF registration step, using the same
data-prism-connectors-rest configuration-driven JSON REST mode summarised
below.
This guide is for the cases that YAML-only path cannot cover: a response that
nests objects two levels or more, custom fetch logic beyond a single
templated GET, or a model no allowlisted catalogue can express. For any of
those, this is the path a consumer walks to point Data Prism at their own
API: write a DataSourceAdapter, write (or reuse) an IdentityResolver,
classify the response model with @LlmExposedModel, shape the pom, register
the extension, load it into the server, and configure it. It assumes nothing
about this codebase beyond what README.md already says: Data Prism is a
privacy layer between MCP clients and your API, and it never exposes a field
nobody classified.
The configuration-driven JSON REST mode docs/protect-your-own-api.md walks
through is real and already shipped, as a normal published artifact,
data-prism-connectors-rest — no annotated Java model, no pom.xml, no
compiled adapter class. An operator loads that jar the same way as any
reviewed extension (-Dloader.path) and writes a YAML catalogue instead:
dataprism.json-sources.config-location names a file whose json-sources:
entries state a transport (base-url, a path template, timeout), a
model-version tag, and an allowlisted fields: catalogue — one entry
per JSON property, each an identifier, nonSensitive, classified, or a
nested: <name> pointer into a nested-catalogues: entry — the same
vocabulary @SensitiveData/@NonSensitive/@InternalIdentifier express
below
(data-prism-connectors-rest/src/main/java/io/github/aindriub/dataprism/connectors/rest/ConfiguredJsonSource.java:8-34,
.../ConfiguredJsonSourcesAutoConfiguration.java:82).
That mode has a real limit worth knowing before choosing it: a nested:
field's own catalogue is exactly one level deep, and it carries no identifier
of its own — it inherits its subject from the enclosing record — so its
leaves may be nonSensitive or classified only; identifier: true and a
further nested: are both refused there. descendable only ever answers
true for one of a source's own minted
nested-catalogue tokens, never recursively
(data-prism-connectors-rest/src/main/java/io/github/aindriub/dataprism/connectors/rest/ConfiguredJsonFieldMetadataResolver.java).
There is also no dotted path or JSONPath anywhere in this grammar: every
fields:/nested-catalogues: key, and subject-json-path itself, is a
single bare, exact-match property name. If your source's response nests
objects two levels or more, needs custom fetch logic beyond a single
templated GET, or needs a model no such catalogue can express, that limit
is why this guide exists: the Java-first path below has no such ceiling.
Configuration for the JSON REST mode is not covered further here —
see docs/protect-your-own-api.md for the
worked walkthrough and docs/configuration.md for its
full configuration vocabulary — because this guide is about the path that
requires writing code.
The worked example this guide cites throughout is
data-prism-quickstart-extension, a real module in this repository that CI
builds and exercises end to end via QuickstartSmokeIT
(data-prism-quickstart-extension/src/test/java/io/github/aindriub/dataprism/quickstart/extension/QuickstartSmokeIT.java:34-40).
Every snippet below is either copied verbatim from that module (or another
cited file) or is output actually captured by running a command shown beside
it — nothing here was composed by hand and left unchecked.
This guide does not restate the dataprism.* configuration vocabulary; that
is docs/configuration.md. It does not describe the two
MCP tools an agent calls; that is docs/tools.md. It does not re-specify the
platform's design; that is docs/pack.md. It is the missing piece: how the
pieces those documents assume already exist get built.
For a shorter, guided walk to the same destination, see the developer guide, starting with Write a data-source adapter. This page remains the full reference either one points back to.
The -Dloader.path trap
Read this before the pom section, because the mistake it describes is invisible until the packaged server is actually running against your data.
-Dloader.path (and its environment-variable equivalent, LOADER_PATH, read
by Spring Boot's PropertiesLauncher) adds your extension jar's own
classes and resources to the running server's classpath. It does not add
that jar's dependencies. Your extension is a plain jar, not a Spring Boot
executable with its libraries bundled inside — so if your pom.xml declares
a real (non-provided) dependency your adapter code needs at runtime, the
build succeeds, mvn package succeeds, and the failure only surfaces the
first time the server tries to construct a bean that touches a class from
that dependency — a ClassNotFoundException or NoClassDefFoundError at
startup or, worse, on the first request that reaches your adapter. This is
already documented in this repository, in exactly two places, and nowhere a
consumer building their own extension would find it:
A plain jar, not a Spring Boot executable:
-Dloader.pathadds only this jar's own classes and resources to the running server's classpath, never its dependencies, so everything below other thandata-prism-coreanddata-prism-annotationsis scoped "provided" — it must already be ondata-prism-server's own classpath, andspring-boot-starter-webmvcandspring-boot-starter-oauth2-resource-server(data-prism-server's own dependencies) put it there.
— data-prism-quickstart-extension/pom.xml:19-25
LOADER_PATH: a directory, read by Spring Boot'sPropertiesLauncherexactly asServerPackagingIT.executableLoadsAReviewedAdapterExtensionFromLoaderPathproves for a single jar passed via-Dloader.path—PropertiesLauncherreads the same property from this environment variable (its own upper-case, underscore convention), and, given a directory rather than one jar, adds every*.jarplaced directly inside it to the classpath.
— docker/distribution/Dockerfile:38-40
The consequence is the pom shape in the next section: everything your
extension needs that data-prism-server already carries on its own
classpath (spring-web, spring-boot-autoconfigure, data-prism-core,
data-prism-annotations) must be declared provided, never a normal
compile-scope dependency. provided tells Maven "compile against this, but
do not put a copy of it in the jar" — which is correct here precisely because
-Dloader.path will never supply that copy either. Anything your extension
needs that data-prism-server does not already carry has no home: it
cannot be bundled by -Dloader.path, so it cannot be a real runtime
dependency of a loader-path extension at all.
Implement DataSourceAdapter
DataSourceAdapter<T> is the SPI for one external system:
public interface DataSourceAdapter<T> {
String sourceName();
Class<T> responseType();
T fetch(DataRequest request);
}
data-prism-core/src/main/java/io/github/aindriub/dataprism/core/spi/DataSourceAdapter.java:10-17
sourceName()is the string that later binds this adapter to adataprism.sources.<name>configuration entry — see the binding section below; nothing else about this class name, package or bean name matters for that.responseType()returns the@LlmExposedModel-annotated record this adapter produces. It, and every user type nested in it, must be a record: any other class is refused at startup withSOURCE_MODEL_NOT_A_RECORD.-
fetch(DataRequest request)does the actual call.DataRequestcarries onlyentityType,subjectIdand a parameters map your adapter chooses to read — never a caller-supplied URL, path or query (data-prism-core/src/main/java/io/github/aindriub/dataprism/core/spi/DataRequest.java:6-12). -
description()is optional. It tells the catalog what the source holds:entityTypes, theinputKeysit accepts as a subject key (aKeyNamespaceeach), advisorycostandfreshness, and neutral texts. The default declares nothing. A source is a target of a sealed link only for a key namespace ininputKeys. If it declares severalentityTypes, it must also say which type a read keyed by each namespace answers, with thenamespaceTypesmap ofSourceDescription(each key one ofinputKeys, each value one ofentityTypes; anything else is refused when the description is built). Without a mapping for a namespace, a source with several types is not a link target for it: no link is issued and an old link is refused. A source with no declared type, or exactly one, needs no mapping. The unmapped case is easy to miss: declare the mapping whenever you list more than one type and any input key.
The worked implementation:
final class QuickstartCustomerAdapter implements DataSourceAdapter<CustomerModel> {
private static final Logger LOG = LoggerFactory.getLogger(QuickstartCustomerAdapter.class);
/** Must equal the {@code dataprism.sources.*} key this adapter is configured under. */
static final String SOURCE_NAME = "customer";
private final RestClient client;
QuickstartCustomerAdapter(RestClient client) {
this.client = Objects.requireNonNull(client, "client");
}
@Override
public String sourceName() {
return SOURCE_NAME;
}
@Override
public Class<CustomerModel> responseType() {
return CustomerModel.class;
}
@Override
public CustomerModel fetch(DataRequest request) {
Objects.requireNonNull(request, "request");
try {
return client.get()
.uri("/customers/{id}", request.subjectId())
.retrieve()
.body(CustomerModel.class);
} catch (HttpClientErrorException.NotFound absent) {
LOG.debug("source {} holds no record for the requested subject", SOURCE_NAME);
return null;
}
}
}
data-prism-quickstart-extension/src/main/java/io/github/aindriub/dataprism/quickstart/extension/QuickstartCustomerAdapter.java:22-58
Two things worth carrying over even though they are not enforced by the
platform: the subject id is passed as a URI template variable
("/customers/{id}", request.subjectId()) so RestClient encodes it,
rather than being concatenated into the path; and a 404 is treated as data —
this source has nothing for the subject — by returning null, not by
throwing. That is what lets the orchestrator record this source's outcome as
NO_DATA — a per-source status surfaced next to the result, not a
consistency finding — instead of tripping this source's circuit breaker
(data-prism-orchestration/src/main/java/io/github/aindriub/dataprism/orchestration/SourceOutcome.java:19-23,
QuickstartCustomerAdapter.java:12-21).
Using the caller's correlation id in an adapter
When the caller sent a correlation id and it passed validation, it arrives on
the request, not in parameters:
@Override
public Object fetch(DataRequest request) {
var spec = client.get().uri("/customers/{id}", request.subjectId());
var withId = request.context().externalCorrelationId()
.map(id -> spec.header("X-Correlation-Id", id.value()))
.orElse(spec);
return withId.retrieve().body(Customer.class);
}
The value has already been validated against the operator's policy, so it is syntactically constrained (validation does not prove it is free of personal data). Do not log it next to source data: it is what joins an audit record to the caller's own systems, and logging it beside the payload rebuilds that join in a place the privacy engine does not control. Do not send it to a source unless that source's owner expects it; an unexpected header is a disclosure to a third party.
The built-in REST and configured JSON sources do this for you, without any
adapter code. A source that sets correlation-header in its YAML has the id
set on that header by an interceptor that reads it from the request, so it is
correct under parallel fan-out. A source without the key sends nothing, unless
dataprism.correlation.outbound.header is set: that global default then
applies to every source without its own key, and each such source is one more
party that can join the id.
React to audit events with AuditEventListener
Declare a bean to receive every audit event after it has been written, for example to forward it to a log pipeline. Listeners are read-only and best-effort; read Audit event listeners first for when they are called, what they can lose, and who owns the destination.
import io.github.aindriub.dataprism.audit.AuditEventListener;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
/** Your own client for wherever the events go; a placeholder so this example compiles. */
interface SiemClient {
void send(long sequence, String eventId, String tool, String policyDecision);
}
@Configuration(proxyBeanMethods = false)
class AuditForwarding {
@Bean @Order(1)
AuditEventListener auditForwarder(SiemClient siem) {
// Runs on the shared listener thread: return promptly, and log your own failures.
return event -> siem.send(event.sequence(), event.eventId(), event.tool(), event.policyDecision());
}
}
Beans are called in @Order. A listener that throws is isolated and logged by
class name only; one that blocks delays the listeners after it, not the audited
call.
Implement IdentityResolver
IdentityResolver is how a subject's per-source keys relate to one canonical
identity:
public interface IdentityResolver {
/** The canonical subject a source record belongs to. */
CanonicalId resolve(SourceRef ref);
/**
* The per-source keys to fetch for a subject. A source absent from the result
* is not queried, which is how a subject known to only some systems avoids
* pointless calls and misleading "no data" findings.
*/
List<SourceRef> expand(CanonicalId id, List<String> sourceNames);
data-prism-core/src/main/java/io/github/aindriub/dataprism/core/spi/IdentityResolver.java:22-32
Most integrations do not need a custom implementation. PassThroughIdentityResolver
is the honest default:
public final class PassThroughIdentityResolver implements IdentityResolver {
@Override
public CanonicalId resolve(SourceRef ref) {
return new CanonicalId(Objects.requireNonNull(ref, "ref").key());
}
@Override
public List<SourceRef> expand(CanonicalId id, List<String> sourceNames) {
Objects.requireNonNull(id, "id");
return sourceNames.stream().map(name -> new SourceRef(name, id.value())).toList();
}
}
data-prism-core/src/main/java/io/github/aindriub/dataprism/core/spi/PassThroughIdentityResolver.java:14-26
Use it only when every configured source genuinely keys its records on
the same identifier (see the README's "Building and running" section): it
does nothing, treating the subject id you were asked for as every source's
own key. If your sources disagree about identity — different customer
numbers, a probabilistic match on name and date of birth, a master data
service you must call first — write your own IdentityResolver; that
difficulty is exactly what the interface exists to hold, and nothing in the
platform will paper over a wrong pass-through.
The worked example wires PassThroughIdentityResolver because its one
fixture source shares a single id:
@Bean
@ConditionalOnMissingBean
IdentityResolver quickstartIdentityResolver() {
// Every source the quickstart configures already keys on the same
// subjectId, so the honest default resolver is the correct one — see
// PassThroughIdentityResolver's own Javadoc.
return new PassThroughIdentityResolver();
}
data-prism-quickstart-extension/src/main/java/io/github/aindriub/dataprism/quickstart/extension/QuickstartExtensionAutoConfiguration.java:36-43
Classify the model with @LlmExposedModel
A response type must be a record
The response type, and every user type nested inside it, must be a Java
record. Enums, JDK types (String, numbers, Instant, UUID, ...),
JsonNode, and collections, maps (with String, enum, number, UUID, date or
similar keys, and values that are not records), Optionals and arrays of
those are also fine. A record is read
by its components only; an extra getX() on it is not emitted. Any other
class (a bean or POJO, a subclass of a collection, a Throwable), at any
depth, is refused at startup with SOURCE_MODEL_NOT_A_RECORD, and again at
runtime if one turns up behind an Object- or interface-typed component.
A source model must not be generic: a record that declares type parameters,
or a component of a parameterized user type (Envelope<Leaf>), is refused
with SOURCE_MODEL_GENERIC, because the fields it holds are its own and not
its type argument's. A map's values must not be records, at any depth
(Map<String, Leaf>, Map<String, List<Leaf>>): that is refused with
SOURCE_MODEL_MAP_OF_RECORD, since the keys are data and are not classified.
Use a list of records, or a record with named fields, instead.
A record must not sit inside a collection inside a collection
(List<List<Leaf>>, Leaf[][], List<Leaf[]>, Set<List<Leaf>>): that is
refused with SOURCE_MODEL_NESTED_COLLECTION_OF_RECORD, since a field's own
policy cannot tell what the inner arrays hold. Wrap the inner list in a record.
From 0.8 the engine reads a source record itself, component by component and
from the fields, so a Jackson annotation on a record, its components, its
methods or the enums it holds changes nothing and is refused at startup with
SOURCE_MODEL_JACKSON_ANNOTATION; only @JsonAlias, @JsonCreator,
@JsonDeserialize, @JsonSetter and @JsonIgnoreProperties(ignoreUnknown = true)
are allowed, because they affect only how an adapter reads the upstream
response. A record must sit at a position declared as that record itself. A
plain-Java embedder that builds the engine without Spring must call
SourceModels.require on every response type before serving, because that is
where an unreadable record is refused. Adapter code, and the objects it
builds, are trusted: a callback in such an object can stall a request or
exhaust memory but cannot bypass the scrub. The walk of a value is stopped,
and that source refused, once it exceeds 1,000,000 nodes; this is not a memory
limit: memory can still run out before the cap is reached, and a single large
value counts as one node (see the
trust model for source adapters).
The message names the place (Model.component, or the class) and the
annotation's simple name, never a value.
To fix a model refused with SOURCE_MODEL_JACKSON_ANNOTATION, remove the annotation the message names. From 0.8, Data Prism reads a source record itself: each component is written under its own name with its own value, and Jackson annotations on the record, its components, its methods and the enums it holds change nothing. If the annotation renamed a component in the output, rename the component. If @JsonProperty mapped an upstream response's name onto the component, use @JsonAlias for that name instead. If it changed how a value is written (@JsonValue, @JsonSerialize, @JsonFormat), give the component the type you want written: for a single sensitive value, a String with @SensitiveData instead of a record that serialises as one; for a formatted date, a String holding the text. If it hid a component (@JsonIgnore), remove the component or classify it. If it added keys to the object (@JsonAnyGetter), the keys can no longer sit beside the components: hold them in a named Map<String, Object> or JsonNode component. That component is never descended into. Classify it as a whole: unannotated or marked @NonSensitive, the profile's setting for unclassified data decides the whole value, and the bundled DEFAULT profile refuses it. If the keys must be handled one by one, serve that payload as a configured JSON source instead. @JsonAlias, @JsonCreator, @JsonDeserialize, @JsonSetter and @JsonIgnoreProperties(ignoreUnknown = true) stay allowed, because they affect only how an adapter reads the upstream response. To fix SOURCE_MODEL_RECORD_NOT_DECLARED, declare the position as the record type itself, not Object, an interface, Record, a type variable, an unbounded wildcard or a raw collection. To fix SOURCE_MODEL_NOT_READABLE, which can happen only on the module path, add an unqualified opens for the model's package to its module-info.java. To fix SOURCE_MODEL_NOT_A_RECORD on an AtomicReference or another reference type, hold the value itself, or an Optional of it. On a Stream, an Iterator or another single-use type, collect it into a List first. To fix SOURCE_MODEL_FOREIGN_NODE, build the tree from Jackson's own node types and do not put a Java object into it (putPOJO).
A response type is never exposed through MCP unless it carries
@LlmExposedModel, and every field of an annotated type must then carry one
of four annotations: @SensitiveData, @NonSensitive, @InternalIdentifier
or @SubjectIdentifier — the exact set LlmExposedModelProcessor checks for
(data-prism-processor/src/main/java/io/github/aindriub/dataprism/processor/LlmExposedModelProcessor.java:43-45).
The worked model:
@LlmExposedModel
public record CustomerModel(
@InternalIdentifier
String customerId,
@SensitiveData(
classifications = DataClassification.PII,
namespace = PrivacyNamespace.PERSON_NAME,
suggestedAction = PrivacyAction.SYNTHESIZE)
String customerName,
@SensitiveData(
classifications = DataClassification.CONTACT,
namespace = PrivacyNamespace.EMAIL,
suggestedAction = PrivacyAction.REDACT)
String email,
@NonSensitive(reason = "Enumerated lifecycle state; no free text and no bearing on identity")
String status) {
}
data-prism-quickstart-extension/src/main/java/io/github/aindriub/dataprism/quickstart/extension/CustomerModel.java:17-37
@InternalIdentifiermarks the field holding this record's own correlation id — the default pseudonymisation subject for any@SensitiveDatafield on the same record that does not name one explicitly (data-prism-annotations/src/main/java/io/github/aindriub/dataprism/annotations/InternalIdentifier.java:10-19).@SensitiveDataclassifies a field:classificationssays what kind of data it is,namespaceis what makes the same person's name synthesize to the same value across sources, andsuggestedActionis what the model author believes should happen — a suggestion policy may tighten but never loosen (data-prism-annotations/src/main/java/io/github/aindriub/dataprism/annotations/SensitiveData.java:9-38). The GDPR Art. 9 special categories arePHI,BIOMETRIC,GENETIC,ETHNIC_ORIGIN,POLITICAL_OPINION,RELIGIOUS_BELIEF,TRADE_UNIONandSEX_LIFE_ORIENTATION. No profile can expose them: a profile that maps one weaker thanREDACTfails at startup withSPECIAL_CATEGORY_EXPOSED, and a field carrying one is removed when the profile has no rule for it. This supports data minimisation under GDPR Art. 9; it is one control among those a deployment needs.@NonSensitiveasserts a field is safe to emit unchanged and requires areason()— that string is the review artefact; "not sensitive" is explicitly called out as not a reason (data-prism-annotations/src/main/java/io/github/aindriub/dataprism/annotations/NonSensitive.java:9-25).@SubjectIdentifieris for a record describing more than one subject — an applicant and a guarantor — where@InternalIdentifier's single default is wrong (data-prism-annotations/src/main/java/io/github/aindriub/dataprism/annotations/SubjectIdentifier.java:9-25).
Two more exist for cases the quickstart model does not need: @SensitiveObject
marks a nested type as safe to descend into rather than treated as an
unclassified blob
(data-prism-annotations/src/main/java/io/github/aindriub/dataprism/annotations/SensitiveObject.java:9-25),
and undeclaredFields() on @LlmExposedModel lets a large legacy model state
one retrofit decision for every field nobody annotated, instead of annotating
each of them
(data-prism-annotations/src/main/java/io/github/aindriub/dataprism/annotations/LlmExposedModel.java:26-37).
Profiles that admit unclassified data
A source payload can carry a property the model does not declare. Its name comes
from the payload, not from reviewed code, and a name can be personal data (a map
keyed by email address). The profile's unclassified setting decides what
happens to an undeclared property's name and value:
| Setting | Name | Value |
|---|---|---|
FAIL_REQUEST (default) |
the request is refused with UNKNOWN_FIELD; the refusal path shows <undeclared>, never the key |
not emitted |
REDACT_AND_WARN |
replaced by <undeclared-1>, <undeclared-2>, and so on, numbered by raw key in alphabetical order within each object |
[REDACTED] |
DROP_AND_WARN |
not emitted | not emitted |
PASS_THROUGH_UNSAFE |
emitted unchanged | emitted unchanged |
Under PASS_THROUGH_UNSAFE, property names from the source payload reach the
model unchanged. The leak validators scan property names as well as values, under
every profile: a name shaped like an identifier the scanner knows (an email
address, a card number, an IBAN and so on) is refused by
SensitivePatternValidator, and a name equal to a prohibited raw source value is
refused by RawValueLeakValidator. A name in the scope's emitted set is exempt,
as a value is. The violation path shows <undeclared> in place of the name, and
so does everything beneath it. A name that matches no known shape still reaches
the model, so use that setting only for data whose keys carry nothing
sensitive.
Declared fields keep their names under every setting. Audit records and
disposition keys use the single segment <undeclared> for any undeclared
property, never the numbered form.
No dataprism.* property selects a profile that admits unclassified data. Such
a profile is built in Java.
What the processor actually rejects — proven by compiling
The rule above is not a style preference; LlmExposedModelProcessor fails
the build. To see the real diagnostic rather than take that on faith, this
throwaway file was written outside the repository (it is not part of this
module and does not exist in this codebase) with one field left unclassified:
import io.github.aindriub.dataprism.annotations.InternalIdentifier;
import io.github.aindriub.dataprism.annotations.LlmExposedModel;
@LlmExposedModel
public record BadModel(
@InternalIdentifier
String customerId,
String unclassifiedField) {
}
It was compiled directly against this repository's own built
data-prism-annotations and data-prism-processor jars (recorded against
0.3.0), with the processor placed on -processorpath, the same way
annotationProcessorPaths places it for Maven:
$ javac -cp data-prism-annotations-0.3.0.jar \
-processorpath data-prism-processor-0.3.0.jar:data-prism-annotations-0.3.0.jar \
-d out BadModel.java
BadModel.java:8: error: field is on an @LlmExposedModel but carries no classification. Add @SensitiveData, or @NonSensitive(reason = "...") stating why it is safe to expose.
String unclassifiedField) {
^
1 error
That is the literal, only Diagnostic.Kind.ERROR this processor ever emits
(data-prism-processor/src/main/java/io/github/aindriub/dataprism/processor/LlmExposedModelProcessor.java:70-75).
A type that sets undeclaredFields() to anything other than the default is
exempt from this check entirely — it has already made the statement once, for
the whole class (LlmExposedModelProcessor.java:58-64).
The pom shape
data-prism-quickstart-extension/pom.xml is where the shape below comes
from, but it cannot be copied verbatim into a pom outside this repository.
Its dependencies carry no <version>, and its annotationProcessorPaths
names ${project.version}:
<dependencies>
<dependency>
<groupId>io.github.aindriub</groupId>
<artifactId>data-prism-core</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>io.github.aindriub</groupId>
<artifactId>data-prism-annotations</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId>
<scope>provided</scope>
</dependency>
data-prism-quickstart-extension/pom.xml:39-59
That works inside this repository, and only inside it, because this module
inherits from the reactor's own parent pom
(data-prism-quickstart-extension/pom.xml:7-11), which pins every
io.github.aindriub artifact to ${project.version}, imports
spring-boot-dependencies to pin spring-web and
spring-boot-autoconfigure, and sets <maven.compiler.release>21</maven.compiler.release>
(pom.xml:68). A consumer project has no relationship to that parent, and
loses all three inherited settings, not just the versions: copying the
dependency block above as shown gets a missing-version error for the two
io.github.aindriub dependencies; copying ${project.version} into
annotationProcessorPaths (below) asks for a data-prism-processor at the
consumer's own project version, an artifact that does not exist; and with
no release set at all, maven-compiler-plugin falls back to its own
default of 1.8, at which point a record (used below) is a syntax error
and data-prism-core-0.3.0.class files — compiled for 21 — fail to load
with class file has wrong version 65.0.
The version-complete equivalent, standing alone, with no parent from this
repository. It assumes your own pom already has the usual top-level
<modelVersion>, groupId, artifactId, version and
<packaging>jar</packaging> — only the three blocks below are specific to
depending on Data Prism:
<properties>
<maven.compiler.release>21</maven.compiler.release>
<data-prism.version>0.7.0</data-prism.version>
<spring-boot.version>4.1.1</spring-boot.version>
</properties>
<dependencyManagement>
<dependencies>
<!-- Spring Boot 4.1.1 manages Jackson 3 at 3.1.5, which is affected by the five GHSAs
listed in the changelog; remove this once Boot manages 3.1.7 or later. -->
<dependency>
<groupId>tools.jackson</groupId>
<artifactId>jackson-bom</artifactId>
<version>3.1.8</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>io.github.aindriub</groupId>
<artifactId>data-prism-core</artifactId>
<version>${data-prism.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>io.github.aindriub</groupId>
<artifactId>data-prism-annotations</artifactId>
<version>${data-prism.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>
spring-boot.version (4.1.1, which brings spring-web 7.0.9 and
spring-boot-autoconfigure 4.1.1) is the exact Spring Boot version the
server distribution is built against — importing its
spring-boot-dependencies BOM is what lets spring-web and
spring-boot-autoconfigure above go unversioned safely, resolving to the
same versions already on the running server's classpath, which is the whole
point of marking them provided in the first place. The reason for
provided itself is otherwise unchanged from the trap section above:
data-prism-server already carries all four dependencies on its own
classpath, -Dloader.path never supplies them, and a real
(non-provided) scope would bundle a redundant copy this jar does not need
to ship. (The in-repo module also
carries six test-scope dependencies at lines 62-105: three
data-prism-quickstart-*/data-prism-server entries and two MCP SDK entries
that exist only so the Maven reactor builds the packaged artifacts this
module's own smoke test starts as subprocesses and drives, and
spring-boot-test for its ApplicationContextRunner test. A consumer's
extension pom has no reason to carry the first five.)
Every application that embeds data-prism-spring-boot-starter must manage its
own embedded Tomcat version, unless its pom inherits io.github.aindriub:data-prism.
The dependencyManagement of this repository's parent pom is not published to
consumers of the starter (Maven 3 does not apply a dependency's own management
to the consumer), so a bare consumer resolves whatever its own Spring Boot BOM
manages, as it does for Jackson, whose jackson-bom import the snippet above
also carries for that reason. Spring Boot 4.1.1 manages Tomcat 11.0.24, which
is affected by GHSA-9xv2-5v5q-p794 (CVE-2026-65905), GHSA-gcx9-497g-6cp6
(CVE-2026-65182) and GHSA-h3x4-894j-xpx5 (CVE-2026-68525), fixed in 11.0.25.
Until Spring Boot manages a fixed version, manage the org.apache.tomcat.embed
artifacts at 11.0.25 or later, ahead of the Spring Boot BOM import:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-core</artifactId>
<version>11.0.27</version>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-el</artifactId>
<version>11.0.27</version>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-websocket</artifactId>
<version>11.0.27</version>
</dependency>
<!-- then the spring-boot-dependencies import, as above -->
</dependencies>
</dependencyManagement>
An application that inherits spring-boot-starter-parent can instead set the
tomcat.version property. The adapter jar built from the snippet above is not
affected, because it resolves no Tomcat.
The Tomcat fragment above is not one of the two blocks described as checked below; it was not extracted or built.
What has been checked, and what has not (recorded 2026-10-07, updated 2026-10-20). The two XML
blocks in this section were extracted programmatically and pasted unmodified
into a pom whose only other content is modelVersion, groupId,
artifactId, version and packaging. Alongside a minimal
DataSourceAdapter and an @LlmExposedModel record, that pom was built with
mvn package against Maven Central, with an empty local repository, in its
0.4.1 form. It resolved data-prism-core, data-prism-annotations and
data-prism-processor 0.4.1, spring-web 7.0.9 and
spring-boot-autoconfigure 4.1.1 (Spring Boot 4.1.1), and the build
produced a jar. The 0.5.0 and 0.6.0 forms of the snippet differ from it only in
data-prism.version. The snippet's Spring dependencies were re-resolved
against Spring Boot 4.1.1 and spring-web 7.0.9 during the Spring Boot 4.1
migration, at data-prism.version 0.4.1; those Spring versions come from the
imported BOM and do not depend on data-prism.version. On 2026-10-08, after publication, a full mvn package of the 0.5.0 form
of the snippet against the published 0.5.0 artifacts, with an empty local
repository, resolved data-prism-core, data-prism-annotations and
data-prism-processor 0.5.0, spring-web 7.0.9 and
spring-boot-autoconfigure 4.1.1, and produced a jar. The annotation
processor ran and rejected an unclassified @LlmExposedModel field at compile
time. On 2026-10-09, before publication, a full mvn package of the 0.6.0
form of the snippet, with the same minimal DataSourceAdapter (importing
core.spi) and an @LlmExposedModel record, was built with an empty local
repository against a scratch file repository holding the 0.6.0 reactor build
of this repository before the tag, not against Maven Central. It resolved data-prism-core,
data-prism-annotations and data-prism-processor 0.6.0, spring-web
7.0.9 and spring-boot-autoconfigure 4.1.1, and produced a jar. That run
shows the snippet and the reactor's POMs agree; it is not evidence about the
artifacts Central will serve. The same was done on 2026-10-20, before
publication, for the 0.7.0 form: a full mvn package with an empty local
repository against a scratch file repository holding the 0.7.0 reactor build
of this repository, not against Maven Central. It resolved data-prism-core,
data-prism-annotations and data-prism-processor 0.7.0, spring-web
7.0.9 and spring-boot-autoconfigure 4.1.1, and produced a jar. The
0.7.0 form differs from the earlier ones in data-prism.version and in the
tools.jackson:jackson-bom import ahead of the Spring Boot BOM, and the
tools.jackson artifacts it resolved (jackson-databind, jackson-core and
jackson-dataformat-yaml) were 3.1.8. Without that import they resolve to
3.1.5, the version the Spring Boot BOM manages, because a consumer's own
dependency management decides it and this repository's parent pom does not
reach it. Like the 0.6.0 run, it shows the snippet and the
reactor's POMs agree and is not evidence about the artifacts Central will
serve. Nothing here has been shown for any other version.
The annotation processor is configured separately, and only here — with an
explicit version, not ${project.version}, for the reason above. If your
pom already has a <build><plugins> section, add this <plugin> inside it
instead of duplicating the wrapper:
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>io.github.aindriub</groupId>
<artifactId>data-prism-processor</artifactId>
<version>${data-prism.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
The <plugin> itself is adapted from
data-prism-quickstart-extension/pom.xml:111-126, which reads identically
except for the version (the <build><plugins> wrapper around it is already
present elsewhere in that module's pom, so its own citation does not include
one). That module's own comment explains the placement:
On the processor path, not the compile classpath: this module depends on the
@LlmExposedModelclassification check running, not on the checker's own classes.
— data-prism-quickstart-extension/pom.xml:115-117
data-prism-processor must never appear as a <dependency> — only under
annotationProcessorPaths, as above. It is a build-time tool that runs
during your compile, not a class your adapter code calls; giving it a real
dependency scope would put a build tool on the classpath a -Dloader.path
extension eventually ships from.
Register the extension
Spring Boot needs to be told which auto-configuration class to load from a
jar arriving via -Dloader.path, since nothing on that path is
component-scanned. Registration is one line in one file:
io.github.aindriub.dataprism.quickstart.extension.QuickstartExtensionAutoConfiguration
data-prism-quickstart-extension/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
That class is an ordinary @AutoConfiguration providing your IdentityResolver
and DataSourceAdapter beans:
@AutoConfiguration
public class QuickstartExtensionAutoConfiguration {
@Bean
@ConditionalOnMissingBean
IdentityResolver quickstartIdentityResolver() {
// Every source the quickstart configures already keys on the same
// subjectId, so the honest default resolver is the correct one — see
// PassThroughIdentityResolver's own Javadoc.
return new PassThroughIdentityResolver();
}
@Bean
@ConditionalOnMissingBean
DataSourceAdapter<CustomerModel> quickstartCustomerAdapter(
@Value("${dataprism.sources.customer.base-url}") String baseUrl,
@Value("${dataprism.sources.customer.timeout}") String timeout) {
Duration readTimeout = DurationStyle.detectAndParse(timeout);
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setConnectTimeout((int) readTimeout.toMillis());
requestFactory.setReadTimeout((int) readTimeout.toMillis());
RestClient client = RestClient.builder()
.baseUrl(baseUrl)
.requestFactory(requestFactory)
.build();
return new QuickstartCustomerAdapter(client);
}
}
data-prism-quickstart-extension/src/main/java/io/github/aindriub/dataprism/quickstart/extension/QuickstartExtensionAutoConfiguration.java:33-60
Note what this class reads its base URL and timeout from — dataprism.sources.customer.*
— which is exactly the vocabulary docs/configuration.md
describes for dataprism.sources.<name>; nothing here lets an MCP caller
choose a host or path.
Load the extension
With the jar built (mvn package in your extension module), two equivalent
ways to load it into the packaged server exist, both documented already in
the README's "Building and running" section:
LOADER_PATH=/opt/data-prism/extensions \
java -jar data-prism-server/target/data-prism-server-0.7.0.jar \
--spring.config.additional-location=file:/etc/data-prism/application.yaml
— LOADER_PATH names a directory; every *.jar placed directly inside it is
added. The single-jar form used directly is -Dloader.path=/path/to/your-extension.jar
as a JVM system property, passed before -jar. Both are the same mechanism
ServerPackagingIT.executableLoadsAReviewedAdapterExtensionFromLoaderPath
proves against a real packaged server jar
(docker/distribution/Dockerfile:39), and both are subject to the trap
above: only the jar's own classes travel this way.
Bind dataprism.sources.<name> to your adapter
Binding is by return value, not by bean name, class name or annotation:
the server compares the set of names configured under dataprism.sources
against the set of strings every DataSourceAdapter bean's sourceName()
returns, and refuses startup unless the two sets are identical:
Set<String> configured=properties.getSources().keySet(); Set<String> supplied=adapterList.stream().map(DataSourceAdapter::sourceName).collect(Collectors.toSet());
if(configured.isEmpty()) throw new DataPrismConfigurationException("MISSING_SOURCE_ADAPTER","dataprism.sources must name at least one reviewed adapter");
if(!configured.equals(supplied)) throw new DataPrismConfigurationException("UNRESOLVED_SOURCE_ADAPTER","configured sources and DataSourceAdapter beans differ");
data-prism-spring-boot-autoconfigure/src/main/java/io/github/aindriub/dataprism/spring/boot/validation/DataPrismContractValidator.java
The Spring bean method name (quickstartCustomerAdapter, above) and the
class name (QuickstartCustomerAdapter) are never consulted for this check —
only the string sourceName() returns is. Configure your source under a
different key than that string, and every source in dataprism.sources
still refuses to start, with UNRESOLVED_SOURCE_ADAPTER. This refusal is
proven in this repository's own test suite
(data-prism-server/src/test/java/io/github/aindriub/dataprism/server/ServerStartupTest.java:55),
and it was reproduced here directly against the real packaged jars — the
quickstart extension's sourceName() returns "customer", so configuring
dataprism.sources.customer (satisfied) alongside a second, unbacked
dataprism.sources.wrongsource entry (no adapter returns that name)
triggers the same refusal:
$ java -Dloader.path=$EXTENSION_JAR -jar $SERVER_JAR \
--dataprism.sources.customer.base-url=https://customer.example \
--dataprism.sources.customer.timeout=2s \
--dataprism.sources.wrongsource.base-url=https://wrongsource.example \
--dataprism.sources.wrongsource.timeout=2s \
[... rest of the required dataprism.* configuration, see docs/configuration.md ...]
...
Caused by: io.github.aindriub.dataprism.spring.boot.DataPrismConfigurationException: UNRESOLVED_SOURCE_ADAPTER: configured sources and DataSourceAdapter beans differ
at io.github.aindriub.dataprism.spring.boot.validation.DataPrismContractValidator.validateIntegrations(DataPrismContractValidator.java)
at io.github.aindriub.dataprism.spring.boot.PropertiesValidation.dataPrismPropertiesValidated(PropertiesValidation.java:50)
$EXTENSION_JAR and $SERVER_JAR were the built
data-prism-quickstart-extension-0.3.0.jar and data-prism-server-0.3.0.jar
(recorded against 0.3.0) from this repository's own target/ directories;
the omitted arguments are
the same security, privacy, audit, metrics and Hazelcast configuration
docs/configuration.md requires for any protected deployment and are unrelated
to this refusal.
The full dataprism.sources.<name> shape — base-url, timeout, mTLS and
credential references — is documented in the dataprism.* vocabulary table of
docs/configuration.md; this section covers only how the
name binds to your code.