feat(approval): bundle-driven approval policy + spacecraft/ground_station soak flag

Approval Workflow v2 Phase 3 — soak prep:

- BundleManifest.DictionaryEntry: добавлены опциональные approvalRequired
  (Boolean) и approvalMinRole (String).
- CuodBundleImporter: применяет policy на fresh + on-update + при стабильной
  схеме (flip без bumping schema_version). Apply ТОЛЬКО когда явно изменился
  → не штормим UPDATE'ами.
- CuodBundleProperties.honorApprovalPolicy (default false): env-gated guard.
  Bundle ship'ит approvalRequired:true для пилотных dicts, prod игнорирует
  пока не явно включит.
- manifest cuod 1.3.0 → 1.3.1: spacecraft + ground_station получили
  approvalRequired:true. Без honor flag — no-op.

Этого commit'а достаточно для соответствующего helm change в
ordinis-infra (bundleHonorApprovalPolicy=true в values-staging) — flip'ает
spacecraft/ground_station на staging при следующем deploy. Prod остаётся
default=false до окончания soak.
This commit is contained in:
Zimin A.N.
2026-05-08 14:59:34 +03:00
parent 9ef9b8147e
commit d229c545f9
4 changed files with 56 additions and 5 deletions
@@ -25,5 +25,9 @@ public record BundleManifest(
String schemaVersion,
List<String> supportedLocales,
String defaultLocale,
String recordsResource) {}
String recordsResource,
/** Approval Workflow v2 (Phase 3 soak): per-dict opt-in maker-checker. */
Boolean approvalRequired,
/** Optional Keycloak role для reviewer'а (D1=A approval policy). */
String approvalMinRole) {}
}
@@ -111,6 +111,15 @@ public class CuodBundleImporter {
def.setDisplayName(entry.displayName());
def.setSupportedLocales(entryLocales.toArray(String[]::new));
def.setDefaultLocale(entryDefault);
// Approval Workflow v2: bundle-driven opt-in. Только если entry задаёт
// явное значение — иначе оставляем текущее (не сбрасываем UI-toggle'и).
// Honor только когда props.honorApprovalPolicy=true (Phase 3 soak gate).
if (props.honorApprovalPolicy() && entry.approvalRequired() != null) {
def.setApprovalRequired(entry.approvalRequired());
}
if (props.honorApprovalPolicy() && entry.approvalMinRole() != null) {
def.setApprovalMinRole(entry.approvalMinRole());
}
DictionaryDefinition saved = repository.save(def);
var event = new DictionaryDefinitionUpdated(
@@ -128,6 +137,29 @@ public class CuodBundleImporter {
def = saved;
} else {
log.debug("Справочник '{}' v{} актуален, schema не трогаем", entry.name(), def.getSchemaVersion());
// Approval Workflow v2 soak: даже если schema_version неизменилась,
// bundle-driven opt-in требует, чтобы Boolean approvalRequired в
// manifest применился к существующему dict (Phase 3 — flip без bumping
// schema). Apply ТОЛЬКО при изменении — иначе spam UPDATE'ов. Honor
// только когда props.honorApprovalPolicy=true (env-gated).
if (props.honorApprovalPolicy()) {
boolean changed = false;
if (entry.approvalRequired() != null
&& entry.approvalRequired() != def.isApprovalRequired()) {
def.setApprovalRequired(entry.approvalRequired());
changed = true;
}
if (entry.approvalMinRole() != null
&& !java.util.Objects.equals(entry.approvalMinRole(), def.getApprovalMinRole())) {
def.setApprovalMinRole(entry.approvalMinRole());
changed = true;
}
if (changed) {
def = repository.save(def);
log.info("Справочник '{}' approval policy обновлён: required={} role={}",
def.getName(), def.isApprovalRequired(), def.getApprovalMinRole());
}
}
}
// Records seed запускаем всегда — они идемпотентны (skip on existing businessKey).
importRecords(manifest, entry, def);
@@ -142,6 +174,15 @@ public class CuodBundleImporter {
fresh.setBundle(manifest.bundle());
fresh.setSupportedLocales(entryLocales.toArray(String[]::new));
fresh.setDefaultLocale(entryDefault);
// Approval Workflow v2: per-dict opt-in через manifest (Phase 3 soak).
// Honor только когда props.honorApprovalPolicy=true — staging opts in,
// prod игнорирует пока явно не включит.
if (props.honorApprovalPolicy() && entry.approvalRequired() != null) {
fresh.setApprovalRequired(entry.approvalRequired());
}
if (props.honorApprovalPolicy() && entry.approvalMinRole() != null) {
fresh.setApprovalMinRole(entry.approvalMinRole());
}
DictionaryDefinition saved = repository.save(fresh);
var event = new DictionaryDefinitionCreated(
@@ -6,7 +6,13 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
public record CuodBundleProperties(
boolean autoImport,
boolean updateExistingSchema,
String resourceRoot) {
String resourceRoot,
/**
* Approval Workflow v2 (Phase 3 soak): применять {@code approvalRequired} /
* {@code approvalMinRole} из manifest.json при импорте. Default {@code false}
* — bundle ship'ит флаги для staging, prod игнорирует пока не явно включат.
*/
boolean honorApprovalPolicy) {
public CuodBundleProperties {
if (resourceRoot == null) resourceRoot = "classpath:/bundles/cuod/";
@@ -2,14 +2,14 @@
"bundle": "cuod",
"displayName": "ЦУОД ДЗЗ",
"description": "Bundle справочников для Центра Управления Обработкой Данных ДЗЗ. Anchor v1.",
"version": "1.3.0",
"version": "1.3.1",
"supportedLocales": ["ru-RU", "en-US"],
"defaultLocale": "ru-RU",
"dictionaries": [
{ "name": "spacecraft", "displayName": "Космические аппараты", "description": "Каталог КА (космических аппаратов) ДЗЗ-миссий", "scope": "PUBLIC", "schemaResource": "spacecraft.schema.json", "schemaVersion": "1.1.0" },
{ "name": "spacecraft", "displayName": "Космические аппараты", "description": "Каталог КА (космических аппаратов) ДЗЗ-миссий", "scope": "PUBLIC", "schemaResource": "spacecraft.schema.json", "schemaVersion": "1.1.0", "approvalRequired": true },
{ "name": "satellite_type", "displayName": "Типы КА", "description": "Классификация типов космических аппаратов по миссиям и характеристикам", "scope": "PUBLIC", "schemaResource": "satellite_type.schema.json", "schemaVersion": "1.0.0" },
{ "name": "spacecraft_status", "displayName": "Статусы КА", "description": "Жизненный цикл КА: planned → operational → decommissioned/lost.", "scope": "PUBLIC", "schemaResource": "spacecraft_status.schema.json", "schemaVersion": "1.0.0" },
{ "name": "ground_station", "displayName": "Наземные станции", "description": "Каталог наземных пунктов приёма и управления", "scope": "PUBLIC", "schemaResource": "ground_station.schema.json", "schemaVersion": "1.0.0" },
{ "name": "ground_station", "displayName": "Наземные станции", "description": "Каталог наземных пунктов приёма и управления", "scope": "PUBLIC", "schemaResource": "ground_station.schema.json", "schemaVersion": "1.0.0", "approvalRequired": true },
{ "name": "antenna", "displayName": "Антенны", "description": "Антенны наземных станций с G/T и поддерживаемыми диапазонами.", "scope": "PUBLIC", "schemaResource": "antenna.schema.json", "schemaVersion": "1.0.0" },
{ "name": "mission", "displayName": "Миссии / программы", "description": "Программы ДЗЗ — Ресурс-П, Канопус-В, Sentinel и т.д. Группируют семейства КА.", "scope": "PUBLIC", "schemaResource": "mission.schema.json", "schemaVersion": "1.0.0" },
{ "name": "instrument", "displayName": "Инструменты съёмки", "description": "Полезная нагрузка / сенсоры на борту КА (Геотон-Л1, MODIS, C-SAR, ...)", "scope": "PUBLIC", "schemaResource": "instrument.schema.json", "schemaVersion": "1.0.0" },