use std::collections::{BTreeMap, BTreeSet};
use super::{Rows, recovery_error, storage_error};
use crate::core::backup::{
ARTIFACT_REGISTRY_SCHEMA, AUDIT_HISTORY_SCHEMA, BackupArtifactRegistry, BackupAuditHistory,
BackupRestoredDerivedAssetReport, BackupWorkHistory, WORK_HISTORY_CHUNK_ROWS,
read_restored_derived_json, task_episode_json,
};
use crate::db::{DbConnection, JournalEntryListFilter, StoredJournalEntry};
use crate::models::{DomainError, RedactionLevel};
use crate::policy::{InstructionRisk, detect_instruction_like_content};
const WORK_TABLES: &[&str] = &[
"journal_entries",
"search_index_jobs",
"task_episodes",
"artifacts",
"artifact_links",
];
pub(super) struct OperationalExpectation {
workspace_id: String,
rows: Rows,
audit_ids: BTreeSet<String>,
}
impl OperationalExpectation {
pub(super) fn from_assets(
assets: &[BackupRestoredDerivedAssetReport],
backup_id: &str,
workspace_id: &str,
) -> Result<Self, DomainError> {
let mut expected = Self {
workspace_id: workspace_id.to_owned(),
rows: Rows::default(),
audit_ids: BTreeSet::new(),
};
for kind in ["work_history", "artifact_registry", "audit_history"] {
let count = assets.iter().filter(|asset| asset.kind == kind).count();
let mut slots = BTreeSet::new();
let mut source_workspace: Option<String> = None;
for asset in assets.iter().filter(|asset| asset.kind == kind) {
let value = read_restored_derived_json(asset)?;
let (source, index, declared, lengths) = match kind {
"work_history" => {
let chunk: BackupWorkHistory = serde_json::from_value(value)
.map_err(|_| recovery_error("Invalid recovered work history"))?;
if chunk.schema != "ee.backup.work_history.v1" {
return Err(recovery_error("Unsupported recovered work history"));
}
let lengths = [chunk.journal_entries.len(), chunk.search_index_jobs.len()];
for mut row in chunk.journal_entries {
rebind(&mut row.workspace_id, &chunk.workspace_id, workspace_id)?;
rescreen_journal(&mut row)?;
expected
.rows
.insert("journal_entries", &row.entry_id, &row)?;
}
for mut row in chunk.search_index_jobs {
rebind(&mut row.workspace_id, &chunk.workspace_id, workspace_id)?;
if row.status == "running" {
row.status = "pending".to_owned();
row.documents_indexed = 0;
row.started_at = None;
row.completed_at = None;
row.error_message = None;
}
expected.rows.insert("search_index_jobs", &row.id, &row)?;
}
(
chunk.workspace_id,
chunk.chunk_index,
chunk.chunk_count,
lengths,
)
}
"artifact_registry" => {
let chunk: BackupArtifactRegistry = serde_json::from_value(value)
.map_err(|_| recovery_error("Invalid recovered artifact registry"))?;
if chunk.schema != ARTIFACT_REGISTRY_SCHEMA || chunk.backup_id != backup_id
{
return Err(recovery_error("Substituted recovered artifact registry"));
}
let lengths = [chunk.artifacts.len(), chunk.links.len()];
for entry in chunk.artifacts {
let mut row = entry.row;
rebind(&mut row.workspace_id, &chunk.workspace_id, workspace_id)?;
expected.rows.insert("artifacts", &row.id, &row)?;
}
for row in chunk.links {
expected.rows.insert(
"artifact_links",
&(
&row.artifact_id,
&row.target_type,
&row.target_id,
&row.relation,
),
&row,
)?;
}
(
chunk.workspace_id,
chunk.chunk_index,
chunk.chunk_count,
lengths,
)
}
_ => {
let chunk: BackupAuditHistory = serde_json::from_value(value)
.map_err(|_| recovery_error("Invalid recovered audit history"))?;
if chunk.schema != AUDIT_HISTORY_SCHEMA || chunk.backup_id != backup_id {
return Err(recovery_error("Substituted recovered audit history"));
}
let lengths = [chunk.rows.len(), 0];
for entry in chunk.rows {
let row = entry.row;
if row
.workspace_id
.as_deref()
.is_some_and(|id| id != workspace_id)
{
return Err(recovery_error("Foreign recovered audit history"));
}
expected.audit_ids.insert(row.id.clone());
expected.rows.insert("audit_log", &row.id, &row)?;
}
(
chunk.workspace_id,
chunk.chunk_index,
chunk.chunk_count,
lengths,
)
}
};
if declared != count
|| index >= count
|| !slots.insert(index)
|| source_workspace.as_deref().is_some_and(|id| id != source)
|| lengths.into_iter().any(|n| n > WORK_HISTORY_CHUNK_ROWS)
{
return Err(recovery_error(
"Incomplete or foreign recovered work evidence",
));
}
source_workspace = Some(source);
}
}
for asset in assets.iter().filter(|asset| asset.kind == "lab_episode") {
let value = read_restored_derived_json(asset)?;
if value.get("schema").and_then(serde_json::Value::as_str)
!= Some("ee.backup.derived.lab_episode.v1")
{
return Err(recovery_error("Unsupported recovered task episode"));
}
let row = value
.get("episode")
.filter(|row| row.is_object())
.ok_or_else(|| recovery_error("Invalid recovered task episode"))?;
let id = row
.get("id")
.and_then(serde_json::Value::as_str)
.filter(|id| !id.is_empty())
.ok_or_else(|| recovery_error("Missing recovered task identity"))?
.to_owned();
if row.get("workspaceId").and_then(serde_json::Value::as_str) != Some(workspace_id) {
return Err(recovery_error("Foreign recovered task episode"));
}
expected.rows.insert("task_episodes", &id, row)?;
}
Ok(expected)
}
pub(super) fn verify_connection(&self, db: &DbConnection) -> Result<(), DomainError> {
let mut actual = Rows::default();
for row in db
.list_journal_entries(
&self.workspace_id,
&JournalEntryListFilter {
limit: u32::MAX,
..Default::default()
},
)
.map_err(storage_error)?
{
actual.insert("journal_entries", &row.entry_id, &row)?;
}
for row in db
.list_search_index_jobs(&self.workspace_id, None)
.map_err(storage_error)?
{
actual.insert("search_index_jobs", &row.id, &row)?;
}
for row in db
.list_task_episodes(Some(&self.workspace_id), None, u32::MAX)
.map_err(storage_error)?
{
let value = task_episode_json(&row, "", RedactionLevel::None, &BTreeMap::new());
actual.insert("task_episodes", &row.id, &value["episode"])?;
}
for row in db
.list_artifacts(&self.workspace_id, None)
.map_err(storage_error)?
{
for link in db.list_artifact_links(&row.id).map_err(storage_error)? {
actual.insert(
"artifact_links",
&(
&link.artifact_id,
&link.target_type,
&link.target_id,
&link.relation,
),
&link,
)?;
}
actual.insert("artifacts", &row.id, &row)?;
}
self.rows.verify_complete(&actual, db, WORK_TABLES)?;
for id in &self.audit_ids {
let row = db
.get_audit(id)
.map_err(storage_error)?
.ok_or_else(|| recovery_error("Restored durable content differs for audit_log"))?;
actual.insert("audit_log", &row.id, &row)?;
}
self.rows.verify(&actual, &["audit_log"])
}
}
fn rebind(value: &mut String, source: &str, target: &str) -> Result<(), DomainError> {
if value.as_str() != source {
return Err(recovery_error("Foreign recovered work row"));
}
*value = target.to_owned();
Ok(())
}
fn rescreen_journal(row: &mut StoredJournalEntry) -> Result<(), DomainError> {
let recorded = match row.instruction_risk.as_str() {
"none" => InstructionRisk::None,
"low" => InstructionRisk::Low,
"medium" => InstructionRisk::Medium,
"high" => InstructionRisk::High,
_ => return Err(recovery_error("Invalid recovered journal risk")),
};
let content = detect_instruction_like_content(&row.body).risk;
let structured = row
.structured
.as_deref()
.map_or(InstructionRisk::None, |text| {
detect_instruction_like_content(text).risk
});
row.instruction_risk = recorded.max(content).max(structured).as_str().to_owned();
Ok(())
}
#[cfg(test)]
#[path = "backup_operational_recovery_tests.rs"]
mod tests;