diff --git a/backend/apps/dev/build.gradle b/backend/apps/dev/build.gradle
index ced247e784..29475cd733 100644
--- a/backend/apps/dev/build.gradle
+++ b/backend/apps/dev/build.gradle
@@ -51,6 +51,9 @@ dependencies {
implementation "org.springframework.boot:spring-boot-starter-webflux"
implementation "org.springframework.boot:spring-boot-starter-security"
+ // OpenSearch client for InsecureSslOpenSearchClientConfig
+ implementation "org.opensearch.client:spring-data-opensearch-starter:${springDataOpenSearchVersion}"
+
// Spring cloud stream RabbitMQ
implementation "org.springframework.cloud:spring-cloud-starter-stream-rabbit:${springCloudStreamVersion}"
implementation "com.rabbitmq:amqp-client:$amqpCLientVersion"
diff --git a/backend/apps/dev/docker-compose.yaml b/backend/apps/dev/docker-compose.yaml
index a7be480dce..afaa1a1af0 100644
--- a/backend/apps/dev/docker-compose.yaml
+++ b/backend/apps/dev/docker-compose.yaml
@@ -82,6 +82,24 @@ services:
volumes:
- gzac-database-data-mysql:/var/lib/mysql # persist data even if container shuts down
+ gzac-opensearch:
+ container_name: gzac-docker-compose-gzac-opensearch
+ image: opensearchproject/opensearch:2.19.2
+ ports:
+ - "9200:9200"
+ environment:
+ - discovery.type=single-node
+ - OPENSEARCH_INITIAL_ADMIN_PASSWORD=2cr8zkOoEXQJ3xUk!Aa1
+ - OPENSEARCH_JAVA_OPTS=-Xms256m -Xmx256m
+ volumes:
+ - gzac-opensearch-data:/usr/share/opensearch/data
+ healthcheck:
+ test: [ "CMD-SHELL", "curl -sf -u admin:2cr8zkOoEXQJ3xUk!Aa1 https://localhost:9200/_cluster/health -k || exit 1" ]
+ interval: 10s
+ timeout: 5s
+ retries: 12
+ start_period: 30s
+
gzac-rabbitmq:
image: rabbitmq:4.1.0-management
container_name: gzac-docker-compose-gzac-rabbitmq
@@ -733,3 +751,4 @@ services:
volumes:
gzac-database-data:
gzac-database-data-mysql:
+ gzac-opensearch-data:
diff --git a/backend/apps/dev/src/main/kotlin/com/ritense/gzac/opensearch/InsecureSslOpenSearchClientConfig.kt b/backend/apps/dev/src/main/kotlin/com/ritense/gzac/opensearch/InsecureSslOpenSearchClientConfig.kt
new file mode 100644
index 0000000000..7b99905f44
--- /dev/null
+++ b/backend/apps/dev/src/main/kotlin/com/ritense/gzac/opensearch/InsecureSslOpenSearchClientConfig.kt
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2015-2026 Ritense BV, the Netherlands.
+ *
+ * Licensed under EUPL, Version 1.2 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.ritense.gzac.opensearch
+
+import org.apache.http.auth.AuthScope
+import org.apache.http.auth.UsernamePasswordCredentials
+import org.apache.http.conn.ssl.NoopHostnameVerifier
+import org.apache.http.conn.ssl.TrustAllStrategy
+import org.apache.http.impl.client.BasicCredentialsProvider
+import org.apache.http.ssl.SSLContextBuilder
+import org.opensearch.client.RestClientBuilder
+import org.opensearch.spring.boot.autoconfigure.RestClientBuilderCustomizer
+import org.springframework.beans.factory.annotation.Value
+import org.springframework.context.annotation.Bean
+import org.springframework.context.annotation.Configuration
+
+/**
+ * Dev-only configuration that disables SSL certificate verification for OpenSearch
+ * and configures basic authentication.
+ * Allows connecting to OpenSearch with self-signed certificates in local development.
+ *
+ * DO NOT use this configuration in production.
+ */
+@Configuration
+class InsecureSslOpenSearchClientConfig {
+
+ @Value("\${opensearch.username:}")
+ private lateinit var username: String
+
+ @Value("\${opensearch.password:}")
+ private lateinit var password: String
+
+ @Bean
+ fun insecureSslRestClientBuilderCustomizer(): RestClientBuilderCustomizer {
+ return object : RestClientBuilderCustomizer {
+ override fun customize(builder: RestClientBuilder) {
+ builder.setHttpClientConfigCallback { httpClientBuilder ->
+ val sslContext = SSLContextBuilder.create()
+ .loadTrustMaterial(TrustAllStrategy.INSTANCE)
+ .build()
+
+ httpClientBuilder
+ .setSSLContext(sslContext)
+ .setSSLHostnameVerifier(NoopHostnameVerifier.INSTANCE)
+
+ if (username.isNotBlank() && password.isNotBlank()) {
+ val credentialsProvider = BasicCredentialsProvider()
+ credentialsProvider.setCredentials(
+ AuthScope.ANY,
+ UsernamePasswordCredentials(username, password)
+ )
+ httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider)
+ }
+
+ httpClientBuilder
+ }
+ }
+ }
+ }
+}
diff --git a/backend/apps/dev/src/main/resources/config/application.yml b/backend/apps/dev/src/main/resources/config/application.yml
index 8c578757ab..e46547c891 100644
--- a/backend/apps/dev/src/main/resources/config/application.yml
+++ b/backend/apps/dev/src/main/resources/config/application.yml
@@ -175,7 +175,14 @@ mailing:
whitelistedDomains:
sendRedirectedMailsTo:
+opensearch:
+ uris: https://localhost:9200
+ username: admin
+ password: 2cr8zkOoEXQJ3xUk!Aa1
+
valtimo:
+ opensearch:
+ enabled: true
app:
scheme: http
hostname: ${VALTIMO_APP_HOSTNAME}
diff --git a/backend/apps/dev/src/main/resources/logback-spring.xml b/backend/apps/dev/src/main/resources/logback-spring.xml
index e21b79db93..2197248e46 100644
--- a/backend/apps/dev/src/main/resources/logback-spring.xml
+++ b/backend/apps/dev/src/main/resources/logback-spring.xml
@@ -63,6 +63,7 @@
+ true
diff --git a/backend/apps/evenementenvergunning/src/main/resources/config/application.yml b/backend/apps/evenementenvergunning/src/main/resources/config/application.yml
index 2d7fbfed62..9f8b884bee 100644
--- a/backend/apps/evenementenvergunning/src/main/resources/config/application.yml
+++ b/backend/apps/evenementenvergunning/src/main/resources/config/application.yml
@@ -137,6 +137,11 @@ server:
mime-types: text/html,text/xml,text/plain,text/css, application/javascript, application/json
min-response-size: 1024
+opensearch:
+ uris: ${OPENSEARCH_URIS:}
+ username: ${OPENSEARCH_USERNAME:}
+ password: ${OPENSEARCH_PASSWORD:}
+
mailing:
onlyAllowWhitelistedRecipients: true
redirectAllMails: false
@@ -145,6 +150,8 @@ mailing:
sendRedirectedMailsTo:
valtimo:
+ opensearch:
+ enabled: ${VALTIMO_OPENSEARCH_ENABLED:false}
app:
scheme: http
hostname: ${VALTIMO_APP_HOSTNAME}
diff --git a/backend/apps/evenementenvergunning/src/main/resources/logback-spring.xml b/backend/apps/evenementenvergunning/src/main/resources/logback-spring.xml
index 590e9819e8..4292a05559 100644
--- a/backend/apps/evenementenvergunning/src/main/resources/logback-spring.xml
+++ b/backend/apps/evenementenvergunning/src/main/resources/logback-spring.xml
@@ -1,5 +1,21 @@
+
+
@@ -44,6 +60,7 @@
+ true
diff --git a/backend/apps/gzac/src/main/resources/config/application.yml b/backend/apps/gzac/src/main/resources/config/application.yml
index 2d7fbfed62..9f8b884bee 100644
--- a/backend/apps/gzac/src/main/resources/config/application.yml
+++ b/backend/apps/gzac/src/main/resources/config/application.yml
@@ -137,6 +137,11 @@ server:
mime-types: text/html,text/xml,text/plain,text/css, application/javascript, application/json
min-response-size: 1024
+opensearch:
+ uris: ${OPENSEARCH_URIS:}
+ username: ${OPENSEARCH_USERNAME:}
+ password: ${OPENSEARCH_PASSWORD:}
+
mailing:
onlyAllowWhitelistedRecipients: true
redirectAllMails: false
@@ -145,6 +150,8 @@ mailing:
sendRedirectedMailsTo:
valtimo:
+ opensearch:
+ enabled: ${VALTIMO_OPENSEARCH_ENABLED:false}
app:
scheme: http
hostname: ${VALTIMO_APP_HOSTNAME}
diff --git a/backend/apps/gzac/src/main/resources/logback-spring.xml b/backend/apps/gzac/src/main/resources/logback-spring.xml
index 590e9819e8..4292a05559 100644
--- a/backend/apps/gzac/src/main/resources/logback-spring.xml
+++ b/backend/apps/gzac/src/main/resources/logback-spring.xml
@@ -1,5 +1,21 @@
+
+
@@ -44,6 +60,7 @@
+ true
diff --git a/backend/apps/valtimo/src/main/resources/config/application.yml b/backend/apps/valtimo/src/main/resources/config/application.yml
index e4ff90f8bd..4b9c346fcc 100644
--- a/backend/apps/valtimo/src/main/resources/config/application.yml
+++ b/backend/apps/valtimo/src/main/resources/config/application.yml
@@ -18,6 +18,8 @@ logging:
management:
endpoint:
health:
+ probes:
+ enabled: true
group:
# readiness (and a dedicated startup group) only report UP once the bootstrap
# health indicator is UP, i.e. after migrations + autodeployments have finished.
@@ -137,6 +139,11 @@ server:
mime-types: text/html,text/xml,text/plain,text/css, application/javascript, application/json
min-response-size: 1024
+opensearch:
+ uris: ${OPENSEARCH_URIS:}
+ username: ${OPENSEARCH_USERNAME:}
+ password: ${OPENSEARCH_PASSWORD:}
+
mailing:
onlyAllowWhitelistedRecipients: true
redirectAllMails: false
@@ -145,6 +152,8 @@ mailing:
sendRedirectedMailsTo:
valtimo:
+ opensearch:
+ enabled: ${VALTIMO_OPENSEARCH_ENABLED:false}
app:
scheme: http
hostname: ${VALTIMO_APP_HOSTNAME}
diff --git a/backend/apps/valtimo/src/main/resources/logback-spring.xml b/backend/apps/valtimo/src/main/resources/logback-spring.xml
index 590e9819e8..4292a05559 100644
--- a/backend/apps/valtimo/src/main/resources/logback-spring.xml
+++ b/backend/apps/valtimo/src/main/resources/logback-spring.xml
@@ -1,5 +1,21 @@
+
+
@@ -44,6 +60,7 @@
+ true
diff --git a/backend/case-opensearch/build.gradle b/backend/case-opensearch/build.gradle
new file mode 100644
index 0000000000..0cf3155a73
--- /dev/null
+++ b/backend/case-opensearch/build.gradle
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2015-2024 Ritense BV, the Netherlands.
+ *
+ * Licensed under EUPL, Version 1.2 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+dockerCompose {
+ projectName = "case-opensearch"
+ integrationTestingPostgresql {
+ isRequiredBy(project.tasks.integrationTestingPostgresql)
+ useComposeFiles.addAll(
+ "../docker-resources/docker-compose-base-test-postgresql.yml",
+ "docker-compose-override-postgresql.yml"
+ )
+ }
+}
+
+dependencies {
+ implementation project(":backend:admin-settings")
+ implementation project(":backend:authorization")
+ implementation project(":backend:case")
+ implementation project(":backend:inbox")
+ implementation project(":backend:outbox")
+
+ // ShedLock for cross-instance coordination of the re-index job. The LockProvider bean itself
+ // is supplied at runtime by core's SchedulerAutoConfiguration; here we only need the API.
+ implementation "net.javacrumbs.shedlock:shedlock-spring:${shedlockVersion}"
+
+ implementation "org.springframework.boot:spring-boot-starter-data-jpa"
+ implementation "org.springframework.boot:spring-boot-starter-web"
+ implementation "org.springframework.boot:spring-boot-starter-security"
+ implementation "org.springframework.boot:spring-boot-autoconfigure"
+ implementation "com.fasterxml.jackson.module:jackson-module-kotlin"
+ implementation "io.github.oshai:kotlin-logging:${kotlinLoggingVersion}"
+
+ // OpenSearch via spring-data-opensearch (Apache 2.0 licensed)
+ implementation "org.opensearch.client:spring-data-opensearch-starter:${springDataOpenSearchVersion}"
+
+ annotationProcessor "org.springframework.boot:spring-boot-autoconfigure-processor"
+
+ testImplementation project(':backend:test-utils-common')
+ testImplementation project(':backend:core')
+ testImplementation(project(':backend:audit')) {
+ exclude(group: "com.ritense.valtimo", module: "case")
+ }
+ testImplementation "org.springframework.boot:spring-boot-starter-test"
+ testImplementation "org.mockito.kotlin:mockito-kotlin:${mockitoKotlinVersion}"
+ testImplementation "org.postgresql:postgresql"
+ testImplementation "org.springframework.security:spring-security-test"
+
+ jar {
+ enabled = true
+ manifest {
+ attributes("Implementation-Title": "Ritense Case OpenSearch module")
+ attributes("Implementation-Version": projectVersion)
+ }
+ }
+}
+
+tasks.named("integrationTestingPostgresql") {
+ systemProperty("liquibase.duplicateFileMode", "WARN")
+}
+
+apply from: "gradle/publishing.gradle"
diff --git a/backend/case-opensearch/docker-compose-override-postgresql.yml b/backend/case-opensearch/docker-compose-override-postgresql.yml
new file mode 100644
index 0000000000..b1be670e23
--- /dev/null
+++ b/backend/case-opensearch/docker-compose-override-postgresql.yml
@@ -0,0 +1,20 @@
+services:
+ db:
+ ports:
+ - "3365:5432"
+ environment:
+ - POSTGRES_DB=case-opensearch-test
+ opensearch:
+ image: opensearchproject/opensearch:2.19.2
+ environment:
+ - discovery.type=single-node
+ - DISABLE_SECURITY_PLUGIN=true
+ - DISABLE_INSTALL_DEMO_CONFIG=true
+ ports:
+ - "39200:9200"
+ healthcheck:
+ test: ["CMD-SHELL", "curl -sf http://localhost:9200/_cluster/health || exit 1"]
+ interval: 5s
+ timeout: 10s
+ retries: 40
+ start_period: 15s
diff --git a/backend/case-opensearch/gradle/publishing.gradle b/backend/case-opensearch/gradle/publishing.gradle
new file mode 100644
index 0000000000..f991a5d60f
--- /dev/null
+++ b/backend/case-opensearch/gradle/publishing.gradle
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2015-2024 Ritense BV, the Netherlands.
+ *
+ * Licensed under EUPL, Version 1.2 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+pluginManager.withPlugin('maven-publish') {
+ publishing {
+ publications {
+ maven(MavenPublication) {
+ pom {
+ name = 'Case OpenSearch module'
+ description = 'The case-opensearch module syncs json_schema_document to OpenSearch as a CQRS read model'
+ developers {
+ developer {
+ id = "team-valtimo"
+ name = "Team Valtimo"
+ email = "team-valtimo@ritense.com"
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/OpenSearchProperties.kt b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/OpenSearchProperties.kt
new file mode 100644
index 0000000000..8bfb7af614
--- /dev/null
+++ b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/OpenSearchProperties.kt
@@ -0,0 +1,72 @@
+/*
+ * Copyright 2015-2026 Ritense BV, the Netherlands.
+ *
+ * Licensed under EUPL, Version 1.2 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.ritense.document.opensearch
+
+import org.springframework.boot.context.properties.ConfigurationProperties
+import org.springframework.boot.context.properties.NestedConfigurationProperty
+import java.time.Duration
+
+@ConfigurationProperties(prefix = "valtimo.opensearch")
+data class OpenSearchProperties(
+ val enabled: Boolean = false,
+ val healthCheckEnabled: Boolean = true,
+ val healthCheckIntervalMs: Long = 30000,
+ val fallbackWarningIntervalMs: Long = 300000,
+
+ @NestedConfigurationProperty
+ val reconcile: Reconcile = Reconcile(),
+
+ @NestedConfigurationProperty
+ val reindex: Reindex = Reindex(),
+) {
+ /**
+ * Configuration for the self-healing reconciler that keeps the OpenSearch index in sync with
+ * PostgreSQL as a derived read-model.
+ *
+ * @property enabled whether the scheduled reconcile job runs at all.
+ * @property interval delay between the end of one reconcile cycle and the start of the next
+ * (also bound directly by the job's `@Scheduled(fixedDelayString)`).
+ * @property overlap δ subtracted from the watermark each cycle to safely cover the
+ * flush→commit boundary; re-indexes a small trailing window (idempotent).
+ * @property pageSize DB keyset page size for the incremental upsert scan.
+ * @property pendingDeletionBatchSize number of pending index deletions drained per batch.
+ */
+ data class Reconcile(
+ val enabled: Boolean = true,
+ // Keep in sync with the @Scheduled(fixedDelayString) default in DocumentOpenSearchReconcileJob (PT2M).
+ val interval: Duration = Duration.ofMinutes(2),
+ val overlap: Duration = Duration.ofSeconds(10),
+ val pageSize: Int = 5000,
+ val pendingDeletionBatchSize: Int = 500,
+ )
+
+ /**
+ * Behaviour while an admin (re)index run is filling the index.
+ *
+ * @property fallbackToPostgresWhileRunning while a reindex run is in progress, route document search
+ * to PostgreSQL so users never query a partially-filled index; search returns to OpenSearch
+ * automatically once all runs finish. Does not affect the reconciler (which keeps the index
+ * complete) — only the admin reindex.
+ * @property runningHeartbeatTimeout a RUNNING run is only treated as in-progress while its heartbeat
+ * is fresher than this. Guards against a run left behind by a crashed instance pinning search
+ * to PostgreSQL indefinitely. Must exceed the longest expected gap between reindex batches.
+ */
+ data class Reindex(
+ val fallbackToPostgresWhileRunning: Boolean = true,
+ val runningHeartbeatTimeout: Duration = Duration.ofMinutes(5),
+ )
+}
diff --git a/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/authorization/OpenSearchAuthorizationEntityMapper.kt b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/authorization/OpenSearchAuthorizationEntityMapper.kt
new file mode 100644
index 0000000000..e6cffef10d
--- /dev/null
+++ b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/authorization/OpenSearchAuthorizationEntityMapper.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2015-2024 Ritense BV, the Netherlands.
+ *
+ * Licensed under EUPL, Version 1.2 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.ritense.document.opensearch.authorization
+
+import com.ritense.authorization.permission.condition.PermissionCondition
+import org.opensearch.index.query.QueryBuilder
+
+/**
+ * OpenSearch equivalent of [com.ritense.authorization.AuthorizationEntityMapper].
+ *
+ * Translates a [com.ritense.authorization.permission.condition.ContainerPermissionCondition]
+ * on entity type [TO] into an OpenSearch [QueryBuilder] that filters [FROM] documents.
+ *
+ * Implement this interface and register the implementation as a Spring bean to add support
+ * for a new container relationship without modifying the core translator.
+ */
+interface OpenSearchAuthorizationEntityMapper {
+
+ /**
+ * Given conditions on the [TO] entity type, returns an OpenSearch [QueryBuilder] that filters
+ * [FROM] documents satisfying those conditions, or `null` if no filter is needed
+ * (i.e. any [FROM] document qualifies regardless of [conditions]).
+ */
+ fun mapQuery(conditions: List): QueryBuilder?
+
+ fun supports(fromClass: Class<*>, toClass: Class<*>): Boolean
+}
diff --git a/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/authorization/OpenSearchPermissionConditionTranslator.kt b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/authorization/OpenSearchPermissionConditionTranslator.kt
new file mode 100644
index 0000000000..1c9ed6bfcc
--- /dev/null
+++ b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/authorization/OpenSearchPermissionConditionTranslator.kt
@@ -0,0 +1,225 @@
+/*
+ * Copyright 2015-2024 Ritense BV, the Netherlands.
+ *
+ * Licensed under EUPL, Version 1.2 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.ritense.document.opensearch.authorization
+
+import com.ritense.authorization.Action
+import com.ritense.authorization.AuthorizationContext.Companion.runWithoutAuthorization
+import com.ritense.authorization.AuthorizationService
+import com.ritense.authorization.permission.ConditionContainer
+import com.ritense.authorization.permission.Permission
+import com.ritense.authorization.permission.condition.ContainerPermissionCondition
+import com.ritense.authorization.permission.condition.ExpressionPermissionCondition
+import com.ritense.authorization.permission.condition.FieldPermissionCondition
+import com.ritense.authorization.permission.condition.PermissionCondition
+import com.ritense.authorization.permission.condition.PermissionConditionOperator
+import com.ritense.authorization.request.EntityAuthorizationRequest
+import com.ritense.authorization.role.Role
+import com.ritense.document.domain.impl.JsonSchemaDocument
+import com.ritense.document.repository.impl.JsonSchemaDocumentRepository
+import com.ritense.valtimo.contract.authorization.CurrentUserExpressionHandler
+import io.github.oshai.kotlinlogging.KotlinLogging
+import org.opensearch.index.query.BoolQueryBuilder
+import org.opensearch.index.query.QueryBuilder
+import org.opensearch.index.query.QueryBuilders
+
+class OpenSearchPermissionConditionTranslator(
+ private val openSearchMappers: List>,
+ private val authorizationService: AuthorizationService,
+ private val documentRepository: JsonSchemaDocumentRepository,
+) {
+
+ /**
+ * Translates a list of [Permission]s into a single OpenSearch [QueryBuilder] that, when applied
+ * to a search, returns only the documents the current user is allowed to see for [action].
+ *
+ * Permissions are OR-ed; conditions within a permission are AND-ed.
+ * Returns a deny-all query if no permissions match [action].
+ */
+ fun toQuery(permissions: List, action: Action<*>): QueryBuilder {
+ val matching = permissions.filter {
+ it.resourceType == JsonSchemaDocument::class.java && it.actions.contains(action)
+ }
+ logger.debug { "toQuery: ${permissions.size} permissions total, ${matching.size} matching action=$action" }
+ if (matching.isEmpty()) {
+ return denyAll()
+ }
+
+ val perPermissionQueries = matching.map { permission ->
+ val conditionQueries = permission.conditionContainer.conditions.map { translateCondition(it) }
+ andAll(conditionQueries)
+ }
+ val result = if (perPermissionQueries.size == 1) {
+ perPermissionQueries.first()
+ } else {
+ QueryBuilders.boolQuery().apply {
+ perPermissionQueries.forEach { should(it) }
+ minimumShouldMatch(1)
+ }
+ }
+ logger.debug { "toQuery: generated query for action=$action" }
+ return result
+ }
+
+ private fun translateCondition(condition: PermissionCondition): QueryBuilder = when (condition) {
+ is FieldPermissionCondition<*> -> translateField(condition)
+ is ExpressionPermissionCondition<*> -> translateExpression(condition)
+ is ContainerPermissionCondition<*> -> translateContainer(condition)
+ else -> throw IllegalArgumentException("Unknown permission condition type: ${condition::class.qualifiedName}")
+ }
+
+ private fun translateField(cond: FieldPermissionCondition<*>): QueryBuilder {
+ val baseField = jpaToOsField(cond.field)
+ val value = resolveFieldValue(cond)
+ val osField = if (isDynamicTextField(baseField, cond.operator, value)) "$baseField.keyword" else baseField
+ return Companion.applyOperator(osField, cond.operator, value)
+ }
+
+ private fun translateExpression(cond: ExpressionPermissionCondition<*>): QueryBuilder {
+ val dotPath = cond.path.removePrefix("$.").replace("/", ".")
+ val baseField = "${jpaToOsField(cond.field)}.$dotPath"
+ val value = CurrentUserExpressionHandler.resolveValue(cond.value)
+ // Content sub-fields are dynamically mapped as text — use .keyword for string term queries
+ val osField = if (isDynamicTextField(baseField, cond.operator, value)) "$baseField.keyword" else baseField
+ logger.debug { "translateExpression: field=${cond.field} → osField=$osField, op=${cond.operator}, value=$value (${value?.javaClass?.simpleName})" }
+ return Companion.applyOperator(osField, cond.operator, value)
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ private fun translateContainer(cond: ContainerPermissionCondition<*>): QueryBuilder {
+ val osMapper = openSearchMappers.find {
+ it.supports(JsonSchemaDocument::class.java, cond.resourceType)
+ } as? OpenSearchAuthorizationEntityMapper
+
+ if (osMapper != null) {
+ return osMapper.mapQuery(cond.conditions) ?: noFilter()
+ }
+
+ logger.warn {
+ "No OpenSearchAuthorizationEntityMapper registered for " +
+ "JsonSchemaDocument → ${cond.resourceType.simpleName}. " +
+ "Falling back to JPA ID resolution — may be slow for large datasets."
+ }
+ return jpaFallback(cond)
+ }
+
+ /**
+ * Fallback for [ContainerPermissionCondition] types that have no registered
+ * [OpenSearchAuthorizationEntityMapper]. Uses JPA to find matching document IDs and
+ * returns an `ids` query.
+ */
+ private fun jpaFallback(cond: ContainerPermissionCondition<*>): QueryBuilder {
+ val syntheticPermission = Permission(
+ resourceType = JsonSchemaDocument::class.java,
+ actions = mutableListOf(Action(Action.IGNORE)),
+ conditionContainer = ConditionContainer(listOf(cond)),
+ role = Role(key = ""),
+ )
+ val spec = authorizationService.getAuthorizationSpecification(
+ EntityAuthorizationRequest(JsonSchemaDocument::class.java, Action(Action.IGNORE)),
+ listOf(syntheticPermission)
+ )
+ val allowedIds: List = runWithoutAuthorization {
+ documentRepository.findAll(spec).map { doc -> doc.id().toString() }
+ }
+ return QueryBuilders.idsQuery().addIds(*allowedIds.toTypedArray())
+ }
+
+ private fun resolveFieldValue(cond: FieldPermissionCondition<*>): Any? =
+ if (cond.value is List<*>) {
+ (cond.value as List<*>).map { CurrentUserExpressionHandler.resolveValue(it) }
+ } else {
+ CurrentUserExpressionHandler.resolveValue(cond.value)
+ }
+
+ companion object {
+ private val logger = KotlinLogging.logger {}
+
+ fun applyOperator(field: String, op: PermissionConditionOperator, value: Any?): QueryBuilder =
+ when (op) {
+ PermissionConditionOperator.EQUAL_TO -> {
+ if (value == null) {
+ QueryBuilders.boolQuery().mustNot(QueryBuilders.existsQuery(field))
+ } else {
+ QueryBuilders.termQuery(field, value)
+ }
+ }
+ PermissionConditionOperator.NOT_EQUAL_TO -> {
+ if (value == null) {
+ QueryBuilders.existsQuery(field)
+ } else {
+ QueryBuilders.boolQuery().mustNot(QueryBuilders.termQuery(field, value))
+ }
+ }
+ PermissionConditionOperator.GREATER_THAN ->
+ QueryBuilders.rangeQuery(field).gt(value)
+ PermissionConditionOperator.GREATER_THAN_OR_EQUAL_TO ->
+ QueryBuilders.rangeQuery(field).gte(value)
+ PermissionConditionOperator.LESS_THAN ->
+ QueryBuilders.rangeQuery(field).lt(value)
+ PermissionConditionOperator.LESS_THAN_OR_EQUAL_TO ->
+ QueryBuilders.rangeQuery(field).lte(value)
+ PermissionConditionOperator.LIST_CONTAINS ->
+ QueryBuilders.termQuery(field, value)
+ PermissionConditionOperator.IN -> {
+ val collection = value as? Collection<*>
+ ?: throw IllegalArgumentException("IN operator requires a Collection value")
+ QueryBuilders.termsQuery(field, collection.toList())
+ }
+ }
+
+ /**
+ * Maps JPA entity field names (as used in [FieldPermissionCondition.field]) to
+ * their corresponding field names in the OpenSearch document.
+ */
+ val fieldMappings: Map = mapOf(
+ "createdBy" to "createdBy",
+ "assigneeId" to "assigneeId",
+ "assigneeFullName" to "assigneeFullName",
+ "content" to "content",
+ "content.content" to "content",
+ "sequence" to "sequence",
+ "retentionDate" to "retentionDate",
+ )
+
+ fun jpaToOsField(jpaField: String): String = fieldMappings[jpaField] ?: jpaField
+
+ /**
+ * Content sub-fields use dynamic mapping (text + keyword). String term queries
+ * (EQUAL_TO, NOT_EQUAL_TO, LIST_CONTAINS, IN) need the .keyword sub-field for exact match.
+ */
+ fun isDynamicTextField(field: String, op: PermissionConditionOperator, value: Any?): Boolean {
+ if (!field.startsWith("content.")) return false
+ if (value == null) return false
+ val isStringValue = value is String || (value is Collection<*> && value.firstOrNull() is String)
+ val isTermOp = op in setOf(
+ PermissionConditionOperator.EQUAL_TO,
+ PermissionConditionOperator.NOT_EQUAL_TO,
+ PermissionConditionOperator.LIST_CONTAINS,
+ PermissionConditionOperator.IN,
+ )
+ return isStringValue && isTermOp
+ }
+
+ fun denyAll(): QueryBuilder = QueryBuilders.idsQuery()
+ fun noFilter(): QueryBuilder = QueryBuilders.matchAllQuery()
+ fun andAll(list: List): QueryBuilder = when {
+ list.isEmpty() -> noFilter()
+ list.size == 1 -> list.first()
+ else -> QueryBuilders.boolQuery().apply { list.forEach { must(it) } }
+ }
+ }
+}
diff --git a/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/authorization/mapper/JsonSchemaDocumentCaseDefinitionOpenSearchMapper.kt b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/authorization/mapper/JsonSchemaDocumentCaseDefinitionOpenSearchMapper.kt
new file mode 100644
index 0000000000..1673076c67
--- /dev/null
+++ b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/authorization/mapper/JsonSchemaDocumentCaseDefinitionOpenSearchMapper.kt
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2015-2026 Ritense BV, the Netherlands.
+ *
+ * Licensed under EUPL, Version 1.2 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.ritense.document.opensearch.authorization.mapper
+
+import com.ritense.authorization.permission.condition.FieldPermissionCondition
+import com.ritense.authorization.permission.condition.PermissionCondition
+import com.ritense.case_.domain.definition.CaseDefinition
+import com.ritense.document.domain.impl.JsonSchemaDocument
+import com.ritense.document.opensearch.authorization.OpenSearchAuthorizationEntityMapper
+import com.ritense.document.opensearch.authorization.OpenSearchPermissionConditionTranslator.Companion.andAll
+import com.ritense.document.opensearch.authorization.OpenSearchPermissionConditionTranslator.Companion.applyOperator
+import com.ritense.valtimo.contract.authorization.CurrentUserExpressionHandler
+import org.opensearch.index.query.QueryBuilder
+import org.opensearch.index.query.QueryBuilders
+
+/**
+ * Handles [com.ritense.authorization.permission.condition.ContainerPermissionCondition]
+ * where the container resource type is [CaseDefinition].
+ *
+ * Field paths mirror the JPA entity: `definitionId.blueprintId.*`.
+ */
+class JsonSchemaDocumentCaseDefinitionOpenSearchMapper : OpenSearchAuthorizationEntityMapper {
+
+ override fun mapQuery(conditions: List): QueryBuilder? {
+ val conditionQueries = conditions.map { condition ->
+ when (condition) {
+ is FieldPermissionCondition<*> -> {
+ val osField = mapCaseDefinitionField(condition.field)
+ val value = CurrentUserExpressionHandler.resolveValue(condition.value)
+ applyOperator(osField, condition.operator, value)
+ }
+ else -> throw UnsupportedOperationException(
+ "Condition type ${condition::class.simpleName} is not supported in " +
+ "${this::class.simpleName}. Register a custom ${OpenSearchAuthorizationEntityMapper::class.simpleName} " +
+ "or extend this mapper to handle it."
+ )
+ }
+ }
+
+ // Constrain to CASE blueprint type to exclude BUILDING_BLOCK documents
+ val typeQuery = QueryBuilders.termQuery("definitionId.blueprintId.blueprintType", "CASE")
+ return andAll(conditionQueries + typeQuery)
+ }
+
+ override fun supports(fromClass: Class<*>, toClass: Class<*>): Boolean =
+ fromClass == JsonSchemaDocument::class.java &&
+ toClass == CaseDefinition::class.java
+
+ private fun mapCaseDefinitionField(field: String): String = when (field) {
+ "id.key" -> "definitionId.blueprintId.blueprintKey"
+ "id.versionTag" -> "definitionId.blueprintId.blueprintVersionTag"
+ else -> throw UnsupportedOperationException(
+ "Field '$field' on CaseDefinition is not yet mapped for OpenSearch. " +
+ "Add it to ${this::class.simpleName}."
+ )
+ }
+}
diff --git a/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/authorization/mapper/JsonSchemaDocumentDefinitionOpenSearchMapper.kt b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/authorization/mapper/JsonSchemaDocumentDefinitionOpenSearchMapper.kt
new file mode 100644
index 0000000000..9a274dbe57
--- /dev/null
+++ b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/authorization/mapper/JsonSchemaDocumentDefinitionOpenSearchMapper.kt
@@ -0,0 +1,69 @@
+/*
+ * Copyright 2015-2026 Ritense BV, the Netherlands.
+ *
+ * Licensed under EUPL, Version 1.2 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.ritense.document.opensearch.authorization.mapper
+
+import com.ritense.authorization.permission.condition.FieldPermissionCondition
+import com.ritense.authorization.permission.condition.PermissionCondition
+import com.ritense.document.domain.impl.JsonSchemaDocument
+import com.ritense.document.domain.impl.JsonSchemaDocumentDefinition
+import com.ritense.document.opensearch.authorization.OpenSearchAuthorizationEntityMapper
+import com.ritense.document.opensearch.authorization.OpenSearchPermissionConditionTranslator.Companion.andAll
+import com.ritense.document.opensearch.authorization.OpenSearchPermissionConditionTranslator.Companion.applyOperator
+import com.ritense.valtimo.contract.authorization.CurrentUserExpressionHandler
+import org.opensearch.index.query.QueryBuilder
+
+/**
+ * Handles [com.ritense.authorization.permission.condition.ContainerPermissionCondition]
+ * where the container resource type is [JsonSchemaDocumentDefinition].
+ *
+ * Field paths mirror the JPA entity: `definitionId.name` and `definitionId.version`.
+ */
+class JsonSchemaDocumentDefinitionOpenSearchMapper : OpenSearchAuthorizationEntityMapper {
+
+ override fun mapQuery(conditions: List): QueryBuilder? {
+ if (conditions.isEmpty()) return null
+
+ val queries = conditions.map { condition ->
+ when (condition) {
+ is FieldPermissionCondition<*> -> {
+ val osField = mapDefinitionField(condition.field)
+ val value = CurrentUserExpressionHandler.resolveValue(condition.value)
+ applyOperator(osField, condition.operator, value)
+ }
+ else -> throw UnsupportedOperationException(
+ "Condition type ${condition::class.simpleName} is not supported in " +
+ "${this::class.simpleName}. Register a custom ${OpenSearchAuthorizationEntityMapper::class.simpleName} " +
+ "or extend this mapper to handle it."
+ )
+ }
+ }
+ return andAll(queries)
+ }
+
+ override fun supports(fromClass: Class<*>, toClass: Class<*>): Boolean =
+ fromClass == JsonSchemaDocument::class.java &&
+ toClass == JsonSchemaDocumentDefinition::class.java
+
+ private fun mapDefinitionField(field: String): String = when (field) {
+ "id.name" -> "definitionId.name"
+ "id.version" -> "definitionId.version"
+ else -> throw UnsupportedOperationException(
+ "Field '$field' on JsonSchemaDocumentDefinition is not yet mapped for OpenSearch. " +
+ "Add it to ${this::class.simpleName}."
+ )
+ }
+}
diff --git a/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/autoconfigure/DocumentOpenSearchAutoConfiguration.kt b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/autoconfigure/DocumentOpenSearchAutoConfiguration.kt
new file mode 100644
index 0000000000..7c99d2d5fe
--- /dev/null
+++ b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/autoconfigure/DocumentOpenSearchAutoConfiguration.kt
@@ -0,0 +1,381 @@
+/*
+ * Copyright 2015-2026 Ritense BV, the Netherlands.
+ *
+ * Licensed under EUPL, Version 1.2 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.ritense.document.opensearch.autoconfigure
+
+import com.fasterxml.jackson.databind.ObjectMapper
+import io.github.oshai.kotlinlogging.KotlinLogging
+import com.ritense.adminsettings.service.FeatureToggleOverridesService
+import com.ritense.authorization.AuthorizationService
+import com.ritense.document.opensearch.OpenSearchProperties
+import com.ritense.document.autoconfigure.DocumentAutoConfiguration
+import com.ritense.document.opensearch.authorization.OpenSearchAuthorizationEntityMapper
+import com.ritense.document.opensearch.authorization.OpenSearchPermissionConditionTranslator
+import com.ritense.document.opensearch.authorization.mapper.JsonSchemaDocumentCaseDefinitionOpenSearchMapper
+import com.ritense.document.opensearch.authorization.mapper.JsonSchemaDocumentDefinitionOpenSearchMapper
+import com.ritense.document.opensearch.domain.OpenSearchReindexRun
+import com.ritense.document.opensearch.handler.DocumentOpenSearchEventListener
+import com.ritense.document.opensearch.handler.PendingIndexDeletionListener
+import com.ritense.document.opensearch.repository.JsonSchemaDocumentOpenSearchRepository
+import com.ritense.document.opensearch.repository.OpenSearchReconcileStateRepository
+import com.ritense.document.opensearch.repository.OpenSearchReindexRunRepository
+import com.ritense.document.opensearch.repository.PendingIndexDeletionRepository
+import com.ritense.document.opensearch.security.DocumentOpenSearchHttpSecurityConfigurer
+import com.ritense.document.opensearch.service.DelegatingDocumentSearchService
+import com.ritense.document.opensearch.service.DocumentOpenSearchQueryService
+import com.ritense.document.opensearch.service.DocumentOpenSearchReconcileJob
+import com.ritense.document.opensearch.service.DocumentOpenSearchIndexInitializer
+import com.ritense.document.opensearch.service.DocumentOpenSearchReconcileService
+import com.ritense.document.opensearch.service.DocumentOpenSearchReindexService
+import com.ritense.document.opensearch.service.DocumentOpenSearchSyncService
+import com.ritense.document.opensearch.service.JsonSchemaDocumentOpenSearchService
+import com.ritense.document.opensearch.service.JsonSchemaDocumentOsConverter
+import com.ritense.document.opensearch.service.OpenSearchReindexRunService
+import com.ritense.document.opensearch.service.ReindexProgressGate
+import com.ritense.document.opensearch.service.OpenSearchHealthService
+import com.ritense.document.opensearch.service.SearchEngineToggle
+import com.ritense.document.opensearch.web.DocumentOpenSearchReindexResource
+import com.ritense.document.opensearch.web.SearchEngineResource
+import com.ritense.document.repository.impl.JsonSchemaDocumentRepository
+import com.ritense.document.service.DocumentSearchService
+import com.ritense.document.service.impl.JsonSchemaDocumentDefinitionService
+import com.ritense.document.service.SearchFieldService
+import com.ritense.document.service.impl.JsonSchemaDocumentSearchService
+import com.ritense.case.service.CaseDefinitionService
+import com.ritense.valtimo.contract.database.QueryDialectHelper
+import com.ritense.outbox.OutboxService
+import com.ritense.valtimo.contract.authentication.TeamManagementService
+import com.ritense.valtimo.contract.authentication.UserManagementService
+import jakarta.persistence.EntityManager
+import net.javacrumbs.shedlock.core.LockProvider
+import org.springframework.boot.ApplicationRunner
+import org.springframework.boot.autoconfigure.AutoConfiguration
+import org.springframework.boot.autoconfigure.AutoConfigureBefore
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean
+import org.springframework.boot.autoconfigure.condition.ConditionalOnClass
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
+import org.springframework.boot.context.properties.EnableConfigurationProperties
+import org.springframework.boot.autoconfigure.domain.EntityScan
+import org.springframework.context.annotation.Bean
+import org.springframework.core.annotation.Order
+import org.springframework.data.elasticsearch.core.ElasticsearchOperations
+import org.springframework.data.elasticsearch.repository.config.EnableElasticsearchRepositories
+import org.springframework.data.jpa.repository.config.EnableJpaRepositories
+import org.springframework.transaction.PlatformTransactionManager
+import org.springframework.scheduling.annotation.EnableScheduling
+import org.springframework.scheduling.annotation.Scheduled
+
+@AutoConfiguration
+@AutoConfigureBefore(DocumentAutoConfiguration::class)
+@ConditionalOnClass(ElasticsearchOperations::class)
+@EnableScheduling
+@EnableElasticsearchRepositories(basePackages = ["com.ritense.document.opensearch.repository"])
+@EnableConfigurationProperties(OpenSearchProperties::class)
+@EnableJpaRepositories(basePackageClasses = [OpenSearchReindexRunRepository::class])
+@EntityScan(basePackageClasses = [OpenSearchReindexRun::class])
+class DocumentOpenSearchAutoConfiguration {
+
+ @Bean
+ @ConditionalOnMissingBean
+ fun jsonSchemaDocumentDefinitionOpenSearchMapper(): JsonSchemaDocumentDefinitionOpenSearchMapper =
+ JsonSchemaDocumentDefinitionOpenSearchMapper()
+
+ @Bean
+ @ConditionalOnMissingBean
+ fun jsonSchemaDocumentCaseDefinitionOpenSearchMapper(): JsonSchemaDocumentCaseDefinitionOpenSearchMapper =
+ JsonSchemaDocumentCaseDefinitionOpenSearchMapper()
+
+ @Bean
+ @ConditionalOnMissingBean
+ fun openSearchPermissionConditionTranslator(
+ openSearchMappers: List>,
+ authorizationService: AuthorizationService,
+ documentRepository: JsonSchemaDocumentRepository,
+ ): OpenSearchPermissionConditionTranslator =
+ OpenSearchPermissionConditionTranslator(openSearchMappers, authorizationService, documentRepository)
+
+ @Bean
+ @ConditionalOnMissingBean
+ fun documentOpenSearchQueryService(
+ elasticsearchOperations: ElasticsearchOperations,
+ authorizationService: AuthorizationService,
+ translator: OpenSearchPermissionConditionTranslator,
+ ): DocumentOpenSearchQueryService =
+ DocumentOpenSearchQueryService(elasticsearchOperations, authorizationService, translator)
+
+ @Bean
+ @ConditionalOnMissingBean
+ fun jsonSchemaDocumentOsConverter(
+ objectMapper: ObjectMapper,
+ openSearchRepository: JsonSchemaDocumentOpenSearchRepository,
+ ): JsonSchemaDocumentOsConverter =
+ JsonSchemaDocumentOsConverter(objectMapper, openSearchRepository)
+
+ @Bean
+ @ConditionalOnMissingBean
+ fun documentOpenSearchSyncService(
+ repository: JsonSchemaDocumentOpenSearchRepository,
+ documentRepository: JsonSchemaDocumentRepository,
+ converter: JsonSchemaDocumentOsConverter,
+ transactionManager: PlatformTransactionManager,
+ ): DocumentOpenSearchSyncService =
+ DocumentOpenSearchSyncService(repository, documentRepository, converter, transactionManager)
+
+ @Bean
+ @ConditionalOnProperty(prefix = "valtimo.opensearch", name = ["enabled"], havingValue = "true", matchIfMissing = false)
+ fun documentOpenSearchEventListener(
+ syncService: DocumentOpenSearchSyncService,
+ searchEngineToggle: SearchEngineToggle,
+ ): DocumentOpenSearchEventListener =
+ DocumentOpenSearchEventListener(syncService, searchEngineToggle)
+
+ @Bean
+ @ConditionalOnMissingBean
+ fun openSearchReindexRunService(
+ openSearchReindexRunRepository: OpenSearchReindexRunRepository,
+ objectMapper: ObjectMapper,
+ openSearchProperties: OpenSearchProperties,
+ entityManager: EntityManager,
+ ): OpenSearchReindexRunService =
+ OpenSearchReindexRunService(openSearchReindexRunRepository, objectMapper, openSearchProperties, entityManager)
+
+ @Bean
+ @ConditionalOnMissingBean
+ fun documentOpenSearchReindexService(
+ entityManager: EntityManager,
+ converter: JsonSchemaDocumentOsConverter,
+ elasticsearchOperations: ElasticsearchOperations,
+ transactionManager: PlatformTransactionManager,
+ lockProvider: LockProvider,
+ openSearchReindexRunService: OpenSearchReindexRunService,
+ openSearchRepository: JsonSchemaDocumentOpenSearchRepository,
+ ): DocumentOpenSearchReindexService =
+ DocumentOpenSearchReindexService(
+ entityManager,
+ converter,
+ elasticsearchOperations,
+ transactionManager,
+ lockProvider,
+ openSearchReindexRunService,
+ openSearchRepository,
+ )
+
+ @Bean
+ @ConditionalOnMissingBean
+ @ConditionalOnProperty(prefix = "valtimo.opensearch", name = ["enabled"], havingValue = "true", matchIfMissing = false)
+ fun documentOpenSearchReconcileService(
+ entityManager: EntityManager,
+ converter: JsonSchemaDocumentOsConverter,
+ openSearchRepository: JsonSchemaDocumentOpenSearchRepository,
+ reconcileStateRepository: OpenSearchReconcileStateRepository,
+ pendingIndexDeletionRepository: PendingIndexDeletionRepository,
+ transactionManager: PlatformTransactionManager,
+ lockProvider: LockProvider,
+ openSearchProperties: OpenSearchProperties,
+ ): DocumentOpenSearchReconcileService =
+ DocumentOpenSearchReconcileService(
+ entityManager,
+ converter,
+ openSearchRepository,
+ reconcileStateRepository,
+ pendingIndexDeletionRepository,
+ transactionManager,
+ lockProvider,
+ openSearchProperties,
+ )
+
+ @Bean
+ @ConditionalOnMissingBean
+ @ConditionalOnBean(DocumentOpenSearchReconcileService::class)
+ @ConditionalOnProperty(prefix = "valtimo.opensearch.reconcile", name = ["enabled"], havingValue = "true", matchIfMissing = true)
+ fun documentOpenSearchReconcileJob(
+ reconcileService: DocumentOpenSearchReconcileService,
+ searchEngineToggle: SearchEngineToggle,
+ ): DocumentOpenSearchReconcileJob =
+ DocumentOpenSearchReconcileJob(reconcileService, searchEngineToggle)
+
+ @Bean
+ @ConditionalOnMissingBean
+ @ConditionalOnProperty(prefix = "valtimo.opensearch", name = ["enabled"], havingValue = "true", matchIfMissing = false)
+ fun pendingIndexDeletionListener(
+ pendingIndexDeletionRepository: PendingIndexDeletionRepository,
+ ): PendingIndexDeletionListener =
+ PendingIndexDeletionListener(pendingIndexDeletionRepository)
+
+ @Order(294)
+ @Bean
+ @ConditionalOnMissingBean
+ fun documentOpenSearchHttpSecurityConfigurer(): DocumentOpenSearchHttpSecurityConfigurer =
+ DocumentOpenSearchHttpSecurityConfigurer()
+
+ // --- Search engine toggle: both implementations + delegating service ---
+
+ @Bean
+ @ConditionalOnMissingBean
+ // Start in POSTGRES so no OpenSearch call happens before searchEngineSettingLoader resolves the real
+ // engine. The @Scheduled reconciler is armed during context refresh, before ApplicationRunners run, so
+ // an OPENSEARCH default could otherwise leak one spurious call at startup even when the engine is off.
+ fun searchEngineToggle(): SearchEngineToggle = SearchEngineToggle(SearchEngineToggle.Engine.POSTGRES)
+
+ @Bean
+ @ConditionalOnMissingBean
+ fun reindexProgressGate(
+ openSearchReindexRunService: OpenSearchReindexRunService,
+ openSearchProperties: OpenSearchProperties,
+ ): ReindexProgressGate =
+ ReindexProgressGate(openSearchReindexRunService, openSearchProperties)
+
+ @Bean("openSearchDocumentSearchService")
+ fun openSearchDocumentSearchService(
+ elasticsearchOperations: ElasticsearchOperations,
+ translator: OpenSearchPermissionConditionTranslator,
+ authorizationService: AuthorizationService,
+ jpaRepository: JsonSchemaDocumentRepository,
+ userManagementService: UserManagementService,
+ searchFieldService: SearchFieldService,
+ outboxService: OutboxService,
+ objectMapper: ObjectMapper,
+ caseDefinitionService: CaseDefinitionService,
+ ): JsonSchemaDocumentOpenSearchService =
+ JsonSchemaDocumentOpenSearchService(
+ elasticsearchOperations, translator, authorizationService,
+ jpaRepository, userManagementService, searchFieldService, outboxService, objectMapper,
+ caseDefinitionService,
+ )
+
+ @Bean("jpaDocumentSearchService")
+ fun jpaDocumentSearchService(
+ entityManager: EntityManager,
+ queryDialectHelper: QueryDialectHelper,
+ searchFieldService: SearchFieldService,
+ userManagementService: UserManagementService,
+ teamManagementService: TeamManagementService,
+ authorizationService: AuthorizationService,
+ outboxService: OutboxService,
+ jsonSchemaDocumentDefinitionService: JsonSchemaDocumentDefinitionService,
+ objectMapper: ObjectMapper,
+ ): JsonSchemaDocumentSearchService =
+ JsonSchemaDocumentSearchService(
+ entityManager, queryDialectHelper, searchFieldService,
+ userManagementService, teamManagementService, authorizationService, outboxService,
+ jsonSchemaDocumentDefinitionService, objectMapper,
+ )
+
+ @Bean
+ @org.springframework.context.annotation.Primary
+ fun documentSearchService(
+ openSearchDocumentSearchService: JsonSchemaDocumentOpenSearchService,
+ jpaDocumentSearchService: JsonSchemaDocumentSearchService,
+ searchEngineToggle: SearchEngineToggle,
+ reindexProgressGate: ReindexProgressGate,
+ ): DelegatingDocumentSearchService =
+ DelegatingDocumentSearchService(
+ openSearchDocumentSearchService, jpaDocumentSearchService, searchEngineToggle, reindexProgressGate,
+ )
+
+ @Bean
+ @ConditionalOnMissingBean
+ fun searchEngineResource(
+ toggle: SearchEngineToggle,
+ openSearchProperties: OpenSearchProperties,
+ featureToggleOverridesService: FeatureToggleOverridesService,
+ indexInitializer: DocumentOpenSearchIndexInitializer,
+ ): SearchEngineResource =
+ SearchEngineResource(toggle, openSearchProperties, featureToggleOverridesService, indexInitializer)
+
+ @Bean
+ @ConditionalOnMissingBean
+ fun documentOpenSearchReindexResource(
+ reindexService: DocumentOpenSearchReindexService,
+ ): DocumentOpenSearchReindexResource =
+ DocumentOpenSearchReindexResource(reindexService)
+
+ @Bean
+ @ConditionalOnMissingBean
+ fun documentOpenSearchIndexInitializer(
+ elasticsearchOperations: ElasticsearchOperations,
+ ): DocumentOpenSearchIndexInitializer =
+ DocumentOpenSearchIndexInitializer(elasticsearchOperations)
+
+ /**
+ * Resolves the active search engine on startup from configuration and the persisted feature-toggle
+ * override, then — only when OpenSearch is the active engine — provisions the index. Merged into a
+ * single ordered runner so the toggle is always set before any index/OpenSearch work is decided, and
+ * so a disabled or toggled-off engine performs no active OpenSearch call at all on boot.
+ */
+ @Bean
+ fun searchEngineSettingLoader(
+ toggle: SearchEngineToggle,
+ featureToggleOverridesService: FeatureToggleOverridesService,
+ openSearchProperties: OpenSearchProperties,
+ indexInitializer: DocumentOpenSearchIndexInitializer,
+ ): ApplicationRunner = ApplicationRunner {
+ if (!openSearchProperties.enabled) {
+ toggle.set(SearchEngineToggle.Engine.POSTGRES)
+ logger.info { "OpenSearch disabled via configuration; using PostgreSQL for document search" }
+ return@ApplicationRunner
+ }
+
+ val overrides = featureToggleOverridesService.getOverrides().overrides
+ val useOpenSearch = overrides[SEARCH_ENGINE_TOGGLE_KEY] ?: true
+ val engine = if (useOpenSearch) SearchEngineToggle.Engine.OPENSEARCH else SearchEngineToggle.Engine.POSTGRES
+ toggle.set(engine)
+ logger.info { "Document search engine set to: ${engine.name}" }
+
+ if (toggle.isOpenSearchActive()) {
+ try {
+ indexInitializer.ensureIndex()
+ } catch (e: Exception) {
+ logger.warn(e) { "Failed to initialize OpenSearch index at startup — is OpenSearch running?" }
+ }
+ }
+ }
+
+ @Bean
+ @ConditionalOnMissingBean
+ @ConditionalOnProperty(prefix = "valtimo.opensearch", name = ["enabled"], havingValue = "true", matchIfMissing = false)
+ @ConditionalOnProperty(prefix = "valtimo.opensearch", name = ["health-check-enabled"], havingValue = "true", matchIfMissing = true)
+ fun openSearchHealthService(
+ restHighLevelClient: org.opensearch.client.RestHighLevelClient,
+ toggle: SearchEngineToggle,
+ openSearchProperties: OpenSearchProperties,
+ ): OpenSearchHealthService =
+ OpenSearchHealthService(restHighLevelClient, toggle, openSearchProperties)
+
+ @Bean
+ @ConditionalOnBean(OpenSearchHealthService::class)
+ fun openSearchHealthScheduler(
+ healthService: OpenSearchHealthService,
+ openSearchProperties: OpenSearchProperties,
+ ): OpenSearchHealthScheduler =
+ OpenSearchHealthScheduler(healthService, openSearchProperties)
+
+ companion object {
+ private val logger = KotlinLogging.logger {}
+ const val SEARCH_ENGINE_TOGGLE_KEY = "useOpenSearchForDocumentSearch"
+ }
+}
+
+class OpenSearchHealthScheduler(
+ private val healthService: OpenSearchHealthService,
+ private val properties: OpenSearchProperties,
+) {
+ @Scheduled(fixedDelayString = "\${valtimo.opensearch.health-check-interval-ms:30000}")
+ fun checkHealth() {
+ healthService.checkAndRecover()
+ }
+}
diff --git a/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/autoconfigure/ExcludeElasticsearchAutoConfigurationFilter.kt b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/autoconfigure/ExcludeElasticsearchAutoConfigurationFilter.kt
new file mode 100644
index 0000000000..3bfa920f4a
--- /dev/null
+++ b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/autoconfigure/ExcludeElasticsearchAutoConfigurationFilter.kt
@@ -0,0 +1,56 @@
+/*
+ * Copyright 2015-2026 Ritense BV, the Netherlands.
+ *
+ * Licensed under EUPL, Version 1.2 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.ritense.document.opensearch.autoconfigure
+
+import org.springframework.boot.autoconfigure.AutoConfigurationImportFilter
+import org.springframework.boot.autoconfigure.AutoConfigurationMetadata
+
+/**
+ * Vetoes Spring Boot's built-in Elasticsearch auto-configurations.
+ *
+ * The `spring-data-opensearch-starter` transitively puts `spring-data-elasticsearch` (and its
+ * Elasticsearch client classes) on the classpath. Spring Boot detects those classes and activates
+ * its own Elasticsearch auto-configuration — including a reactive REST client configuration that
+ * fails to construct against OpenSearch and aborts application startup with
+ * "Lookup method resolution failed".
+ *
+ * OpenSearch connectivity is provided instead by spring-data-opensearch's own auto-configuration
+ * (driven by the `opensearch.*` properties), so Boot's Elasticsearch auto-configs are not just
+ * unnecessary but actively harmful. Excluding them here — inside the library — means any consuming
+ * application gets a working setup out of the box, without having to add
+ * `spring.autoconfigure.exclude` entries to its own configuration.
+ */
+class ExcludeElasticsearchAutoConfigurationFilter : AutoConfigurationImportFilter {
+
+ override fun match(
+ autoConfigurationClasses: Array,
+ autoConfigurationMetadata: AutoConfigurationMetadata,
+ ): BooleanArray = BooleanArray(autoConfigurationClasses.size) { index ->
+ autoConfigurationClasses[index] !in EXCLUDED_AUTO_CONFIGURATIONS
+ }
+
+ companion object {
+ private val EXCLUDED_AUTO_CONFIGURATIONS = setOf(
+ "org.springframework.boot.autoconfigure.elasticsearch.ElasticsearchClientAutoConfiguration",
+ "org.springframework.boot.autoconfigure.elasticsearch.ElasticsearchRestClientAutoConfiguration",
+ "org.springframework.boot.autoconfigure.elasticsearch.ReactiveElasticsearchClientAutoConfiguration",
+ "org.springframework.boot.autoconfigure.data.elasticsearch.ElasticsearchDataAutoConfiguration",
+ "org.springframework.boot.autoconfigure.data.elasticsearch.ElasticsearchRepositoriesAutoConfiguration",
+ "org.springframework.boot.autoconfigure.data.elasticsearch.ReactiveElasticsearchRepositoriesAutoConfiguration",
+ )
+ }
+}
diff --git a/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/domain/JsonSchemaDocumentOsDocument.kt b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/domain/JsonSchemaDocumentOsDocument.kt
new file mode 100644
index 0000000000..c1fac61acf
--- /dev/null
+++ b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/domain/JsonSchemaDocumentOsDocument.kt
@@ -0,0 +1,85 @@
+/*
+ * Copyright 2015-2026 Ritense BV, the Netherlands.
+ *
+ * Licensed under EUPL, Version 1.2 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.ritense.document.opensearch.domain
+
+import org.springframework.data.annotation.Id
+import org.springframework.data.annotation.Version
+import org.springframework.data.elasticsearch.annotations.Document
+import org.springframework.data.elasticsearch.annotations.Field
+import org.springframework.data.elasticsearch.annotations.FieldType
+import org.springframework.data.elasticsearch.annotations.InnerField
+import org.springframework.data.elasticsearch.annotations.MultiField
+import java.time.LocalDateTime
+
+/**
+ * OpenSearch read model for [com.ritense.document.domain.impl.JsonSchemaDocument].
+ *
+ * Uses [Map] types for dynamic content and typed classes for known structure fields.
+ * [definitionId] uses [OsDefinitionId] / [OsBlueprintId] so sub-fields are mapped as
+ * [FieldType.Keyword] directly — avoids unnecessary text analysis and removes the need
+ * for `.keyword` suffix in term queries.
+ *
+ * The [contentText] field holds space-separated leaf values from [content] and is indexed
+ * as both [FieldType.Text] (for analyzed search) and [FieldType.Keyword] (for wildcard search
+ * preserving partial-match behaviour for wildcard queries).
+ */
+@Document(indexName = "json_schema_document", createIndex = false)
+data class JsonSchemaDocumentOsDocument(
+ @Id val id: String,
+ @Field(type = FieldType.Object) val content: Map?,
+ @Field(type = FieldType.Object) val definitionId: OsDefinitionId?,
+ @Field(type = FieldType.Date, format = [], pattern = ["uuuu-MM-dd'T'HH:mm:ss.SSS"]) val createdOn: LocalDateTime?,
+ @Field(type = FieldType.Date, format = [], pattern = ["uuuu-MM-dd'T'HH:mm:ss.SSS"]) val modifiedOn: LocalDateTime?,
+ @Field(type = FieldType.Keyword) val createdBy: String?,
+ @Field(type = FieldType.Long) val sequence: Long?,
+ @Field(type = FieldType.Integer) val version: Int?,
+ @Field(type = FieldType.Keyword) val assigneeId: String?,
+ @Field(type = FieldType.Keyword) val assigneeFullName: String?,
+ @Field(type = FieldType.Keyword) val internalStatus: String?,
+ @Field(type = FieldType.Object) val caseTags: List?,
+ @Field(type = FieldType.Object, enabled = false) val relations: Any?,
+ @Field(type = FieldType.Object, enabled = false) val relatedFiles: Any?,
+ @Field(type = FieldType.Date, format = [], pattern = ["uuuu-MM-dd'T'HH:mm:ss.SSS"]) val retentionDate: LocalDateTime?,
+ @MultiField(
+ mainField = Field(type = FieldType.Text),
+ otherFields = [InnerField(suffix = "keyword", type = FieldType.Keyword)],
+ )
+ val contentText: String? = null,
+ // OpenSearch external version (maps to _version metadata, VersionType.EXTERNAL — not a source field, so
+ // no index-mapping change). Populated from the JPA optimistic-lock counter; "highest version wins" makes
+ // redundant reconciler re-sends and stale async writes benign version-conflict no-ops.
+ @Version val indexVersion: Long? = null,
+)
+
+data class OsDefinitionId(
+ @Field(type = FieldType.Keyword) val name: String?,
+ @Field(type = FieldType.Long) val version: Long?,
+ @Field(type = FieldType.Object) val blueprintId: OsBlueprintId?,
+)
+
+data class OsBlueprintId(
+ @Field(type = FieldType.Keyword) val blueprintType: String?,
+ @Field(type = FieldType.Keyword) val blueprintKey: String?,
+ @Field(type = FieldType.Keyword) val blueprintVersionTag: String?,
+ @Field(type = FieldType.Boolean) val isBuildingBlock: Boolean?,
+ @Field(type = FieldType.Boolean) val isCase: Boolean?,
+)
+
+data class OsCaseTag(
+ @Field(type = FieldType.Keyword) val key: String?,
+ @Field(type = FieldType.Keyword) val name: String?,
+)
diff --git a/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/domain/OpenSearchReconcileState.kt b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/domain/OpenSearchReconcileState.kt
new file mode 100644
index 0000000000..18a6edd07d
--- /dev/null
+++ b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/domain/OpenSearchReconcileState.kt
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2015-2026 Ritense BV, the Netherlands.
+ *
+ * Licensed under EUPL, Version 1.2 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.ritense.document.opensearch.domain
+
+import jakarta.persistence.Column
+import jakarta.persistence.Entity
+import jakarta.persistence.Id
+import jakarta.persistence.Table
+import java.time.LocalDateTime
+
+/**
+ * Single-row persisted state for the OpenSearch reconciler: the [watermark] is the highest
+ * `json_schema_document.changed_on` value that has been fully reconciled into OpenSearch. The next cycle
+ * scans everything changed after (watermark − overlap). The watermark is advanced only after a completely
+ * successful cycle, so an OpenSearch outage simply parks it until recovery.
+ */
+@Entity
+@Table(name = "document_index_reconcile_state")
+class OpenSearchReconcileState(
+
+ @Id
+ @Column(name = "id")
+ val id: String = SINGLETON_ID,
+
+ @Column(name = "watermark", nullable = false)
+ var watermark: LocalDateTime,
+) {
+ companion object {
+ /** There is only ever one reconcile-state row; this is its fixed primary key. */
+ const val SINGLETON_ID = "SINGLETON"
+ }
+}
diff --git a/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/domain/OpenSearchReindexRun.kt b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/domain/OpenSearchReindexRun.kt
new file mode 100644
index 0000000000..5267309e1d
--- /dev/null
+++ b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/domain/OpenSearchReindexRun.kt
@@ -0,0 +1,145 @@
+/*
+ * Copyright 2015-2026 Ritense BV, the Netherlands.
+ *
+ * Licensed under EUPL, Version 1.2 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.ritense.document.opensearch.domain
+
+import jakarta.persistence.Column
+import jakarta.persistence.Entity
+import jakarta.persistence.EnumType
+import jakarta.persistence.Enumerated
+import jakarta.persistence.Id
+import jakarta.persistence.Table
+import java.time.LocalDateTime
+import java.util.UUID
+
+/**
+ * Persisted record of a single OpenSearch re-index run.
+ *
+ * The state lives in the database (rather than in JVM memory) so that:
+ * - status is consistent regardless of which clustered instance answers a query,
+ * - progress survives a crash/redeploy ([lastId] is the resume cursor),
+ * - an orphaned [ReindexRunStatus.RUNNING] row can be reconciled on startup.
+ *
+ * [scope] holds the serialized [com.ritense.document.opensearch.service.ReindexRequest] (JSON string)
+ * for auditing/display only — it is never queried as JSON in the database.
+ */
+@Entity
+@Table(name = "document_opensearch_reindex_run")
+class OpenSearchReindexRun(
+
+ @Id
+ @Column(name = "id")
+ val id: UUID = UUID.randomUUID(),
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "status", nullable = false, length = 20)
+ var status: ReindexRunStatus = ReindexRunStatus.RUNNING,
+
+ @Column(name = "scope")
+ val scope: String? = null,
+
+ @Column(name = "page_size", nullable = false)
+ val pageSize: Int = 0,
+
+ @Column(name = "last_id")
+ var lastId: UUID? = null,
+
+ @Column(name = "processed_count", nullable = false)
+ var processedCount: Long = 0,
+
+ @Column(name = "skipped_count", nullable = false)
+ var skippedCount: Long = 0,
+
+ @Column(name = "pruned_count", nullable = false)
+ var prunedCount: Long = 0,
+
+ @Column(name = "started_on", nullable = false)
+ val startedOn: LocalDateTime = LocalDateTime.now(),
+
+ @Column(name = "heartbeat_on", nullable = false)
+ var heartbeatOn: LocalDateTime = LocalDateTime.now(),
+
+ @Column(name = "finished_on")
+ var finishedOn: LocalDateTime? = null,
+
+ @Column(name = "error")
+ var error: String? = null,
+
+ @Column(name = "total_count")
+ var totalCount: Long? = null,
+
+ @Column(name = "prune_checked_count", nullable = false)
+ var pruneCheckedCount: Long = 0,
+
+ @Column(name = "prune_total_count")
+ var pruneTotalCount: Long? = null,
+
+ @Column(name = "pruning_phase", nullable = false)
+ var pruningPhase: Boolean = false,
+) {
+
+ /** Records progress after a committed batch: the keyset cursor, counts and a fresh heartbeat. */
+ fun recordProgress(lastId: UUID?, processed: Long, skipped: Long, heartbeat: LocalDateTime) {
+ this.lastId = lastId
+ this.processedCount = processed
+ this.skippedCount = skipped
+ this.heartbeatOn = heartbeat
+ }
+
+ fun startPruning(totalOsCount: Long, heartbeat: LocalDateTime) {
+ this.pruningPhase = true
+ this.pruneTotalCount = totalOsCount
+ this.pruneCheckedCount = 0
+ this.prunedCount = 0
+ this.heartbeatOn = heartbeat
+ }
+
+ fun recordPruneProgress(checked: Long, pruned: Long, heartbeat: LocalDateTime) {
+ this.pruneCheckedCount = checked
+ this.prunedCount = pruned
+ this.heartbeatOn = heartbeat
+ }
+
+ fun complete(now: LocalDateTime) {
+ this.status = ReindexRunStatus.COMPLETED
+ this.finishedOn = now
+ this.heartbeatOn = now
+ this.pruningPhase = false
+ }
+
+ fun fail(now: LocalDateTime, error: String?) {
+ this.status = ReindexRunStatus.FAILED
+ this.finishedOn = now
+ this.heartbeatOn = now
+ this.error = error
+ this.pruningPhase = false
+ }
+
+ fun stop(now: LocalDateTime) {
+ this.status = ReindexRunStatus.STOPPED
+ this.finishedOn = now
+ this.heartbeatOn = now
+ this.pruningPhase = false
+ }
+
+ /** Re-arms a previously-finished (FAILED/STOPPED) run so it can be resumed from its cursor. */
+ fun resume(now: LocalDateTime) {
+ this.status = ReindexRunStatus.RUNNING
+ this.finishedOn = null
+ this.error = null
+ this.heartbeatOn = now
+ }
+}
diff --git a/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/domain/PendingIndexDeletion.kt b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/domain/PendingIndexDeletion.kt
new file mode 100644
index 0000000000..b05402bbf9
--- /dev/null
+++ b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/domain/PendingIndexDeletion.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2015-2026 Ritense BV, the Netherlands.
+ *
+ * Licensed under EUPL, Version 1.2 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.ritense.document.opensearch.domain
+
+import jakarta.persistence.Column
+import jakarta.persistence.Entity
+import jakarta.persistence.Id
+import jakarta.persistence.Table
+import java.time.LocalDateTime
+import java.util.UUID
+
+/**
+ * Durable record that a document was deleted from PostgreSQL and its OpenSearch entry still has to be
+ * removed.
+ *
+ * Written **inside** the deleting transaction (see
+ * [com.ritense.document.opensearch.handler.PendingIndexDeletionListener]) so it commits atomically with
+ * the delete — surviving an OpenSearch outage of any length. The reconciler drains these rows at
+ * O(deletes): remove each id from the index, then delete the drained rows. Idempotent.
+ */
+@Entity
+@Table(name = "document_index_pending_deletion")
+class PendingIndexDeletion(
+
+ @Id
+ @Column(name = "document_id")
+ val documentId: UUID,
+
+ @Column(name = "deleted_on", nullable = false)
+ val deletedOn: LocalDateTime = LocalDateTime.now(),
+)
diff --git a/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/domain/ReindexRunStatus.kt b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/domain/ReindexRunStatus.kt
new file mode 100644
index 0000000000..3c7239c762
--- /dev/null
+++ b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/domain/ReindexRunStatus.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2015-2026 Ritense BV, the Netherlands.
+ *
+ * Licensed under EUPL, Version 1.2 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.ritense.document.opensearch.domain
+
+enum class ReindexRunStatus {
+ /** The run is currently in progress (or was, for a row left behind by a crashed instance). */
+ RUNNING,
+
+ /** The run finished and indexed all documents in scope. */
+ COMPLETED,
+
+ /** The run aborted with an error; resumable from [OpenSearchReindexRun.lastId]. */
+ FAILED,
+
+ /** The run was cancelled (e.g. graceful shutdown); resumable from [OpenSearchReindexRun.lastId]. */
+ STOPPED,
+}
diff --git a/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/handler/DocumentOpenSearchEventListener.kt b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/handler/DocumentOpenSearchEventListener.kt
new file mode 100644
index 0000000000..9fe8a5c388
--- /dev/null
+++ b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/handler/DocumentOpenSearchEventListener.kt
@@ -0,0 +1,131 @@
+/*
+ * Copyright 2015-2026 Ritense BV, the Netherlands.
+ *
+ * Licensed under EUPL, Version 1.2 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.ritense.document.opensearch.handler
+
+import com.ritense.document.domain.impl.event.JsonSchemaDocumentCreatedEvent
+import com.ritense.document.domain.impl.event.JsonSchemaDocumentModifiedEvent
+import com.ritense.document.event.DocumentAssigneeChangedEvent
+import com.ritense.document.event.DocumentRetentionPeriodSetEvent
+import com.ritense.document.event.DocumentRetentionPeriodUnsetEvent
+import com.ritense.document.event.DocumentUnassignedEvent
+import com.ritense.document.opensearch.service.DocumentOpenSearchSyncService
+import com.ritense.document.opensearch.service.SearchEngineToggle
+import com.ritense.valtimo.contract.document.event.DocumentRelatedFileAddedEvent
+import com.ritense.valtimo.contract.document.event.DocumentRelatedFileRemovedEvent
+import com.ritense.valtimo.contract.event.DocumentDeletedEvent
+import io.github.oshai.kotlinlogging.KotlinLogging
+import org.springframework.beans.factory.DisposableBean
+import org.springframework.transaction.event.TransactionPhase.AFTER_COMMIT
+import org.springframework.transaction.event.TransactionalEventListener
+import java.util.UUID
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.LinkedBlockingQueue
+import java.util.concurrent.RejectedExecutionException
+import java.util.concurrent.ThreadPoolExecutor
+import java.util.concurrent.TimeUnit
+
+/**
+ * Best-effort, low-latency live sync of single-document mutations into OpenSearch.
+ *
+ * Listens to the real Spring application events emitted inside each document-mutation transaction and,
+ * **after commit**, reloads the document by id and upserts it (or deletes it) in OpenSearch on a managed
+ * single-thread daemon executor. Every task is fully isolated: a failure (e.g. OpenSearch unreachable) is
+ * logged and swallowed so it can never fail or roll back the originating business transaction. Missed
+ * writes are repaired by [com.ritense.document.opensearch.service.DocumentOpenSearchReconcileService];
+ * this listener is only about freshness.
+ *
+ * Status/tags changes and bulk deletes have no dedicated live event and are handled by the reconciler
+ * (upserts) and the pending-index-deletion drain (deletes) respectively.
+ */
+class DocumentOpenSearchEventListener(
+ private val syncService: DocumentOpenSearchSyncService,
+ private val toggle: SearchEngineToggle,
+) : DisposableBean {
+
+ private val executor: ExecutorService = ThreadPoolExecutor(
+ 1, 1, 0L, TimeUnit.MILLISECONDS,
+ LinkedBlockingQueue(QUEUE_CAPACITY),
+ { runnable -> Thread(runnable, "opensearch-live-sync").apply { isDaemon = true } },
+ ThreadPoolExecutor.DiscardOldestPolicy()
+ )
+
+ @TransactionalEventListener(phase = AFTER_COMMIT)
+ fun onCreated(event: JsonSchemaDocumentCreatedEvent) = enqueueUpsert(event.documentId().id)
+
+ @TransactionalEventListener(phase = AFTER_COMMIT)
+ fun onModified(event: JsonSchemaDocumentModifiedEvent) = enqueueUpsert(event.documentId().id)
+
+ @TransactionalEventListener(phase = AFTER_COMMIT)
+ fun onAssigneeChanged(event: DocumentAssigneeChangedEvent) = enqueueUpsert(event.documentId)
+
+ @TransactionalEventListener(phase = AFTER_COMMIT)
+ fun onUnassigned(event: DocumentUnassignedEvent) = enqueueUpsert(event.documentId)
+
+ @TransactionalEventListener(phase = AFTER_COMMIT)
+ fun onRetentionSet(event: DocumentRetentionPeriodSetEvent) = enqueueUpsert(event.getDocumentId())
+
+ @TransactionalEventListener(phase = AFTER_COMMIT)
+ fun onRetentionUnset(event: DocumentRetentionPeriodUnsetEvent) = enqueueUpsert(event.getDocumentId())
+
+ @TransactionalEventListener(phase = AFTER_COMMIT)
+ fun onRelatedFileAdded(event: DocumentRelatedFileAddedEvent) = enqueueUpsert(event.documentId)
+
+ @TransactionalEventListener(phase = AFTER_COMMIT)
+ fun onRelatedFileRemoved(event: DocumentRelatedFileRemovedEvent) = enqueueUpsert(event.documentId)
+
+ @TransactionalEventListener(phase = AFTER_COMMIT)
+ fun onDeleted(event: DocumentDeletedEvent) = enqueueDelete(event.caseDocumentId)
+
+ private fun enqueueUpsert(documentId: UUID) = submit { syncService.upsertById(documentId) }
+
+ private fun enqueueDelete(documentId: UUID) = submit { syncService.delete(documentId) }
+
+ private fun submit(task: () -> Unit) {
+ // Engine off (feature toggled off or OpenSearch disabled): skip the write entirely — no thread,
+ // no OpenSearch call. The reconciler catches up from its watermark once the engine is re-enabled.
+ if (!toggle.isOpenSearchActive()) return
+ try {
+ executor.execute {
+ try {
+ task()
+ } catch (e: Exception) {
+ logger.warn(e) { "Live OpenSearch sync failed — the reconciler will repair the index on its next cycle" }
+ }
+ }
+ } catch (e: RejectedExecutionException) {
+ logger.warn(e) { "Live OpenSearch sync rejected (executor shutting down) — the reconciler will repair the index" }
+ }
+ }
+
+ override fun destroy() {
+ executor.shutdown()
+ try {
+ if (!executor.awaitTermination(SHUTDOWN_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
+ executor.shutdownNow()
+ }
+ } catch (e: InterruptedException) {
+ executor.shutdownNow()
+ Thread.currentThread().interrupt()
+ }
+ }
+
+ companion object {
+ private val logger = KotlinLogging.logger {}
+ private const val SHUTDOWN_TIMEOUT_SECONDS = 30L
+ private const val QUEUE_CAPACITY = 1000
+ }
+}
diff --git a/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/handler/PendingIndexDeletionListener.kt b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/handler/PendingIndexDeletionListener.kt
new file mode 100644
index 0000000000..b4ec98a229
--- /dev/null
+++ b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/handler/PendingIndexDeletionListener.kt
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2015-2026 Ritense BV, the Netherlands.
+ *
+ * Licensed under EUPL, Version 1.2 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.ritense.document.opensearch.handler
+
+import com.ritense.document.opensearch.domain.PendingIndexDeletion
+import com.ritense.document.opensearch.repository.PendingIndexDeletionRepository
+import com.ritense.valtimo.contract.event.DocumentDeletedEvent
+import io.github.oshai.kotlinlogging.KotlinLogging
+import org.springframework.context.event.EventListener
+
+/**
+ * Records a durable pending index deletion for every deleted document.
+ *
+ * Deliberately a **synchronous, in-transaction** [EventListener] (not `@TransactionalEventListener`): the
+ * [DocumentDeletedEvent] is published inside the deleting transaction, so the pending-deletion row commits
+ * atomically with the delete. This is what guarantees deletes survive an OpenSearch outage — the
+ * reconciler drains the pending deletion once OpenSearch is reachable again. The best-effort AFTER_COMMIT
+ * delete in [DocumentOpenSearchEventListener] still runs for freshness; this listener is the durability
+ * backstop.
+ */
+open class PendingIndexDeletionListener(
+ private val pendingIndexDeletionRepository: PendingIndexDeletionRepository,
+) {
+
+ @EventListener
+ open fun onDocumentDeleted(event: DocumentDeletedEvent) {
+ pendingIndexDeletionRepository.save(PendingIndexDeletion(documentId = event.caseDocumentId))
+ logger.debug { "Recorded pending index deletion for document ${event.caseDocumentId}" }
+ }
+
+ companion object {
+ private val logger = KotlinLogging.logger {}
+ }
+}
diff --git a/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/repository/JsonSchemaDocumentOpenSearchRepository.kt b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/repository/JsonSchemaDocumentOpenSearchRepository.kt
new file mode 100644
index 0000000000..2241604807
--- /dev/null
+++ b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/repository/JsonSchemaDocumentOpenSearchRepository.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2015-2024 Ritense BV, the Netherlands.
+ *
+ * Licensed under EUPL, Version 1.2 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.ritense.document.opensearch.repository
+
+import com.ritense.document.opensearch.domain.JsonSchemaDocumentOsDocument
+import org.springframework.data.elasticsearch.repository.ElasticsearchRepository
+
+interface JsonSchemaDocumentOpenSearchRepository : ElasticsearchRepository
diff --git a/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/repository/OpenSearchReconcileStateRepository.kt b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/repository/OpenSearchReconcileStateRepository.kt
new file mode 100644
index 0000000000..79e1bb4fd3
--- /dev/null
+++ b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/repository/OpenSearchReconcileStateRepository.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2015-2026 Ritense BV, the Netherlands.
+ *
+ * Licensed under EUPL, Version 1.2 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.ritense.document.opensearch.repository
+
+import com.ritense.document.opensearch.domain.OpenSearchReconcileState
+import org.springframework.data.jpa.repository.JpaRepository
+
+interface OpenSearchReconcileStateRepository : JpaRepository
diff --git a/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/repository/OpenSearchReindexRunRepository.kt b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/repository/OpenSearchReindexRunRepository.kt
new file mode 100644
index 0000000000..48e6c7612b
--- /dev/null
+++ b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/repository/OpenSearchReindexRunRepository.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2015-2026 Ritense BV, the Netherlands.
+ *
+ * Licensed under EUPL, Version 1.2 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.ritense.document.opensearch.repository
+
+import com.ritense.document.opensearch.domain.OpenSearchReindexRun
+import com.ritense.document.opensearch.domain.ReindexRunStatus
+import org.springframework.data.domain.Page
+import org.springframework.data.domain.Pageable
+import org.springframework.data.jpa.repository.JpaRepository
+import java.time.LocalDateTime
+import java.util.UUID
+
+interface OpenSearchReindexRunRepository : JpaRepository {
+ fun findFirstByOrderByStartedOnDesc(): OpenSearchReindexRun?
+ fun findFirstByStatusOrderByStartedOnDesc(status: ReindexRunStatus): OpenSearchReindexRun?
+ fun findAllByStatusAndHeartbeatOnBefore(status: ReindexRunStatus, heartbeatOn: LocalDateTime): List
+ fun existsByStatusAndHeartbeatOnAfter(status: ReindexRunStatus, heartbeatOn: LocalDateTime): Boolean
+ fun findAllByOrderByStartedOnDesc(pageable: Pageable): Page
+}
diff --git a/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/repository/PendingIndexDeletionRepository.kt b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/repository/PendingIndexDeletionRepository.kt
new file mode 100644
index 0000000000..581a4e801d
--- /dev/null
+++ b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/repository/PendingIndexDeletionRepository.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2015-2026 Ritense BV, the Netherlands.
+ *
+ * Licensed under EUPL, Version 1.2 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.ritense.document.opensearch.repository
+
+import com.ritense.document.opensearch.domain.PendingIndexDeletion
+import org.springframework.data.domain.Pageable
+import org.springframework.data.jpa.repository.JpaRepository
+import java.util.UUID
+
+interface PendingIndexDeletionRepository : JpaRepository {
+ fun findByOrderByDeletedOnAsc(pageable: Pageable): List
+}
diff --git a/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/security/DocumentOpenSearchHttpSecurityConfigurer.kt b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/security/DocumentOpenSearchHttpSecurityConfigurer.kt
new file mode 100644
index 0000000000..d270f8fcf2
--- /dev/null
+++ b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/security/DocumentOpenSearchHttpSecurityConfigurer.kt
@@ -0,0 +1,50 @@
+/*
+ * Copyright 2015-2026 Ritense BV, the Netherlands.
+ *
+ * Licensed under EUPL, Version 1.2 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.ritense.document.opensearch.security
+
+import com.ritense.valtimo.contract.authentication.AuthoritiesConstants.ADMIN
+import com.ritense.valtimo.contract.security.config.HttpConfigurerConfigurationException
+import com.ritense.valtimo.contract.security.config.HttpSecurityConfigurer
+import org.springframework.http.HttpMethod.GET
+import org.springframework.http.HttpMethod.POST
+import org.springframework.http.HttpMethod.PUT
+import org.springframework.security.config.annotation.web.builders.HttpSecurity
+import org.springframework.security.web.util.matcher.AntPathRequestMatcher.antMatcher
+
+class DocumentOpenSearchHttpSecurityConfigurer : HttpSecurityConfigurer {
+
+ override fun configure(http: HttpSecurity) {
+ try {
+ http.authorizeHttpRequests { requests ->
+ requests.requestMatchers(antMatcher(POST, "/api/management/v1/document-opensearch/reindex"))
+ .hasAuthority(ADMIN)
+ requests.requestMatchers(antMatcher(GET, "/api/management/v1/document-opensearch/reindex/runs"))
+ .hasAuthority(ADMIN)
+ requests.requestMatchers(antMatcher(GET, "/api/management/v1/document-opensearch/reindex/status"))
+ .hasAuthority(ADMIN)
+ requests.requestMatchers(antMatcher(GET, "/api/management/v1/document-opensearch/reindex/*"))
+ .hasAuthority(ADMIN)
+ requests.requestMatchers(antMatcher(GET, "/api/management/v1/search-engine"))
+ .hasAuthority(ADMIN)
+ requests.requestMatchers(antMatcher(PUT, "/api/management/v1/search-engine"))
+ .hasAuthority(ADMIN)
+ }
+ } catch (e: Exception) {
+ throw HttpConfigurerConfigurationException(e)
+ }
+ }
+}
diff --git a/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/service/ContentTextExtractor.kt b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/service/ContentTextExtractor.kt
new file mode 100644
index 0000000000..067853d855
--- /dev/null
+++ b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/service/ContentTextExtractor.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2015-2024 Ritense BV, the Netherlands.
+ *
+ * Licensed under EUPL, Version 1.2 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.ritense.document.opensearch.service
+
+import com.fasterxml.jackson.databind.JsonNode
+
+/**
+ * Extracts all leaf values from a [JsonNode] as a single space-separated string.
+ * Used to populate [com.ritense.document.opensearch.domain.JsonSchemaDocumentOsDocument.contentText]
+ * for full-document search.
+ */
+fun extractLeafValues(node: JsonNode?): String? {
+ if (node == null) return null
+ val parts = mutableListOf()
+ collectLeaves(node, parts)
+ return parts.joinToString(" ").ifBlank { null }
+}
+
+private fun collectLeaves(node: JsonNode, out: MutableList) {
+ when {
+ node.isObject -> node.fields().forEach { (_, v) -> collectLeaves(v, out) }
+ node.isArray -> node.forEach { collectLeaves(it, out) }
+ !node.isNull && !node.isMissingNode -> out.add(node.asText())
+ }
+}
diff --git a/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/service/DelegatingDocumentSearchService.kt b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/service/DelegatingDocumentSearchService.kt
new file mode 100644
index 0000000000..64e11ab693
--- /dev/null
+++ b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/service/DelegatingDocumentSearchService.kt
@@ -0,0 +1,105 @@
+/*
+ * Copyright 2015-2026 Ritense BV, the Netherlands.
+ *
+ * Licensed under EUPL, Version 1.2 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.ritense.document.opensearch.service
+
+import com.ritense.document.domain.Document
+import com.ritense.document.domain.search.AdvancedSearchRequest
+import com.ritense.document.domain.search.SearchWithConfigRequest
+import com.ritense.document.service.DocumentSearchService
+import com.ritense.document.service.impl.SearchRequest
+import com.ritense.valtimo.contract.blueprint.BlueprintType
+import org.springframework.data.domain.Page
+import org.springframework.data.domain.Pageable
+
+class DelegatingDocumentSearchService(
+ private val openSearchService: DocumentSearchService,
+ private val jpaService: DocumentSearchService,
+ private val toggle: SearchEngineToggle,
+ private val reindexProgressGate: ReindexProgressGate,
+) : DocumentSearchService {
+
+ override fun search(
+ searchRequest: SearchRequest,
+ blueprintType: BlueprintType,
+ pageable: Pageable
+ ): Page = executeWithFallback { active().search(searchRequest, blueprintType, pageable) }
+
+ override fun search(
+ documentDefinitionName: String,
+ blueprintType: BlueprintType,
+ searchWithConfigRequest: SearchWithConfigRequest,
+ pageable: Pageable
+ ): Page = executeWithFallback {
+ active().search(documentDefinitionName, blueprintType, searchWithConfigRequest, pageable)
+ }
+
+ override fun search(
+ documentDefinitionName: String,
+ blueprintType: BlueprintType,
+ advancedSearchRequest: AdvancedSearchRequest,
+ pageable: Pageable
+ ): Page = executeWithFallback {
+ active().search(documentDefinitionName, blueprintType, advancedSearchRequest, pageable)
+ }
+
+ override fun searchForExport(
+ documentDefinitionName: String,
+ blueprintType: BlueprintType,
+ searchWithConfigRequest: SearchWithConfigRequest,
+ pageable: Pageable
+ ): Page = executeWithFallback {
+ active().searchForExport(documentDefinitionName, blueprintType, searchWithConfigRequest, pageable)
+ }
+
+ override fun count(
+ documentDefinitionName: String,
+ blueprintType: BlueprintType,
+ advancedSearchRequest: AdvancedSearchRequest
+ ): Long = executeWithFallback { active().count(documentDefinitionName, blueprintType, advancedSearchRequest) }
+
+ private fun active(): DocumentSearchService =
+ if (toggle.shouldUsePostgres { reindexProgressGate.isReindexInProgress() }) jpaService else openSearchService
+
+ private fun executeWithFallback(block: () -> T): T {
+ if (toggle.shouldUsePostgres { reindexProgressGate.isReindexInProgress() }) {
+ return block()
+ }
+ return try {
+ block()
+ } catch (e: Exception) {
+ if (isConnectionError(e)) {
+ toggle.activateFallback()
+ block()
+ } else {
+ throw e
+ }
+ }
+ }
+
+ private fun isConnectionError(e: Exception): Boolean {
+ val message = e.message?.lowercase() ?: ""
+ return e is java.net.ConnectException ||
+ e is java.net.SocketTimeoutException ||
+ e is java.net.NoRouteToHostException ||
+ e is java.net.UnknownHostException ||
+ message.contains("connection refused") ||
+ message.contains("connect timed out") ||
+ message.contains("no route to host") ||
+ e.cause?.let { isConnectionError(it as? Exception ?: return false) } ?: false
+ }
+
+}
diff --git a/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/service/DocumentOpenSearchIndexInitializer.kt b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/service/DocumentOpenSearchIndexInitializer.kt
new file mode 100644
index 0000000000..85118f708f
--- /dev/null
+++ b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/service/DocumentOpenSearchIndexInitializer.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2015-2026 Ritense BV, the Netherlands.
+ *
+ * Licensed under EUPL, Version 1.2 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.ritense.document.opensearch.service
+
+import com.ritense.document.opensearch.domain.JsonSchemaDocumentOsDocument
+import io.github.oshai.kotlinlogging.KotlinLogging
+import org.springframework.data.elasticsearch.core.ElasticsearchOperations
+import org.springframework.data.elasticsearch.core.document.Document
+
+/**
+ * Creates the OpenSearch index and mappings if they do not yet exist. Invoked before the engine toggle
+ * switches to OpenSearch — failure prevents the swap, keeping queries on Postgres until the cluster is healthy.
+ * [ensureIndex] is idempotent, so repeated calls are safe.
+ */
+open class DocumentOpenSearchIndexInitializer(
+ private val elasticsearchOperations: ElasticsearchOperations,
+) {
+
+ open fun ensureIndex() {
+ val indexOps = elasticsearchOperations.indexOps(JsonSchemaDocumentOsDocument::class.java)
+ if (!indexOps.exists()) {
+ val settings = Document.create()
+ settings["index.number_of_replicas"] = 0
+ indexOps.create(settings)
+
+ val annotatedMapping = indexOps.createMapping(JsonSchemaDocumentOsDocument::class.java)
+ val dynamicTemplates = listOf(
+ mapOf("content_fields_as_text" to mapOf(
+ "path_match" to "content.*",
+ "match_mapping_type" to "string",
+ "mapping" to mapOf(
+ "type" to "text",
+ "fields" to mapOf("keyword" to mapOf("type" to "keyword", "ignore_above" to 256))
+ )
+ ))
+ )
+ annotatedMapping["dynamic_templates"] = dynamicTemplates
+ indexOps.putMapping(annotatedMapping)
+ }
+ }
+
+ companion object {
+ private val logger = KotlinLogging.logger {}
+ }
+}
\ No newline at end of file
diff --git a/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/service/DocumentOpenSearchQueryService.kt b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/service/DocumentOpenSearchQueryService.kt
new file mode 100644
index 0000000000..36aed351c3
--- /dev/null
+++ b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/service/DocumentOpenSearchQueryService.kt
@@ -0,0 +1,78 @@
+/*
+ * Copyright 2015-2024 Ritense BV, the Netherlands.
+ *
+ * Licensed under EUPL, Version 1.2 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.ritense.document.opensearch.service
+
+import com.ritense.authorization.Action
+import com.ritense.authorization.AuthorizationService
+import com.ritense.document.domain.impl.JsonSchemaDocument
+import com.ritense.document.opensearch.authorization.OpenSearchPermissionConditionTranslator
+import com.ritense.document.opensearch.authorization.OpenSearchPermissionConditionTranslator.Companion.andAll
+import com.ritense.document.opensearch.domain.JsonSchemaDocumentOsDocument
+import com.ritense.document.service.JsonSchemaDocumentActionProvider
+import com.ritense.valtimo.contract.utils.SecurityUtils
+import org.opensearch.index.query.QueryBuilder
+import org.opensearch.index.query.QueryBuilders
+import org.springframework.data.domain.Page
+import org.springframework.data.domain.PageImpl
+import org.springframework.data.domain.Pageable
+import org.springframework.data.elasticsearch.core.ElasticsearchOperations
+import org.springframework.data.elasticsearch.core.query.StringQuery
+
+class DocumentOpenSearchQueryService(
+ private val elasticsearchOperations: ElasticsearchOperations,
+ private val authorizationService: AuthorizationService,
+ private val translator: OpenSearchPermissionConditionTranslator,
+) {
+
+ /**
+ * Returns a page of documents for the given [definitionName], restricted to those
+ * the current user is allowed to see (VIEW_LIST action).
+ */
+ fun findAllByDefinitionName(definitionName: String, pageable: Pageable): Page {
+ val authQuery = buildAuthQuery(JsonSchemaDocumentActionProvider.VIEW_LIST)
+ val definitionFilter = QueryBuilders.termQuery("definitionId.name", definitionName)
+ val combined = andAll(listOf(authQuery, definitionFilter))
+
+ val dataQuery = StringQuery(combined.toString(), pageable)
+
+ val hits = elasticsearchOperations.search(dataQuery, JsonSchemaDocumentOsDocument::class.java)
+ val total = hits.totalHits
+ val content = hits.searchHits.mapNotNull { hit -> hit.content }
+ return PageImpl(content, pageable, total)
+ }
+
+ /**
+ * Returns the document with the given [id] if the current user has VIEW permission,
+ * or `null` if it does not exist or is not accessible.
+ */
+ fun findById(id: String): JsonSchemaDocumentOsDocument? {
+ val authQuery = buildAuthQuery(JsonSchemaDocumentActionProvider.VIEW)
+ val idFilter = QueryBuilders.idsQuery().addIds(id)
+ val combined = andAll(listOf(authQuery, idFilter))
+
+ val query = StringQuery(combined.toString())
+ val hits = elasticsearchOperations.search(query, JsonSchemaDocumentOsDocument::class.java)
+ return hits.searchHits.firstOrNull()?.content
+ }
+
+ private fun buildAuthQuery(action: Action): QueryBuilder {
+ val userRoles = SecurityUtils.getCurrentUserRoles().toSet()
+ val permissions = authorizationService.getPermissions(JsonSchemaDocument::class.java, action)
+ .filter { it.role.key in userRoles }
+ return translator.toQuery(permissions, action)
+ }
+}
diff --git a/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/service/DocumentOpenSearchReconcileJob.kt b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/service/DocumentOpenSearchReconcileJob.kt
new file mode 100644
index 0000000000..68fac30130
--- /dev/null
+++ b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/service/DocumentOpenSearchReconcileJob.kt
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2015-2026 Ritense BV, the Netherlands.
+ *
+ * Licensed under EUPL, Version 1.2 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.ritense.document.opensearch.service
+
+import org.springframework.scheduling.annotation.Scheduled
+import java.util.concurrent.atomic.AtomicBoolean
+
+/**
+ * Fires the reconcile cycle on a fixed delay. The [running] guard makes overlapping scheduled invocations
+ * on this node a no-op; cross-node exclusivity is handled by the ShedLock inside the service. `fixedDelay`
+ * (not `fixedRate`) so a slow cycle never queues up back-to-back runs.
+ */
+class DocumentOpenSearchReconcileJob(
+ private val reconcileService: DocumentOpenSearchReconcileService,
+ private val toggle: SearchEngineToggle,
+) {
+ private val running = AtomicBoolean(false)
+
+ // The PT2M default must match OpenSearchProperties.Reconcile.interval; the live path owns freshness,
+ // so this safety-net reconciler runs on a relaxed interval.
+ @Scheduled(fixedDelayString = "\${valtimo.opensearch.reconcile.interval:PT2M}")
+ fun reconcile() {
+ // Engine off: skip this cycle without touching OpenSearch. The tick keeps firing cheaply and
+ // resumes reconciling from the persisted watermark on the first cycle after the engine is re-enabled.
+ if (!toggle.isOpenSearchActive()) return
+ if (running.compareAndSet(false, true)) {
+ try {
+ reconcileService.reconcile()
+ } finally {
+ running.set(false)
+ }
+ }
+ }
+}
diff --git a/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/service/DocumentOpenSearchReconcileService.kt b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/service/DocumentOpenSearchReconcileService.kt
new file mode 100644
index 0000000000..d18c27c317
--- /dev/null
+++ b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/service/DocumentOpenSearchReconcileService.kt
@@ -0,0 +1,223 @@
+/*
+ * Copyright 2015-2026 Ritense BV, the Netherlands.
+ *
+ * Licensed under EUPL, Version 1.2 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.ritense.document.opensearch.service
+
+import com.ritense.document.domain.impl.JsonSchemaDocument
+import com.ritense.document.opensearch.OpenSearchProperties
+import com.ritense.document.opensearch.domain.JsonSchemaDocumentOsDocument
+import com.ritense.document.opensearch.domain.OpenSearchReconcileState
+import com.ritense.document.opensearch.repository.JsonSchemaDocumentOpenSearchRepository
+import com.ritense.document.opensearch.repository.OpenSearchReconcileStateRepository
+import com.ritense.document.opensearch.repository.PendingIndexDeletionRepository
+import io.github.oshai.kotlinlogging.KotlinLogging
+import jakarta.persistence.EntityManager
+import net.javacrumbs.shedlock.core.LockConfiguration
+import net.javacrumbs.shedlock.core.LockProvider
+import org.springframework.data.domain.PageRequest
+import org.springframework.transaction.PlatformTransactionManager
+import org.springframework.transaction.support.TransactionTemplate
+import java.time.Duration
+import java.time.Instant
+import java.time.LocalDateTime
+import java.util.UUID
+
+/**
+ * The self-healing backbone: a single-writer, watermark-based incremental reindex that makes the
+ * OpenSearch index a derived read-model of PostgreSQL.
+ *
+ * Each cycle (guarded cluster-wide by a ShedLock named lock so exactly one node runs it):
+ * 1. reads the persisted watermark (initialised to the current `MAX(changed_on)` on first ever run, so a
+ * fresh deploy does not re-index the whole corpus — initial population stays with the admin re-index);
+ * 2. keyset-scans every document with `changed_on > (watermark − overlap)` and idempotently upserts it —
+ * covering status/tags/anything the live path missed or that happened during an OpenSearch outage;
+ * 3. drains the pending-index-deletion table (O(deletes), never an O(index) scan);
+ * 4. advances the watermark to the highest `changed_on` processed — **only** after a fully successful
+ * cycle. Any failure leaves the watermark parked, so the next cycle simply retries from the same point.
+ *
+ * All writes are idempotent, so re-processing the overlap window (or a whole failed cycle) is safe.
+ */
+open class DocumentOpenSearchReconcileService(
+ private val entityManager: EntityManager,
+ private val converter: JsonSchemaDocumentOsConverter,
+ private val openSearchRepository: JsonSchemaDocumentOpenSearchRepository,
+ private val stateRepository: OpenSearchReconcileStateRepository,
+ private val pendingIndexDeletionRepository: PendingIndexDeletionRepository,
+ private val transactionManager: PlatformTransactionManager,
+ private val lockProvider: LockProvider,
+ private val properties: OpenSearchProperties,
+) {
+
+ open fun reconcile() {
+ if (!properties.enabled) return
+
+ val lock = lockProvider.lock(
+ LockConfiguration(Instant.now(), LOCK_NAME, LOCK_AT_MOST_FOR, Duration.ZERO)
+ )
+ if (lock.isEmpty) {
+ logger.debug { "Another node is reconciling — skipping this cycle" }
+ return
+ }
+
+ try {
+ val watermark = currentWatermark()
+ val from = watermark.minus(properties.reconcile.overlap)
+ val maxSeen = processUpserts(from)
+ drainPendingDeletions()
+ if (maxSeen.isAfter(watermark)) {
+ advanceWatermark(maxSeen)
+ logger.debug { "Reconcile advanced watermark to $maxSeen" }
+ }
+ } catch (e: Exception) {
+ logger.error(e) { "OpenSearch reconcile cycle failed — watermark not advanced; retrying next cycle" }
+ } finally {
+ lock.get().unlock()
+ }
+ }
+
+ /**
+ * Keyset-paginates over `changed_on > from` (tie-broken by id), converting and idempotently upserting
+ * each page. Returns the highest `changed_on` seen (or [from] when nothing changed).
+ */
+ private fun processUpserts(from: LocalDateTime): LocalDateTime {
+ var maxSeen = from
+ var lastChangedOn: LocalDateTime? = null
+ var lastId: UUID? = null
+ val pageSize = properties.reconcile.pageSize
+ val txTemplate = TransactionTemplate(transactionManager).apply { isReadOnly = true }
+
+ while (true) {
+ val cursorChangedOn = lastChangedOn
+ val cursorId = lastId
+ val page = txTemplate.execute {
+ val batch = fetchPage(from, cursorChangedOn, cursorId, pageSize)
+ if (batch.isEmpty()) {
+ null
+ } else {
+ val osDocuments = batch.mapNotNull { document ->
+ try {
+ converter.toOsDocument(document)
+ } catch (e: Exception) {
+ logger.warn(e) { "Failed to convert document ${document.id().id} during reconcile — skipping" }
+ null
+ }
+ }
+ val last = batch.last()
+ ReconcilePage(osDocuments, last.changedOn(), last.id().id).also { entityManager.clear() }
+ }
+ } ?: break
+
+ page.osDocuments.chunked(JsonSchemaDocumentOsConverter.BULK_CHUNK_SIZE)
+ .forEach { converter.indexChunk(it) }
+
+ if (page.lastChangedOn.isAfter(maxSeen)) maxSeen = page.lastChangedOn
+ lastChangedOn = page.lastChangedOn
+ lastId = page.lastId
+ }
+ return maxSeen
+ }
+
+ /**
+ * Scoped keyset fetch. Eagerly loads the lazy `internalStatus` `@ManyToOne` so the converted document
+ * carries the real status key, and keeps a composite `(changed_on, id)` cursor for constant-cost
+ * pagination that is stable when many rows share the same `changed_on`.
+ */
+ private fun fetchPage(
+ from: LocalDateTime,
+ lastChangedOn: LocalDateTime?,
+ lastId: UUID?,
+ pageSize: Int,
+ ): List {
+ val hasCursor = lastChangedOn != null && lastId != null
+ val jpql = buildString {
+ append("SELECT d FROM JsonSchemaDocument d LEFT JOIN FETCH d.internalStatus WHERE d.changedOn > :from")
+ if (hasCursor) {
+ append(" AND (d.changedOn > :lastChangedOn OR (d.changedOn = :lastChangedOn AND d.id.id > :lastId))")
+ }
+ append(" ORDER BY d.changedOn ASC, d.id.id ASC")
+ }
+ val query = entityManager.createQuery(jpql, JsonSchemaDocument::class.java)
+ query.setParameter("from", from)
+ if (hasCursor) {
+ query.setParameter("lastChangedOn", lastChangedOn)
+ query.setParameter("lastId", lastId)
+ }
+ return query.setMaxResults(pageSize).resultList
+ }
+
+ /**
+ * Removes pending-deletion documents from OpenSearch in batches, then deletes the drained
+ * pending-deletion rows.
+ */
+ private fun drainPendingDeletions() {
+ val batchSize = properties.reconcile.pendingDeletionBatchSize
+ val readTemplate = TransactionTemplate(transactionManager).apply { isReadOnly = true }
+ val writeTemplate = TransactionTemplate(transactionManager)
+ while (true) {
+ val pendingDeletions = readTemplate.execute {
+ pendingIndexDeletionRepository.findByOrderByDeletedOnAsc(PageRequest.of(0, batchSize))
+ }.orEmpty()
+ if (pendingDeletions.isEmpty()) break
+
+ pendingDeletions.forEach { openSearchRepository.deleteById(it.documentId.toString()) }
+ writeTemplate.execute { pendingIndexDeletionRepository.deleteAllById(pendingDeletions.map { it.documentId }) }
+ logger.debug { "Drained ${pendingDeletions.size} pending index deletion(s) from OpenSearch" }
+ }
+ }
+
+ private fun currentWatermark(): LocalDateTime =
+ requireNotNull(
+ TransactionTemplate(transactionManager).execute {
+ stateRepository.findById(OpenSearchReconcileState.SINGLETON_ID)
+ .map { it.watermark }
+ .orElseGet {
+ val initial = initialWatermark()
+ stateRepository.save(OpenSearchReconcileState(watermark = initial))
+ logger.info { "Initialised OpenSearch reconcile watermark to $initial" }
+ initial
+ }
+ }
+ )
+
+ private fun initialWatermark(): LocalDateTime =
+ entityManager
+ .createQuery("SELECT MAX(d.changedOn) FROM JsonSchemaDocument d", LocalDateTime::class.java)
+ .singleResult ?: LocalDateTime.now()
+
+ private fun advanceWatermark(newWatermark: LocalDateTime) {
+ TransactionTemplate(transactionManager).execute {
+ val state = stateRepository.findById(OpenSearchReconcileState.SINGLETON_ID)
+ .orElseGet { OpenSearchReconcileState(watermark = newWatermark) }
+ state.watermark = newWatermark
+ stateRepository.save(state)
+ }
+ }
+
+ private data class ReconcilePage(
+ val osDocuments: List,
+ val lastChangedOn: LocalDateTime,
+ val lastId: UUID,
+ )
+
+ companion object {
+ private val logger = KotlinLogging.logger {}
+
+ const val LOCK_NAME = "document-opensearch-reconcile"
+
+ /** Lock lease per cycle; a cycle should complete well within this. */
+ val LOCK_AT_MOST_FOR: Duration = Duration.ofMinutes(10)
+ }
+}
diff --git a/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/service/DocumentOpenSearchReindexService.kt b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/service/DocumentOpenSearchReindexService.kt
new file mode 100644
index 0000000000..9a4ae206f7
--- /dev/null
+++ b/backend/case-opensearch/src/main/kotlin/com/ritense/document/opensearch/service/DocumentOpenSearchReindexService.kt
@@ -0,0 +1,338 @@
+/*
+ * Copyright 2015-2026 Ritense BV, the Netherlands.
+ *
+ * Licensed under EUPL, Version 1.2 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.ritense.document.opensearch.service
+
+import com.ritense.document.domain.impl.JsonSchemaDocument
+import com.ritense.document.opensearch.domain.JsonSchemaDocumentOsDocument
+import com.ritense.document.opensearch.repository.JsonSchemaDocumentOpenSearchRepository
+import io.github.oshai.kotlinlogging.KotlinLogging
+import jakarta.persistence.EntityManager
+import jakarta.persistence.criteria.JoinType
+import jakarta.persistence.criteria.Predicate
+import net.javacrumbs.shedlock.core.LockConfiguration
+import net.javacrumbs.shedlock.core.LockProvider
+import org.springframework.beans.factory.DisposableBean
+import org.opensearch.index.query.BoolQueryBuilder
+import org.opensearch.index.query.QueryBuilders
+import org.springframework.data.domain.Page
+import org.springframework.data.domain.PageRequest
+import org.springframework.data.domain.Pageable
+import org.springframework.data.elasticsearch.core.ElasticsearchOperations
+import org.opensearch.data.client.orhlc.NativeSearchQueryBuilder
+import org.springframework.transaction.PlatformTransactionManager
+import org.springframework.transaction.support.TransactionTemplate
+import java.time.Duration
+import java.time.Instant
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
+import java.util.UUID
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
+
+/**
+ * Re-runnable, scoped re-index of [JsonSchemaDocument] rows into the live `json_schema_document`
+ * OpenSearch index.
+ *
+ * - **Cluster-safe single runner**: a ShedLock named lock ([LOCK_NAME]) guarantees at most one run
+ * cluster-wide; a concurrent [start] returns `null` (→ HTTP 409).
+ * - **Persisted, resumable state**: progress (cursor, counts, heartbeat) lives in the database via
+ * [OpenSearchReindexRunService]; a FAILED/STOPPED run can be resumed from its cursor with an
+ * idempotent upsert.
+ * - **Scoped**: only documents matching the [ReindexRequest] filters are (re)indexed; the index stays
+ * complete and queryable throughout.
+ * - **Crash-safe refresh**: no global `refresh_interval` toggle — a single explicit refresh runs at
+ * successful completion.
+ */
+open class DocumentOpenSearchReindexService(
+ private val entityManager: EntityManager,
+ private val converter: JsonSchemaDocumentOsConverter,
+ private val elasticsearchOperations: ElasticsearchOperations,
+ private val transactionManager: PlatformTransactionManager,
+ private val lockProvider: LockProvider,
+ private val runService: OpenSearchReindexRunService,
+ private val openSearchRepository: JsonSchemaDocumentOpenSearchRepository,
+) : DisposableBean {
+
+ private val executor: ExecutorService = Executors.newSingleThreadExecutor { runnable ->
+ Thread(runnable, "opensearch-reindex").apply { isDaemon = true }
+ }
+
+ @Volatile
+ private var cancelRequested = false
+
+ /**
+ * Acquires the cluster-wide lock, creates (or resumes) a run record and dispatches the re-index on
+ * the managed executor. Returns the run id, or `null` if a re-index is already running anywhere in
+ * the cluster.
+ */
+ fun start(request: ReindexRequest): UUID? {
+ val lock = lockProvider.lock(
+ LockConfiguration(Instant.now(), LOCK_NAME, LOCK_AT_MOST_FOR, Duration.ZERO)
+ )
+ if (lock.isEmpty) return null
+
+ cancelRequested = false
+ val run = try {
+ runService.startOrResume(request)
+ } catch (e: Exception) {
+ lock.get().unlock()
+ throw e
+ }
+
+ executor.execute {
+ try {
+ reindex(run.id)
+ } catch (e: Exception) {
+ logger.error(e) { "Re-index run ${run.id} terminated with error" }
+ } finally {
+ lock.get().unlock()
+ }
+ }
+ return run.id
+ }
+
+ /**
+ * Runs the chunked, resumable re-index loop for [runId].
+ * Each DB page is read in its own short read-only transaction (keeping snapshots short) and the
+ * persistence context is cleared after every page. Returns the number of documents processed.
+ */
+ open fun reindex(runId: UUID): Long {
+ val scope = runService.scopeOf(runId)
+ var lastId: UUID? = runService.cursorOf(runId)
+ var processed: Long = runService.processedOf(runId)
+ var skipped = 0L
+ val pageSize = runService.pageSizeOf(runId)
+ val txTemplate = TransactionTemplate(transactionManager).apply { isReadOnly = true }
+
+ try {
+ while (!cancelRequested) {
+ val cursor = lastId
+ val batch = txTemplate.execute {
+ fetchBatch(scope, cursor, pageSize).also { entityManager.clear() }
+ } ?: break
+ if (batch.isEmpty()) break
+
+ val docs = batch.mapNotNull { jpaDoc ->
+ try {
+ converter.toOsDocument(jpaDoc)
+ } catch (e: Exception) {
+ skipped++
+ logger.warn(e) { "Failed to convert document — skipping" }
+ null
+ }
+ }
+ skipped += docs.chunked(JsonSchemaDocumentOsConverter.BULK_CHUNK_SIZE).sumOf { converter.indexChunk(it) }
+
+ processed += docs.size
+ lastId = batch.last().id().id
+ runService.recordProgress(runId, lastId, processed, skipped)
+ }
+
+ if (cancelRequested) {
+ logger.info { "Re-index run $runId cancelled — marking STOPPED (processed=$processed, skipped=$skipped)" }
+ runService.stop(runId)
+ } else {
+ if (scope.pruneOrphans) {
+ val pruned = pruneOrphans(runId, scope)
+ logger.info { "Pruned $pruned orphan document(s) from OpenSearch" }
+ }
+ indexOps().refresh()
+ logger.info { "Re-index run $runId complete (processed=$processed, skipped=$skipped)" }
+ runService.complete(runId, processed + skipped)
+ }
+ } catch (e: Exception) {
+ runService.fail(runId, e.message)
+ logger.error(e) { "Re-index failed (run $runId)" }
+ throw e
+ }
+ return processed
+ }
+
+ /** Status of a specific run (by id) or the most recent run when [runId] is null. */
+ fun status(runId: UUID? = null): Map = runService.toStatusMap(runId)
+
+ fun listRuns(pageable: Pageable): Page