diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml
index f1322251..471e92bf 100644
--- a/.github/workflows/pull-request.yml
+++ b/.github/workflows/pull-request.yml
@@ -248,6 +248,36 @@ jobs:
- uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- run: ./gradlew :opa-slf4j:test
+ verify-proto-vendor:
+ name: Verify vendored proto schemas match go.mod
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+ - uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
+ with:
+ distribution: temurin
+ java-version: 21
+ - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
+ - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
+ with:
+ go-version-file: tools/generate-compliance-tests/go.mod
+ # The vendored .proto files in opa-proto must always match the OPA version
+ # pinned in tools/generate-compliance-tests/go.mod (the source of truth).
+ # Re-vendor via the Gradle task and fail if it produces any change — i.e. the
+ # OPA version was bumped, or a .proto was hand-edited, without re-vendoring.
+ - name: Re-vendor and check for drift
+ run: |
+ set -e
+ ./gradlew --rerun-tasks :opa-proto:vendorProtoSchemas
+ if ! git diff --quiet -- opa-proto/src/main/proto; then
+ echo "::error::Vendored proto schemas are out of sync with the OPA version in tools/generate-compliance-tests/go.mod. Run ./gradlew :opa-proto:vendorProtoSchemas and commit the result."
+ git --no-pager diff -- opa-proto/src/main/proto
+ exit 1
+ fi
+ echo "Vendored proto schemas are in sync with the pinned OPA version."
+
pr-check-summary:
name: PR Check Summary
runs-on: ubuntu-latest
@@ -256,6 +286,7 @@ jobs:
- gh-actions-lint
- lint
- validate-pom
+ - verify-proto-vendor
- build
- test-opa-evaluator
- test-opa-jackson
diff --git a/.gitignore b/.gitignore
index 84168191..452690c3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,6 +13,9 @@
*.tar.gz
*.rar
+# …but keep bundle fixtures checked in as test resources
+!**/src/test/resources/**/*.tar.gz
+
# Build directories
build/
target/
@@ -60,3 +63,6 @@ nb-configuration.xml
.DS_Store
Thumbs.db
+# Go — compiled helper tool binary (built by `go build` in the compliance-test tool)
+/tools/generate-compliance-tests/generate-compliance-tests
+
diff --git a/cli/build.gradle.kts b/cli/build.gradle.kts
index 93975ad2..1b558fbf 100644
--- a/cli/build.gradle.kts
+++ b/cli/build.gradle.kts
@@ -15,6 +15,9 @@ dependencies {
runtimeOnly(project(":opa-builtins"))
runtimeOnly(project(":opa-jackson"))
+ // Enables the CLI to read proto-format bundles (opa build --format=proto). opa-jackson still
+ // handles data.json and JSON-format plans/manifests; opa-proto decodes plan.pb/.manifest.pb.
+ runtimeOnly(project(":opa-proto"))
testImplementation("org.junit.jupiter:junit-jupiter:6.1.1")
testRuntimeOnly("org.junit.platform:junit-platform-launcher:6.1.1")
diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/BundleAssembler.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/BundleAssembler.java
index bcbd24dd..280e48b3 100644
--- a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/BundleAssembler.java
+++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/BundleAssembler.java
@@ -3,13 +3,11 @@
import io.github.open_policy_agent.opa.ast.types.RegoObject;
import io.github.open_policy_agent.opa.ast.types.RegoString;
import io.github.open_policy_agent.opa.ir.PolicyReader;
+import io.github.open_policy_agent.opa.spi.Services;
import io.github.open_policy_agent.opa.storage.Store;
import java.io.IOException;
import java.io.InputStream;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.ServiceLoader;
/**
* Processes individual bundle files and assembles them into a {@link Bundle}.
@@ -38,39 +36,53 @@ public class BundleAssembler {
static final BundleParser BUNDLE_PARSER = loadSingleton(BundleParser.class);
+ /**
+ * Holds the optional proto decoder, resolved lazily on first proto-format load.
+ *
+ *
Deferring resolution (rather than a {@code static final} field on {@link BundleAssembler})
+ * keeps a proto-specific misconfiguration — no {@code opa-proto} module, two providers, or a
+ * provider that fails to instantiate — from running in {@code BundleAssembler}'s own class
+ * initializer, where it would break all bundle loading, including JSON-only bundles that
+ * never touch proto. The holder class initializes only when {@link #requireProtoDecoder} first
+ * references it, so any such failure surfaces only when a proto bundle is actually read.
+ */
+ private static final class ProtoDecoderHolder {
+ static final ProtoBundleDecoder INSTANCE = loadOptional(ProtoBundleDecoder.class);
+ }
+
/**
* Loads exactly one implementation of the given SPI from the classpath. Throws if zero or more
* than one implementation is registered, since either case produces an ambiguous runtime.
*/
private static T loadSingleton(Class spi) {
- List impls = new ArrayList<>();
- for (T impl : ServiceLoader.load(spi)) {
- impls.add(impl);
- }
- if (impls.isEmpty()) {
- throw new IllegalStateException(
- "No "
- + spi.getSimpleName()
- + " implementation found on the classpath. Add a module that provides "
- + spi.getSimpleName()
- + " (e.g. opa-jackson).");
- }
- if (impls.size() > 1) {
- StringBuilder names = new StringBuilder();
- for (int i = 0; i < impls.size(); i++) {
- if (i > 0) {
- names.append(", ");
- }
- names.append(impls.get(i).getClass().getName());
- }
+ return Services.loadAtMostOne(spi)
+ .orElseThrow(
+ () ->
+ new IllegalStateException(
+ "No "
+ + spi.getSimpleName()
+ + " implementation found on the classpath. Add a module that provides "
+ + spi.getSimpleName()
+ + " (e.g. opa-jackson)."));
+ }
+
+ /**
+ * Loads an optional SPI: returns {@code null} when no implementation is registered, the single
+ * implementation when exactly one is, and throws when more than one is registered.
+ */
+ private static T loadOptional(Class spi) {
+ return Services.loadAtMostOne(spi).orElse(null);
+ }
+
+ private static ProtoBundleDecoder requireProtoDecoder() {
+ ProtoBundleDecoder decoder = ProtoDecoderHolder.INSTANCE;
+ if (decoder == null) {
throw new IllegalStateException(
- "Multiple "
- + spi.getSimpleName()
- + " implementations found on the classpath: "
- + names
- + ". Only one provider may be registered.");
+ "No ProtoBundleDecoder implementation found on the classpath, but the bundle is in proto"
+ + " format (plan.pb / .manifest.pb). Add the opa-proto module to read proto-format"
+ + " bundles.");
}
- return impls.get(0);
+ return decoder;
}
private final Bundle.Builder builder = new Bundle.Builder();
@@ -83,6 +95,16 @@ public void loadPlan(InputStream in) throws IOException {
hasContent = true;
}
+ /**
+ * Load a compiled IR policy from a protobuf {@code plan.pb} stream.
+ *
+ * @throws IllegalStateException if no {@link ProtoBundleDecoder} is registered (add opa-proto)
+ */
+ public void loadPlanProto(InputStream in) throws IOException {
+ builder.withIrPolicy(requireProtoDecoder().decodePlan(in));
+ hasContent = true;
+ }
+
/**
* Load data from a {@code data.json} at the given path within the bundle.
*
@@ -132,6 +154,63 @@ public void loadManifest(InputStream in) throws IOException {
builder.withManifest(BUNDLE_PARSER.parseManifest(in));
}
+ /**
+ * Load bundle metadata from a protobuf {@code .manifest.pb} stream.
+ *
+ * @throws IllegalStateException if no {@link ProtoBundleDecoder} is registered (add opa-proto)
+ */
+ public void loadManifestProto(InputStream in) throws IOException {
+ builder.withManifest(requireProtoDecoder().decodeManifest(in));
+ }
+
+ /**
+ * Detect the bundle's wire format from which plan/manifest artifacts are present, reject
+ * mixed-format bundles, and load the plan and manifest in the correct format.
+ *
+ *
This centralizes the format-detection and routing policy so {@link BundleLoader}
+ * implementations only need to locate the four possible artifacts and expose each as an {@link
+ * InputStreamSource} (or {@code null} when absent), regardless of whether the bytes come from a
+ * directory, a tarball, or elsewhere. Proto takes precedence over JSON for each artifact, matching
+ * OPA; a proto plan cannot be paired with a JSON manifest and vice versa (see {@link
+ * BundleFormat#validate}).
+ *
+ * @param planJson source for {@code plan.json}, or {@code null} if absent
+ * @param planProto source for {@code plan.pb}, or {@code null} if absent
+ * @param manifestJson source for {@code .manifest}, or {@code null} if absent
+ * @param manifestProto source for {@code .manifest.pb}, or {@code null} if absent
+ * @throws IllegalArgumentException if the bundle mixes wire formats
+ * @throws IOException if a source cannot be opened or its contents cannot be parsed
+ */
+ public void loadPlanAndManifest(
+ InputStreamSource planJson,
+ InputStreamSource planProto,
+ InputStreamSource manifestJson,
+ InputStreamSource manifestProto)
+ throws IOException {
+ BundleFormat.validate(
+ planJson != null, planProto != null, manifestJson != null, manifestProto != null);
+
+ if (planProto != null) {
+ try (InputStream in = planProto.open()) {
+ loadPlanProto(in);
+ }
+ } else if (planJson != null) {
+ try (InputStream in = planJson.open()) {
+ loadPlan(in);
+ }
+ }
+
+ if (manifestProto != null) {
+ try (InputStream in = manifestProto.open()) {
+ loadManifestProto(in);
+ }
+ } else if (manifestJson != null) {
+ try (InputStream in = manifestJson.open()) {
+ loadManifest(in);
+ }
+ }
+ }
+
/** Add a Rego source file by its relative path. */
public void addRego(String path, String content) {
builder.withRego(path, content);
diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/BundleFormat.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/BundleFormat.java
new file mode 100644
index 00000000..2daabe51
--- /dev/null
+++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/BundleFormat.java
@@ -0,0 +1,86 @@
+package io.github.open_policy_agent.opa.bundle;
+
+/**
+ * Constants and validation for the two on-disk bundle wire formats.
+ *
+ *
OPA's {@code opa build} emits plan bundles in either JSON (the default) or protobuf form. The
+ * two forms use distinct filenames for the plan and manifest:
+ *
+ *
+ *
Format-specific filenames
+ *
Artifact
JSON
Proto
+ *
Plan
{@code plan.json}
{@code plan.pb}
+ *
Manifest
{@code .manifest}
{@code .manifest.pb}
+ *
+ *
+ *
Data files ({@code data.json}) are JSON in both forms; only the plan and manifest change.
+ *
+ *
OPA rejects bundles whose plan and manifest formats disagree (e.g. {@code plan.pb} paired with
+ * a JSON {@code .manifest}). {@link BundleLoader} implementations call {@link #validate} to enforce
+ * the same rule and to reject bundles that ambiguously contain both formats of the same artifact.
+ */
+public final class BundleFormat {
+
+ /** Filename of a JSON-format IR plan. */
+ public static final String PLAN_JSON = "plan.json";
+
+ /** Filename of a protobuf-format IR plan. */
+ public static final String PLAN_PROTO = "plan.pb";
+
+ /** Filename of a JSON-format bundle manifest. */
+ public static final String MANIFEST_JSON = ".manifest";
+
+ /** Filename of a protobuf-format bundle manifest. */
+ public static final String MANIFEST_PROTO = ".manifest.pb";
+
+ private BundleFormat() {}
+
+ /**
+ * Reject bundles that mix the two wire formats.
+ *
+ *
Fails if a bundle contains both formats of the same artifact (e.g. both {@code plan.json} and
+ * {@code plan.pb}), or if a plan and a manifest are present in disagreeing formats (e.g. a proto
+ * plan with a JSON manifest), matching OPA's own auto-detection semantics. A bundle carrying only
+ * a plan or only a manifest is always accepted.
+ *
+ * @param hasPlanJson whether a {@code plan.json} is present
+ * @param hasPlanProto whether a {@code plan.pb} is present
+ * @param hasManifestJson whether a {@code .manifest} is present
+ * @param hasManifestProto whether a {@code .manifest.pb} is present
+ * @throws IllegalArgumentException if the bundle mixes formats
+ */
+ public static void validate(
+ boolean hasPlanJson,
+ boolean hasPlanProto,
+ boolean hasManifestJson,
+ boolean hasManifestProto) {
+ if (hasPlanJson && hasPlanProto) {
+ throw new IllegalArgumentException(
+ "Bundle contains both " + PLAN_JSON + " and " + PLAN_PROTO + "; plan format is ambiguous");
+ }
+ if (hasManifestJson && hasManifestProto) {
+ throw new IllegalArgumentException(
+ "Bundle contains both "
+ + MANIFEST_JSON
+ + " and "
+ + MANIFEST_PROTO
+ + "; manifest format is ambiguous");
+ }
+ if (hasPlanProto && hasManifestJson) {
+ throw new IllegalArgumentException(
+ "Bundle mixes plan and manifest formats: proto plan ("
+ + PLAN_PROTO
+ + ") with JSON manifest ("
+ + MANIFEST_JSON
+ + ")");
+ }
+ if (hasPlanJson && hasManifestProto) {
+ throw new IllegalArgumentException(
+ "Bundle mixes plan and manifest formats: JSON plan ("
+ + PLAN_JSON
+ + ") with proto manifest ("
+ + MANIFEST_PROTO
+ + ")");
+ }
+ }
+}
diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/FileSystemBundleLoader.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/FileSystemBundleLoader.java
index 1c5db932..9cbb4994 100644
--- a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/FileSystemBundleLoader.java
+++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/FileSystemBundleLoader.java
@@ -31,19 +31,14 @@ public Bundle load(Store store) {
BundleAssembler assembler = new BundleAssembler();
try {
- Path planPath = directory.resolve("plan.json");
- if (Files.isRegularFile(planPath)) {
- try (InputStream in = Files.newInputStream(planPath)) {
- assembler.loadPlan(in);
- }
- }
-
- Path manifestPath = directory.resolve(".manifest");
- if (Files.isRegularFile(manifestPath)) {
- try (InputStream in = Files.newInputStream(manifestPath)) {
- assembler.loadManifest(in);
- }
- }
+ // OPA emits plan/manifest in either JSON (plan.json/.manifest) or proto
+ // (plan.pb/.manifest.pb). BundleAssembler detects the format, rejects mixed-format bundles,
+ // and loads each artifact; we only supply a stream for the files that exist.
+ assembler.loadPlanAndManifest(
+ fileSource(directory.resolve(BundleFormat.PLAN_JSON)),
+ fileSource(directory.resolve(BundleFormat.PLAN_PROTO)),
+ fileSource(directory.resolve(BundleFormat.MANIFEST_JSON)),
+ fileSource(directory.resolve(BundleFormat.MANIFEST_PROTO)));
try (Stream walk = Files.walk(directory)) {
walk.filter(Files::isRegularFile)
@@ -81,4 +76,9 @@ public Bundle load(Store store) {
"Error reading bundle directory: " + e.getMessage(), e);
}
}
+
+ /** An {@link InputStreamSource} that opens {@code path}, or {@code null} if it is not a file. */
+ private static InputStreamSource fileSource(Path path) {
+ return Files.isRegularFile(path) ? () -> Files.newInputStream(path) : null;
+ }
}
diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/InputStreamSource.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/InputStreamSource.java
new file mode 100644
index 00000000..17b8a54a
--- /dev/null
+++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/InputStreamSource.java
@@ -0,0 +1,23 @@
+package io.github.open_policy_agent.opa.bundle;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * Supplies a fresh {@link InputStream} for a single bundle artifact.
+ *
+ *
Lets {@link BundleLoader} implementations hand {@link BundleAssembler} a lazily-opened stream
+ * (e.g. a file opened on demand, or an in-memory buffer) without exposing how the bytes are
+ * sourced. Unlike {@link java.util.function.Supplier}, {@link #open()} may throw {@link IOException}.
+ */
+@FunctionalInterface
+public interface InputStreamSource {
+
+ /**
+ * Opens a new stream over the artifact's bytes. The caller closes it.
+ *
+ * @return the opened stream
+ * @throws IOException if the stream cannot be opened
+ */
+ InputStream open() throws IOException;
+}
diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/ProtoBundleDecoder.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/ProtoBundleDecoder.java
new file mode 100644
index 00000000..00f58003
--- /dev/null
+++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/bundle/ProtoBundleDecoder.java
@@ -0,0 +1,54 @@
+package io.github.open_policy_agent.opa.bundle;
+
+import io.github.open_policy_agent.opa.ir.policy.Policy;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Map;
+
+/**
+ * Optional SPI for decoding protobuf-format bundle files produced by
+ * {@code opa build --format=proto}.
+ *
+ *
OPA emits IR plan bundles in two wire formats. The default JSON form stores the plan at
+ * {@code /plan.json} and the manifest at {@code /.manifest}; the proto form stores them at
+ * {@code /plan.pb} and {@code /.manifest.pb} respectively. This SPI decodes the proto form into the
+ * same in-memory model the JSON {@link io.github.open_policy_agent.opa.ir.PolicyReader} produces, so
+ * evaluation is identical regardless of the on-disk format.
+ *
+ *
Unlike {@link io.github.open_policy_agent.opa.ir.PolicyReader} and {@link BundleParser} — of
+ * which exactly one implementation must be registered — this decoder is optional. The core
+ * {@code opa-evaluator} carries no protobuf dependency; a {@link BundleLoader} only requires a
+ * decoder when it actually encounters proto-format files. Zero registered implementations is a valid
+ * runtime (JSON-only bundles work without it); at most one may be registered.
+ *
+ *
Register implementations via {@link java.util.ServiceLoader} or, when using JPMS, via a
+ * {@code provides} declaration in {@code module-info.java}. The {@code opa-proto} module provides
+ * one.
+ *
+ *
Note that a proto-format bundle still stores data files as JSON ({@code data.json}); only the
+ * plan and manifest change format. Data parsing therefore continues to flow through
+ * {@link BundleParser}.
+ */
+public interface ProtoBundleDecoder {
+
+ /**
+ * Decode a compiled IR policy from a {@code plan.pb} stream.
+ *
+ * @param in the {@code plan.pb} input stream
+ * @return the decoded policy
+ * @throws IOException if the stream cannot be read or the bytes are not a valid proto plan
+ */
+ Policy decodePlan(InputStream in) throws IOException;
+
+ /**
+ * Decode bundle metadata from a {@code .manifest.pb} stream.
+ *
+ *
The returned map mirrors the shape produced by {@link BundleParser#parseManifest} for the
+ * JSON {@code .manifest}, so downstream consumers do not need to distinguish the two formats.
+ *
+ * @param in the {@code .manifest.pb} input stream
+ * @return the decoded manifest as a map
+ * @throws IOException if the stream cannot be read or the bytes are not a valid proto manifest
+ */
+ Map decodeManifest(InputStream in) throws IOException;
+}
diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/mapper/AnnotationIntrospectors.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/mapper/AnnotationIntrospectors.java
index 4e33faf9..e694bf3b 100644
--- a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/mapper/AnnotationIntrospectors.java
+++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/mapper/AnnotationIntrospectors.java
@@ -1,14 +1,13 @@
package io.github.open_policy_agent.opa.mapper;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.ServiceLoader;
+import io.github.open_policy_agent.opa.spi.Services;
/**
* Static accessor for the active {@link AnnotationIntrospector}. Discovers an implementation via
- * {@link ServiceLoader}; if none is registered, falls back to {@link DefaultAnnotationIntrospector}
- * (which performs no annotation lookups and so honors only JavaBean conventions). Throws if more
- * than one implementation is registered, to avoid an ambiguous runtime.
+ * {@link java.util.ServiceLoader}; if none is registered, falls back to {@link
+ * DefaultAnnotationIntrospector} (which performs no annotation lookups and so honors only JavaBean
+ * conventions). Throws if more than one implementation is registered, to avoid an ambiguous
+ * runtime.
*/
final class AnnotationIntrospectors {
@@ -21,26 +20,7 @@ static AnnotationIntrospector get() {
}
private static AnnotationIntrospector load() {
- List impls = new ArrayList<>();
- for (AnnotationIntrospector impl : ServiceLoader.load(AnnotationIntrospector.class)) {
- impls.add(impl);
- }
- if (impls.isEmpty()) {
- return new DefaultAnnotationIntrospector();
- }
- if (impls.size() > 1) {
- StringBuilder names = new StringBuilder();
- for (int i = 0; i < impls.size(); i++) {
- if (i > 0) {
- names.append(", ");
- }
- names.append(impls.get(i).getClass().getName());
- }
- throw new IllegalStateException(
- "Multiple AnnotationIntrospector implementations found on the classpath: "
- + names
- + ". Only one provider may be registered.");
- }
- return impls.get(0);
+ return Services.loadAtMostOne(AnnotationIntrospector.class)
+ .orElseGet(DefaultAnnotationIntrospector::new);
}
}
diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/spi/Services.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/spi/Services.java
new file mode 100644
index 00000000..af25e85c
--- /dev/null
+++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/spi/Services.java
@@ -0,0 +1,51 @@
+package io.github.open_policy_agent.opa.spi;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.ServiceLoader;
+
+/**
+ * Helpers for loading SPI implementations via {@link ServiceLoader} with a consistent
+ * "at most one provider" policy and error message.
+ *
+ *
Several SPIs in this SDK ({@code PolicyReader}, {@code BundleParser}, {@code
+ * ProtoBundleDecoder}, {@code AnnotationIntrospector}) permit only a single registered
+ * implementation. They differ only in how they treat the zero-implementations case — hard error,
+ * {@code null}, or a fallback — which callers express by mapping the returned {@link Optional}.
+ */
+public final class Services {
+
+ private Services() {}
+
+ /**
+ * Loads the single registered implementation of {@code spi}, if any.
+ *
+ * @param spi the service interface to load
+ * @param the service type
+ * @return the sole implementation, or {@link Optional#empty()} if none is registered
+ * @throws IllegalStateException if more than one implementation is registered
+ */
+ public static Optional loadAtMostOne(Class spi) {
+ List impls = new ArrayList<>();
+ for (T impl : ServiceLoader.load(spi)) {
+ impls.add(impl);
+ }
+ if (impls.size() > 1) {
+ StringBuilder names = new StringBuilder();
+ for (int i = 0; i < impls.size(); i++) {
+ if (i > 0) {
+ names.append(", ");
+ }
+ names.append(impls.get(i).getClass().getName());
+ }
+ throw new IllegalStateException(
+ "Multiple "
+ + spi.getSimpleName()
+ + " implementations found on the classpath: "
+ + names
+ + ". Only one provider may be registered.");
+ }
+ return impls.isEmpty() ? Optional.empty() : Optional.of(impls.get(0));
+ }
+}
diff --git a/opa-proto/build.gradle.kts b/opa-proto/build.gradle.kts
new file mode 100644
index 00000000..4d8a200b
--- /dev/null
+++ b/opa-proto/build.gradle.kts
@@ -0,0 +1,160 @@
+import com.google.protobuf.gradle.id
+import java.io.ByteArrayOutputStream
+import javax.inject.Inject
+import org.gradle.process.ExecOperations
+
+plugins {
+ `java-library`
+ id("com.google.protobuf") version "0.9.5"
+}
+
+repositories {
+ mavenCentral()
+}
+
+// protobuf-java 4.x is required: the vendored schemas use edition 2023, which
+// needs protoc >= 27 (the 4.27+ line) and a matching runtime.
+val protobufVersion = "4.29.3"
+
+dependencies {
+ api(project(":opa-evaluator"))
+ api("com.google.protobuf:protobuf-java:$protobufVersion")
+
+ testImplementation(project(":opa-jackson"))
+ testImplementation("org.junit.jupiter:junit-jupiter:6.1.0")
+ testImplementation("org.junit.jupiter:junit-jupiter-params:6.1.0")
+ testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.10.1")
+ testImplementation("org.assertj:assertj-core:3.27.7")
+}
+
+protobuf {
+ protoc {
+ artifact = "com.google.protobuf:protoc:$protobufVersion"
+ }
+}
+
+// Re-vendors the OPA .proto schemas from the OPA version pinned in
+// tools/generate-compliance-tests/go.mod (the source of truth). Wired ahead of
+// protobuf codegen and keyed on go.mod/go.sum, so simply building the module
+// re-vendors automatically whenever the pinned OPA version changes — and stays
+// UP-TO-DATE (no network) when it hasn't. Requires the Go toolchain; when Go is
+// unavailable the task is skipped and the committed schemas are used as-is.
+//
+// Implemented natively (via the injected ExecOperations service) rather than by
+// shelling out, so it is OS-agnostic and configuration-cache compatible.
+abstract class VendorProtoSchemas @Inject constructor(private val exec: ExecOperations) :
+ DefaultTask() {
+
+ @get:InputFile
+ abstract val goMod: RegularFileProperty
+
+ @get:InputFile
+ abstract val goSum: RegularFileProperty
+
+ // Absolute path to the resolved go/go.exe, or empty when Go is not installed.
+ @get:Input
+ abstract val goExecutable: Property
+
+ // The protobuf source root the schemas are copied into. Not declared as an @OutputDirectory
+ // because it doubles as protobuf codegen input; the stamp below carries up-to-date state.
+ @get:Internal
+ abstract val protoRoot: DirectoryProperty
+
+ @get:OutputFile
+ abstract val stamp: RegularFileProperty
+
+ @TaskAction
+ fun vendor() {
+ val goDir = goMod.get().asFile.parentFile
+
+ val version = capture("list", "-m", "-f", "{{.Version}}", MODULE).trim()
+ exec.exec {
+ commandLine(go(), "mod", "download", MODULE)
+ workingDir = goDir
+ // The temporary commit pin isn't in sum.golang.org yet; go.sum still enforces integrity.
+ // Scope the bypass to the OPA module (rather than GOSUMDB=off) so the checksum database
+ // stays enabled for everything else — notably the on-demand `golang.org/toolchain`
+ // download the `go 1.26` directive triggers on runners with an older Go, which fails
+ // outright when the sumdb is globally off. Remove once pinned to a tagged OPA release.
+ environment("GONOSUMDB", MODULE)
+ }
+ val moduleCache = capture("env", "GOMODCACHE").trim()
+
+ val src = File(moduleCache, "github.com/open-policy-agent/opa@$version")
+ val root = protoRoot.get().asFile
+ for (rel in listOf("v1/ir/plan.proto", "v1/bundle/manifest.proto")) {
+ val dest = File(root, rel)
+ File(src, rel).copyTo(dest, overwrite = true)
+ dest.setWritable(true) // module cache files are read-only
+ logger.lifecycle("vendored $rel from opa@$version")
+ }
+ stamp.get().asFile.writeText("re-vendored github.com/open-policy-agent/opa@$version\n")
+ }
+
+ private fun go() = goExecutable.get()
+
+ private fun capture(vararg args: String): String {
+ val out = ByteArrayOutputStream()
+ exec.exec {
+ commandLine(listOf(go()) + args)
+ workingDir = goMod.get().asFile.parentFile
+ environment("GONOSUMDB", MODULE)
+ standardOutput = out
+ }
+ return out.toString()
+ }
+
+ companion object {
+ const val MODULE = "github.com/open-policy-agent/opa"
+ }
+}
+
+// Resolve the Go executable at configuration time (OS-aware), or "" when absent.
+val resolvedGoExecutable =
+ providers.environmentVariable("PATH").zip(providers.systemProperty("os.name")) { path, os ->
+ val names = if (os.lowercase().contains("win")) listOf("go.exe", "go") else listOf("go")
+ path.split(File.pathSeparator)
+ .asSequence()
+ .flatMap { dir -> names.asSequence().map { File(dir, it) } }
+ .firstOrNull { it.canExecute() }
+ ?.absolutePath
+ ?: ""
+ }
+
+val vendorProtoSchemas by tasks.registering(VendorProtoSchemas::class) {
+ description = "Re-vendor OPA .proto schemas from the version pinned in go.mod"
+ group = "build"
+
+ goMod.set(rootProject.layout.projectDirectory.file("tools/generate-compliance-tests/go.mod"))
+ goSum.set(rootProject.layout.projectDirectory.file("tools/generate-compliance-tests/go.sum"))
+ goExecutable.set(resolvedGoExecutable)
+ protoRoot.set(layout.projectDirectory.dir("src/main/proto"))
+ stamp.set(layout.buildDirectory.file("vendor-protos.stamp"))
+
+ // Skip (leaving the committed schemas as-is) when Go isn't installed, so pure-Java builds work.
+ onlyIf { goExecutable.get().isNotEmpty() }
+}
+
+tasks.named("generateProto") {
+ dependsOn(vendorProtoSchemas)
+}
+
+// Generated protobuf sources live under opa.ir.v1 / opa.bundle.v1 (from the
+// vendored schemas). They are machine-generated, so exempt them from the code
+// quality gates applied to hand-written sources.
+tasks.withType().configureEach {
+ exclude("**/opa/ir/v1/**", "**/opa/bundle/v1/**")
+}
+tasks.withType().configureEach {
+ exclude("**/opa/ir/v1/**", "**/opa/bundle/v1/**")
+}
+
+tasks.test {
+ useJUnitPlatform()
+}
+
+java {
+ toolchain {
+ languageVersion = JavaLanguageVersion.of(17)
+ }
+}
diff --git a/opa-proto/src/main/java/io/github/open_policy_agent/opa/proto/ManifestMapper.java b/opa-proto/src/main/java/io/github/open_policy_agent/opa/proto/ManifestMapper.java
new file mode 100644
index 00000000..62d9a6b3
--- /dev/null
+++ b/opa-proto/src/main/java/io/github/open_policy_agent/opa/proto/ManifestMapper.java
@@ -0,0 +1,149 @@
+package io.github.open_policy_agent.opa.proto;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import opa.bundle.v1.Annotations;
+import opa.bundle.v1.AuthorAnnotation;
+import opa.bundle.v1.CompileAnnotation;
+import opa.bundle.v1.Manifest;
+import opa.bundle.v1.RelatedResourceAnnotation;
+import opa.bundle.v1.SchemaAnnotation;
+import opa.bundle.v1.WasmResolver;
+
+/**
+ * Maps a decoded proto {@link Manifest} into the {@code Map} shape produced for the
+ * JSON {@code .manifest} by the {@code BundleParser}, so downstream consumers see identical metadata
+ * regardless of the bundle's wire format.
+ *
+ *
Field presence mirrors OPA's JSON marshaling exactly: fields tagged {@code omitempty} (or
+ * emitted conditionally by a custom {@code MarshalJSON}, as {@code ast.Annotations} does) are
+ * included only when non-empty/non-default, so the proto path never emits a key the JSON reader
+ * would drop. Annotation {@code location} is intentionally never emitted: OPA gates it behind a
+ * marshal option that is off by default, so standard {@code opa build} manifests omit it.
+ */
+final class ManifestMapper {
+
+ private ManifestMapper() {}
+
+ static Map toMap(Manifest m) {
+ Map out = new LinkedHashMap<>();
+
+ // Revision has no omitempty in OPA's JSON tags — always present.
+ out.put("revision", m.getRevision());
+
+ // Roots is a pointer in Go (omitempty); roots_set distinguishes an explicitly-set list
+ // (possibly empty) from an absent one.
+ if (m.getRootsSet()) {
+ out.put("roots", new ArrayList<>(m.getRootsList()));
+ }
+
+ if (m.getWasmCount() > 0) {
+ List