diff --git a/ordinis-app/src/main/resources/application.yml b/ordinis-app/src/main/resources/application.yml
index 92410b7..478ee89 100644
--- a/ordinis-app/src/main/resources/application.yml
+++ b/ordinis-app/src/main/resources/application.yml
@@ -69,6 +69,7 @@ spring:
- 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
otel:
sdk:
diff --git a/ordinis-domain/pom.xml b/ordinis-domain/pom.xml
index 3378adc..eab95ed 100644
--- a/ordinis-domain/pom.xml
+++ b/ordinis-domain/pom.xml
@@ -19,6 +19,10 @@
org.springframework.boot
spring-boot-starter-data-jpa
+
+ com.fasterxml.jackson.core
+ jackson-databind
+
io.hypersistence
hypersistence-utils-hibernate-63
@@ -27,6 +31,10 @@
org.hibernate.orm
hibernate-envers
+
+ org.hibernate.orm
+ hibernate-spatial
+
net.postgis
postgis-jdbc
diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/DataScope.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/DataScope.java
new file mode 100644
index 0000000..22197d6
--- /dev/null
+++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/DataScope.java
@@ -0,0 +1,34 @@
+package cloud.nstart.terravault.ordinis.domain;
+
+import java.util.Set;
+
+/**
+ * Видимость данных в линейке Ordinis. Часть Kafka topology (топик per scope) и
+ * read-API filtering. Значения соответствуют CHECK constraint в БД и enum в
+ * JSON Schema поля dictionary_definitions.scope.
+ */
+public enum DataScope {
+
+ /** Открытые справочники (геопортал, публичные consumer'ы). */
+ PUBLIC,
+
+ /** Внутренние данные клиента (Альтум-internal). Не должны утекать наружу клиента. */
+ INTERNAL,
+
+ /** Узкий круг: операторы, audit, compliance. */
+ RESTRICTED;
+
+ public String topicSuffix() {
+ return name().toLowerCase();
+ }
+
+ public boolean visibleTo(DataScope consumerScope) {
+ if (consumerScope == this) return true;
+ if (consumerScope == RESTRICTED) return true; // restricted видит всё в своём bundle
+ return consumerScope == INTERNAL && this == PUBLIC;
+ }
+
+ public static Set all() {
+ return Set.of(values());
+ }
+}
diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/audit/AuditLog.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/audit/AuditLog.java
new file mode 100644
index 0000000..6550c84
--- /dev/null
+++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/audit/AuditLog.java
@@ -0,0 +1,114 @@
+package cloud.nstart.terravault.ordinis.domain.audit;
+
+import cloud.nstart.terravault.ordinis.domain.DataScope;
+import com.fasterxml.jackson.databind.JsonNode;
+import io.hypersistence.utils.hibernate.type.json.JsonType;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import org.hibernate.annotations.Type;
+
+import java.time.OffsetDateTime;
+import java.util.UUID;
+
+/**
+ * Юридически значимый audit log. Source of truth для compliance, отдельно от
+ * технических логов в Loki. Retention — по регламенту ЦУОД.
+ */
+@Entity
+@Table(name = "audit_log")
+public class AuditLog {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "id")
+ private Long id;
+
+ @Column(name = "event_time", nullable = false)
+ private OffsetDateTime eventTime;
+
+ @Column(name = "event_type", nullable = false, length = 64)
+ private String eventType;
+
+ @Column(name = "user_id", length = 128)
+ private String userId;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "user_scope", length = 32)
+ private DataScope userScope;
+
+ @Column(name = "dictionary_id")
+ private UUID dictionaryId;
+
+ @Column(name = "dictionary_name", length = 128)
+ private String dictionaryName;
+
+ @Column(name = "record_id")
+ private UUID recordId;
+
+ @Column(name = "business_key", length = 256)
+ private String businessKey;
+
+ @Column(name = "action", length = 32)
+ private String action;
+
+ @Type(JsonType.class)
+ @Column(name = "payload_before", columnDefinition = "jsonb")
+ private JsonNode payloadBefore;
+
+ @Type(JsonType.class)
+ @Column(name = "payload_after", columnDefinition = "jsonb")
+ private JsonNode payloadAfter;
+
+ @Column(name = "trace_id", length = 64)
+ private String traceId;
+
+ @Column(name = "request_id", length = 64)
+ private String requestId;
+
+ @Column(name = "ip_address", columnDefinition = "inet")
+ private String ipAddress;
+
+ @Column(name = "user_agent", columnDefinition = "TEXT")
+ private String userAgent;
+
+ protected AuditLog() {}
+
+ public Long getId() { return id; }
+ public OffsetDateTime getEventTime() { return eventTime; }
+ public String getEventType() { return eventType; }
+ public String getUserId() { return userId; }
+ public DataScope getUserScope() { return userScope; }
+ public UUID getDictionaryId() { return dictionaryId; }
+ public String getDictionaryName() { return dictionaryName; }
+ public UUID getRecordId() { return recordId; }
+ public String getBusinessKey() { return businessKey; }
+ public String getAction() { return action; }
+ public JsonNode getPayloadBefore() { return payloadBefore; }
+ public JsonNode getPayloadAfter() { return payloadAfter; }
+ public String getTraceId() { return traceId; }
+ public String getRequestId() { return requestId; }
+ public String getIpAddress() { return ipAddress; }
+ public String getUserAgent() { return userAgent; }
+
+ public void setEventTime(OffsetDateTime v) { this.eventTime = v; }
+ public void setEventType(String v) { this.eventType = v; }
+ public void setUserId(String v) { this.userId = v; }
+ public void setUserScope(DataScope v) { this.userScope = v; }
+ public void setDictionaryId(UUID v) { this.dictionaryId = v; }
+ public void setDictionaryName(String v) { this.dictionaryName = v; }
+ public void setRecordId(UUID v) { this.recordId = v; }
+ public void setBusinessKey(String v) { this.businessKey = v; }
+ public void setAction(String v) { this.action = v; }
+ public void setPayloadBefore(JsonNode v) { this.payloadBefore = v; }
+ public void setPayloadAfter(JsonNode v) { this.payloadAfter = v; }
+ public void setTraceId(String v) { this.traceId = v; }
+ public void setRequestId(String v) { this.requestId = v; }
+ public void setIpAddress(String v) { this.ipAddress = v; }
+ public void setUserAgent(String v) { this.userAgent = v; }
+}
diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/audit/AuditLogRepository.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/audit/AuditLogRepository.java
new file mode 100644
index 0000000..2e8323d
--- /dev/null
+++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/audit/AuditLogRepository.java
@@ -0,0 +1,18 @@
+package cloud.nstart.terravault.ordinis.domain.audit;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.time.OffsetDateTime;
+import java.util.List;
+import java.util.UUID;
+
+public interface AuditLogRepository extends JpaRepository {
+
+ List findByDictionaryIdOrderByEventTimeDesc(UUID dictionaryId);
+
+ List findByRecordIdOrderByEventTimeDesc(UUID recordId);
+
+ List findByUserIdOrderByEventTimeDesc(String userId);
+
+ List findByEventTimeBetweenOrderByEventTimeDesc(OffsetDateTime from, OffsetDateTime to);
+}
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
new file mode 100644
index 0000000..34e9e3b
--- /dev/null
+++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/config/JpaAuditingConfig.java
@@ -0,0 +1,32 @@
+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.domain.AuditorAware;
+import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+
+import javax.sql.DataSource;
+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 операций.
+ *
+ * {@link ConditionalOnBean} на {@link DataSource} — без БД (dev smoke
+ * profile) auditing не активируется, JPA metamodel не строится.
+ */
+@Configuration
+@ConditionalOnBean(DataSource.class)
+@EnableJpaAuditing(auditorAwareRef = "auditorAware")
+public class JpaAuditingConfig {
+
+ @Bean
+ @ConditionalOnMissingBean(AuditorAware.class)
+ public AuditorAware auditorAware() {
+ return () -> Optional.of("system");
+ }
+}
diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/definition/DictionaryDefinition.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/definition/DictionaryDefinition.java
new file mode 100644
index 0000000..39c7bf2
--- /dev/null
+++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/definition/DictionaryDefinition.java
@@ -0,0 +1,122 @@
+package cloud.nstart.terravault.ordinis.domain.definition;
+
+import cloud.nstart.terravault.ordinis.domain.DataScope;
+import com.fasterxml.jackson.databind.JsonNode;
+import io.hypersistence.utils.hibernate.type.array.StringArrayType;
+import io.hypersistence.utils.hibernate.type.json.JsonType;
+import jakarta.persistence.Column;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import jakarta.persistence.Version;
+import org.hibernate.annotations.Type;
+import org.springframework.data.annotation.CreatedBy;
+import org.springframework.data.annotation.CreatedDate;
+import org.springframework.data.annotation.LastModifiedBy;
+import org.springframework.data.annotation.LastModifiedDate;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+
+import jakarta.persistence.EntityListeners;
+
+import java.time.OffsetDateTime;
+import java.util.UUID;
+
+/**
+ * Каталоговый entity справочника: имя, JSON Schema, scope, поддерживаемые локали.
+ * Read-mostly — обновления редкие (admin operation). Не Audited через Envers
+ * (история через audit_log + outbox).
+ */
+@Entity
+@Table(name = "dictionary_definitions")
+@EntityListeners(AuditingEntityListener.class)
+public class DictionaryDefinition {
+
+ @Id
+ @Column(name = "id", nullable = false, updatable = false)
+ private UUID id;
+
+ @Column(name = "name", nullable = false, unique = true, length = 128)
+ private String name;
+
+ @Column(name = "display_name", length = 256)
+ private String displayName;
+
+ @Column(name = "description", columnDefinition = "TEXT")
+ private String description;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "scope", nullable = false, length = 32)
+ private DataScope scope;
+
+ @Type(JsonType.class)
+ @Column(name = "schema_json", nullable = false, columnDefinition = "jsonb")
+ private JsonNode schemaJson;
+
+ @Column(name = "schema_version", nullable = false, length = 32)
+ private String schemaVersion = "1.0.0";
+
+ @Column(name = "bundle", nullable = false, length = 64)
+ private String bundle = "cuod";
+
+ @Type(StringArrayType.class)
+ @Column(name = "supported_locales", nullable = false, columnDefinition = "text[]")
+ private String[] supportedLocales = {"ru-RU"};
+
+ @Column(name = "default_locale", nullable = false, length = 10)
+ private String defaultLocale = "ru-RU";
+
+ @CreatedDate
+ @Column(name = "created_at", nullable = false, updatable = false)
+ private OffsetDateTime createdAt;
+
+ @LastModifiedDate
+ @Column(name = "updated_at", nullable = false)
+ private OffsetDateTime updatedAt;
+
+ @CreatedBy
+ @Column(name = "created_by", length = 128, updatable = false)
+ private String createdBy;
+
+ @LastModifiedBy
+ @Column(name = "updated_by", length = 128)
+ private String updatedBy;
+
+ @Version
+ @Column(name = "version")
+ private Long version;
+
+ protected DictionaryDefinition() {}
+
+ public DictionaryDefinition(UUID id, String name, DataScope scope, JsonNode schemaJson) {
+ this.id = id;
+ this.name = name;
+ this.scope = scope;
+ this.schemaJson = schemaJson;
+ }
+
+ public UUID getId() { return id; }
+ public String getName() { return name; }
+ public String getDisplayName() { return displayName; }
+ public String getDescription() { return description; }
+ public DataScope getScope() { return scope; }
+ public JsonNode getSchemaJson() { return schemaJson; }
+ public String getSchemaVersion() { return schemaVersion; }
+ public String getBundle() { return bundle; }
+ public String[] getSupportedLocales() { return supportedLocales; }
+ public String getDefaultLocale() { return defaultLocale; }
+ public OffsetDateTime getCreatedAt() { return createdAt; }
+ public OffsetDateTime getUpdatedAt() { return updatedAt; }
+ public String getCreatedBy() { return createdBy; }
+ public String getUpdatedBy() { return updatedBy; }
+ public Long getVersion() { return version; }
+
+ public void setDisplayName(String v) { this.displayName = v; }
+ public void setDescription(String v) { this.description = v; }
+ public void setSchemaJson(JsonNode v) { this.schemaJson = v; }
+ public void setSchemaVersion(String v) { this.schemaVersion = v; }
+ public void setBundle(String v) { this.bundle = v; }
+ public void setSupportedLocales(String[] v) { this.supportedLocales = v; }
+ public void setDefaultLocale(String v) { this.defaultLocale = v; }
+}
diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/definition/DictionaryDefinitionRepository.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/definition/DictionaryDefinitionRepository.java
new file mode 100644
index 0000000..10343dc
--- /dev/null
+++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/definition/DictionaryDefinitionRepository.java
@@ -0,0 +1,19 @@
+package cloud.nstart.terravault.ordinis.domain.definition;
+
+import cloud.nstart.terravault.ordinis.domain.DataScope;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+public interface DictionaryDefinitionRepository extends JpaRepository {
+
+ Optional findByName(String name);
+
+ List findByBundle(String bundle);
+
+ List findByScope(DataScope scope);
+
+ boolean existsByName(String name);
+}
diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/idempotency/IdempotencyKey.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/idempotency/IdempotencyKey.java
new file mode 100644
index 0000000..7270974
--- /dev/null
+++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/idempotency/IdempotencyKey.java
@@ -0,0 +1,63 @@
+package cloud.nstart.terravault.ordinis.domain.idempotency;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import io.hypersistence.utils.hibernate.type.json.JsonType;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import org.hibernate.annotations.Type;
+
+import java.time.OffsetDateTime;
+
+/**
+ * Idempotency key для exactly-once семантики write API. Header
+ * {@code Idempotency-Key} от клиента кладётся сюда вместе с request hash и
+ * сохранённым response. На повторный запрос с тем же ключом возвращается тот
+ * же ответ, не повторяется side-effect (запись + outbox event).
+ */
+@Entity
+@Table(name = "idempotency_keys")
+public class IdempotencyKey {
+
+ @Id
+ @Column(name = "key", nullable = false, length = 128)
+ private String key;
+
+ @Column(name = "request_hash", nullable = false, length = 64)
+ private String requestHash;
+
+ @Column(name = "response_status")
+ private Integer responseStatus;
+
+ @Type(JsonType.class)
+ @Column(name = "response_body", columnDefinition = "jsonb")
+ private JsonNode responseBody;
+
+ @Column(name = "created_at", nullable = false)
+ private OffsetDateTime createdAt;
+
+ @Column(name = "expires_at", nullable = false)
+ private OffsetDateTime expiresAt;
+
+ protected IdempotencyKey() {}
+
+ public IdempotencyKey(String key, String requestHash, OffsetDateTime expiresAt) {
+ this.key = key;
+ this.requestHash = requestHash;
+ this.createdAt = OffsetDateTime.now();
+ this.expiresAt = expiresAt;
+ }
+
+ public String getKey() { return key; }
+ public String getRequestHash() { return requestHash; }
+ public Integer getResponseStatus() { return responseStatus; }
+ public JsonNode getResponseBody() { return responseBody; }
+ public OffsetDateTime getCreatedAt() { return createdAt; }
+ public OffsetDateTime getExpiresAt() { return expiresAt; }
+
+ public void setResponse(int status, JsonNode body) {
+ this.responseStatus = status;
+ this.responseBody = body;
+ }
+}
diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/idempotency/IdempotencyKeyRepository.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/idempotency/IdempotencyKeyRepository.java
new file mode 100644
index 0000000..b5aa795
--- /dev/null
+++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/idempotency/IdempotencyKeyRepository.java
@@ -0,0 +1,17 @@
+package cloud.nstart.terravault.ordinis.domain.idempotency;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.OffsetDateTime;
+
+public interface IdempotencyKeyRepository extends JpaRepository {
+
+ @Modifying
+ @Transactional
+ @Query("DELETE FROM IdempotencyKey k WHERE k.expiresAt < :now")
+ int deleteExpired(@Param("now") OffsetDateTime now);
+}
diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/outbox/OutboxEvent.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/outbox/OutboxEvent.java
new file mode 100644
index 0000000..fd74254
--- /dev/null
+++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/outbox/OutboxEvent.java
@@ -0,0 +1,125 @@
+package cloud.nstart.terravault.ordinis.domain.outbox;
+
+import cloud.nstart.terravault.ordinis.domain.DataScope;
+import com.fasterxml.jackson.databind.JsonNode;
+import io.hypersistence.utils.hibernate.type.json.JsonType;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import org.hibernate.annotations.Type;
+
+import java.time.OffsetDateTime;
+
+/**
+ * Outbox row для надёжной публикации в Kafka. Записывается в той же транзакции
+ * что и dictionary_records change. {@code @Scheduled} poller (`ordinis-outbox`)
+ * читает unpublished, отправляет в правильный scope-топик, помечает published_at.
+ */
+@Entity
+@Table(name = "outbox_events")
+public class OutboxEvent {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "id")
+ private Long id;
+
+ @Column(name = "event_type", nullable = false, length = 64)
+ private String eventType;
+
+ @Column(name = "aggregate_type", nullable = false, length = 64)
+ private String aggregateType;
+
+ @Column(name = "aggregate_id", nullable = false, length = 256)
+ private String aggregateId;
+
+ @Column(name = "dictionary_name", length = 128)
+ private String dictionaryName;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "data_scope", nullable = false, length = 32)
+ private DataScope dataScope;
+
+ @Type(JsonType.class)
+ @Column(name = "payload", nullable = false, columnDefinition = "jsonb")
+ private JsonNode payload;
+
+ @Column(name = "kafka_topic", nullable = false, length = 256)
+ private String kafkaTopic;
+
+ @Column(name = "kafka_key", nullable = false, length = 512)
+ private String kafkaKey;
+
+ @Column(name = "created_at", nullable = false)
+ private OffsetDateTime createdAt;
+
+ @Column(name = "published_at")
+ private OffsetDateTime publishedAt;
+
+ @Column(name = "trace_id", length = 64)
+ private String traceId;
+
+ @Column(name = "span_id", length = 64)
+ private String spanId;
+
+ @Column(name = "attempt_count", nullable = false)
+ private Integer attemptCount = 0;
+
+ @Column(name = "last_error", columnDefinition = "TEXT")
+ private String lastError;
+
+ protected OutboxEvent() {}
+
+ public OutboxEvent(
+ String eventType,
+ String aggregateType,
+ String aggregateId,
+ DataScope dataScope,
+ JsonNode payload,
+ String kafkaTopic,
+ String kafkaKey) {
+ this.eventType = eventType;
+ this.aggregateType = aggregateType;
+ this.aggregateId = aggregateId;
+ this.dataScope = dataScope;
+ this.payload = payload;
+ this.kafkaTopic = kafkaTopic;
+ this.kafkaKey = kafkaKey;
+ this.createdAt = OffsetDateTime.now();
+ }
+
+ public void markPublished() {
+ this.publishedAt = OffsetDateTime.now();
+ this.lastError = null;
+ }
+
+ public void markFailed(String error) {
+ this.attemptCount = (this.attemptCount == null ? 0 : this.attemptCount) + 1;
+ this.lastError = error;
+ }
+
+ public Long getId() { return id; }
+ public String getEventType() { return eventType; }
+ public String getAggregateType() { return aggregateType; }
+ public String getAggregateId() { return aggregateId; }
+ public String getDictionaryName() { return dictionaryName; }
+ public DataScope getDataScope() { return dataScope; }
+ public JsonNode getPayload() { return payload; }
+ public String getKafkaTopic() { return kafkaTopic; }
+ public String getKafkaKey() { return kafkaKey; }
+ public OffsetDateTime getCreatedAt() { return createdAt; }
+ public OffsetDateTime getPublishedAt() { return publishedAt; }
+ public String getTraceId() { return traceId; }
+ public String getSpanId() { return spanId; }
+ public Integer getAttemptCount() { return attemptCount; }
+ public String getLastError() { return lastError; }
+
+ public void setDictionaryName(String v) { this.dictionaryName = v; }
+ public void setTraceId(String v) { this.traceId = v; }
+ public void setSpanId(String v) { this.spanId = v; }
+}
diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/outbox/OutboxEventRepository.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/outbox/OutboxEventRepository.java
new file mode 100644
index 0000000..0fc740b
--- /dev/null
+++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/outbox/OutboxEventRepository.java
@@ -0,0 +1,18 @@
+package cloud.nstart.terravault.ordinis.domain.outbox;
+
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+
+import java.util.List;
+
+public interface OutboxEventRepository extends JpaRepository {
+
+ /**
+ * Unpublished events для polling. Использует partial index {@code idx_outbox_unpublished}.
+ */
+ @Query("SELECT o FROM OutboxEvent o WHERE o.publishedAt IS NULL ORDER BY o.id ASC")
+ List findUnpublished(Pageable pageable);
+
+ long countByPublishedAtIsNull();
+}
diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DictionaryRecord.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DictionaryRecord.java
new file mode 100644
index 0000000..b0405e0
--- /dev/null
+++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DictionaryRecord.java
@@ -0,0 +1,133 @@
+package cloud.nstart.terravault.ordinis.domain.record;
+
+import cloud.nstart.terravault.ordinis.domain.DataScope;
+import com.fasterxml.jackson.databind.JsonNode;
+import io.hypersistence.utils.hibernate.type.json.JsonType;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EntityListeners;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import jakarta.persistence.Version;
+import org.hibernate.annotations.JdbcTypeCode;
+import org.hibernate.annotations.Type;
+import org.hibernate.envers.Audited;
+import org.hibernate.envers.RelationTargetAuditMode;
+import org.hibernate.type.SqlTypes;
+import org.locationtech.jts.geom.Geometry;
+import org.springframework.data.annotation.CreatedBy;
+import org.springframework.data.annotation.CreatedDate;
+import org.springframework.data.annotation.LastModifiedBy;
+import org.springframework.data.annotation.LastModifiedDate;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+
+import java.time.OffsetDateTime;
+import java.util.UUID;
+
+/**
+ * Bitemporal entity справочной записи.
+ *
+ * Две оси истории:
+ *
+ * - Transaction time — Hibernate Envers ({@code @Audited}). Когда мы
+ * знали о факте.
+ * - Effective time — {@code valid_from}/{@code valid_to}. Когда факт
+ * действует (например запись действует с 2027-01-01 но введена сегодня).
+ *
+ *
+ * EXCLUSION CONSTRAINT в БД (см. Liquibase 0003) гарантирует отсутствие overlap
+ * по {@code (dictionary_id, business_key, valid_from..valid_to)}.
+ */
+@Entity
+@Table(name = "dictionary_records")
+@Audited(targetAuditMode = RelationTargetAuditMode.NOT_AUDITED)
+@EntityListeners(AuditingEntityListener.class)
+public class DictionaryRecord {
+
+ @Id
+ @Column(name = "id", nullable = false, updatable = false)
+ private UUID id;
+
+ @Column(name = "dictionary_id", nullable = false)
+ private UUID dictionaryId;
+
+ @Column(name = "business_key", nullable = false, length = 256)
+ private String businessKey;
+
+ @Type(JsonType.class)
+ @Column(name = "data", nullable = false, columnDefinition = "jsonb")
+ private JsonNode data;
+
+ @JdbcTypeCode(SqlTypes.GEOMETRY)
+ @Column(name = "geometry", columnDefinition = "geometry(Geometry, 4326)")
+ private Geometry geometry;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "data_scope", nullable = false, length = 32)
+ private DataScope dataScope;
+
+ @Column(name = "valid_from", nullable = false)
+ private OffsetDateTime validFrom;
+
+ @Column(name = "valid_to", nullable = false)
+ private OffsetDateTime validTo = OffsetDateTime.parse("9999-12-31T23:59:59Z");
+
+ @CreatedDate
+ @Column(name = "created_at", nullable = false, updatable = false)
+ private OffsetDateTime createdAt;
+
+ @LastModifiedDate
+ @Column(name = "updated_at", nullable = false)
+ private OffsetDateTime updatedAt;
+
+ @CreatedBy
+ @Column(name = "created_by", length = 128, updatable = false)
+ private String createdBy;
+
+ @LastModifiedBy
+ @Column(name = "updated_by", length = 128)
+ private String updatedBy;
+
+ @Version
+ @Column(name = "version")
+ private Long version;
+
+ protected DictionaryRecord() {}
+
+ public DictionaryRecord(
+ UUID id,
+ UUID dictionaryId,
+ String businessKey,
+ JsonNode data,
+ DataScope dataScope,
+ OffsetDateTime validFrom) {
+ this.id = id;
+ this.dictionaryId = dictionaryId;
+ this.businessKey = businessKey;
+ this.data = data;
+ this.dataScope = dataScope;
+ this.validFrom = validFrom;
+ }
+
+ public UUID getId() { return id; }
+ public UUID getDictionaryId() { return dictionaryId; }
+ public String getBusinessKey() { return businessKey; }
+ public JsonNode getData() { return data; }
+ public Geometry getGeometry() { return geometry; }
+ public DataScope getDataScope() { return dataScope; }
+ public OffsetDateTime getValidFrom() { return validFrom; }
+ public OffsetDateTime getValidTo() { return validTo; }
+ public OffsetDateTime getCreatedAt() { return createdAt; }
+ public OffsetDateTime getUpdatedAt() { return updatedAt; }
+ public String getCreatedBy() { return createdBy; }
+ public String getUpdatedBy() { return updatedBy; }
+ public Long getVersion() { return version; }
+
+ public void setData(JsonNode v) { this.data = v; }
+ public void setGeometry(Geometry v) { this.geometry = v; }
+ public void setValidFrom(OffsetDateTime v) { this.validFrom = v; }
+ public void setValidTo(OffsetDateTime v) { this.validTo = v; }
+ public void setDataScope(DataScope v) { this.dataScope = v; }
+}
diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DictionaryRecordRepository.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DictionaryRecordRepository.java
new file mode 100644
index 0000000..97be205
--- /dev/null
+++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DictionaryRecordRepository.java
@@ -0,0 +1,51 @@
+package cloud.nstart.terravault.ordinis.domain.record;
+
+import cloud.nstart.terravault.ordinis.domain.DataScope;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import java.time.OffsetDateTime;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+public interface DictionaryRecordRepository extends JpaRepository {
+
+ /**
+ * Все версии записи по бизнес-ключу (включая прошлые и будущие). Sorted by valid_from desc.
+ */
+ List findByDictionaryIdAndBusinessKeyOrderByValidFromDesc(
+ UUID dictionaryId, String businessKey);
+
+ /**
+ * Активная версия в момент {@code at}. Использует EXCLUSION CONSTRAINT — гарантировано не больше одной.
+ */
+ @Query("""
+ SELECT r FROM DictionaryRecord r
+ WHERE r.dictionaryId = :dictionaryId
+ AND r.businessKey = :businessKey
+ AND r.validFrom <= :at
+ AND r.validTo > :at
+ """)
+ Optional findActiveAt(
+ @Param("dictionaryId") UUID dictionaryId,
+ @Param("businessKey") String businessKey,
+ @Param("at") OffsetDateTime at);
+
+ /**
+ * Все активные записи в справочнике в момент {@code at}, отфильтрованные по доступным scope'ам
+ * для consumer'а. Используется read-api после locale negotiation.
+ */
+ @Query("""
+ SELECT r FROM DictionaryRecord r
+ WHERE r.dictionaryId = :dictionaryId
+ AND r.dataScope IN :allowedScopes
+ AND r.validFrom <= :at
+ AND r.validTo > :at
+ """)
+ List findActiveByDictionaryAndScopes(
+ @Param("dictionaryId") UUID dictionaryId,
+ @Param("allowedScopes") List allowedScopes,
+ @Param("at") OffsetDateTime at);
+}
diff --git a/ordinis-events-api/pom.xml b/ordinis-events-api/pom.xml
index 56406e2..fe2501d 100644
--- a/ordinis-events-api/pom.xml
+++ b/ordinis-events-api/pom.xml
@@ -19,5 +19,9 @@
com.fasterxml.jackson.core
jackson-annotations
+
+ com.fasterxml.jackson.core
+ jackson-databind
+
diff --git a/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/DataScope.java b/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/DataScope.java
new file mode 100644
index 0000000..a1aa854
--- /dev/null
+++ b/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/DataScope.java
@@ -0,0 +1,13 @@
+package cloud.nstart.terravault.ordinis.events;
+
+/**
+ * Mirror domain {@code DataScope} но без зависимости на JPA. События — публичный
+ * API, consumers не должны тянуть domain entities ради enum.
+ *
+ *
Значения должны соответствовать {@code domain.DataScope}.
+ */
+public enum DataScope {
+ PUBLIC,
+ INTERNAL,
+ RESTRICTED
+}
diff --git a/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/EventEnvelope.java b/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/EventEnvelope.java
new file mode 100644
index 0000000..eb5272a
--- /dev/null
+++ b/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/EventEnvelope.java
@@ -0,0 +1,46 @@
+package cloud.nstart.terravault.ordinis.events;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+
+import java.time.OffsetDateTime;
+import java.util.UUID;
+
+/**
+ * Метаданные-обёртка вокруг payload каждого события. Едет в Kafka как value.
+ *
+ *
Schema versioning: {@code schemaVersion} = semver shared DTO модуля
+ * (ordinis-events-api). Consumer проверяет совместимость через major version.
+ *
+ * @param тип payload (DictionaryRecordEvent, DictionaryDefinitionEvent)
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public record EventEnvelope(
+ UUID eventId,
+ OffsetDateTime occurredAt,
+ String schemaVersion,
+ String bundle,
+ String dictionaryName,
+ DataScope dataScope,
+ String traceId,
+ String spanId,
+ String causedBy,
+ T payload) {
+
+ public static EventEnvelope of(
+ String dictionaryName,
+ DataScope scope,
+ String bundle,
+ T payload) {
+ return new EventEnvelope<>(
+ UUID.randomUUID(),
+ OffsetDateTime.now(),
+ EventSchemaVersion.CURRENT,
+ bundle,
+ dictionaryName,
+ scope,
+ null,
+ null,
+ null,
+ payload);
+ }
+}
diff --git a/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/EventSchemaVersion.java b/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/EventSchemaVersion.java
new file mode 100644
index 0000000..773eb9d
--- /dev/null
+++ b/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/EventSchemaVersion.java
@@ -0,0 +1,12 @@
+package cloud.nstart.terravault.ordinis.events;
+
+/**
+ * Semver версии DTO модуля. Major bump = breaking change. Consumers должны
+ * проверять major перед deserialization.
+ */
+public final class EventSchemaVersion {
+ public static final String CURRENT = "1.0.0";
+ public static final int MAJOR = 1;
+
+ private EventSchemaVersion() {}
+}
diff --git a/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/definition/DictionaryDefinitionCreated.java b/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/definition/DictionaryDefinitionCreated.java
new file mode 100644
index 0000000..fd505f0
--- /dev/null
+++ b/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/definition/DictionaryDefinitionCreated.java
@@ -0,0 +1,24 @@
+package cloud.nstart.terravault.ordinis.events.definition;
+
+import cloud.nstart.terravault.ordinis.events.DataScope;
+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 DictionaryDefinitionCreated(
+ UUID definitionId,
+ String dictionaryName,
+ String displayName,
+ String description,
+ DataScope scope,
+ JsonNode schemaJson,
+ String schemaVersion,
+ String bundle,
+ List supportedLocales,
+ String defaultLocale,
+ OffsetDateTime createdAt,
+ String createdBy) implements DictionaryDefinitionEvent {}
diff --git a/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/definition/DictionaryDefinitionEvent.java b/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/definition/DictionaryDefinitionEvent.java
new file mode 100644
index 0000000..b322c4a
--- /dev/null
+++ b/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/definition/DictionaryDefinitionEvent.java
@@ -0,0 +1,17 @@
+package cloud.nstart.terravault.ordinis.events.definition;
+
+import com.fasterxml.jackson.annotation.JsonSubTypes;
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
+
+@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
+@JsonSubTypes({
+ @JsonSubTypes.Type(value = DictionaryDefinitionCreated.class, name = "DefinitionCreated"),
+ @JsonSubTypes.Type(value = DictionaryDefinitionUpdated.class, name = "DefinitionUpdated")
+})
+public sealed interface DictionaryDefinitionEvent
+ permits DictionaryDefinitionCreated, DictionaryDefinitionUpdated {
+
+ java.util.UUID definitionId();
+
+ String dictionaryName();
+}
diff --git a/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/definition/DictionaryDefinitionUpdated.java b/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/definition/DictionaryDefinitionUpdated.java
new file mode 100644
index 0000000..49506ba
--- /dev/null
+++ b/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/definition/DictionaryDefinitionUpdated.java
@@ -0,0 +1,29 @@
+package cloud.nstart.terravault.ordinis.events.definition;
+
+import cloud.nstart.terravault.ordinis.events.DataScope;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.JsonNode;
+
+import java.time.OffsetDateTime;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * Изменение определения справочника (schema, supported_locales и т.д.). Вместе
+ * с новой версией прилетает {@code previousSchemaJson} для diff.
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public record DictionaryDefinitionUpdated(
+ UUID definitionId,
+ String dictionaryName,
+ String displayName,
+ String description,
+ DataScope scope,
+ JsonNode schemaJson,
+ JsonNode previousSchemaJson,
+ String schemaVersion,
+ String bundle,
+ List supportedLocales,
+ String defaultLocale,
+ OffsetDateTime updatedAt,
+ String updatedBy) implements DictionaryDefinitionEvent {}
diff --git a/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/record/DictionaryRecordCreated.java b/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/record/DictionaryRecordCreated.java
new file mode 100644
index 0000000..92e46b6
--- /dev/null
+++ b/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/record/DictionaryRecordCreated.java
@@ -0,0 +1,25 @@
+package cloud.nstart.terravault.ordinis.events.record;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.JsonNode;
+
+import java.time.OffsetDateTime;
+import java.util.UUID;
+
+/**
+ * Создана новая версия записи справочника. {@code data} — полный multi-locale
+ * payload (consumer сам сплющивает по нужной локали).
+ *
+ * {@code geometryWkt} — WKT представление геометрии (если есть). EWKT с SRID
+ * 4326 для совместимости с PostGIS. Может быть null.
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public record DictionaryRecordCreated(
+ UUID recordId,
+ String businessKey,
+ JsonNode data,
+ String geometryWkt,
+ OffsetDateTime validFrom,
+ OffsetDateTime validTo,
+ OffsetDateTime createdAt,
+ String createdBy) implements DictionaryRecordEvent {}
diff --git a/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/record/DictionaryRecordDeleted.java b/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/record/DictionaryRecordDeleted.java
new file mode 100644
index 0000000..1702e07
--- /dev/null
+++ b/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/record/DictionaryRecordDeleted.java
@@ -0,0 +1,19 @@
+package cloud.nstart.terravault.ordinis.events.record;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+
+import java.time.OffsetDateTime;
+import java.util.UUID;
+
+/**
+ * Логическое удаление записи (точнее: закрытие текущей версии установкой
+ * valid_to). Реального DELETE из таблицы не происходит — для compliance/audit.
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public record DictionaryRecordDeleted(
+ UUID recordId,
+ String businessKey,
+ OffsetDateTime closedAt,
+ OffsetDateTime deletedAt,
+ String deletedBy,
+ String reason) implements DictionaryRecordEvent {}
diff --git a/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/record/DictionaryRecordEvent.java b/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/record/DictionaryRecordEvent.java
new file mode 100644
index 0000000..b6cebbb
--- /dev/null
+++ b/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/record/DictionaryRecordEvent.java
@@ -0,0 +1,24 @@
+package cloud.nstart.terravault.ordinis.events.record;
+
+import com.fasterxml.jackson.annotation.JsonSubTypes;
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
+
+/**
+ * Sealed interface для всех событий по записям справочника. Jackson
+ * polymorphic deserialization через {@code "type"} discriminator.
+ */
+@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
+@JsonSubTypes({
+ @JsonSubTypes.Type(value = DictionaryRecordCreated.class, name = "RecordCreated"),
+ @JsonSubTypes.Type(value = DictionaryRecordUpdated.class, name = "RecordUpdated"),
+ @JsonSubTypes.Type(value = DictionaryRecordDeleted.class, name = "RecordDeleted")
+})
+public sealed interface DictionaryRecordEvent
+ permits DictionaryRecordCreated, DictionaryRecordUpdated, DictionaryRecordDeleted {
+
+ /** UUID dictionary_records.id. */
+ java.util.UUID recordId();
+
+ /** Бизнес-ключ записи (стабильный across versions). */
+ String businessKey();
+}
diff --git a/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/record/DictionaryRecordUpdated.java b/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/record/DictionaryRecordUpdated.java
new file mode 100644
index 0000000..14769fb
--- /dev/null
+++ b/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/record/DictionaryRecordUpdated.java
@@ -0,0 +1,26 @@
+package cloud.nstart.terravault.ordinis.events.record;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.JsonNode;
+
+import java.time.OffsetDateTime;
+import java.util.UUID;
+
+/**
+ * Обновление существующей записи. Modeling: новая версия (новый recordId, тот
+ * же businessKey) с новой valid_from. Старая версия получила valid_to=newRecord.validFrom.
+ *
+ *
{@code previousData} даёт consumer'у возможность сделать diff.
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public record DictionaryRecordUpdated(
+ UUID recordId,
+ UUID previousRecordId,
+ String businessKey,
+ JsonNode data,
+ JsonNode previousData,
+ String geometryWkt,
+ OffsetDateTime validFrom,
+ OffsetDateTime validTo,
+ OffsetDateTime updatedAt,
+ String updatedBy) implements DictionaryRecordEvent {}
diff --git a/ordinis-migrations/src/main/resources/db/changelog/changes/0002-dictionary-definitions.xml b/ordinis-migrations/src/main/resources/db/changelog/changes/0002-dictionary-definitions.xml
index 643d87a..0a559a5 100644
--- a/ordinis-migrations/src/main/resources/db/changelog/changes/0002-dictionary-definitions.xml
+++ b/ordinis-migrations/src/main/resources/db/changelog/changes/0002-dictionary-definitions.xml
@@ -28,6 +28,12 @@
+
+
+
+
+
+