diff --git a/apps/worker/src/main/java/com/orgmemory/worker/graph/GraphIndexingProcessor.java b/apps/worker/src/main/java/com/orgmemory/worker/graph/GraphIndexingProcessor.java
index adfb4b2c5..5a3b4c4aa 100644
--- a/apps/worker/src/main/java/com/orgmemory/worker/graph/GraphIndexingProcessor.java
+++ b/apps/worker/src/main/java/com/orgmemory/worker/graph/GraphIndexingProcessor.java
@@ -79,7 +79,7 @@ class GraphIndexingProcessor {
embeddingModels,
routes,
properties,
- eventSinks.getIfAvailable(() -> GraphRagEventSink.NO_OP));
+ GraphRagEventSink.composite(eventSinks.orderedStream().toList()));
}
GraphIndexingProcessor(
diff --git a/components/graph-rag-core/src/main/java/com/orgmemory/graphrag/observability/GraphRagEventSink.java b/components/graph-rag-core/src/main/java/com/orgmemory/graphrag/observability/GraphRagEventSink.java
index 944a5fc0e..885cebf09 100644
--- a/components/graph-rag-core/src/main/java/com/orgmemory/graphrag/observability/GraphRagEventSink.java
+++ b/components/graph-rag-core/src/main/java/com/orgmemory/graphrag/observability/GraphRagEventSink.java
@@ -2,6 +2,7 @@
import java.time.Duration;
import java.time.Instant;
+import java.util.List;
import java.util.Objects;
import java.util.UUID;
import java.util.regex.Pattern;
@@ -20,6 +21,45 @@ public interface GraphRagEventSink {
void emit(GraphRagEvent event);
+ /**
+ * Fans one event out to every sink so an application can observe the same
+ * stage through more than one backend.
+ *
+ *
Sinks fail independently: one failing backend still lets the others
+ * receive the event. The first failure is rethrown with the remaining ones
+ * suppressed, so a caller that already treats emission as non-critical keeps
+ * that behavior and a caller that does not still learns something broke.
+ */
+ static GraphRagEventSink composite(List sinks) {
+ List delegates =
+ List.copyOf(Objects.requireNonNull(sinks, "sinks"));
+ if (delegates.isEmpty()) {
+ return NO_OP;
+ }
+ if (delegates.size() == 1) {
+ return delegates.getFirst();
+ }
+ return event -> {
+ RuntimeException failure = null;
+ for (GraphRagEventSink delegate : delegates) {
+ try {
+ delegate.emit(event);
+ } catch (RuntimeException sinkFailure) {
+ if (failure == null) {
+ failure = sinkFailure;
+ } else if (failure != sinkFailure) {
+ // Throwable.addSuppressed rejects self-suppression, and two
+ // sinks can raise one shared instance.
+ failure.addSuppressed(sinkFailure);
+ }
+ }
+ }
+ if (failure != null) {
+ throw failure;
+ }
+ };
+ }
+
record GraphRagEvent(
UUID operationId,
UUID organizationId,
diff --git a/components/graph-rag-core/src/test/java/com/orgmemory/graphrag/observability/GraphRagEventSinkTests.java b/components/graph-rag-core/src/test/java/com/orgmemory/graphrag/observability/GraphRagEventSinkTests.java
index e1e3e8b6f..2422b07d9 100644
--- a/components/graph-rag-core/src/test/java/com/orgmemory/graphrag/observability/GraphRagEventSinkTests.java
+++ b/components/graph-rag-core/src/test/java/com/orgmemory/graphrag/observability/GraphRagEventSinkTests.java
@@ -1,9 +1,13 @@
package com.orgmemory.graphrag.observability;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.time.Duration;
import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.Test;
@@ -25,6 +29,98 @@ void rejectsUnboundedFailureDiagnostics() {
"provider failed: raw payload"));
}
+ @Test
+ void deliversOneEventToEveryConfiguredBackend() {
+ var otel = new RecordingSink();
+ var meters = new RecordingSink();
+
+ GraphRagEventSink.composite(List.of(otel, meters))
+ .emit(event(GraphRagEventSink.Outcome.SUCCEEDED, null, null));
+
+ assertEquals(1, otel.received.size());
+ assertEquals(1, meters.received.size());
+ }
+
+ @Test
+ void oneFailingBackendDoesNotSilenceTheOthers() {
+ var failing = new RecordingSink(new IllegalStateException("collector down"));
+ var healthy = new RecordingSink();
+
+ GraphRagEventSink composite = GraphRagEventSink.composite(List.of(failing, healthy));
+ var event = event(GraphRagEventSink.Outcome.SUCCEEDED, null, null);
+
+ assertThrows(IllegalStateException.class, () -> composite.emit(event));
+ assertEquals(1, healthy.received.size(), "a broken backend must not drop the rest");
+ }
+
+ @Test
+ void reportsEveryFailureRatherThanOnlyTheFirst() {
+ var firstFailure = new IllegalStateException("first");
+ var secondFailure = new IllegalStateException("second");
+
+ GraphRagEventSink composite = GraphRagEventSink.composite(
+ List.of(new RecordingSink(firstFailure), new RecordingSink(secondFailure)));
+ var event = event(GraphRagEventSink.Outcome.SUCCEEDED, null, null);
+
+ RuntimeException thrown =
+ assertThrows(IllegalStateException.class, () -> composite.emit(event));
+
+ assertSame(firstFailure, thrown, "the first failure is the one that propagates");
+ assertEquals(1, thrown.getSuppressed().length);
+ assertSame(secondFailure, thrown.getSuppressed()[0]);
+ }
+
+ @Test
+ void survivesTwoBackendsRaisingOneSharedFailure() {
+ var shared = new IllegalStateException("collector down");
+
+ GraphRagEventSink composite = GraphRagEventSink.composite(
+ List.of(new RecordingSink(shared), new RecordingSink(shared)));
+ var event = event(GraphRagEventSink.Outcome.SUCCEEDED, null, null);
+
+ RuntimeException thrown =
+ assertThrows(IllegalStateException.class, () -> composite.emit(event));
+
+ assertSame(shared, thrown);
+ assertEquals(
+ 0,
+ thrown.getSuppressed().length,
+ "Throwable.addSuppressed rejects self-suppression, so it must not be called");
+ }
+
+ @Test
+ void anApplicationWithoutBackendsEmitsNothingRatherThanFailing() {
+ assertSame(GraphRagEventSink.NO_OP, GraphRagEventSink.composite(List.of()));
+ }
+
+ @Test
+ void doesNotWrapASingleBackend() {
+ var only = new RecordingSink();
+ assertSame(only, GraphRagEventSink.composite(List.of(only)));
+ }
+
+ private static final class RecordingSink implements GraphRagEventSink {
+
+ private final List received = new ArrayList<>();
+ private final RuntimeException failure;
+
+ private RecordingSink() {
+ this(null);
+ }
+
+ private RecordingSink(RuntimeException failure) {
+ this.failure = failure;
+ }
+
+ @Override
+ public void emit(GraphRagEvent event) {
+ if (failure != null) {
+ throw failure;
+ }
+ received.add(event);
+ }
+ }
+
private static GraphRagEventSink.GraphRagEvent event(
GraphRagEventSink.Outcome outcome,
String routeFingerprint,
diff --git a/core/src/main/java/com/orgmemory/core/knowledge/GraphRagKnowledgeRetrievalConfiguration.java b/core/src/main/java/com/orgmemory/core/knowledge/GraphRagKnowledgeRetrievalConfiguration.java
index a0ab68417..aa970e148 100644
--- a/core/src/main/java/com/orgmemory/core/knowledge/GraphRagKnowledgeRetrievalConfiguration.java
+++ b/core/src/main/java/com/orgmemory/core/knowledge/GraphRagKnowledgeRetrievalConfiguration.java
@@ -40,6 +40,6 @@ GraphRagKnowledgeRetrievalService graphRagKnowledgeRetrievalService(
policy,
audit,
retrievalProperties,
- eventSinks.getIfAvailable(() -> GraphRagEventSink.NO_OP));
+ GraphRagEventSink.composite(eventSinks.orderedStream().toList()));
}
}
diff --git a/docs/specs/domains/secure-graph-rag.md b/docs/specs/domains/secure-graph-rag.md
index 9d390a94c..9f6ece893 100644
--- a/docs/specs/domains/secure-graph-rag.md
+++ b/docs/specs/domains/secure-graph-rag.md
@@ -5,7 +5,7 @@ Source: `components/graph-rag-core`, `components/graph-rag-testkit`,
`core/src/main/java/com/orgmemory/core/knowledge`, and
`apps/web/src/features/knowledge`.
-Reconciled: `2026-07-29-polyglot-apps-workspace (7acda3a)`.
+Reconciled: `2026-07-29-graph-rag-observability-wiring (fd495d0)`.
## Current Contract
@@ -140,6 +140,10 @@ Reconciled: `2026-07-29-polyglot-apps-workspace (7acda3a)`.
- Payload-free OpenTelemetry stages separate keyword planning/cache status,
embedding, hashed per-snapshot retrieval, consolidation, authorization and
provider-only reranking duration.
+- Indexing and retrieval fan one stage event out to every registered
+ `GraphRagEventSink`, so an application may observe the same stage through more
+ than one backend. Sinks fail independently and emission never controls
+ indexing or retrieval availability.
## Graph Explorer
diff --git a/docs/tests/domains/secure-graph-rag.md b/docs/tests/domains/secure-graph-rag.md
index b8f0cd49f..c2c742e1c 100644
--- a/docs/tests/domains/secure-graph-rag.md
+++ b/docs/tests/domains/secure-graph-rag.md
@@ -6,7 +6,7 @@ Source: `components/graph-rag-core/src/test`,
`core/src/test/java/com/orgmemory/core/knowledge`, and
`apps/web/test/e2e`.
-Reconciled: `2026-07-29-polyglot-apps-workspace (7acda3a)`.
+Reconciled: `2026-07-29-graph-rag-observability-wiring (fd495d0)`.
## Automated
@@ -50,6 +50,17 @@ Reconciled: `2026-07-29-polyglot-apps-workspace (7acda3a)`.
- OpenTelemetry adapter tests prove the closed payload-free attribute set,
original stage timing, cache status, model-route fingerprint and hashed scope
fingerprint.
+- Event-sink composition tests prove every registered backend receives the
+ event, that one failing backend neither silences the others nor hides its own
+ failure, that the first failure is the one propagated with later ones
+ suppressed, that two backends raising one shared failure instance do not trip
+ self-suppression, and that an application with no backend emits nothing.
+- Storage adapter auto-configuration tests prove PostgreSQL, OpenSearch and
+ Neo4j stay discoverable through their registration files, that PostgreSQL owns
+ the canonical ports without an opt-in, that OpenSearch and Neo4j claim no port
+ until enabled, and that an enabled Neo4j without a password fails startup.
+ The OpenSearch wiring assertion runs against the container because its
+ publication store creates an index while the bean is built.
## Verification
diff --git a/integrations/graph-rag-neo4j/build.gradle.kts b/integrations/graph-rag-neo4j/build.gradle.kts
index d5fe0e73c..1a6c9bea1 100644
--- a/integrations/graph-rag-neo4j/build.gradle.kts
+++ b/integrations/graph-rag-neo4j/build.gradle.kts
@@ -10,6 +10,9 @@ dependencies {
testImplementation(project(":components:graph-rag-testkit"))
testImplementation("org.junit.jupiter:junit-jupiter")
+ testImplementation("org.mockito:mockito-core")
+ testImplementation("org.springframework.boot:spring-boot-test")
+ testImplementation("org.assertj:assertj-core")
testImplementation("org.testcontainers:testcontainers-neo4j")
testImplementation("org.testcontainers:testcontainers-junit-jupiter")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
diff --git a/integrations/graph-rag-neo4j/src/test/java/com/orgmemory/graphrag/neo4j/Neo4jGraphRagAutoConfigurationTests.java b/integrations/graph-rag-neo4j/src/test/java/com/orgmemory/graphrag/neo4j/Neo4jGraphRagAutoConfigurationTests.java
new file mode 100644
index 000000000..b250fc47b
--- /dev/null
+++ b/integrations/graph-rag-neo4j/src/test/java/com/orgmemory/graphrag/neo4j/Neo4jGraphRagAutoConfigurationTests.java
@@ -0,0 +1,124 @@
+package com.orgmemory.graphrag.neo4j;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+
+import com.orgmemory.graphrag.storage.GraphStore;
+import com.orgmemory.graphrag.storage.ProjectionPublicationStore;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+import org.neo4j.driver.Driver;
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.context.annotation.ImportCandidates;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * Neo4j takes the graph port away from whichever adapter would otherwise hold
+ * it, so the same two questions matter as for OpenSearch: it must stay inert
+ * until an operator enables it, and enabling it must actually produce the
+ * store rather than fail somewhere Spring reports as a missing bean.
+ */
+class Neo4jGraphRagAutoConfigurationTests {
+
+ private final ApplicationContextRunner runner = new ApplicationContextRunner()
+ .withConfiguration(AutoConfigurations.of(Neo4jGraphRagAutoConfiguration.class))
+ .withUserConfiguration(CollaboratorConfiguration.class);
+
+ @Test
+ void staysDiscoverableWithoutAnApplicationNamingIt() {
+ assertTrue(
+ registeredAutoConfigurations()
+ .contains(Neo4jGraphRagAutoConfiguration.class.getName()),
+ "META-INF/spring/…AutoConfiguration.imports no longer names this class, "
+ + "so enabling the property would silently do nothing");
+ }
+
+ @Test
+ void claimsNoGraphPortUntilAnOperatorAsksForIt() {
+ runner.run(context -> assertTrue(
+ context.getBeansOfType(GraphStore.class).isEmpty(),
+ "classpath presence must not displace the canonical graph store"));
+ }
+
+ @Test
+ void contributesTheGraphStoreOnceEnabled() {
+ runner.withPropertyValues(
+ "orgmemory.graph-rag.neo4j.enabled=true",
+ "orgmemory.graph-rag.neo4j.password=test-password")
+ .run(context -> assertInstanceOf(
+ Neo4jGraphStore.class, context.getBean(GraphStore.class)));
+ }
+
+ @Test
+ void refusesToStartRatherThanReachNeo4jUnauthenticated() {
+ new ApplicationContextRunner()
+ .withConfiguration(AutoConfigurations.of(Neo4jGraphRagAutoConfiguration.class))
+ .withUserConfiguration(PublicationConfiguration.class)
+ .withPropertyValues("orgmemory.graph-rag.neo4j.enabled=true")
+ .run(context -> {
+ Throwable cause = rootCause(context.getStartupFailure());
+ assertInstanceOf(
+ IllegalArgumentException.class,
+ cause,
+ "an enabled adapter without a password must fail startup");
+ assertEquals(
+ "Neo4j password must be configured when the adapter is enabled",
+ cause.getMessage(),
+ "the password branch must be what fails, not another "
+ + "validation rule");
+ });
+ }
+
+ private static Throwable rootCause(Throwable failure) {
+ Throwable cause = failure;
+ while (cause != null && cause.getCause() != null) {
+ cause = cause.getCause();
+ }
+ return cause;
+ }
+
+ private static List registeredAutoConfigurations() {
+ var names = new ArrayList();
+ ImportCandidates.load(
+ AutoConfiguration.class,
+ Neo4jGraphRagAutoConfigurationTests.class.getClassLoader())
+ .forEach(names::add);
+ return names;
+ }
+
+ /**
+ * The collaborators an application already owns before this adapter loads.
+ * Supplying the driver and {@link Neo4jOperations} keeps a live Neo4j out of
+ * a unit test; the adapter backs off both because they are conditional on a
+ * missing bean.
+ */
+ @Configuration(proxyBeanMethods = false)
+ static class CollaboratorConfiguration extends PublicationConfiguration {
+
+ @Bean
+ Driver neo4jDriver() {
+ return mock(Driver.class);
+ }
+
+ @Bean
+ Neo4jOperations neo4jOperations() {
+ return mock(Neo4jOperations.class);
+ }
+ }
+
+ /** Only the port the adapter expects an application to already provide. */
+ @Configuration(proxyBeanMethods = false)
+ static class PublicationConfiguration {
+
+ @Bean
+ ProjectionPublicationStore projectionPublicationStore() {
+ return mock(ProjectionPublicationStore.class);
+ }
+ }
+}
diff --git a/integrations/graph-rag-opensearch/build.gradle.kts b/integrations/graph-rag-opensearch/build.gradle.kts
index 571049ee4..3d5e73bb6 100644
--- a/integrations/graph-rag-opensearch/build.gradle.kts
+++ b/integrations/graph-rag-opensearch/build.gradle.kts
@@ -12,6 +12,8 @@ dependencies {
testImplementation(project(":components:graph-rag-testkit"))
testImplementation("org.junit.jupiter:junit-jupiter")
+ testImplementation("org.springframework.boot:spring-boot-test")
+ testImplementation("org.assertj:assertj-core")
testImplementation("org.testcontainers:testcontainers")
testImplementation("org.testcontainers:testcontainers-junit-jupiter")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
diff --git a/integrations/graph-rag-opensearch/src/test/java/com/orgmemory/graphrag/opensearch/OpenSearchGraphRagAutoConfigurationTests.java b/integrations/graph-rag-opensearch/src/test/java/com/orgmemory/graphrag/opensearch/OpenSearchGraphRagAutoConfigurationTests.java
new file mode 100644
index 000000000..f6cea1943
--- /dev/null
+++ b/integrations/graph-rag-opensearch/src/test/java/com/orgmemory/graphrag/opensearch/OpenSearchGraphRagAutoConfigurationTests.java
@@ -0,0 +1,67 @@
+package com.orgmemory.graphrag.opensearch;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.orgmemory.graphrag.storage.ContentStore;
+import com.orgmemory.graphrag.storage.GraphStore;
+import com.orgmemory.graphrag.storage.LexicalIndex;
+import com.orgmemory.graphrag.storage.ProcessingStatusIndex;
+import com.orgmemory.graphrag.storage.ProjectionPublicationStore;
+import com.orgmemory.graphrag.storage.VectorIndex;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.context.annotation.ImportCandidates;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+
+/**
+ * OpenSearch takes the storage ports away from PostgreSQL when it loads, so the
+ * property that turns it on is a production-shaped decision rather than a tuning
+ * knob. These are the two questions that need no server to answer: Spring can
+ * still find the adapter, and the adapter stays out of the way until asked for.
+ * {@link OpenSearchProjectionPublicationIntegrationTests} covers what wiring it
+ * up actually produces, because these beans reach OpenSearch as they are built.
+ */
+class OpenSearchGraphRagAutoConfigurationTests {
+
+ private final ApplicationContextRunner runner = new ApplicationContextRunner()
+ .withConfiguration(
+ AutoConfigurations.of(OpenSearchGraphRagAutoConfiguration.class));
+
+ @Test
+ void staysDiscoverableWithoutAnApplicationNamingIt() {
+ assertTrue(
+ registeredAutoConfigurations()
+ .contains(OpenSearchGraphRagAutoConfiguration.class.getName()),
+ "META-INF/spring/…AutoConfiguration.imports no longer names this class, "
+ + "so enabling the property would silently do nothing");
+ }
+
+ /** Every port {@link OpenSearchGraphRagAutoConfiguration} contributes when enabled. */
+ private static final List> CLAIMED_PORTS = List.of(
+ ProjectionPublicationStore.class,
+ ContentStore.class,
+ GraphStore.class,
+ LexicalIndex.class,
+ VectorIndex.class,
+ ProcessingStatusIndex.class);
+
+ @Test
+ void claimsNoPortUntilAnOperatorAsksForIt() {
+ runner.run(context -> CLAIMED_PORTS.forEach(port -> assertTrue(
+ context.getBeansOfType(port).isEmpty(),
+ "%s must stay with the canonical PostgreSQL adapter until OpenSearch is enabled"
+ .formatted(port.getSimpleName()))));
+ }
+
+ private static List registeredAutoConfigurations() {
+ var names = new ArrayList();
+ ImportCandidates.load(
+ AutoConfiguration.class,
+ OpenSearchGraphRagAutoConfigurationTests.class.getClassLoader())
+ .forEach(names::add);
+ return names;
+ }
+}
diff --git a/integrations/graph-rag-opensearch/src/test/java/com/orgmemory/graphrag/opensearch/OpenSearchProjectionPublicationIntegrationTests.java b/integrations/graph-rag-opensearch/src/test/java/com/orgmemory/graphrag/opensearch/OpenSearchProjectionPublicationIntegrationTests.java
index e7e275fc3..5e55a8651 100644
--- a/integrations/graph-rag-opensearch/src/test/java/com/orgmemory/graphrag/opensearch/OpenSearchProjectionPublicationIntegrationTests.java
+++ b/integrations/graph-rag-opensearch/src/test/java/com/orgmemory/graphrag/opensearch/OpenSearchProjectionPublicationIntegrationTests.java
@@ -1,6 +1,7 @@
package com.orgmemory.graphrag.opensearch;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -17,6 +18,7 @@
import com.orgmemory.graphrag.model.RelationOrientation;
import com.orgmemory.graphrag.port.GraphRevisionContributions;
import com.orgmemory.graphrag.storage.ContentStore;
+import com.orgmemory.graphrag.storage.GraphStore;
import com.orgmemory.graphrag.storage.LexicalIndex;
import com.orgmemory.graphrag.storage.ProjectionBatch;
import com.orgmemory.graphrag.storage.ProjectionKind;
@@ -36,6 +38,10 @@
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import org.testcontainers.junit.jupiter.Container;
@@ -608,6 +614,55 @@ void pplAppliesAuthorizedEvidenceFilterAtEveryTraversalHop() {
assertTrue(ppl.successfulExecutions() > executionsBefore);
}
+ /**
+ * The rest of this class builds adapters directly, which proves they work but
+ * not that Spring produces them. Enabling the adapter has to contribute every
+ * port it declares, because each one it claims is a port PostgreSQL no longer
+ * gets to serve.
+ */
+ @Test
+ void autoConfigurationContributesEveryPortItClaimsOnceEnabled() {
+ new ApplicationContextRunner()
+ .withConfiguration(
+ AutoConfigurations.of(OpenSearchGraphRagAutoConfiguration.class))
+ .withUserConfiguration(ObjectMapperConfiguration.class)
+ .withPropertyValues(
+ "orgmemory.graph-rag.opensearch.enabled=true",
+ "orgmemory.graph-rag.opensearch.endpoint=http://%s:%d"
+ .formatted(
+ opensearch.getHost(),
+ opensearch.getMappedPort(9200)),
+ "orgmemory.graph-rag.opensearch.index-prefix=orgmemory-wiring-test")
+ .run(context -> {
+ assertInstanceOf(
+ OpenSearchProjectionPublicationStore.class,
+ context.getBean(
+ com.orgmemory.graphrag.storage.ProjectionPublicationStore
+ .class));
+ assertInstanceOf(
+ OpenSearchContentStore.class, context.getBean(ContentStore.class));
+ assertInstanceOf(
+ OpenSearchGraphStore.class, context.getBean(GraphStore.class));
+ assertInstanceOf(
+ OpenSearchLexicalIndex.class, context.getBean(LexicalIndex.class));
+ assertInstanceOf(
+ OpenSearchVectorIndex.class, context.getBean(VectorIndex.class));
+ assertInstanceOf(
+ OpenSearchProcessingStatusIndex.class,
+ context.getBean(ProcessingStatusIndex.class));
+ });
+ }
+
+ /** The collaborator an application already owns before this adapter loads. */
+ @Configuration(proxyBeanMethods = false)
+ static class ObjectMapperConfiguration {
+
+ @Bean
+ ObjectMapper wiringTestObjectMapper() {
+ return new ObjectMapper();
+ }
+ }
+
private static ProjectionBatch batch(
ProjectionNamespace namespace,
String key,
diff --git a/integrations/graph-rag-postgres/build.gradle.kts b/integrations/graph-rag-postgres/build.gradle.kts
index f6fad813c..43c269459 100644
--- a/integrations/graph-rag-postgres/build.gradle.kts
+++ b/integrations/graph-rag-postgres/build.gradle.kts
@@ -13,6 +13,9 @@ dependencies {
testImplementation(project(":core"))
testImplementation(project(":components:graph-rag-testkit"))
testImplementation("org.junit.jupiter:junit-jupiter")
+ testImplementation("org.mockito:mockito-core")
+ testImplementation("org.springframework.boot:spring-boot-test")
+ testImplementation("org.assertj:assertj-core")
testImplementation("org.flywaydb:flyway-core")
testImplementation("org.flywaydb:flyway-database-postgresql")
testImplementation("org.testcontainers:testcontainers-postgresql")
diff --git a/integrations/graph-rag-postgres/src/test/java/com/orgmemory/graphrag/postgres/PostgresGraphRagAutoConfigurationTests.java b/integrations/graph-rag-postgres/src/test/java/com/orgmemory/graphrag/postgres/PostgresGraphRagAutoConfigurationTests.java
new file mode 100644
index 000000000..8139f5c3f
--- /dev/null
+++ b/integrations/graph-rag-postgres/src/test/java/com/orgmemory/graphrag/postgres/PostgresGraphRagAutoConfigurationTests.java
@@ -0,0 +1,110 @@
+package com.orgmemory.graphrag.postgres;
+
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+
+import com.orgmemory.graphrag.cache.ModelInvocationCache;
+import com.orgmemory.graphrag.cache.RetrievalResultCache;
+import com.orgmemory.graphrag.storage.ContentStore;
+import com.orgmemory.graphrag.storage.GraphStore;
+import com.orgmemory.graphrag.storage.LexicalIndex;
+import com.orgmemory.graphrag.storage.ProjectionPublicationStore;
+import com.orgmemory.graphrag.storage.VectorIndex;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.context.annotation.ImportCandidates;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
+import org.springframework.transaction.PlatformTransactionManager;
+
+/**
+ * Every other test in this module builds an adapter directly, so none of them
+ * notices when Spring stops loading the module at all. The registration file is
+ * plain text that no compiler checks, and the ports are contributed under
+ * conditions that a property or an ordering change can quietly invert. These
+ * tests fail in that case instead of leaving it for production to discover.
+ */
+class PostgresGraphRagAutoConfigurationTests {
+
+ /** Every port this adapter contributes outside a servlet application. */
+ private static final List> CANONICAL_PORTS = List.of(
+ ProjectionPublicationStore.class,
+ ContentStore.class,
+ GraphStore.class,
+ LexicalIndex.class,
+ VectorIndex.class,
+ ModelInvocationCache.class,
+ RetrievalResultCache.class);
+
+ private final ApplicationContextRunner runner = new ApplicationContextRunner()
+ .withConfiguration(
+ AutoConfigurations.of(PostgresGraphRagAutoConfiguration.class))
+ .withUserConfiguration(CollaboratorConfiguration.class);
+
+ @Test
+ void staysDiscoverableWithoutAnApplicationNamingIt() {
+ assertTrue(
+ registeredAutoConfigurations()
+ .contains(PostgresGraphRagAutoConfiguration.class.getName()),
+ "META-INF/spring/…AutoConfiguration.imports no longer names this class, "
+ + "so Spring Boot will never load the PostgreSQL adapter");
+ }
+
+ @Test
+ void ownsTheCanonicalPortsWithoutAnyOptIn() {
+ runner.run(context -> {
+ assertInstanceOf(
+ PostgresProjectionPublicationStore.class,
+ context.getBean(ProjectionPublicationStore.class));
+ assertInstanceOf(PostgresContentStore.class, context.getBean(ContentStore.class));
+ assertInstanceOf(PostgresGraphStore.class, context.getBean(GraphStore.class));
+ assertInstanceOf(PostgresLexicalIndex.class, context.getBean(LexicalIndex.class));
+ assertInstanceOf(PostgresVectorIndex.class, context.getBean(VectorIndex.class));
+ assertInstanceOf(
+ PostgresModelInvocationCache.class,
+ context.getBean(ModelInvocationCache.class));
+ assertInstanceOf(
+ PostgresRetrievalResultCache.class,
+ context.getBean(RetrievalResultCache.class));
+ });
+ }
+
+ @Test
+ void leavesEveryPortUnclaimedWhenTurnedOff() {
+ runner.withPropertyValues("orgmemory.graph-rag.postgres.enabled=false")
+ .run(context -> CANONICAL_PORTS.forEach(port -> assertTrue(
+ context.getBeansOfType(port).isEmpty(),
+ "disabling the adapter must not leave %s wired"
+ .formatted(port.getSimpleName()))));
+ }
+
+ private static List registeredAutoConfigurations() {
+ var names = new ArrayList();
+ ImportCandidates.load(
+ AutoConfiguration.class,
+ PostgresGraphRagAutoConfigurationTests.class.getClassLoader())
+ .forEach(names::add);
+ return names;
+ }
+
+ /** The collaborators an application already owns before this adapter loads. */
+ @Configuration(proxyBeanMethods = false)
+ static class CollaboratorConfiguration {
+
+ @Bean
+ NamedParameterJdbcTemplate namedParameterJdbcTemplate() {
+ return mock(NamedParameterJdbcTemplate.class);
+ }
+
+ @Bean
+ PlatformTransactionManager transactionManager() {
+ return mock(PlatformTransactionManager.class);
+ }
+ }
+}