diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DependentsQuery.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DependentsQuery.java
new file mode 100644
index 0000000..cce617e
--- /dev/null
+++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DependentsQuery.java
@@ -0,0 +1,72 @@
+package cloud.nstart.terravault.ordinis.domain.record;
+
+import java.time.OffsetDateTime;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * Native SQL для record-level dependents lookup: "какие active записи в
+ * source dictionary ссылаются на target record через JSONB
+ * {@code data->>source_field == target_value}".
+ *
+ *
Используется {@code LineageIndexService.findRecordDependents} в Phase 1.
+ * Phase 2 переносит этот lookup на materialized view с UNIQUE INDEX(id) для
+ * REFRESH CONCURRENTLY — пока живём с прямым JSONB query (acceptable для
+ * {@code <}5k records SLO).
+ *
+ *
Interface вместо concrete класса — JDK 25 + Mockito inline-mock не работает
+ * на concrete classes (см. memo "JDK 25 + Mockito subclass mock-maker incompat").
+ * Default impl {@link DependentsQueryJdbc} автоинджектится через @Component.
+ */
+public interface DependentsQuery {
+
+ /**
+ * Active records в source_dict где {@code data->>source_field == value}.
+ * Возвращает paginated slice + total count (один call для UI: count first,
+ * потом page если total > 0).
+ *
+ *
NULL-значения в source_field автоматически исключены SQL'ом
+ * (NULL != value никогда не true).
+ *
+ *
Order by created_at DESC чтобы newest dependents видны первыми
+ * (admin debug pattern).
+ *
+ * @param sourceDictionaryId source dict (referencing)
+ * @param sourceField JSONB key в source data что хранит ref value
+ * @param targetValue фактическое значение target field в target record
+ * @param at момент времени — обычно now()
+ * @param limit page size (caller должен ограничить ≤ 200)
+ * @param offset page offset (≥ 0)
+ * @return slice records sorted by created_at DESC
+ */
+ List findActive(
+ UUID sourceDictionaryId,
+ String sourceField,
+ String targetValue,
+ OffsetDateTime at,
+ int limit,
+ int offset);
+
+ /**
+ * Total count active dependents — отдельный query без LIMIT/OFFSET для
+ * pagination header. Faster чем {@code SELECT COUNT(*)} над основным
+ * results когда LIMIT/OFFSET идёт в отдельном request.
+ */
+ long countActive(
+ UUID sourceDictionaryId,
+ String sourceField,
+ String targetValue,
+ OffsetDateTime at);
+
+ /**
+ * Lightweight projection — лишний JSONB data в payload не нужен на этом
+ * endpoint, экономим bandwidth.
+ */
+ record DependentRow(
+ UUID id,
+ UUID dictionaryId,
+ String businessKey,
+ OffsetDateTime validFrom,
+ OffsetDateTime validTo,
+ OffsetDateTime createdAt) {}
+}
diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DependentsQueryJdbc.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DependentsQueryJdbc.java
new file mode 100644
index 0000000..3098d3e
--- /dev/null
+++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DependentsQueryJdbc.java
@@ -0,0 +1,97 @@
+package cloud.nstart.terravault.ordinis.domain.record;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Component;
+
+import java.time.OffsetDateTime;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * JdbcTemplate-backed default impl {@link DependentsQuery}.
+ *
+ *
Поля {@code source_field} приходят из schema parser (валидируются на
+ * bundle import), плюс whitelist regex здесь — SQL injection невозможна.
+ * Native SQL потому что JdbcTemplate не bind'ит JSONB key paths.
+ */
+@Component
+public class DependentsQueryJdbc implements DependentsQuery {
+
+ private final JdbcTemplate jdbc;
+
+ @Autowired
+ public DependentsQueryJdbc(JdbcTemplate jdbc) {
+ this.jdbc = jdbc;
+ }
+
+ @Override
+ public List findActive(
+ UUID sourceDictionaryId,
+ String sourceField,
+ String targetValue,
+ OffsetDateTime at,
+ int limit,
+ int offset) {
+ validateField(sourceField);
+ if (limit <= 0 || limit > 500) {
+ throw new IllegalArgumentException("limit must be 1..500, got " + limit);
+ }
+ if (offset < 0) {
+ throw new IllegalArgumentException("offset must be >= 0, got " + offset);
+ }
+
+ String sql = """
+ SELECT id, dictionary_id, business_key, valid_from, valid_to, created_at
+ FROM dictionary_records
+ WHERE dictionary_id = ?
+ AND valid_from <= ?
+ AND valid_to > ?
+ AND data ->> '%s' = ?
+ ORDER BY created_at DESC
+ LIMIT ? OFFSET ?
+ """.formatted(sourceField);
+
+ return jdbc.query(
+ sql,
+ (rs, n) -> new DependentRow(
+ (UUID) rs.getObject("id"),
+ (UUID) rs.getObject("dictionary_id"),
+ rs.getString("business_key"),
+ rs.getObject("valid_from", OffsetDateTime.class),
+ rs.getObject("valid_to", OffsetDateTime.class),
+ rs.getObject("created_at", OffsetDateTime.class)),
+ sourceDictionaryId, at, at, targetValue, limit, offset);
+ }
+
+ @Override
+ public long countActive(
+ UUID sourceDictionaryId,
+ String sourceField,
+ String targetValue,
+ OffsetDateTime at) {
+ validateField(sourceField);
+
+ String sql = """
+ SELECT COUNT(*) FROM dictionary_records
+ WHERE dictionary_id = ?
+ AND valid_from <= ?
+ AND valid_to > ?
+ AND data ->> '%s' = ?
+ """.formatted(sourceField);
+
+ Long count = jdbc.queryForObject(sql, Long.class, sourceDictionaryId, at, at, targetValue);
+ return count == null ? 0 : count;
+ }
+
+ /** Paranoid SQL injection guard. Mirror'ит {@code OrphanReferenceQuery}. */
+ private static void validateField(String field) {
+ if (field == null || field.isBlank()) {
+ throw new IllegalArgumentException("field name is empty");
+ }
+ if (!field.matches("^[a-zA-Z_][a-zA-Z0-9_]{0,127}$")) {
+ throw new IllegalArgumentException(
+ "field name has unsafe characters: " + field);
+ }
+ }
+}
diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/reference/LineageIndexService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/reference/LineageIndexService.java
new file mode 100644
index 0000000..b21f108
--- /dev/null
+++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/reference/LineageIndexService.java
@@ -0,0 +1,289 @@
+package cloud.nstart.terravault.ordinis.restapi.service.reference;
+
+import cloud.nstart.terravault.ordinis.domain.DataScope;
+import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
+import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinitionRepository;
+import cloud.nstart.terravault.ordinis.domain.record.DependentsQuery;
+import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecord;
+import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecordRepository;
+import com.fasterxml.jackson.databind.JsonNode;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.OffsetDateTime;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * Reverse FK lineage index: "кто ссылается на этот словарь / запись".
+ *
+ *
Phase 1 read-side для dict-relationships-v2 epic. Два режима:
+ *
+ *
+ *
Schema-level: {@link #findSchemaDependents(String, Set)} —
+ * берёт target dict name, обходит все definitions и собирает все
+ * fields у которых {@code x-references = "."}. Дёшево
+ * (in-memory traversal, ~40 dicts × ~20 fields), кэш не нужен в v1.
+ *
Record-level: {@link #findRecordDependents} — для конкретного
+ * business_key в target_dict находит active records в source dicts
+ * что на него ссылаются. Paginated через {@link DependentsQuery} (native
+ * SQL JSONB lookup). Phase 2 переносит на materialized view.
+ *
+ *
+ *
Caching: Phase 1 не кэширует — schema lookup микросекунды, record-level
+ * SQL paginated и каждый bbox разный. Phase 2 добавит {@code @Cacheable} для
+ * schema-level + invalidation на bundle import (см. eng review A.1).
+ *
+ *
Scope: filter target dict + source dicts через {@code allowedScopes}.
+ * Если каллер не имеет access к target dict — возвращаем "not found"
+ * (consistent с CRUD endpoint behaviour, чтобы не leak'ало existence).
+ */
+@Service
+public class LineageIndexService {
+
+ private static final Logger log = LoggerFactory.getLogger(LineageIndexService.class);
+
+ private final DictionaryDefinitionRepository definitionRepository;
+ private final DictionaryRecordRepository recordRepository;
+ private final DependentsQuery dependentsQuery;
+
+ public LineageIndexService(
+ DictionaryDefinitionRepository definitionRepository,
+ DictionaryRecordRepository recordRepository,
+ DependentsQuery dependentsQuery) {
+ this.definitionRepository = definitionRepository;
+ this.recordRepository = recordRepository;
+ this.dependentsQuery = dependentsQuery;
+ }
+
+ /**
+ * Schema-level dependents target dict: какие dict.field ссылаются.
+ *
+ * @param targetDictName dict name (e.g. "satellite_type")
+ * @param allowedScopes что caller видит (источники с недоступным scope filtered)
+ * @return list reverse FK pointers, sorted by sourceDict, sourceField asc.
+ * Empty если target не существует или у caller нет к нему access.
+ *
+ * @throws SchemaDependentsTarget#NOT_FOUND нет такого dict.
+ */
+ @Transactional(readOnly = true)
+ public List findSchemaDependents(
+ String targetDictName, Set allowedScopes) {
+ Optional targetOpt = definitionRepository.findByName(targetDictName);
+ if (targetOpt.isEmpty()) return List.of();
+ DictionaryDefinition target = targetOpt.get();
+ if (!allowedScopes.contains(target.getScope())) {
+ // Scope hide — target invisible. Return empty (same shape as not_found).
+ return List.of();
+ }
+
+ List all = definitionRepository.findAll();
+ // Pre-compute counts active records per source dict (для UI badge).
+ Map counts = new HashMap<>();
+ OffsetDateTime now = OffsetDateTime.now();
+ for (Object[] row : recordRepository.countActiveGroupedByDictionary(allowedScopes, now)) {
+ counts.put((java.util.UUID) row[0], (Long) row[1]);
+ }
+
+ List out = new ArrayList<>();
+ for (DictionaryDefinition source : all) {
+ if (source.getId().equals(target.getId())) continue; // self-reference skipped
+ if (!allowedScopes.contains(source.getScope())) continue;
+ JsonNode schema = source.getSchemaJson();
+ if (schema == null || !schema.isObject()) continue;
+ JsonNode properties = schema.get("properties");
+ if (properties == null || !properties.isObject()) continue;
+
+ Iterator> it = properties.fields();
+ while (it.hasNext()) {
+ Map.Entry entry = it.next();
+ String fieldName = entry.getKey();
+ JsonNode propSchema = entry.getValue();
+ JsonNode refNode = propSchema.get("x-references");
+ if (refNode == null || !refNode.isTextual()) continue;
+
+ ReferenceValidator.ParsedRef parsed;
+ try {
+ parsed = ReferenceValidator.parseRef(refNode.asText(), propSchema);
+ } catch (IllegalArgumentException e) {
+ log.warn("Schema {} field {} malformed x-references: {}",
+ source.getName(), fieldName, e.getMessage());
+ continue;
+ }
+ if (!parsed.dictionaryName().equals(target.getName())) continue;
+
+ out.add(new SchemaDependent(
+ source.getName(),
+ source.getDisplayName(),
+ fieldName,
+ parsed.fieldName(),
+ parsed.onClose(),
+ counts.getOrDefault(source.getId(), 0L)));
+ }
+ }
+ out.sort((a, b) -> {
+ int c = a.sourceDict().compareTo(b.sourceDict());
+ return c != 0 ? c : a.sourceField().compareTo(b.sourceField());
+ });
+ return out;
+ }
+
+ /**
+ * Record-level dependents: для target record (dict + business_key) ищет
+ * все active records в source dicts что на него ссылаются.
+ *
+ *
Логика: для каждого source dict у которого schema имеет
+ * {@code x-references: "."} — берём value
+ * {@code target_record.data->>target_field} и считаем/перечисляем active
+ * source records где {@code data->>source_field == that_value}.
+ *
+ * @param page page index 0..N
+ * @param size page size 1..200
+ * @return paged dependents + per-source totals
+ */
+ @Transactional(readOnly = true)
+ public RecordDependentsPage findRecordDependents(
+ String targetDictName,
+ String targetBusinessKey,
+ Set allowedScopes,
+ int page,
+ int size) {
+ if (page < 0) throw new IllegalArgumentException("page must be >= 0");
+ if (size < 1 || size > 200) {
+ throw new IllegalArgumentException("size must be 1..200, got " + size);
+ }
+
+ Optional targetOpt = definitionRepository.findByName(targetDictName);
+ if (targetOpt.isEmpty()) {
+ throw new IllegalArgumentException("dictionary not found: " + targetDictName);
+ }
+ DictionaryDefinition target = targetOpt.get();
+ if (!allowedScopes.contains(target.getScope())) {
+ throw new IllegalArgumentException("dictionary not found: " + targetDictName);
+ }
+
+ OffsetDateTime now = OffsetDateTime.now();
+ Optional recOpt =
+ recordRepository.findActiveAt(target.getId(), targetBusinessKey, now);
+ if (recOpt.isEmpty()) {
+ throw new IllegalArgumentException(
+ "active record not found: " + targetDictName + "/" + targetBusinessKey);
+ }
+ DictionaryRecord targetRecord = recOpt.get();
+
+ // Schema-level reverse index: какие (source_dict, source_field, target_field, onClose).
+ List schemaRefs = findSchemaDependents(targetDictName, allowedScopes);
+ if (schemaRefs.isEmpty()) {
+ return new RecordDependentsPage(
+ targetDictName, targetBusinessKey, List.of(), List.of(), 0, page, size);
+ }
+
+ // Per-source: pull target value, count + paginate.
+ // Phase 1: linear scan по source schemas — ОК для <40 dicts.
+ // Phase 2: MV сворачивает в один query.
+ List all = new ArrayList<>();
+ List summary = new ArrayList<>();
+ long total = 0;
+
+ for (SchemaDependent ref : schemaRefs) {
+ JsonNode targetData = targetRecord.getData();
+ if (targetData == null) continue;
+ JsonNode valueNode = targetData.get(ref.targetField());
+ if (valueNode == null || !valueNode.isTextual()) continue;
+ String targetValue = valueNode.asText();
+
+ Optional sourceOpt =
+ definitionRepository.findByName(ref.sourceDict());
+ if (sourceOpt.isEmpty()) continue;
+ DictionaryDefinition sourceDef = sourceOpt.get();
+
+ long perSourceCount = dependentsQuery.countActive(
+ sourceDef.getId(), ref.sourceField(), targetValue, now);
+ summary.add(new PerSourceSummary(
+ ref.sourceDict(),
+ ref.sourceDisplayName(),
+ ref.sourceField(),
+ ref.onClose(),
+ perSourceCount));
+ total += perSourceCount;
+
+ if (perSourceCount == 0) continue;
+
+ // Paginate by aggregated total: каждый source pulled полностью если
+ // нужен (proper offset с union в SQL отложен Phase 2 — здесь simpler).
+ List rows = dependentsQuery.findActive(
+ sourceDef.getId(), ref.sourceField(), targetValue, now,
+ // Защитный bound: <= 200 на source per request.
+ Math.min(size + page * size + 1, 200), 0);
+ for (DependentsQuery.DependentRow r : rows) {
+ all.add(new RecordDependent(
+ ref.sourceDict(),
+ ref.sourceDisplayName(),
+ ref.sourceField(),
+ ref.onClose(),
+ r.id(),
+ r.businessKey(),
+ r.validFrom(),
+ r.validTo(),
+ r.createdAt()));
+ }
+ }
+
+ // Sort all dependents by createdAt DESC, then page slice.
+ all.sort((a, b) -> b.createdAt().compareTo(a.createdAt()));
+ int from = Math.min(page * size, all.size());
+ int to = Math.min(from + size, all.size());
+ List slice = all.subList(from, to);
+
+ return new RecordDependentsPage(
+ targetDictName, targetBusinessKey, slice, summary, total, page, size);
+ }
+
+ // === DTOs ===
+
+ /** Schema-level reverse FK pointer: source dict → target dict через field. */
+ public record SchemaDependent(
+ String sourceDict,
+ String sourceDisplayName,
+ String sourceField,
+ String targetField,
+ OnCloseAction onClose,
+ long activeRecordsInSourceDict) {}
+
+ /** Record-level dependent — конкретная active record что ссылается. */
+ public record RecordDependent(
+ String sourceDict,
+ String sourceDisplayName,
+ String sourceField,
+ OnCloseAction onClose,
+ java.util.UUID id,
+ String businessKey,
+ OffsetDateTime validFrom,
+ OffsetDateTime validTo,
+ OffsetDateTime createdAt) {}
+
+ /** Per-source summary — сколько dependents в каждом source dict. */
+ public record PerSourceSummary(
+ String sourceDict,
+ String sourceDisplayName,
+ String sourceField,
+ OnCloseAction onClose,
+ long count) {}
+
+ /** Paginated page + per-source aggregates. */
+ public record RecordDependentsPage(
+ String targetDict,
+ String targetBusinessKey,
+ List items,
+ List perSource,
+ long total,
+ int page,
+ int size) {}
+}
diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/LineageController.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/LineageController.java
new file mode 100644
index 0000000..9f6107c
--- /dev/null
+++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/LineageController.java
@@ -0,0 +1,94 @@
+package cloud.nstart.terravault.ordinis.restapi.web;
+
+import cloud.nstart.terravault.ordinis.auth.ScopeContext;
+import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
+import cloud.nstart.terravault.ordinis.restapi.service.reference.LineageIndexService;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/**
+ * Lineage / dependents read-side. Phase 1 dict-relationships-v2 epic.
+ *
+ *
Два endpoint'а:
+ *
+ *
{@code GET /api/v1/dictionaries/{name}/dependents} — schema-level
+ * reverse FK list ("какие словари ссылаются на этот словарь и через
+ * какие поля").
404 {@code dictionary_not_found} — target dict не существует или
+ * не виден caller'у в его scopes (одинаковая ошибка чтобы не leak'ало
+ * existence).
+ *
404 {@code record_not_active} — нет active record на now().
+ *
400 {@code invalid_pagination} — page/size вне допустимого диапазона.
+ *
+ */
+@RestController
+@RequestMapping("/api/v1")
+public class LineageController {
+
+ private final LineageIndexService lineage;
+ private final ScopeContext scopeContext;
+
+ public LineageController(LineageIndexService lineage, ScopeContext scopeContext) {
+ this.lineage = lineage;
+ this.scopeContext = scopeContext;
+ }
+
+ /**
+ * Schema-level reverse FK list. Empty list если target не существует
+ * или не виден — consistent с CRUD endpoints (no leak existence).
+ *
+ *
Возврат: list объектов
+ * {@code {sourceDict, sourceDisplayName, sourceField, targetField,
+ * onClose, activeRecordsInSourceDict}}.
+ */
+ @GetMapping("/dictionaries/{name}/dependents")
+ public List schemaDependents(
+ @PathVariable String name) {
+ return lineage.findSchemaDependents(name, scopeContext.currentScopes());
+ }
+
+ /**
+ * Record-level dependents для конкретной record (target_dict + business_key).
+ *
+ *