Skip to content

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.path adds only this jar's own classes and resources to the running server's classpath, never its dependencies, so everything below other than data-prism-core and data-prism-annotations is scoped "provided" — it must already be on data-prism-server's own classpath, and spring-boot-starter-webmvc and spring-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's PropertiesLauncher exactly as ServerPackagingIT.executableLoadsAReviewedAdapterExtensionFromLoaderPath proves for a single jar passed via -Dloader.path — PropertiesLauncher reads the same property from this environment variable (its own upper-case, underscore convention), and, given a directory rather than one jar, adds every *.jar placed 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 a dataprism.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 with SOURCE_MODEL_NOT_A_RECORD.
  • fetch(DataRequest request) does the actual call. DataRequest carries only entityType, subjectId and 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, the inputKeys it accepts as a subject key (a KeyNamespace each), advisory cost and freshness, and neutral texts. The default declares nothing. A source is a target of a sealed link only for a key namespace in inputKeys. If it declares several entityTypes, it must also say which type a read keyed by each namespace answers, with the namespaceTypes map of SourceDescription (each key one of inputKeys, each value one of entityTypes; 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

  • @InternalIdentifier marks the field holding this record's own correlation id — the default pseudonymisation subject for any @SensitiveData field 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).
  • @SensitiveData classifies a field: classifications says what kind of data it is, namespace is what makes the same person's name synthesize to the same value across sources, and suggestedAction is 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 are PHI, BIOMETRIC, GENETIC, ETHNIC_ORIGIN, POLITICAL_OPINION, RELIGIOUS_BELIEF, TRADE_UNION and SEX_LIFE_ORIENTATION. No profile can expose them: a profile that maps one weaker than REDACT fails at startup with SPECIAL_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.
  • @NonSensitive asserts a field is safe to emit unchanged and requires a reason() — 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).
  • @SubjectIdentifier is 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 @LlmExposedModel classification 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.