diff --git a/ordinis-app/src/main/java/cloud/nstart/terravault/ordinis/app/OrdinisApplication.java b/ordinis-app/src/main/java/cloud/nstart/terravault/ordinis/app/OrdinisApplication.java
index 327a0c7..32902b3 100644
--- a/ordinis-app/src/main/java/cloud/nstart/terravault/ordinis/app/OrdinisApplication.java
+++ b/ordinis-app/src/main/java/cloud/nstart/terravault/ordinis/app/OrdinisApplication.java
@@ -2,9 +2,13 @@ package cloud.nstart.terravault.ordinis.app;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.autoconfigure.domain.EntityScan;
+import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication(scanBasePackages = "cloud.nstart.terravault.ordinis")
+@EnableJpaRepositories(basePackages = "cloud.nstart.terravault.ordinis")
+@EntityScan(basePackages = "cloud.nstart.terravault.ordinis")
@EnableScheduling
public class OrdinisApplication {
diff --git a/ordinis-app/src/main/resources/application.yml b/ordinis-app/src/main/resources/application.yml
index 478ee89..c095503 100644
--- a/ordinis-app/src/main/resources/application.yml
+++ b/ordinis-app/src/main/resources/application.yml
@@ -12,7 +12,7 @@ management:
endpoints:
web:
exposure:
- include: health,info,prometheus,refresh,metrics,env
+ include: health,info,prometheus,refresh,metrics,env,mappings
base-path: /actuator
endpoint:
health:
@@ -23,7 +23,7 @@ management:
liveness:
include: livenessState
readiness:
- include: readinessState
+ include: readinessState,db
prometheus:
metrics:
export:
@@ -33,7 +33,6 @@ management:
application: ${spring.application.name}
environment: ${ENVIRONMENT:dev}
-# Defaults для OTel (можно отключить через otel.sdk.disabled=true в dev)
otel:
exporter:
otlp:
@@ -53,8 +52,16 @@ logging:
root: INFO
cloud.nstart.terravault.ordinis: DEBUG
+ordinis:
+ outbox:
+ enabled: ${ORDINIS_OUTBOX_ENABLED:true}
+ poll-interval-ms: ${ORDINIS_OUTBOX_POLL_INTERVAL_MS:1000}
+ batch-size: ${ORDINIS_OUTBOX_BATCH_SIZE:100}
+
---
-# DEV profile — local run без Cloud Config / Vault / OTel.
+# DEV profile: подключается к cluster PG+Kafka через kubectl port-forward.
+# Перед запуском: kubectl port-forward -n ordinis-dev svc/ordinis-postgres-rw 5432:5432 &
+# kubectl port-forward -n ordinis-dev svc/ordinis-kafka-kafka-bootstrap 9092:9092 &
spring:
config:
activate:
@@ -66,17 +73,45 @@ spring:
enabled: false
autoconfigure:
exclude:
- - org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
- - org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration
- - org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration
- - org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration
+ - org.springframework.cloud.vault.config.VaultAutoConfiguration
+ - org.springframework.cloud.vault.config.VaultObservationAutoConfiguration
+ - org.springframework.cloud.vault.config.VaultReactiveAutoConfiguration
+ datasource:
+ url: jdbc:postgresql://${ORDINIS_PG_HOST:localhost}:${ORDINIS_PG_PORT:5432}/${ORDINIS_PG_DB:ordinis}
+ username: ${ORDINIS_PG_USER:ordinis_app}
+ password: ${ORDINIS_PG_PASSWORD:}
+ driver-class-name: org.postgresql.Driver
+ jpa:
+ hibernate:
+ ddl-auto: update
+ properties:
+ hibernate.dialect: org.hibernate.dialect.PostgreSQLDialect
+ hibernate.format_sql: true
+ hibernate.jdbc.lob.non_contextual_creation: true
+ open-in-view: false
+ kafka:
+ bootstrap-servers: ${ORDINIS_KAFKA_BOOTSTRAP:localhost:9092}
+ properties:
+ security.protocol: ${ORDINIS_KAFKA_SECURITY:SASL_PLAINTEXT}
+ sasl.mechanism: ${ORDINIS_KAFKA_SASL_MECHANISM:SCRAM-SHA-512}
+ sasl.jaas.config: >-
+ org.apache.kafka.common.security.scram.ScramLoginModule required
+ username="${ORDINIS_KAFKA_USER:ordinis-app}"
+ password="${ORDINIS_KAFKA_PASSWORD:}";
+ producer:
+ acks: all
+ key-serializer: org.apache.kafka.common.serialization.StringSerializer
+ value-serializer: org.apache.kafka.common.serialization.StringSerializer
+ properties:
+ enable.idempotence: true
+ compression.type: lz4
otel:
sdk:
disabled: true
---
-# K8s profile — реальная инфра: Spring Cloud Config + Vault Kubernetes auth.
+# K8s profile: реальная инфра в кластере. Cloud Config + Vault Kubernetes auth.
spring:
config:
activate:
@@ -98,3 +133,7 @@ spring:
enabled: true
backend: secret
application-name: ordinis/cuod
+ jpa:
+ hibernate:
+ ddl-auto: validate
+ open-in-view: false
diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/config/JpaAuditingConfig.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/config/JpaAuditingConfig.java
index 34e9e3b..2d1bbd7 100644
--- a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/config/JpaAuditingConfig.java
+++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/config/JpaAuditingConfig.java
@@ -1,27 +1,28 @@
package cloud.nstart.terravault.ordinis.domain.config;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
+import org.springframework.data.auditing.DateTimeProvider;
import org.springframework.data.domain.AuditorAware;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
-import javax.sql.DataSource;
+import java.time.OffsetDateTime;
import java.util.Optional;
/**
- * Включает {@code @CreatedBy}/{@code @LastModifiedBy}. Domain не знает откуда
- * приходит user identity — модули выше (app, rest-api) подключают свой
- * {@link AuditorAware} (JWT subject из @nstart/auth). Default тут возвращает
- * {@code "system"} для batch/internal операций.
+ * Включает {@code @CreatedBy}/{@code @LastModifiedBy}/{@code @CreatedDate}/{@code @LastModifiedDate}.
*
- *
{@link ConditionalOnBean} на {@link DataSource} — без БД (dev smoke
- * profile) auditing не активируется, JPA metamodel не строится.
+ *
Domain не знает откуда приходит user identity — модули выше (app,
+ * rest-api) подключают свой {@link AuditorAware} (JWT subject из @nstart/auth).
+ * Default тут возвращает {@code "system"} для batch/internal операций.
+ *
+ *
{@link DateTimeProvider} явно возвращает {@link OffsetDateTime} — Spring
+ * Data Auditing по дефолту не поддерживает {@code OffsetDateTime}, так что без
+ * этого bean'а получим {@code IllegalArgumentException} при сохранении.
*/
@Configuration
-@ConditionalOnBean(DataSource.class)
-@EnableJpaAuditing(auditorAwareRef = "auditorAware")
+@EnableJpaAuditing(auditorAwareRef = "auditorAware", dateTimeProviderRef = "auditingDateTimeProvider")
public class JpaAuditingConfig {
@Bean
@@ -29,4 +30,9 @@ public class JpaAuditingConfig {
public AuditorAware auditorAware() {
return () -> Optional.of("system");
}
+
+ @Bean
+ public DateTimeProvider auditingDateTimeProvider() {
+ return () -> Optional.of(OffsetDateTime.now());
+ }
}
diff --git a/ordinis-outbox/pom.xml b/ordinis-outbox/pom.xml
index 751dc49..fcece78 100644
--- a/ordinis-outbox/pom.xml
+++ b/ordinis-outbox/pom.xml
@@ -23,6 +23,18 @@
org.springframework.kafka
spring-kafka
+
+ com.fasterxml.jackson.core
+ jackson-databind
+
+
+ io.micrometer
+ micrometer-core
+
+
+ io.micrometer
+ micrometer-tracing
+
cloud.nstart.terravault.ordinis
ordinis-domain
diff --git a/ordinis-outbox/src/main/java/cloud/nstart/terravault/ordinis/outbox/KafkaTopicResolver.java b/ordinis-outbox/src/main/java/cloud/nstart/terravault/ordinis/outbox/KafkaTopicResolver.java
new file mode 100644
index 0000000..a2d2195
--- /dev/null
+++ b/ordinis-outbox/src/main/java/cloud/nstart/terravault/ordinis/outbox/KafkaTopicResolver.java
@@ -0,0 +1,20 @@
+package cloud.nstart.terravault.ordinis.outbox;
+
+import cloud.nstart.terravault.ordinis.domain.DataScope;
+
+/**
+ * Маршрутизация события в правильный scope-топик. Pattern:
+ * {@code ordinis..events.}.
+ *
+ * Bundle сейчас всегда "cuod" (anchor customer); для второго клиента —
+ * другой bundle, своя тройка топиков. ACL/provisioning через Strimzi (см.
+ * ordinis-infra/k8s/kafka/topology.yaml).
+ */
+public final class KafkaTopicResolver {
+
+ private KafkaTopicResolver() {}
+
+ public static String topic(String bundle, DataScope scope) {
+ return "ordinis." + bundle + ".events." + scope.topicSuffix();
+ }
+}
diff --git a/ordinis-outbox/src/main/java/cloud/nstart/terravault/ordinis/outbox/OutboxLagGauge.java b/ordinis-outbox/src/main/java/cloud/nstart/terravault/ordinis/outbox/OutboxLagGauge.java
new file mode 100644
index 0000000..e523961
--- /dev/null
+++ b/ordinis-outbox/src/main/java/cloud/nstart/terravault/ordinis/outbox/OutboxLagGauge.java
@@ -0,0 +1,33 @@
+package cloud.nstart.terravault.ordinis.outbox;
+
+import cloud.nstart.terravault.ordinis.domain.outbox.OutboxEventRepository;
+import io.micrometer.core.instrument.Gauge;
+import io.micrometer.core.instrument.MeterRegistry;
+import jakarta.annotation.PostConstruct;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Component;
+
+/**
+ * Gauge {@code ordinis_outbox_pending_events} — сколько unpublished. Алертится
+ * Prometheus'ом если растёт (poller не успевает или Kafka недоступна).
+ */
+@Component
+@ConditionalOnProperty(name = "ordinis.outbox.enabled", havingValue = "true", matchIfMissing = false)
+public class OutboxLagGauge {
+
+ private final OutboxEventRepository repository;
+ private final MeterRegistry meterRegistry;
+
+ public OutboxLagGauge(OutboxEventRepository repository, MeterRegistry meterRegistry) {
+ this.repository = repository;
+ this.meterRegistry = meterRegistry;
+ }
+
+ @PostConstruct
+ void register() {
+ Gauge.builder("ordinis_outbox_pending_events", repository,
+ r -> (double) r.countByPublishedAtIsNull())
+ .description("Количество outbox events ожидающих публикацию в Kafka")
+ .register(meterRegistry);
+ }
+}
diff --git a/ordinis-outbox/src/main/java/cloud/nstart/terravault/ordinis/outbox/OutboxPoller.java b/ordinis-outbox/src/main/java/cloud/nstart/terravault/ordinis/outbox/OutboxPoller.java
new file mode 100644
index 0000000..1f1542b
--- /dev/null
+++ b/ordinis-outbox/src/main/java/cloud/nstart/terravault/ordinis/outbox/OutboxPoller.java
@@ -0,0 +1,112 @@
+package cloud.nstart.terravault.ordinis.outbox;
+
+import cloud.nstart.terravault.ordinis.domain.outbox.OutboxEvent;
+import cloud.nstart.terravault.ordinis.domain.outbox.OutboxEventRepository;
+import io.micrometer.core.instrument.Counter;
+import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.core.instrument.Timer;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.kafka.core.KafkaTemplate;
+import org.springframework.kafka.support.SendResult;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+/**
+ * @Scheduled poller для outbox. Sweep каждые {@code ordinis.outbox.poll-interval-ms},
+ * batch size {@code ordinis.outbox.batch-size}. Активируется только если
+ * {@code ordinis.outbox.enabled=true} (отключаем в read-api / projection-writer).
+ */
+@Component
+@ConditionalOnProperty(name = "ordinis.outbox.enabled", havingValue = "true", matchIfMissing = false)
+public class OutboxPoller {
+
+ private static final Logger log = LoggerFactory.getLogger(OutboxPoller.class);
+
+ private final OutboxEventRepository repository;
+ private final KafkaTemplate kafkaTemplate;
+ private final com.fasterxml.jackson.databind.ObjectMapper objectMapper;
+ private final Counter publishedCounter;
+ private final Counter failedCounter;
+ private final Timer publishTimer;
+
+ @Value("${ordinis.outbox.batch-size:100}")
+ private int batchSize;
+
+ @Value("${ordinis.outbox.send-timeout-ms:10000}")
+ private long sendTimeoutMs;
+
+ public OutboxPoller(
+ OutboxEventRepository repository,
+ KafkaTemplate kafkaTemplate,
+ com.fasterxml.jackson.databind.ObjectMapper objectMapper,
+ MeterRegistry meterRegistry) {
+ this.repository = repository;
+ this.kafkaTemplate = kafkaTemplate;
+ this.objectMapper = objectMapper;
+ this.publishedCounter = Counter.builder("ordinis_outbox_published_total")
+ .description("Количество успешно опубликованных outbox events")
+ .register(meterRegistry);
+ this.failedCounter = Counter.builder("ordinis_outbox_publish_errors_total")
+ .description("Количество ошибок публикации в Kafka")
+ .register(meterRegistry);
+ this.publishTimer = Timer.builder("ordinis_kafka_publish_duration_seconds")
+ .description("Время публикации одного события в Kafka")
+ .register(meterRegistry);
+ }
+
+ @Scheduled(fixedDelayString = "${ordinis.outbox.poll-interval-ms:1000}")
+ @Transactional
+ public void poll() {
+ List batch = repository.findUnpublished(PageRequest.of(0, batchSize));
+ if (batch.isEmpty()) return;
+
+ log.debug("Outbox poll: {} unpublished events", batch.size());
+
+ for (OutboxEvent event : batch) {
+ Timer.Sample sample = Timer.start();
+ try {
+ String value = objectMapper.writeValueAsString(event.getPayload());
+ SendResult result = kafkaTemplate
+ .send(event.getKafkaTopic(), event.getKafkaKey(), value)
+ .get(sendTimeoutMs, TimeUnit.MILLISECONDS);
+
+ event.markPublished();
+ repository.save(event);
+ publishedCounter.increment();
+ sample.stop(publishTimer);
+
+ log.debug("Published outbox event id={} topic={} partition={} offset={}",
+ event.getId(),
+ event.getKafkaTopic(),
+ result.getRecordMetadata().partition(),
+ result.getRecordMetadata().offset());
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ markFailed(event, "Interrupted: " + e.getMessage());
+ return;
+ } catch (ExecutionException | TimeoutException e) {
+ markFailed(event, e.getClass().getSimpleName() + ": " + e.getMessage());
+ } catch (Exception e) {
+ markFailed(event, e.getClass().getSimpleName() + ": " + e.getMessage());
+ }
+ }
+ }
+
+ private void markFailed(OutboxEvent event, String error) {
+ log.warn("Failed to publish outbox event id={} topic={}: {}",
+ event.getId(), event.getKafkaTopic(), error);
+ event.markFailed(error);
+ repository.save(event);
+ failedCounter.increment();
+ }
+}
diff --git a/ordinis-outbox/src/main/java/cloud/nstart/terravault/ordinis/outbox/OutboxRecorder.java b/ordinis-outbox/src/main/java/cloud/nstart/terravault/ordinis/outbox/OutboxRecorder.java
new file mode 100644
index 0000000..3668f60
--- /dev/null
+++ b/ordinis-outbox/src/main/java/cloud/nstart/terravault/ordinis/outbox/OutboxRecorder.java
@@ -0,0 +1,75 @@
+package cloud.nstart.terravault.ordinis.outbox;
+
+import cloud.nstart.terravault.ordinis.domain.DataScope;
+import cloud.nstart.terravault.ordinis.domain.outbox.OutboxEvent;
+import cloud.nstart.terravault.ordinis.domain.outbox.OutboxEventRepository;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.micrometer.tracing.Tracer;
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * Запись события в outbox в той же транзакции что и изменение справочной
+ * записи. Гарантирует at-least-once публикацию (poller потом отправит в Kafka).
+ *
+ * Tracer optional — если в кластере включён OTel, prefix trace_id/span_id
+ * прокинется в outbox row для корреляции.
+ */
+@Component
+public class OutboxRecorder {
+
+ private final OutboxEventRepository repository;
+ private final ObjectMapper objectMapper;
+ private final ObjectProvider tracerProvider;
+
+ public OutboxRecorder(
+ OutboxEventRepository repository,
+ ObjectMapper objectMapper,
+ ObjectProvider tracerProvider) {
+ this.repository = repository;
+ this.objectMapper = objectMapper;
+ this.tracerProvider = tracerProvider;
+ }
+
+ /**
+ * @param eventType строковый тип (например "RecordCreated")
+ * @param aggregateType "DictionaryRecord" или "DictionaryDefinition"
+ * @param aggregateId стабильный id аггрегата (UUID или dictionary name)
+ * @param dictionaryName имя словаря (для рутинга в Kafka топик и для логов)
+ * @param scope scope записи → определяет в какой топик
+ * @param bundle bundle (cuod, energy, ...)
+ * @param kafkaKey ключ публикации (обычно {@code :})
+ * @param payload объект-payload (будет сериализован в JsonNode)
+ */
+ @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
+ public OutboxEvent record(
+ String eventType,
+ String aggregateType,
+ String aggregateId,
+ String dictionaryName,
+ DataScope scope,
+ String bundle,
+ String kafkaKey,
+ Object payload) {
+
+ JsonNode payloadJson = objectMapper.valueToTree(payload);
+ String topic = KafkaTopicResolver.topic(bundle, scope);
+
+ OutboxEvent event = new OutboxEvent(
+ eventType, aggregateType, aggregateId, scope, payloadJson, topic, kafkaKey);
+ event.setDictionaryName(dictionaryName);
+
+ Tracer tracer = tracerProvider.getIfAvailable();
+ if (tracer != null) {
+ var span = tracer.currentSpan();
+ if (span != null) {
+ event.setTraceId(span.context().traceId());
+ event.setSpanId(span.context().spanId());
+ }
+ }
+
+ return repository.save(event);
+ }
+}
diff --git a/ordinis-rest-api/pom.xml b/ordinis-rest-api/pom.xml
index c5831da..5c161d5 100644
--- a/ordinis-rest-api/pom.xml
+++ b/ordinis-rest-api/pom.xml
@@ -35,5 +35,17 @@
cloud.nstart.terravault.ordinis
ordinis-outbox
+
+ cloud.nstart.terravault.ordinis
+ ordinis-events-api
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+
+
+ io.micrometer
+ micrometer-tracing
+
diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/CreateDictionaryRequest.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/CreateDictionaryRequest.java
new file mode 100644
index 0000000..3864474
--- /dev/null
+++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/CreateDictionaryRequest.java
@@ -0,0 +1,22 @@
+package cloud.nstart.terravault.ordinis.restapi.dto;
+
+import cloud.nstart.terravault.ordinis.domain.DataScope;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.JsonNode;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Pattern;
+
+import java.util.List;
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public record CreateDictionaryRequest(
+ @NotBlank @Pattern(regexp = "^[a-z][a-z0-9_]{1,127}$") String name,
+ String displayName,
+ String description,
+ @NotNull DataScope scope,
+ @NotNull JsonNode schemaJson,
+ String schemaVersion,
+ String bundle,
+ List supportedLocales,
+ String defaultLocale) {}
diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/CreateRecordRequest.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/CreateRecordRequest.java
new file mode 100644
index 0000000..0314b25
--- /dev/null
+++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/CreateRecordRequest.java
@@ -0,0 +1,16 @@
+package cloud.nstart.terravault.ordinis.restapi.dto;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.JsonNode;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+
+import java.time.OffsetDateTime;
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public record CreateRecordRequest(
+ @NotBlank String businessKey,
+ @NotNull JsonNode data,
+ String geometryWkt,
+ OffsetDateTime validFrom,
+ OffsetDateTime validTo) {}
diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/DictionaryResponse.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/DictionaryResponse.java
new file mode 100644
index 0000000..069c679
--- /dev/null
+++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/DictionaryResponse.java
@@ -0,0 +1,46 @@
+package cloud.nstart.terravault.ordinis.restapi.dto;
+
+import cloud.nstart.terravault.ordinis.domain.DataScope;
+import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.JsonNode;
+
+import java.time.OffsetDateTime;
+import java.util.List;
+import java.util.UUID;
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public record DictionaryResponse(
+ UUID id,
+ String name,
+ String displayName,
+ String description,
+ DataScope scope,
+ JsonNode schemaJson,
+ String schemaVersion,
+ String bundle,
+ List supportedLocales,
+ String defaultLocale,
+ OffsetDateTime createdAt,
+ OffsetDateTime updatedAt,
+ String createdBy,
+ String updatedBy) {
+
+ public static DictionaryResponse from(DictionaryDefinition d) {
+ return new DictionaryResponse(
+ d.getId(),
+ d.getName(),
+ d.getDisplayName(),
+ d.getDescription(),
+ d.getScope(),
+ d.getSchemaJson(),
+ d.getSchemaVersion(),
+ d.getBundle(),
+ d.getSupportedLocales() == null ? List.of() : List.of(d.getSupportedLocales()),
+ d.getDefaultLocale(),
+ d.getCreatedAt(),
+ d.getUpdatedAt(),
+ d.getCreatedBy(),
+ d.getUpdatedBy());
+ }
+}
diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/RecordResponse.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/RecordResponse.java
new file mode 100644
index 0000000..671875e
--- /dev/null
+++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/RecordResponse.java
@@ -0,0 +1,45 @@
+package cloud.nstart.terravault.ordinis.restapi.dto;
+
+import cloud.nstart.terravault.ordinis.domain.DataScope;
+import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecord;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.JsonNode;
+import org.locationtech.jts.io.WKTWriter;
+
+import java.time.OffsetDateTime;
+import java.util.UUID;
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public record RecordResponse(
+ UUID id,
+ UUID dictionaryId,
+ String businessKey,
+ JsonNode data,
+ String geometryWkt,
+ DataScope dataScope,
+ OffsetDateTime validFrom,
+ OffsetDateTime validTo,
+ OffsetDateTime createdAt,
+ OffsetDateTime updatedAt,
+ String createdBy,
+ String updatedBy,
+ Long version) {
+
+ public static RecordResponse from(DictionaryRecord r) {
+ String wkt = r.getGeometry() == null ? null : new WKTWriter().write(r.getGeometry());
+ return new RecordResponse(
+ r.getId(),
+ r.getDictionaryId(),
+ r.getBusinessKey(),
+ r.getData(),
+ wkt,
+ r.getDataScope(),
+ r.getValidFrom(),
+ r.getValidTo(),
+ r.getCreatedAt(),
+ r.getUpdatedAt(),
+ r.getCreatedBy(),
+ r.getUpdatedBy(),
+ r.getVersion());
+ }
+}
diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/error/ApiErrorResponse.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/error/ApiErrorResponse.java
new file mode 100644
index 0000000..0cc7db4
--- /dev/null
+++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/error/ApiErrorResponse.java
@@ -0,0 +1,27 @@
+package cloud.nstart.terravault.ordinis.restapi.error;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+
+import java.time.OffsetDateTime;
+import java.util.List;
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public record ApiErrorResponse(
+ int status,
+ String code,
+ String message,
+ List fieldErrors,
+ String traceId,
+ OffsetDateTime timestamp) {
+
+ public record FieldError(String path, String code, String message) {}
+
+ public static ApiErrorResponse of(int status, String code, String message, String traceId) {
+ return new ApiErrorResponse(status, code, message, null, traceId, OffsetDateTime.now());
+ }
+
+ public static ApiErrorResponse withFieldErrors(
+ int status, String code, String message, List errors, String traceId) {
+ return new ApiErrorResponse(status, code, message, errors, traceId, OffsetDateTime.now());
+ }
+}
diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/error/GlobalExceptionHandler.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/error/GlobalExceptionHandler.java
new file mode 100644
index 0000000..7baed2d
--- /dev/null
+++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/error/GlobalExceptionHandler.java
@@ -0,0 +1,61 @@
+package cloud.nstart.terravault.ordinis.restapi.error;
+
+import cloud.nstart.terravault.ordinis.validation.ValidationException;
+import io.micrometer.tracing.Tracer;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+import java.util.List;
+
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+
+ private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
+
+ private final ObjectProvider tracerProvider;
+
+ public GlobalExceptionHandler(ObjectProvider tracerProvider) {
+ this.tracerProvider = tracerProvider;
+ }
+
+ @ExceptionHandler(ValidationException.class)
+ public ResponseEntity onValidation(ValidationException e) {
+ List fields = e.getErrors().stream()
+ .map(err -> new ApiErrorResponse.FieldError(err.path(), err.code(), err.message()))
+ .toList();
+ var body = ApiErrorResponse.withFieldErrors(
+ 422, "validation_failed", e.getMessage(), fields, currentTraceId());
+ return ResponseEntity.unprocessableEntity().body(body);
+ }
+
+ @ExceptionHandler(OrdinisException.class)
+ public ResponseEntity onOrdinis(OrdinisException e) {
+ var body = ApiErrorResponse.of(e.getStatus().value(), e.getCode(), e.getMessage(), currentTraceId());
+ return ResponseEntity.status(e.getStatus()).body(body);
+ }
+
+ @ExceptionHandler(IllegalArgumentException.class)
+ public ResponseEntity onIllegalArgument(IllegalArgumentException e) {
+ var body = ApiErrorResponse.of(400, "bad_request", e.getMessage(), currentTraceId());
+ return ResponseEntity.badRequest().body(body);
+ }
+
+ @ExceptionHandler(Exception.class)
+ public ResponseEntity onGeneric(Exception e) {
+ log.error("Unhandled exception in REST API", e);
+ var body = ApiErrorResponse.of(500, "internal_error", "Internal server error", currentTraceId());
+ return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body);
+ }
+
+ private String currentTraceId() {
+ Tracer tracer = tracerProvider.getIfAvailable();
+ if (tracer == null) return null;
+ var span = tracer.currentSpan();
+ return span == null ? null : span.context().traceId();
+ }
+}
diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/error/OrdinisException.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/error/OrdinisException.java
new file mode 100644
index 0000000..2f1afcb
--- /dev/null
+++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/error/OrdinisException.java
@@ -0,0 +1,34 @@
+package cloud.nstart.terravault.ordinis.restapi.error;
+
+import org.springframework.http.HttpStatus;
+
+public class OrdinisException extends RuntimeException {
+
+ private final HttpStatus status;
+ private final String code;
+
+ public OrdinisException(HttpStatus status, String code, String message) {
+ super(message);
+ this.status = status;
+ this.code = code;
+ }
+
+ public HttpStatus getStatus() { return status; }
+ public String getCode() { return code; }
+
+ public static OrdinisException notFound(String code, String message) {
+ return new OrdinisException(HttpStatus.NOT_FOUND, code, message);
+ }
+
+ public static OrdinisException conflict(String code, String message) {
+ return new OrdinisException(HttpStatus.CONFLICT, code, message);
+ }
+
+ public static OrdinisException badRequest(String code, String message) {
+ return new OrdinisException(HttpStatus.BAD_REQUEST, code, message);
+ }
+
+ public static OrdinisException forbidden(String code, String message) {
+ return new OrdinisException(HttpStatus.FORBIDDEN, code, message);
+ }
+}
diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryDefinitionService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryDefinitionService.java
new file mode 100644
index 0000000..bc4f40c
--- /dev/null
+++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryDefinitionService.java
@@ -0,0 +1,139 @@
+package cloud.nstart.terravault.ordinis.restapi.service;
+
+import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
+import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinitionRepository;
+import cloud.nstart.terravault.ordinis.events.EventEnvelope;
+import cloud.nstart.terravault.ordinis.events.definition.DictionaryDefinitionCreated;
+import cloud.nstart.terravault.ordinis.events.definition.DictionaryDefinitionUpdated;
+import cloud.nstart.terravault.ordinis.outbox.OutboxRecorder;
+import cloud.nstart.terravault.ordinis.restapi.dto.CreateDictionaryRequest;
+import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+import java.util.UUID;
+
+@Service
+public class DictionaryDefinitionService {
+
+ private final DictionaryDefinitionRepository repository;
+ private final OutboxRecorder outboxRecorder;
+
+ public DictionaryDefinitionService(
+ DictionaryDefinitionRepository repository, OutboxRecorder outboxRecorder) {
+ this.repository = repository;
+ this.outboxRecorder = outboxRecorder;
+ }
+
+ @Transactional(readOnly = true)
+ public List findAll() {
+ return repository.findAll();
+ }
+
+ @Transactional(readOnly = true)
+ public DictionaryDefinition findByName(String name) {
+ return repository.findByName(name)
+ .orElseThrow(() -> OrdinisException.notFound(
+ "dictionary_not_found", "Dictionary not found: " + name));
+ }
+
+ @Transactional
+ public DictionaryDefinition create(CreateDictionaryRequest req) {
+ if (repository.existsByName(req.name())) {
+ throw OrdinisException.conflict(
+ "dictionary_already_exists", "Dictionary already exists: " + req.name());
+ }
+
+ var d = new DictionaryDefinition(UUID.randomUUID(), req.name(), req.scope(), req.schemaJson());
+ d.setDisplayName(req.displayName());
+ d.setDescription(req.description());
+ if (req.schemaVersion() != null) d.setSchemaVersion(req.schemaVersion());
+ if (req.bundle() != null) d.setBundle(req.bundle());
+ if (req.supportedLocales() != null && !req.supportedLocales().isEmpty()) {
+ d.setSupportedLocales(req.supportedLocales().toArray(String[]::new));
+ }
+ if (req.defaultLocale() != null) d.setDefaultLocale(req.defaultLocale());
+
+ var saved = repository.save(d);
+
+ var event = new DictionaryDefinitionCreated(
+ saved.getId(),
+ saved.getName(),
+ saved.getDisplayName(),
+ saved.getDescription(),
+ toEventScope(saved.getScope()),
+ saved.getSchemaJson(),
+ saved.getSchemaVersion(),
+ saved.getBundle(),
+ saved.getSupportedLocales() == null ? List.of() : List.of(saved.getSupportedLocales()),
+ saved.getDefaultLocale(),
+ saved.getCreatedAt(),
+ saved.getCreatedBy());
+
+ var envelope = EventEnvelope.of(saved.getName(), toEventScope(saved.getScope()), saved.getBundle(), event);
+
+ outboxRecorder.record(
+ "DefinitionCreated",
+ "DictionaryDefinition",
+ saved.getId().toString(),
+ saved.getName(),
+ saved.getScope(),
+ saved.getBundle(),
+ "definition:" + saved.getName(),
+ envelope);
+
+ return saved;
+ }
+
+ @Transactional
+ public DictionaryDefinition updateSchema(String name, CreateDictionaryRequest req) {
+ var existing = findByName(name);
+ var prevSchema = existing.getSchemaJson();
+
+ existing.setDisplayName(req.displayName());
+ existing.setDescription(req.description());
+ existing.setSchemaJson(req.schemaJson());
+ if (req.schemaVersion() != null) existing.setSchemaVersion(req.schemaVersion());
+ if (req.supportedLocales() != null && !req.supportedLocales().isEmpty()) {
+ existing.setSupportedLocales(req.supportedLocales().toArray(String[]::new));
+ }
+ if (req.defaultLocale() != null) existing.setDefaultLocale(req.defaultLocale());
+
+ var saved = repository.save(existing);
+
+ var event = new DictionaryDefinitionUpdated(
+ saved.getId(),
+ saved.getName(),
+ saved.getDisplayName(),
+ saved.getDescription(),
+ toEventScope(saved.getScope()),
+ saved.getSchemaJson(),
+ prevSchema,
+ saved.getSchemaVersion(),
+ saved.getBundle(),
+ saved.getSupportedLocales() == null ? List.of() : List.of(saved.getSupportedLocales()),
+ saved.getDefaultLocale(),
+ saved.getUpdatedAt(),
+ saved.getUpdatedBy());
+
+ var envelope = EventEnvelope.of(saved.getName(), toEventScope(saved.getScope()), saved.getBundle(), event);
+
+ outboxRecorder.record(
+ "DefinitionUpdated",
+ "DictionaryDefinition",
+ saved.getId().toString(),
+ saved.getName(),
+ saved.getScope(),
+ saved.getBundle(),
+ "definition:" + saved.getName(),
+ envelope);
+
+ return saved;
+ }
+
+ private static cloud.nstart.terravault.ordinis.events.DataScope toEventScope(
+ cloud.nstart.terravault.ordinis.domain.DataScope d) {
+ return cloud.nstart.terravault.ordinis.events.DataScope.valueOf(d.name());
+ }
+}
diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryRecordService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryRecordService.java
new file mode 100644
index 0000000..3057c93
--- /dev/null
+++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryRecordService.java
@@ -0,0 +1,210 @@
+package cloud.nstart.terravault.ordinis.restapi.service;
+
+import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
+import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecord;
+import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecordRepository;
+import cloud.nstart.terravault.ordinis.events.EventEnvelope;
+import cloud.nstart.terravault.ordinis.events.record.DictionaryRecordCreated;
+import cloud.nstart.terravault.ordinis.events.record.DictionaryRecordDeleted;
+import cloud.nstart.terravault.ordinis.events.record.DictionaryRecordUpdated;
+import cloud.nstart.terravault.ordinis.outbox.OutboxRecorder;
+import cloud.nstart.terravault.ordinis.restapi.dto.CreateRecordRequest;
+import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
+import cloud.nstart.terravault.ordinis.validation.RecordValidator;
+import cloud.nstart.terravault.ordinis.validation.ValidationException;
+import cloud.nstart.terravault.ordinis.validation.ValidationResult;
+import org.locationtech.jts.geom.Geometry;
+import org.locationtech.jts.io.ParseException;
+import org.locationtech.jts.io.WKTReader;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Isolation;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.OffsetDateTime;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+/**
+ * Bitemporal write-side для записей. Все изменения через SERIALIZABLE
+ * транзакцию (FK enforcement + EXCLUSION CONSTRAINT защищают от race).
+ *
+ * Update = "close current version" + "create new version" — никогда не
+ * UPDATE существующей строки in-place. Это обеспечивает Envers history +
+ * проверяемость в любой момент времени.
+ *
+ *
Outbox запись — в той же транзакции что и change. Гарантирует at-least-once.
+ */
+@Service
+public class DictionaryRecordService {
+
+ private final DictionaryRecordRepository repository;
+ private final DictionaryDefinitionService definitionService;
+ private final RecordValidator validator;
+ private final OutboxRecorder outbox;
+
+ private static final OffsetDateTime FAR_FUTURE = OffsetDateTime.parse("9999-12-31T23:59:59Z");
+
+ public DictionaryRecordService(
+ DictionaryRecordRepository repository,
+ DictionaryDefinitionService definitionService,
+ RecordValidator validator,
+ OutboxRecorder outbox) {
+ this.repository = repository;
+ this.definitionService = definitionService;
+ this.validator = validator;
+ this.outbox = outbox;
+ }
+
+ @Transactional(readOnly = true)
+ public Optional findActive(String dictionaryName, String businessKey, OffsetDateTime at) {
+ var d = definitionService.findByName(dictionaryName);
+ return repository.findActiveAt(d.getId(), businessKey, at);
+ }
+
+ @Transactional(readOnly = true)
+ public List findHistory(String dictionaryName, String businessKey) {
+ var d = definitionService.findByName(dictionaryName);
+ return repository.findByDictionaryIdAndBusinessKeyOrderByValidFromDesc(d.getId(), businessKey);
+ }
+
+ @Transactional(isolation = Isolation.SERIALIZABLE)
+ public DictionaryRecord create(String dictionaryName, CreateRecordRequest req) {
+ var d = definitionService.findByName(dictionaryName);
+ validate(d, req);
+
+ OffsetDateTime validFrom = req.validFrom() == null ? OffsetDateTime.now() : req.validFrom();
+ OffsetDateTime validTo = req.validTo() == null ? FAR_FUTURE : req.validTo();
+
+ if (repository.findActiveAt(d.getId(), req.businessKey(), validFrom).isPresent()) {
+ throw OrdinisException.conflict(
+ "record_already_active",
+ "Запись с business_key=" + req.businessKey() + " уже активна на " + validFrom +
+ ". Используйте PUT (update) для новой версии.");
+ }
+
+ var record = new DictionaryRecord(
+ UUID.randomUUID(),
+ d.getId(),
+ req.businessKey(),
+ req.data(),
+ d.getScope(),
+ validFrom);
+ record.setValidTo(validTo);
+ record.setGeometry(parseWkt(req.geometryWkt()));
+
+ var saved = repository.save(record);
+ publishCreated(d, saved);
+ return saved;
+ }
+
+ @Transactional(isolation = Isolation.SERIALIZABLE)
+ public DictionaryRecord update(String dictionaryName, String businessKey, CreateRecordRequest req) {
+ var d = definitionService.findByName(dictionaryName);
+ validate(d, req);
+
+ OffsetDateTime newValidFrom = req.validFrom() == null ? OffsetDateTime.now() : req.validFrom();
+ OffsetDateTime newValidTo = req.validTo() == null ? FAR_FUTURE : req.validTo();
+
+ var current = repository.findActiveAt(d.getId(), businessKey, newValidFrom)
+ .orElseThrow(() -> OrdinisException.notFound(
+ "record_not_active",
+ "Нет активной записи business_key=" + businessKey + " на " + newValidFrom));
+
+ var prevData = current.getData();
+ UUID previousRecordId = current.getId();
+
+ current.setValidTo(newValidFrom);
+ repository.save(current);
+
+ var next = new DictionaryRecord(
+ UUID.randomUUID(),
+ d.getId(),
+ businessKey,
+ req.data(),
+ d.getScope(),
+ newValidFrom);
+ next.setValidTo(newValidTo);
+ next.setGeometry(parseWkt(req.geometryWkt()));
+ var saved = repository.save(next);
+
+ publishUpdated(d, saved, previousRecordId, prevData);
+ return saved;
+ }
+
+ @Transactional(isolation = Isolation.SERIALIZABLE)
+ public void close(String dictionaryName, String businessKey, OffsetDateTime closeAt, String reason) {
+ var d = definitionService.findByName(dictionaryName);
+ OffsetDateTime when = closeAt == null ? OffsetDateTime.now() : closeAt;
+
+ var current = repository.findActiveAt(d.getId(), businessKey, when)
+ .orElseThrow(() -> OrdinisException.notFound(
+ "record_not_active",
+ "Нет активной записи business_key=" + businessKey + " на " + when));
+
+ current.setValidTo(when);
+ repository.save(current);
+
+ publishDeleted(d, current, when, reason);
+ }
+
+ // — internals —
+
+ private void validate(DictionaryDefinition d, CreateRecordRequest req) {
+ ValidationResult vr = validator.validate(d, req.data());
+ if (!vr.valid()) throw new ValidationException(vr.errors());
+ }
+
+ private Geometry parseWkt(String wkt) {
+ if (wkt == null || wkt.isBlank()) return null;
+ try {
+ var reader = new WKTReader();
+ Geometry g = reader.read(wkt);
+ g.setSRID(4326);
+ return g;
+ } catch (ParseException e) {
+ throw OrdinisException.badRequest("invalid_wkt", "Invalid WKT: " + e.getMessage());
+ }
+ }
+
+ private void publishCreated(DictionaryDefinition d, DictionaryRecord r) {
+ String wkt = r.getGeometry() == null ? null : new org.locationtech.jts.io.WKTWriter().write(r.getGeometry());
+ var payload = new DictionaryRecordCreated(
+ r.getId(), r.getBusinessKey(), r.getData(), wkt,
+ r.getValidFrom(), r.getValidTo(), r.getCreatedAt(), r.getCreatedBy());
+ var envelope = EventEnvelope.of(d.getName(), toEventScope(d.getScope()), d.getBundle(), payload);
+ outbox.record(
+ "RecordCreated", "DictionaryRecord", r.getId().toString(),
+ d.getName(), d.getScope(), d.getBundle(),
+ d.getName() + ":" + r.getBusinessKey(), envelope);
+ }
+
+ private void publishUpdated(
+ DictionaryDefinition d, DictionaryRecord r,
+ UUID prevId, com.fasterxml.jackson.databind.JsonNode prevData) {
+ String wkt = r.getGeometry() == null ? null : new org.locationtech.jts.io.WKTWriter().write(r.getGeometry());
+ var payload = new DictionaryRecordUpdated(
+ r.getId(), prevId, r.getBusinessKey(), r.getData(), prevData, wkt,
+ r.getValidFrom(), r.getValidTo(), r.getUpdatedAt(), r.getUpdatedBy());
+ var envelope = EventEnvelope.of(d.getName(), toEventScope(d.getScope()), d.getBundle(), payload);
+ outbox.record(
+ "RecordUpdated", "DictionaryRecord", r.getId().toString(),
+ d.getName(), d.getScope(), d.getBundle(),
+ d.getName() + ":" + r.getBusinessKey(), envelope);
+ }
+
+ private void publishDeleted(DictionaryDefinition d, DictionaryRecord r, OffsetDateTime closedAt, String reason) {
+ var payload = new DictionaryRecordDeleted(
+ r.getId(), r.getBusinessKey(), closedAt, OffsetDateTime.now(), r.getUpdatedBy(), reason);
+ var envelope = EventEnvelope.of(d.getName(), toEventScope(d.getScope()), d.getBundle(), payload);
+ outbox.record(
+ "RecordDeleted", "DictionaryRecord", r.getId().toString(),
+ d.getName(), d.getScope(), d.getBundle(),
+ d.getName() + ":" + r.getBusinessKey(), envelope);
+ }
+
+ private static cloud.nstart.terravault.ordinis.events.DataScope toEventScope(
+ cloud.nstart.terravault.ordinis.domain.DataScope d) {
+ return cloud.nstart.terravault.ordinis.events.DataScope.valueOf(d.name());
+ }
+}
diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryDefinitionController.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryDefinitionController.java
new file mode 100644
index 0000000..cc9b4a9
--- /dev/null
+++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryDefinitionController.java
@@ -0,0 +1,52 @@
+package cloud.nstart.terravault.ordinis.restapi.web;
+
+import cloud.nstart.terravault.ordinis.restapi.dto.CreateDictionaryRequest;
+import cloud.nstart.terravault.ordinis.restapi.dto.DictionaryResponse;
+import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService;
+import jakarta.validation.Valid;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/api/v1/dictionaries")
+public class DictionaryDefinitionController {
+
+ private final DictionaryDefinitionService service;
+
+ public DictionaryDefinitionController(DictionaryDefinitionService service) {
+ this.service = service;
+ }
+
+ @GetMapping
+ public List list() {
+ return service.findAll().stream().map(DictionaryResponse::from).toList();
+ }
+
+ @GetMapping("/{name}")
+ public DictionaryResponse get(@PathVariable String name) {
+ return DictionaryResponse.from(service.findByName(name));
+ }
+
+ @PostMapping
+ @ResponseStatus(HttpStatus.CREATED)
+ public DictionaryResponse create(@Valid @RequestBody CreateDictionaryRequest req) {
+ return DictionaryResponse.from(service.create(req));
+ }
+
+ @PutMapping("/{name}")
+ public DictionaryResponse update(
+ @PathVariable String name,
+ @Valid @RequestBody CreateDictionaryRequest req) {
+ return DictionaryResponse.from(service.updateSchema(name, req));
+ }
+}
diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryRecordController.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryRecordController.java
new file mode 100644
index 0000000..2a9af69
--- /dev/null
+++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryRecordController.java
@@ -0,0 +1,79 @@
+package cloud.nstart.terravault.ordinis.restapi.web;
+
+import cloud.nstart.terravault.ordinis.restapi.dto.CreateRecordRequest;
+import cloud.nstart.terravault.ordinis.restapi.dto.RecordResponse;
+import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
+import cloud.nstart.terravault.ordinis.restapi.service.DictionaryRecordService;
+import jakarta.validation.Valid;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.time.OffsetDateTime;
+import java.util.List;
+
+@RestController
+@RequestMapping("/api/v1/dictionaries/{name}/records")
+public class DictionaryRecordController {
+
+ private final DictionaryRecordService service;
+
+ public DictionaryRecordController(DictionaryRecordService service) {
+ this.service = service;
+ }
+
+ @GetMapping("/{businessKey}")
+ public RecordResponse getActive(
+ @PathVariable String name,
+ @PathVariable String businessKey,
+ @RequestParam(required = false) OffsetDateTime at) {
+ OffsetDateTime when = at == null ? OffsetDateTime.now() : at;
+ return service.findActive(name, businessKey, when)
+ .map(RecordResponse::from)
+ .orElseThrow(() -> OrdinisException.notFound(
+ "record_not_active",
+ "Нет активной записи business_key=" + businessKey + " на " + when));
+ }
+
+ @GetMapping("/{businessKey}/history")
+ public List getHistory(
+ @PathVariable String name,
+ @PathVariable String businessKey) {
+ return service.findHistory(name, businessKey).stream().map(RecordResponse::from).toList();
+ }
+
+ @PostMapping
+ @ResponseStatus(HttpStatus.CREATED)
+ public RecordResponse create(
+ @PathVariable String name,
+ @Valid @RequestBody CreateRecordRequest req) {
+ return RecordResponse.from(service.create(name, req));
+ }
+
+ @PutMapping("/{businessKey}")
+ public RecordResponse update(
+ @PathVariable String name,
+ @PathVariable String businessKey,
+ @Valid @RequestBody CreateRecordRequest req) {
+ return RecordResponse.from(service.update(name, businessKey, req));
+ }
+
+ @DeleteMapping("/{businessKey}")
+ @ResponseStatus(HttpStatus.NO_CONTENT)
+ public void close(
+ @PathVariable String name,
+ @PathVariable String businessKey,
+ @RequestParam(required = false) OffsetDateTime at,
+ @RequestParam(required = false) String reason) {
+ service.close(name, businessKey, at, reason);
+ }
+}
diff --git a/ordinis-validation/src/main/java/cloud/nstart/terravault/ordinis/validation/RecordValidator.java b/ordinis-validation/src/main/java/cloud/nstart/terravault/ordinis/validation/RecordValidator.java
new file mode 100644
index 0000000..6b19ac1
--- /dev/null
+++ b/ordinis-validation/src/main/java/cloud/nstart/terravault/ordinis/validation/RecordValidator.java
@@ -0,0 +1,144 @@
+package cloud.nstart.terravault.ordinis.validation;
+
+import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.networknt.schema.JsonSchema;
+import com.networknt.schema.JsonSchemaFactory;
+import com.networknt.schema.SchemaValidatorsConfig;
+import com.networknt.schema.SpecVersion;
+import com.networknt.schema.ValidationMessage;
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+/**
+ * Двухступенчатая валидация записи:
+ *
+ *
+ * - Стандартный JSON Schema (Draft-7) через networknt — типы, обязательные
+ * поля, форматы, etc.
+ * - Custom правило для полей с extension {@code x-localized: true}: значение
+ * должно быть object {@code {locale: text}}, обязательно содержит
+ * {@code default_locale} справочника, ключи matchать pattern локали.
+ *
+ */
+@Component
+public class RecordValidator {
+
+ private static final Pattern LOCALE_TAG = Pattern.compile("^[a-z]{2}-[A-Z]{2}$");
+
+ private final JsonSchemaFactory schemaFactory =
+ JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7);
+
+ /**
+ * Валидирует {@code data} запись справочника против определения.
+ * Возвращает {@link ValidationResult}, не бросает.
+ */
+ public ValidationResult validate(DictionaryDefinition definition, JsonNode data) {
+ List errors = new ArrayList<>();
+
+ SchemaValidatorsConfig config = SchemaValidatorsConfig.builder().build();
+ JsonSchema schema = schemaFactory.getSchema(definition.getSchemaJson(), config);
+
+ Set messages = schema.validate(data);
+ for (ValidationMessage m : messages) {
+ errors.add(new ValidationError(
+ m.getInstanceLocation().toString(),
+ m.getCode(),
+ m.getMessage()));
+ }
+
+ Set supportedLocales = new HashSet<>(List.of(definition.getSupportedLocales()));
+ String defaultLocale = definition.getDefaultLocale();
+ walkLocalizedFields(definition.getSchemaJson(), data, "", supportedLocales, defaultLocale, errors);
+
+ return errors.isEmpty() ? ValidationResult.ok() : ValidationResult.failed(errors);
+ }
+
+ /**
+ * Рекурсивный обход schema. Для каждого {@code properties[name]} с
+ * {@code x-localized: true} — проверяет значение в data.
+ */
+ private void walkLocalizedFields(
+ JsonNode schema,
+ JsonNode data,
+ String path,
+ Set supported,
+ String defaultLocale,
+ List errors) {
+
+ if (schema == null || !schema.isObject()) return;
+
+ JsonNode properties = schema.get("properties");
+ if (properties == null || !properties.isObject()) return;
+
+ Iterator> it = properties.fields();
+ while (it.hasNext()) {
+ Map.Entry entry = it.next();
+ String fieldName = entry.getKey();
+ JsonNode fieldSchema = entry.getValue();
+ String fieldPath = path.isEmpty() ? "/" + fieldName : path + "/" + fieldName;
+
+ JsonNode dataField = data == null ? null : data.get(fieldName);
+
+ JsonNode xLocalized = fieldSchema.get("x-localized");
+ if (xLocalized != null && xLocalized.isBoolean() && xLocalized.asBoolean()) {
+ validateLocalizedValue(fieldPath, dataField, supported, defaultLocale, errors);
+ } else if (fieldSchema.has("properties")) {
+ walkLocalizedFields(fieldSchema, dataField, fieldPath, supported, defaultLocale, errors);
+ }
+ }
+ }
+
+ private void validateLocalizedValue(
+ String path,
+ JsonNode value,
+ Set supported,
+ String defaultLocale,
+ List errors) {
+
+ if (value == null || value.isNull()) {
+ errors.add(new ValidationError(path, "x-localized.missing",
+ "Localized field is required (must be object with locale keys)"));
+ return;
+ }
+ if (!value.isObject()) {
+ errors.add(new ValidationError(path, "x-localized.not_object",
+ "Localized field must be an object {locale: text}"));
+ return;
+ }
+
+ boolean hasDefault = false;
+ Iterator keys = value.fieldNames();
+ while (keys.hasNext()) {
+ String locale = keys.next();
+ if (!LOCALE_TAG.matcher(locale).matches()) {
+ errors.add(new ValidationError(path + "/" + locale, "x-localized.invalid_tag",
+ "Locale tag must match pattern xx-XX (BCP 47), got: " + locale));
+ continue;
+ }
+ if (!supported.contains(locale)) {
+ errors.add(new ValidationError(path + "/" + locale, "x-localized.unsupported_locale",
+ "Locale " + locale + " is not in supported_locales of dictionary"));
+ continue;
+ }
+ JsonNode text = value.get(locale);
+ if (text == null || !text.isTextual() || text.asText().isBlank()) {
+ errors.add(new ValidationError(path + "/" + locale, "x-localized.empty_value",
+ "Locale " + locale + " value must be non-empty string"));
+ }
+ if (locale.equals(defaultLocale)) hasDefault = true;
+ }
+
+ if (!hasDefault) {
+ errors.add(new ValidationError(path, "x-localized.missing_default",
+ "Default locale '" + defaultLocale + "' is required"));
+ }
+ }
+}
diff --git a/ordinis-validation/src/main/java/cloud/nstart/terravault/ordinis/validation/ValidationError.java b/ordinis-validation/src/main/java/cloud/nstart/terravault/ordinis/validation/ValidationError.java
new file mode 100644
index 0000000..74be302
--- /dev/null
+++ b/ordinis-validation/src/main/java/cloud/nstart/terravault/ordinis/validation/ValidationError.java
@@ -0,0 +1,8 @@
+package cloud.nstart.terravault.ordinis.validation;
+
+public record ValidationError(String path, String code, String message) {
+
+ public static ValidationError of(String path, String code, String message) {
+ return new ValidationError(path, code, message);
+ }
+}
diff --git a/ordinis-validation/src/main/java/cloud/nstart/terravault/ordinis/validation/ValidationException.java b/ordinis-validation/src/main/java/cloud/nstart/terravault/ordinis/validation/ValidationException.java
new file mode 100644
index 0000000..568b58a
--- /dev/null
+++ b/ordinis-validation/src/main/java/cloud/nstart/terravault/ordinis/validation/ValidationException.java
@@ -0,0 +1,17 @@
+package cloud.nstart.terravault.ordinis.validation;
+
+import java.util.List;
+
+public class ValidationException extends RuntimeException {
+
+ private final List errors;
+
+ public ValidationException(List errors) {
+ super("Validation failed with " + errors.size() + " error(s)");
+ this.errors = List.copyOf(errors);
+ }
+
+ public List getErrors() {
+ return errors;
+ }
+}
diff --git a/ordinis-validation/src/main/java/cloud/nstart/terravault/ordinis/validation/ValidationResult.java b/ordinis-validation/src/main/java/cloud/nstart/terravault/ordinis/validation/ValidationResult.java
new file mode 100644
index 0000000..f977582
--- /dev/null
+++ b/ordinis-validation/src/main/java/cloud/nstart/terravault/ordinis/validation/ValidationResult.java
@@ -0,0 +1,14 @@
+package cloud.nstart.terravault.ordinis.validation;
+
+import java.util.List;
+
+public record ValidationResult(boolean valid, List errors) {
+
+ public static ValidationResult ok() {
+ return new ValidationResult(true, List.of());
+ }
+
+ public static ValidationResult failed(List errors) {
+ return new ValidationResult(false, List.copyOf(errors));
+ }
+}