feat(playbook): version generated playbooks
All checks were successful
CD Pipeline / tests (push) Successful in 1m34s
Code Review / ai-code-review (push) Successful in 28s
Type Sync Check / check-type-sync (push) Successful in 1m10s
CD Pipeline / build-and-deploy (push) Successful in 10m19s
CD Pipeline / post-deploy-checks (push) Successful in 3m1s

This commit is contained in:
Your Name
2026-04-30 23:59:39 +08:00
parent 474b913ac9
commit f154ac022e
13 changed files with 426 additions and 5 deletions

View File

@@ -24,8 +24,6 @@ env:
jobs:
migrate:
runs-on: ubuntu-latest # 或 self-hosted runner on 110
container:
image: postgres:15-alpine # 帶 psql
steps:
- name: Checkout
@@ -33,6 +31,28 @@ jobs:
with:
fetch-depth: 2 # 需比對上一個 commit
- name: Install migration tools
run: |
set -euo pipefail
missing=""
for bin in psql jq curl; do
if ! command -v "$bin" >/dev/null 2>&1; then
missing="$missing $bin"
fi
done
if [ -z "$missing" ]; then
exit 0
fi
if command -v apt-get >/dev/null 2>&1; then
apt-get update -qq
apt-get install -y -q postgresql-client jq curl
elif command -v apk >/dev/null 2>&1; then
apk add --no-cache postgresql-client jq curl
else
echo "::error::missing required tools:$missing"
exit 1
fi
- name: Identify new migrations
id: diff
run: |

View File

@@ -207,9 +207,24 @@ async def init_db() -> None:
ADD COLUMN IF NOT EXISTS trust_score FLOAT NOT NULL DEFAULT 0.3,
ADD COLUMN IF NOT EXISTS requires_approval_level VARCHAR(20) NOT NULL DEFAULT 'auto',
ADD COLUMN IF NOT EXISTS stateful_targets JSONB NOT NULL DEFAULT '[]',
ADD COLUMN IF NOT EXISTS requires_pre_backup BOOLEAN NOT NULL DEFAULT FALSE;
ADD COLUMN IF NOT EXISTS requires_pre_backup BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS version INTEGER NOT NULL DEFAULT 1,
ADD COLUMN IF NOT EXISTS parent_playbook_id VARCHAR(36),
ADD COLUMN IF NOT EXISTS supersedes_playbook_id VARCHAR(36),
ADD COLUMN IF NOT EXISTS version_reason TEXT;
""")
)
await conn.execute(text(
"CREATE INDEX IF NOT EXISTS ix_playbook_lineage "
"ON playbooks(parent_playbook_id, version);"
))
await conn.execute(text(
"CREATE INDEX IF NOT EXISTS ix_playbook_supersedes "
"ON playbooks(supersedes_playbook_id) WHERE supersedes_playbook_id IS NOT NULL;"
))
await conn.execute(text(
"UPDATE playbooks SET parent_playbook_id = playbook_id WHERE parent_playbook_id IS NULL;"
))
# 2026-04-15 ogt + Claude Sonnet 4.6(亞太): Phase 4 8D 感官升級
# ADR-084: EvidenceSnapshot 加入 Phase 4 動態異常上下文anomaly_context

View File

@@ -1005,6 +1005,10 @@ class PlaybookRecord(Base):
# Source tracing
source_incident_ids: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
parent_playbook_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
supersedes_playbook_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
version_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
ai_confidence: Mapped[float] = mapped_column(default=0.0, nullable=False)
# Stats — MUST be in PG (AI learning artifacts, cannot expire)
@@ -1048,6 +1052,7 @@ class PlaybookRecord(Base):
Index("ix_playbook_status", "status"),
Index("ix_playbook_trust_score", "trust_score"),
Index("ix_playbook_created_at", "created_at"),
Index("ix_playbook_lineage", "parent_playbook_id", "version"),
# W2 PR-L1: 快速查詢需要人工 review 的 Playbook預期數量少partial index 最省空間)
Index(
"ix_playbook_review_required",

View File

@@ -84,6 +84,12 @@ async def run_playbook_generation_governance_once(force: bool = False) -> Playbo
"notes": (playbook.notes or "") + "\n[Governance: REVIEW -> APPROVED]",
},
)
if playbook.supersedes_playbook_id:
await _deprecate_superseded(
service,
superseded_id=playbook.supersedes_playbook_id,
replacement_id=playbook.playbook_id,
)
report.approved_count += 1
_record_governance("approved")
else:
@@ -102,6 +108,24 @@ async def run_playbook_generation_governance_once(force: bool = False) -> Playbo
return report
async def _deprecate_superseded(service, superseded_id: str, replacement_id: str) -> None:
"""Deprecate an older Playbook version after replacement approval."""
superseded = await service.get_by_id(superseded_id)
if not superseded or superseded.status == PlaybookStatus.DEPRECATED:
return
await service.update_with_validation(
superseded_id,
{
"status": PlaybookStatus.DEPRECATED.value,
"notes": (
(superseded.notes or "")
+ f"\n[Governance: superseded by {replacement_id}]"
),
},
)
_record_governance("superseded")
async def run_playbook_generation_governance_loop() -> None:
"""Run governance forever at configured interval."""
while True:

View File

@@ -197,6 +197,10 @@ class Playbook(BaseModel):
default_factory=list,
description="萃取來源的 Incident ID",
)
version: int = Field(default=1, ge=1, description="Playbook lineage version")
parent_playbook_id: str | None = Field(None, description="Root Playbook ID for this lineage")
supersedes_playbook_id: str | None = Field(None, description="Previous Playbook version superseded by this one")
version_reason: str | None = Field(None, description="Why this version was created")
ai_confidence: float = Field(
default=0.0,
ge=0.0,
@@ -234,6 +238,10 @@ class Playbook(BaseModel):
default=False,
description="執行前是否需要 Pre-flight 備份檢查",
)
review_required: bool = Field(
default=False,
description="KM/治理累積觸發人工或 AI 複審信號",
)
# === 時間軸 ===
created_at: datetime = Field(default_factory=now_taipei)

View File

@@ -62,6 +62,10 @@ def _pydantic_to_orm(playbook: Playbook) -> PlaybookRecord:
repair_steps=[s.model_dump() for s in playbook.repair_steps],
estimated_duration_minutes=playbook.estimated_duration_minutes,
source_incident_ids=playbook.source_incident_ids,
version=playbook.version,
parent_playbook_id=playbook.parent_playbook_id,
supersedes_playbook_id=playbook.supersedes_playbook_id,
version_reason=playbook.version_reason,
ai_confidence=playbook.ai_confidence,
success_count=playbook.success_count,
failure_count=playbook.failure_count,
@@ -74,6 +78,7 @@ def _pydantic_to_orm(playbook: Playbook) -> PlaybookRecord:
requires_approval_level=playbook.requires_approval_level,
stateful_targets=playbook.stateful_targets,
requires_pre_backup=playbook.requires_pre_backup,
review_required=playbook.review_required,
created_at=playbook.created_at,
updated_at=playbook.updated_at,
)
@@ -107,6 +112,10 @@ def _orm_to_pydantic(record: PlaybookRecord) -> Playbook:
"repair_steps": normalized_steps,
"estimated_duration_minutes": record.estimated_duration_minutes,
"source_incident_ids": record.source_incident_ids,
"version": record.version,
"parent_playbook_id": record.parent_playbook_id,
"supersedes_playbook_id": record.supersedes_playbook_id,
"version_reason": record.version_reason,
"ai_confidence": float(record.ai_confidence),
"success_count": record.success_count,
"failure_count": record.failure_count,
@@ -119,6 +128,7 @@ def _orm_to_pydantic(record: PlaybookRecord) -> Playbook:
"requires_approval_level": record.requires_approval_level,
"stateful_targets": record.stateful_targets,
"requires_pre_backup": record.requires_pre_backup,
"review_required": record.review_required,
"created_at": record.created_at,
"updated_at": record.updated_at,
})
@@ -531,6 +541,10 @@ class PlaybookRepository:
repair_steps=[s.model_dump() for s in playbook.repair_steps],
estimated_duration_minutes=playbook.estimated_duration_minutes,
source_incident_ids=playbook.source_incident_ids,
version=playbook.version,
parent_playbook_id=playbook.parent_playbook_id,
supersedes_playbook_id=playbook.supersedes_playbook_id,
version_reason=playbook.version_reason,
ai_confidence=playbook.ai_confidence,
success_count=playbook.success_count,
failure_count=playbook.failure_count,
@@ -543,6 +557,7 @@ class PlaybookRepository:
requires_approval_level=playbook.requires_approval_level,
stateful_targets=playbook.stateful_targets,
requires_pre_backup=playbook.requires_pre_backup,
review_required=playbook.review_required,
created_at=playbook.created_at,
updated_at=playbook.updated_at,
).on_conflict_do_update(
@@ -556,6 +571,10 @@ class PlaybookRepository:
"repair_steps": [s.model_dump() for s in playbook.repair_steps],
"estimated_duration_minutes": playbook.estimated_duration_minutes,
"source_incident_ids": playbook.source_incident_ids,
"version": playbook.version,
"parent_playbook_id": playbook.parent_playbook_id,
"supersedes_playbook_id": playbook.supersedes_playbook_id,
"version_reason": playbook.version_reason,
"ai_confidence": playbook.ai_confidence,
"success_count": playbook.success_count,
"failure_count": playbook.failure_count,
@@ -568,6 +587,7 @@ class PlaybookRepository:
"requires_approval_level": playbook.requires_approval_level,
"stateful_targets": playbook.stateful_targets,
"requires_pre_backup": playbook.requires_pre_backup,
"review_required": playbook.review_required,
"updated_at": playbook.updated_at,
},
)

View File

@@ -179,10 +179,31 @@ class LLMPlaybookGenerator:
playbook.status = PlaybookStatus.DRAFT
if persist:
playbook = await self._service().create(playbook)
playbook = await self._persist_with_lineage(playbook)
return self._record(playbook, "success", provider, "")
async def _persist_with_lineage(self, playbook: Playbook) -> Playbook:
"""Create a new lineage version when a close approved Playbook exists."""
try:
recommendations = await self._service().get_recommendations(
symptoms=playbook.symptom_pattern,
top_k=1,
use_rag=False,
)
if recommendations and recommendations[0].similarity_score >= 0.85:
base = recommendations[0].playbook
created = await self._service().create_new_version(
base_playbook_id=base.playbook_id,
candidate=playbook,
reason="ADR-104 local LLM generated improved Playbook from successful incident",
)
if created is not None:
return created
except Exception as exc:
logger.warning("playbook_generation_lineage_fallback", error=str(exc))
return await self._service().create(playbook)
async def _call_local_llm(
self,
prompt: str,

View File

@@ -28,6 +28,7 @@ from src.models.playbook import (
RepairStep,
RiskLevel,
SymptomPattern,
generate_playbook_id,
)
from src.repositories.interfaces import IPlaybookRepository
from src.repositories.playbook_repository import get_playbook_repository
@@ -79,10 +80,32 @@ class IPlaybookService(Protocol):
self,
symptoms: SymptomPattern,
top_k: int = 3,
use_rag: bool = True,
) -> list[PlaybookRecommendation]:
"""取得 Playbook 推薦"""
...
async def get_by_id(self, playbook_id: str) -> Playbook | None:
"""取得 Playbook"""
...
async def create_new_version(
self,
base_playbook_id: str,
candidate: Playbook,
reason: str,
) -> Playbook | None:
"""從既有 Playbook 建立下一版"""
...
async def update_with_validation(
self,
playbook_id: str,
update_data: dict,
) -> Playbook | None:
"""驗證後更新 Playbook"""
...
async def approve(
self,
playbook_id: str,
@@ -463,6 +486,51 @@ class PlaybookService:
"""更新 Playbook"""
return await self._repository.update(playbook)
async def create_new_version(
self,
base_playbook_id: str,
candidate: Playbook,
reason: str,
) -> Playbook | None:
"""
從既有 Playbook 建立下一版。
ADR-104 T4: LLM 生成的改良方案不覆蓋舊 Playbook而是建立 lineage
root(parent_playbook_id) -> v2 -> v3。舊版在新版 APPROVED 前仍可用。
"""
base = await self._repository.get_by_id(base_playbook_id)
if not base:
logger.warning("playbook_version_base_missing", base_playbook_id=base_playbook_id)
return None
root_id = base.parent_playbook_id or base.playbook_id
candidate.playbook_id = generate_playbook_id()
candidate.version = base.version + 1
candidate.parent_playbook_id = root_id
candidate.supersedes_playbook_id = base.playbook_id
candidate.version_reason = reason[:500]
candidate.success_count = 0
candidate.failure_count = 0
candidate.last_used_at = None
candidate.approved_by = None
candidate.approved_at = None
if base.playbook_id not in candidate.source_incident_ids:
candidate.notes = (
(candidate.notes or "")
+ f"\n[Version lineage: v{candidate.version} supersedes {base.playbook_id}]"
).strip()
created = await self._repository.create(candidate)
logger.info(
"playbook_version_created",
playbook_id=created.playbook_id,
base_playbook_id=base.playbook_id,
root_playbook_id=root_id,
version=created.version,
reason=reason,
)
return created
async def update_with_validation(
self,
playbook_id: str,

View File

@@ -1,6 +1,15 @@
from src.jobs.playbook_generation_governance_job import run_playbook_generation_governance_once
from src.models.incident import Incident, IncidentOutcome, IncidentStatus, Severity, Signal
from src.models.playbook import ActionType, PlaybookStatus, RepairStep, RiskLevel
from src.models.playbook import (
ActionType,
Playbook,
PlaybookRecommendation,
PlaybookStatus,
RepairStep,
RiskLevel,
SymptomPattern,
generate_playbook_id,
)
from src.services.playbook_generator import LLMPlaybookGenerator
from src.utils.timezone import now_taipei
@@ -13,6 +22,44 @@ class InMemoryPlaybookService:
self.items[playbook.playbook_id] = playbook
return playbook
async def get_by_id(self, playbook_id):
return self.items.get(playbook_id)
async def get_recommendations(self, symptoms, top_k=3, use_rag=True):
recommendations = []
for playbook in self.items.values():
if playbook.status != PlaybookStatus.APPROVED:
continue
alert_match = set(symptoms.alert_names) & set(playbook.symptom_pattern.alert_names)
service_match = set(symptoms.affected_services) & set(playbook.symptom_pattern.affected_services)
if alert_match and service_match:
recommendations.append(
PlaybookRecommendation(
playbook=playbook,
similarity_score=1.0,
matched_symptoms=[
*(f"Alert: {name}" for name in alert_match),
*(f"Service: {name}" for name in service_match),
],
reason="test exact match",
)
)
return recommendations[:top_k]
async def create_new_version(self, base_playbook_id, candidate, reason):
base = self.items.get(base_playbook_id)
if base is None:
return None
candidate.playbook_id = generate_playbook_id()
candidate.version = base.version + 1
candidate.parent_playbook_id = base.parent_playbook_id or base.playbook_id
candidate.supersedes_playbook_id = base.playbook_id
candidate.version_reason = reason
candidate.success_count = 0
candidate.failure_count = 0
self.items[candidate.playbook_id] = candidate
return candidate
async def list_playbooks(self, status=None, tags=None, limit=20, offset=0):
values = list(self.items.values())
if status is not None:
@@ -29,6 +76,33 @@ class InMemoryPlaybookService:
return playbook
def make_approved_api_playbook() -> Playbook:
return Playbook(
playbook_id="PB-APPROVED-API",
name="Approved API recovery",
description="Existing approved recovery for API error rate",
status=PlaybookStatus.APPROVED,
symptom_pattern=SymptomPattern(
alert_names=["ApiErrorRateHigh"],
affected_services=["awoooi-api"],
severity_range=["P2"],
),
repair_steps=[
RepairStep(
step_number=1,
action_type=ActionType.KUBECTL,
command="kubectl rollout restart deployment/awoooi-api -n awoooi-prod",
expected_result="new pods become ready",
risk_level=RiskLevel.MEDIUM,
)
],
ai_confidence=0.91,
success_count=12,
failure_count=1,
tags=["api", "rollout"],
)
def make_resolved_incident(action: str = "kubectl rollout restart deployment/awoooi-api -n awoooi-prod") -> Incident:
return Incident(
incident_id="INC-20260430-PLAYBK",
@@ -120,6 +194,22 @@ async def test_llm_playbook_generator_creates_review_playbook():
assert result.playbook.repair_steps[0].command == "kubectl rollout restart deployment/awoooi-api -n awoooi-prod"
async def test_llm_playbook_generator_creates_lineage_version_for_similar_playbook():
service = InMemoryPlaybookService()
base = await service.create(make_approved_api_playbook())
generator = LLMPlaybookGenerator(playbook_service=service, llm_callable=local_llm_ok)
result = await generator.generate_from_incident(make_resolved_incident())
assert result.outcome == "success"
assert result.playbook is not None
assert result.playbook.playbook_id != base.playbook_id
assert result.playbook.version == 2
assert result.playbook.parent_playbook_id == base.playbook_id
assert result.playbook.supersedes_playbook_id == base.playbook_id
assert result.playbook.status == PlaybookStatus.REVIEW
async def test_llm_playbook_generator_downgrades_unsafe_kubectl_to_manual():
service = InMemoryPlaybookService()
generator = LLMPlaybookGenerator(playbook_service=service, llm_callable=local_llm_unsafe)
@@ -153,3 +243,26 @@ async def test_playbook_generation_governance_promotes_review_to_approved(monkey
assert report.approved_count == 1
assert service.items[result.playbook.playbook_id].status == PlaybookStatus.APPROVED
async def test_playbook_generation_governance_deprecates_superseded_version(monkeypatch):
service = InMemoryPlaybookService()
base = await service.create(make_approved_api_playbook())
generator = LLMPlaybookGenerator(playbook_service=service, llm_callable=local_llm_ok)
result = await generator.generate_from_incident(make_resolved_incident())
assert result.playbook is not None
result.playbook.ai_confidence = 0.93
class FakeSettings:
ENABLE_PLAYBOOK_DRAFT_GOVERNANCE_JOB = True
import src.jobs.playbook_generation_governance_job as job
monkeypatch.setattr(job, "settings", FakeSettings())
monkeypatch.setattr("src.services.playbook_service.get_playbook_service", lambda: service)
report = await run_playbook_generation_governance_once()
assert report.approved_count == 1
assert service.items[result.playbook.playbook_id].status == PlaybookStatus.APPROVED
assert service.items[base.playbook_id].status == PlaybookStatus.DEPRECATED

View File

@@ -326,6 +326,39 @@ class TestPlaybookService:
updated = await service.get_by_id(playbook.playbook_id)
assert updated.success_count == 11
@pytest.mark.asyncio
async def test_create_new_version_preserves_lineage(self, service, mock_repo):
"""Test LLM-generated improvements create a new Playbook lineage version."""
base = create_test_playbook(
playbook_id="PB-BASE-001",
status=PlaybookStatus.APPROVED,
success_count=20,
failure_count=1,
)
candidate = create_test_playbook(
playbook_id="PB-CANDIDATE-001",
status=PlaybookStatus.REVIEW,
success_count=7,
failure_count=3,
)
await mock_repo.create(base)
created = await service.create_new_version(
base_playbook_id=base.playbook_id,
candidate=candidate,
reason="generated from successful incident",
)
assert created is not None
assert created.playbook_id not in {base.playbook_id, "PB-CANDIDATE-001"}
assert created.version == 2
assert created.parent_playbook_id == base.playbook_id
assert created.supersedes_playbook_id == base.playbook_id
assert created.version_reason == "generated from successful incident"
assert created.success_count == 0
assert created.failure_count == 0
assert "supersedes PB-BASE-001" in (created.notes or "")
@pytest.mark.asyncio
async def test_playbook_success_rate(self):
"""Test success rate calculation"""

View File

@@ -6,6 +6,23 @@
---
## 2026-04-30 | ADR-104 Playbook 版本化 lineage
承接「自動建立 Playbook」第二段讓 LLM 生成的改良 Playbook 不覆蓋舊知識,而是形成可審核、可追溯、可替換的版本鏈。
### 完成
- 新增 Playbook lineage 欄位:`version``parent_playbook_id``supersedes_playbook_id``version_reason`
- 新增 migration `adr104_playbook_versioning.sql`,既有 Playbook 回填 root lineage並加 lineage / supersedes index。
- `LLMPlaybookGenerator` 生成成功後會先查相似 approved Playbook相似度足夠時建立 v2而不是直接新增孤立 Playbook。
- Governance job 在 REVIEW→APPROVED 後,會將被取代的舊版本標記為 `DEPRECATED`,保留版本證據鏈。
- shared-types schema/types 已同步,避免後端模型新增欄位後 Type Sync 失敗。
- 修復 Gitea migration workflow移除缺 node/curl 的 `postgres:15-alpine` job container改由 runner 環境安裝/檢查 `psql``jq``curl`
### 驗證
- `python3 -m py_compile` 針對 Playbook model/db/repository/service/generator/governance 通過。
- `pytest apps/api/tests/test_playbook_generator.py apps/api/tests/test_playbook_service.py apps/api/tests/test_action_parser_safety.py -q` → 46 passed。
- Prod DB 已手動套用 additive migration確認 `playbooks` 欄位與 `ix_playbook_lineage` / `ix_playbook_supersedes` index 存在。
## 2026-04-30 | Auto Repair 緊急介入補洞 — rule-first + host SSH
統帥批准繼續推進「所有異常要自動修復無法自修復要有緊急通道」。Live 盤查確認 AI 診斷不是全死,而是主機/備份/磁碟告警常在 `auto_repair=false`、Phase2 空動作、或 LLM 產生 K8s 垃圾 target 後,只回到人工卡。

View File

@@ -1997,6 +1997,52 @@
"title": "Source Incident Ids",
"type": "array"
},
"version": {
"default": 1,
"description": "Playbook lineage version",
"minimum": 1,
"title": "Version",
"type": "integer"
},
"parent_playbook_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Root Playbook ID for this lineage",
"title": "Parent Playbook Id"
},
"supersedes_playbook_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Previous Playbook version superseded by this one",
"title": "Supersedes Playbook Id"
},
"version_reason": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Why this version was created",
"title": "Version Reason"
},
"ai_confidence": {
"default": 0.0,
"description": "AI 萃取信心度",
@@ -2109,6 +2155,12 @@
"title": "Requires Pre Backup",
"type": "boolean"
},
"review_required": {
"default": false,
"description": "KM/治理累積觸發人工或 AI 複審信號",
"title": "Review Required",
"type": "boolean"
},
"created_at": {
"format": "date-time",
"title": "Created At",

View File

@@ -668,6 +668,22 @@ export type EstimatedDurationMinutes = number;
* 萃取來源的 Incident ID
*/
export type SourceIncidentIds = string[];
/**
* Playbook lineage version
*/
export type Version = number;
/**
* Root Playbook ID for this lineage
*/
export type ParentPlaybookId = string | null;
/**
* Previous Playbook version superseded by this one
*/
export type SupersedesPlaybookId = string | null;
/**
* Why this version was created
*/
export type VersionReason = string | null;
/**
* AI 萃取信心度
*/
@@ -716,6 +732,10 @@ export type StatefulTargets = string[];
* 執行前是否需要 Pre-flight 備份檢查
*/
export type RequiresPreBackup = boolean;
/**
* KM/治理累積觸發人工或 AI 複審信號
*/
export type ReviewRequired = boolean;
export type CreatedAt6 = string;
export type UpdatedAt3 = string;
export type SuccessRate = number;
@@ -1420,6 +1440,10 @@ export interface Playbook {
repair_steps?: RepairSteps;
estimated_duration_minutes?: EstimatedDurationMinutes;
source_incident_ids?: SourceIncidentIds;
version?: Version;
parent_playbook_id?: ParentPlaybookId;
supersedes_playbook_id?: SupersedesPlaybookId;
version_reason?: VersionReason;
ai_confidence?: AiConfidence;
success_count?: SuccessCount;
failure_count?: FailureCount;
@@ -1432,6 +1456,7 @@ export interface Playbook {
requires_approval_level?: RequiresApprovalLevel;
stateful_targets?: StatefulTargets;
requires_pre_backup?: RequiresPreBackup;
review_required?: ReviewRequired;
created_at?: CreatedAt6;
updated_at?: UpdatedAt3;
[k: string]: unknown;