Skip to main content

ahri_tre_app/service/
lifecycle.rs

1use super::*;
2use ahri_tre_protocol::{asset::AssetSelector, lifecycle as wire, refs::ObjectKind};
3use ahri_tre_types::AssetType;
4
5/// Lifecycle intents admitted through one selector and authorization boundary.
6#[derive(Clone)]
7pub enum GovernedDeleteRequest {
8    Study(wire::StudyDeleteRequest),
9    Asset(wire::AssetDeleteRequest),
10    DataFile(wire::DataFileDeleteRequest),
11    Dataset(wire::DatasetDeleteRequest),
12}
13
14pub struct GovernedDeletePlan {
15    pub planning: ArchiveDeletePlanningResult,
16    pub target: ahri_tre_protocol::refs::ObjectRef,
17    pub kind: wire::DeleteWorkflowResultKind,
18}
19
20impl GovernedDeleteRequest {
21    fn intent(&self) -> &wire::DestructiveIntent {
22        match self {
23            Self::Study(r) => &r.intent,
24            Self::Asset(r) => &r.intent,
25            Self::DataFile(r) => &r.intent,
26            Self::Dataset(r) => &r.intent,
27        }
28    }
29
30    fn policy(&self) -> &wire::ArchiveDeletePolicy {
31        match self {
32            Self::Study(r) => &r.policy,
33            Self::Asset(r) => &r.policy,
34            Self::DataFile(r) => &r.policy,
35            Self::Dataset(r) => &r.policy,
36        }
37    }
38}
39
40impl AppService {
41    /// Resolve names, reference origin, supplied scopes and immutable versions
42    /// before planning. Execution calls this same boundary once and retains the
43    /// resulting target throughout archive and cleanup.
44    pub async fn plan_governed_delete(
45        &self,
46        request: &GovernedDeleteRequest,
47        authenticated_actor: &str,
48    ) -> Result<GovernedDeletePlan, AppError> {
49        let intent = request.intent();
50        let actor = lifecycle_actor(intent, authenticated_actor)?;
51        let policy = request.policy();
52        let ds = self.catalogue_datastore_id()?;
53        let (target, version_target, all, archive_mode, kind, reference) = match request {
54            GovernedDeleteRequest::Study(request) => {
55                let study = self
56                    .resolve_study_selector(
57                        self.catalogue_study_selector(request.study.clone())?,
58                        "deletion Study",
59                    )
60                    .await?;
61                (
62                    DeleteTarget::Study(study.study_id),
63                    None,
64                    false,
65                    if policy.archive {
66                        ArchiveMode::StudyArchive
67                    } else {
68                        ArchiveMode::None
69                    },
70                    wire::DeleteWorkflowResultKind::Study,
71                    crate::projections::object_ref(ds, ObjectKind::Study, study.study_id.0),
72                )
73            }
74            _ => {
75                let (selector, preconditions) = match request {
76                    GovernedDeleteRequest::Asset(r) => (r.asset.clone(), None),
77                    GovernedDeleteRequest::DataFile(r) => (
78                        datafile_asset_selector(r.datafile.clone()),
79                        Some(&r.preconditions),
80                    ),
81                    GovernedDeleteRequest::Dataset(r) => (
82                        dataset_asset_selector(r.dataset.clone()),
83                        Some(&r.preconditions),
84                    ),
85                    GovernedDeleteRequest::Study(_) => unreachable!(),
86                };
87                let (_, catalog, pinned) = self.resolve_catalogue_asset(selector).await?;
88                let version = preconditions
89                    .and_then(|p| p.version.as_deref())
90                    .map(str::trim);
91                let all = preconditions.is_none_or(|p| p.all_versions) || version == Some("all");
92                if all {
93                    if pinned.is_some() || version.is_some_and(|v| v != "all") {
94                        return Err(AppError::Conflict(
95                            "Whole-Asset and version-specific intents conflict".into(),
96                        ));
97                    }
98                    if !policy.cascade {
99                        return Err(AppError::Validation(
100                            "Whole-Asset deletion requires cascade and all-version intent".into(),
101                        ));
102                    }
103                    (
104                        DeleteTarget::Asset(catalog.asset.asset_id),
105                        None,
106                        true,
107                        ArchiveMode::AssetArchive,
108                        wire::DeleteWorkflowResultKind::Asset,
109                        crate::projections::object_ref(
110                            ds,
111                            ObjectKind::Asset,
112                            catalog.asset.asset_id.0,
113                        ),
114                    )
115                } else {
116                    if pinned.is_none() && version.is_none() {
117                        return Err(AppError::Validation(
118                            "Version deletion requires an explicit version target".into(),
119                        ));
120                    }
121                    let version = self.catalogue_version(&catalog, pinned, version)?;
122                    (
123                        DeleteTarget::AssetVersion(version.version_id),
124                        Some(ArchiveDeleteVersionTarget::Version {
125                            version_id: version.version_id,
126                        }),
127                        false,
128                        ArchiveMode::AssetArchive,
129                        if catalog.asset.asset_type == AssetType::File {
130                            wire::DeleteWorkflowResultKind::DataFile
131                        } else {
132                            wire::DeleteWorkflowResultKind::Dataset
133                        },
134                        crate::projections::object_ref(
135                            ds,
136                            ObjectKind::AssetVersion,
137                            version.version_id.0,
138                        ),
139                    )
140                }
141            }
142        };
143        let planning = self
144            .plan_archive_delete(PlanArchiveDeleteRequest {
145                target,
146                archive_mode,
147                cascade: policy.cascade,
148                force: policy.force,
149                reason: intent.reason.clone(),
150                actor: Some(actor),
151                version_target,
152                delete_all_versions: all,
153                dry_run: intent.dry_run,
154            })
155            .await?;
156        Ok(GovernedDeletePlan {
157            planning,
158            target: reference,
159            kind,
160        })
161    }
162}
163
164pub(super) fn lifecycle_actor(
165    intent: &wire::DestructiveIntent,
166    authenticated: &str,
167) -> Result<String, AppError> {
168    let authenticated = authenticated.trim();
169    if authenticated.is_empty() {
170        return Err(AppError::Validation(
171            "An authenticated TRE user is required".into(),
172        ));
173    }
174    if intent
175        .actor
176        .as_deref()
177        .map(str::trim)
178        .filter(|actor| !actor.is_empty())
179        .is_some_and(|actor| actor != authenticated)
180    {
181        return Err(AppError::Validation(
182            "Supplied actor differs from the authenticated TRE user".into(),
183        ));
184    }
185    Ok(authenticated.to_owned())
186}
187
188pub(super) fn dataset_asset_selector(
189    selector: ahri_tre_protocol::dataset::DatasetSelector,
190) -> AssetSelector {
191    use ahri_tre_protocol::dataset::DatasetSelector;
192    match selector {
193        DatasetSelector::Id { dataset, study } => AssetSelector::Id {
194            asset: dataset,
195            study,
196            asset_type: Some(AssetType::Dataset),
197        },
198        DatasetSelector::Name { study, name } => AssetSelector::Name {
199            study,
200            name,
201            asset_type: Some(AssetType::Dataset),
202        },
203    }
204}
205
206fn datafile_asset_selector(
207    selector: ahri_tre_protocol::datafile::DataFileSelector,
208) -> AssetSelector {
209    use ahri_tre_protocol::datafile::DataFileSelector;
210    match selector {
211        DataFileSelector::Id { datafile, study } => AssetSelector::Id {
212            asset: datafile,
213            study,
214            asset_type: Some(AssetType::File),
215        },
216        DataFileSelector::Name { study, name } => AssetSelector::Name {
217            study,
218            name,
219            asset_type: Some(AssetType::File),
220        },
221    }
222}
223
224pub enum GovernedDeleteOutcome {
225    Planned(Box<GovernedDeletePlan>),
226    Study(Box<DeleteArchivedStudyResult>),
227    Asset(Box<DeleteAssetResult>),
228    DataFile(Box<DeleteDataFileAssetVersionResult>),
229    Dataset(Box<DeleteDatasetAssetVersionResult>),
230    Withdrawal(Box<WithdrawDatasetVersionResult>),
231}
232
233impl AppService {
234    pub async fn execute_governed_delete(
235        &self,
236        session: &mut DataStoreSession,
237        request: GovernedDeleteRequest,
238        authenticated_actor: &str,
239    ) -> Result<GovernedDeleteOutcome, AppError> {
240        let resolved = self
241            .plan_governed_delete(&request, authenticated_actor)
242            .await?;
243        let _exclusion = match resolved.planning.plan.as_ref() {
244            Some(plan) => Some(self.lifecycle_exclusion(session, &plan.target).await?),
245            None => None,
246        };
247        if request.intent().dry_run {
248            return Ok(GovernedDeleteOutcome::Planned(Box::new(resolved)));
249        }
250        let plan = accepted_lifecycle_plan(resolved.planning)?;
251        if !request.intent().confirmed {
252            return Err(AppError::Validation(
253                "Deletion requires confirmation; inspect a dry run first".into(),
254            ));
255        }
256        match plan.target {
257            DeleteTarget::Study(study_id) => {
258                let archive = self
259                    .archive_study_for_deletion(
260                        session,
261                        ArchiveStudyForDeletionRequest {
262                            study_id,
263                            reason: plan.reason.clone(),
264                            actor: plan.actor.clone(),
265                            overwrite_existing_exports: true,
266                        },
267                    )
268                    .await?;
269                self.delete_archived_study(session, DeleteArchivedStudyRequest { plan, archive })
270                    .await
271                    .map(|result| GovernedDeleteOutcome::Study(Box::new(result)))
272            }
273            DeleteTarget::Asset(_) => self
274                .delete_asset(session, DeleteAssetRequest { plan })
275                .await
276                .map(|result| GovernedDeleteOutcome::Asset(Box::new(result))),
277            DeleteTarget::AssetVersion(_) => match resolved.kind {
278                wire::DeleteWorkflowResultKind::DataFile => self
279                    .delete_datafile_asset_version(
280                        session,
281                        DeleteDataFileAssetVersionRequest { plan },
282                    )
283                    .await
284                    .map(|result| GovernedDeleteOutcome::DataFile(Box::new(result))),
285                wire::DeleteWorkflowResultKind::Dataset => self
286                    .delete_dataset_asset_version(
287                        session,
288                        DeleteDatasetAssetVersionRequest { plan },
289                    )
290                    .await
291                    .map(|result| GovernedDeleteOutcome::Dataset(Box::new(result))),
292                _ => Err(AppError::Infrastructure(
293                    "Invalid resolved deletion kind".into(),
294                )),
295            },
296        }
297    }
298}
299
300fn accepted_lifecycle_plan(
301    planning: ArchiveDeletePlanningResult,
302) -> Result<ArchiveDeletePlan, AppError> {
303    if !planning.accepted {
304        return Err(AppError::Validation("Deletion plan was rejected".into()));
305    }
306    planning
307        .plan
308        .ok_or_else(|| AppError::Infrastructure("Accepted deletion plan is missing".into()))
309}
310
311impl AppService {
312    async fn lifecycle_exclusion(
313        &self,
314        session: &DataStoreSession,
315        target: &DeleteTarget,
316    ) -> Result<ahri_tre_pgmeta::PgStudyLifecycleGuard, AppError> {
317        let study_id = match target {
318            DeleteTarget::Study(id) => *id,
319            DeleteTarget::Asset(id) => {
320                self.assets
321                    .get_asset_by_id(*id)
322                    .await
323                    .map_err(|error| core_error("resolve lifecycle scope", error))?
324                    .ok_or_else(|| AppError::NotFound("Lifecycle target is unavailable".into()))?
325                    .study_id
326            }
327            DeleteTarget::AssetVersion(id) => {
328                self.catalog_for_version(*id)
329                    .await
330                    .map_err(|error| core_error("resolve lifecycle scope", error))?
331                    .asset
332                    .study_id
333            }
334        };
335        session.acquire_study_lifecycle(study_id)
336    }
337
338    /// A pinned reference is explicit version intent. A moving latest selector
339    /// is never valid for withdrawal, even when it currently matches the pin.
340    pub async fn plan_governed_withdrawal(
341        &self,
342        request: &wire::DatasetWithdrawRequest,
343        authenticated_actor: &str,
344    ) -> Result<WithdrawDatasetVersionRequest, AppError> {
345        self.resolve_governed_withdrawal(request, authenticated_actor)
346            .await
347            .map(|(request, _)| request)
348    }
349
350    async fn resolve_governed_withdrawal(
351        &self,
352        request: &wire::DatasetWithdrawRequest,
353        authenticated_actor: &str,
354    ) -> Result<(WithdrawDatasetVersionRequest, ahri_tre_types::VersionId), AppError> {
355        let actor = lifecycle_actor(&request.intent, authenticated_actor)?;
356        if request.intent.reason.trim().is_empty() || !request.force {
357            return Err(AppError::Validation(
358                "Dataset withdrawal requires a reason and force".into(),
359            ));
360        }
361        if request
362            .version
363            .as_deref()
364            .is_some_and(|v| v.trim().eq_ignore_ascii_case("latest"))
365        {
366            return Err(AppError::Validation(
367                "Dataset withdrawal requires a concrete version, not latest".into(),
368            ));
369        }
370        let (study, catalog, pinned) = self
371            .resolve_catalogue_asset(dataset_asset_selector(request.dataset.clone()))
372            .await?;
373        if pinned.is_none() && request.version.is_none() {
374            return Err(AppError::Validation(
375                "Dataset withdrawal requires an explicit version".into(),
376            ));
377        }
378        let version = self.catalogue_version(&catalog, pinned, request.version.as_deref())?;
379        self.authorize_study_asset_lifecycle_for_app(
380            study.study_id,
381            Some(&actor),
382            "dataset withdrawal",
383        )
384        .await?;
385        self.assets
386            .get_dataset(version.version_id)
387            .await
388            .map_err(|error| core_error("resolve withdrawal Dataset", error))?
389            .ok_or_else(|| {
390                AppError::NotFound("Complete Dataset version is not available".into())
391            })?;
392        let version_id = version.version_id;
393        Ok((
394            WithdrawDatasetVersionRequest {
395                study_id: study.study_id,
396                dataset_name: catalog.asset.name,
397                version: format!("{}.{}.{}", version.major, version.minor, version.patch),
398                reason: request.intent.reason.clone(),
399                actor: Some(actor),
400                force: request.force,
401                drop_lake_table: request.drop_lake_table,
402            },
403            version_id,
404        ))
405    }
406
407    pub async fn execute_governed_withdrawal(
408        &self,
409        session: &mut DataStoreSession,
410        request: wire::DatasetWithdrawRequest,
411        authenticated_actor: &str,
412    ) -> Result<GovernedDeleteOutcome, AppError> {
413        let (resolved, version_id) = self
414            .resolve_governed_withdrawal(&request, authenticated_actor)
415            .await?;
416        let _exclusion = session.acquire_study_lifecycle(resolved.study_id)?;
417        if request.intent.dry_run {
418            // Withdrawal has no archive policy. The target is still the exact
419            // version selected above; this path performs no lifecycle writes.
420            let planning = self
421                .plan_archive_delete(PlanArchiveDeleteRequest {
422                    target: DeleteTarget::AssetVersion(version_id),
423                    archive_mode: ArchiveMode::None,
424                    cascade: false,
425                    force: true,
426                    reason: resolved.reason,
427                    actor: resolved.actor,
428                    version_target: Some(ArchiveDeleteVersionTarget::Version { version_id }),
429                    delete_all_versions: false,
430                    dry_run: true,
431                })
432                .await?;
433            return Ok(GovernedDeleteOutcome::Planned(Box::new(
434                GovernedDeletePlan {
435                    planning,
436                    target: crate::projections::object_ref(
437                        self.catalogue_datastore_id()?,
438                        ObjectKind::AssetVersion,
439                        version_id.0,
440                    ),
441                    kind: wire::DeleteWorkflowResultKind::DatasetWithdrawal,
442                },
443            )));
444        }
445        if !request.intent.confirmed {
446            return Err(AppError::Validation(
447                "Withdrawal requires confirmation".into(),
448            ));
449        }
450        self.withdraw_resolved_dataset_version(Some(session), resolved, Some(version_id))
451            .await
452            .map(|result| GovernedDeleteOutcome::Withdrawal(Box::new(result)))
453    }
454}