1use super::*;
2use ahri_tre_protocol::{asset::AssetSelector, lifecycle as wire, refs::ObjectKind};
3use ahri_tre_types::AssetType;
4
5#[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 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 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 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}