Skip to main content

ahri_tre_app/service/
semantic.rs

1//! Authenticated semantic catalogue workflows. Public references are decoded at
2//! the retained Session boundary; repositories remain the visibility authority.
3use super::*;
4use ahri_tre_protocol::{
5    dictionary, model,
6    refs::{ObjectKind, ObjectRef},
7};
8
9impl AppService {
10    pub(crate) fn semantic_integer_id(
11        &self,
12        reference: ObjectRef,
13        kind: ObjectKind,
14        scope: &str,
15    ) -> Result<i64, AppError> {
16        let id = self.catalogue_reference_id(reference, kind)?;
17        ahri_tre_protocol::refs::decode_scoped_integer_ref(scope, id)
18            .filter(|id| *id > 0)
19            .ok_or_else(|| AppError::Validation("Invalid semantic reference".into()))
20    }
21
22    pub(super) async fn catalogue_variable_selector(
23        &self,
24        selector: dictionary::VariableSelector,
25    ) -> Result<VariableSelector, AppError> {
26        let (selector, constraint) = match selector {
27            dictionary::VariableSelector::Id { variable, domain } => (
28                VariableSelector::Id {
29                    variable_id: ahri_tre_types::VariableId(self.semantic_integer_id(
30                        variable,
31                        ObjectKind::Variable,
32                        "variable",
33                    )?),
34                },
35                domain,
36            ),
37            dictionary::VariableSelector::Name { domain, name } => (
38                VariableSelector::Name {
39                    domain: self.catalogue_domain_selector(domain)?,
40                    name: name.into(),
41                },
42                None,
43            ),
44        };
45        if let Some(domain) = constraint {
46            let domain = self
47                .resolve_domain_selector(
48                    self.catalogue_domain_selector(domain)?,
49                    "semantic Domain constraint",
50                )
51                .await?;
52            let target = self
53                .resolve_variable_selector_optional(selector.clone())
54                .await?
55                .ok_or_else(|| {
56                    AppError::NotFound("Semantic definition was not found or is not visible".into())
57                })?;
58            if target.domain_id != domain.domain_id {
59                return Err(AppError::Conflict(
60                    "Semantic definition and Domain selectors disagree".into(),
61                ));
62            }
63        }
64        Ok(selector)
65    }
66
67    pub(super) async fn catalogue_vocabulary_selector(
68        &self,
69        selector: dictionary::VocabularySelector,
70    ) -> Result<VocabularySelector, AppError> {
71        let (selector, constraint) = match selector {
72            dictionary::VocabularySelector::Id { vocabulary, domain } => (
73                VocabularySelector::Id {
74                    vocabulary_id: ahri_tre_types::VocabularyId(self.semantic_integer_id(
75                        vocabulary,
76                        ObjectKind::Vocabulary,
77                        "vocabulary",
78                    )?),
79                },
80                domain,
81            ),
82            dictionary::VocabularySelector::Name { domain, name } => (
83                VocabularySelector::Name {
84                    domain: self.catalogue_domain_selector(domain)?,
85                    name: name.into(),
86                },
87                None,
88            ),
89        };
90        if let Some(domain) = constraint {
91            let domain = self
92                .resolve_domain_selector(
93                    self.catalogue_domain_selector(domain)?,
94                    "semantic Domain constraint",
95                )
96                .await?;
97            let target = self
98                .resolve_vocabulary_selector_optional(selector.clone())
99                .await?
100                .ok_or_else(|| {
101                    AppError::NotFound("Semantic definition was not found or is not visible".into())
102                })?;
103            if target.domain_id != domain.domain_id {
104                return Err(AppError::Conflict(
105                    "Semantic definition and Domain selectors disagree".into(),
106                ));
107            }
108        }
109        Ok(selector)
110    }
111
112    pub(super) async fn catalogue_entity_selector(
113        &self,
114        selector: model::EntitySelector,
115    ) -> Result<EntitySelector, AppError> {
116        let (selector, constraint) = match selector {
117            model::EntitySelector::Id { entity, domain } => (
118                EntitySelector::Id {
119                    entity_id: ahri_tre_types::EntityId(self.semantic_integer_id(
120                        entity,
121                        ObjectKind::Entity,
122                        "entity",
123                    )?),
124                },
125                domain,
126            ),
127            model::EntitySelector::Name { domain, name } => (
128                EntitySelector::Name {
129                    domain: self.catalogue_domain_selector(domain)?,
130                    name: name.into(),
131                },
132                None,
133            ),
134        };
135        if let Some(domain) = constraint {
136            let domain = self
137                .resolve_domain_selector(
138                    self.catalogue_domain_selector(domain)?,
139                    "semantic Domain constraint",
140                )
141                .await?;
142            let target = self
143                .resolve_entity_selector_optional(selector.clone())
144                .await?
145                .ok_or_else(|| {
146                    AppError::NotFound("Semantic definition was not found or is not visible".into())
147                })?;
148            if target.domain_id != domain.domain_id {
149                return Err(AppError::Conflict(
150                    "Semantic definition and Domain selectors disagree".into(),
151                ));
152            }
153        }
154        Ok(selector)
155    }
156
157    pub(super) async fn catalogue_relation_selector(
158        &self,
159        selector: model::RelationSelector,
160    ) -> Result<EntityRelationSelector, AppError> {
161        let (selector, constraint) = match selector {
162            model::RelationSelector::Id { relation, domain } => (
163                EntityRelationSelector::Id {
164                    entity_relation_id: ahri_tre_types::EntityRelationId(
165                        self.semantic_integer_id(relation, ObjectKind::Relation, "relation")?,
166                    ),
167                },
168                domain,
169            ),
170            model::RelationSelector::Name { domain, name } => (
171                EntityRelationSelector::Name {
172                    domain: self.catalogue_domain_selector(domain)?,
173                    name: name.into(),
174                },
175                None,
176            ),
177        };
178        if let Some(domain) = constraint {
179            let domain = self
180                .resolve_domain_selector(
181                    self.catalogue_domain_selector(domain)?,
182                    "semantic Domain constraint",
183                )
184                .await?;
185            let target = self
186                .resolve_entity_relation_selector_optional(selector.clone())
187                .await?
188                .ok_or_else(|| {
189                    AppError::NotFound("Semantic definition was not found or is not visible".into())
190                })?;
191            if target.domain_id != domain.domain_id {
192                return Err(AppError::Conflict(
193                    "Semantic definition and Domain selectors disagree".into(),
194                ));
195            }
196        }
197        Ok(selector)
198    }
199
200    pub async fn list_catalogue_variables(
201        &self,
202        request: dictionary::ListVariablesRequest,
203    ) -> Result<dictionary::VariableListResponse, AppError> {
204        let values = self
205            .list_variables(ListVariablesRequest {
206                domain: request
207                    .domain
208                    .map(|v| self.catalogue_domain_selector(v))
209                    .transpose()?,
210                study: request
211                    .study
212                    .map(|v| self.catalogue_study_selector(v))
213                    .transpose()?,
214            })
215            .await?;
216        let mut variables = Vec::with_capacity(values.len());
217        for value in values {
218            variables.push(self.variable_response(value).await?.variable.summary);
219        }
220        Ok(dictionary::VariableListResponse {
221            variables,
222            warnings: None,
223        })
224    }
225
226    pub async fn get_catalogue_variable(
227        &self,
228        request: dictionary::GetVariableRequest,
229    ) -> Result<dictionary::VariableDetailResponse, AppError> {
230        let variable = self
231            .get_variable(GetVariableRequest {
232                variable: self.catalogue_variable_selector(request.variable).await?,
233            })
234            .await?
235            .ok_or_else(|| AppError::NotFound("Variable was not found or is not visible".into()))?;
236        self.variable_response(variable).await
237    }
238
239    pub async fn add_catalogue_variable(
240        &self,
241        request: dictionary::AddVariableRequest,
242    ) -> Result<dictionary::VariableDetailResponse, AppError> {
243        let variable = self
244            .add_variable(AddVariableRequest {
245                domain: self.catalogue_domain_selector(request.domain)?,
246                name: request.name.into(),
247                value_type: request.value_type,
248                value_format: request.value_format,
249                key_role: request.key_role,
250                description: request.description,
251                ontology_namespace: request.ontology_namespace,
252                ontology_class: request.ontology_class,
253                vocabulary: request.vocabulary.map(Into::into),
254                vocabulary_items: request.vocabulary_items,
255            })
256            .await?;
257        self.variable_response(variable).await
258    }
259
260    pub async fn update_catalogue_variable(
261        &self,
262        request: dictionary::UpdateVariableRequest,
263    ) -> Result<dictionary::VariableDetailResponse, AppError> {
264        let selector = self.catalogue_variable_selector(request.variable).await?;
265        let existing = self
266            .resolve_variable_selector_optional(selector)
267            .await?
268            .ok_or_else(|| AppError::NotFound("Variable was not found or is not visible".into()))?;
269        let variable = self
270            .update_variable(UpdateVariableRequest {
271                variable: VariableSelector::Id {
272                    variable_id: existing.variable_id,
273                },
274                value_type: request.value_type,
275                value_format: request.value_format,
276                key_role: request.key_role,
277                description: request.description,
278                ontology_namespace: request.ontology_namespace,
279                ontology_class: request.ontology_class,
280                vocabulary: request.vocabulary.map(Into::into),
281            })
282            .await?;
283        self.variable_response(variable).await
284    }
285
286    async fn variable_response(
287        &self,
288        variable: RegisteredVariable,
289    ) -> Result<dictionary::VariableDetailResponse, AppError> {
290        let tags = self
291            .semantic_target_tags(TagAttachmentTarget::Variable(variable.variable.variable_id))
292            .await?;
293        let items = match variable.vocabulary.as_ref() {
294            Some(vocabulary) => self.declared_vocabulary_items(vocabulary.vocabulary_id),
295            None => Some(Vec::new()),
296        };
297        let mut variable =
298            crate::projections::semantic_variable(self.catalogue_datastore_id()?, variable);
299        if let Some(items) = items {
300            variable.vocabulary_items = items.into_iter().map(schema_item).collect();
301            variable.vocabulary_items_withheld = false;
302            if let Some(vocabulary) = variable.summary.vocabulary.as_mut() {
303                vocabulary.item_count = Some(variable.vocabulary_items.len());
304            }
305        }
306        variable.summary.tags = tags;
307        Ok(dictionary::VariableDetailResponse {
308            variable,
309            warnings: None,
310        })
311    }
312
313    async fn semantic_target_tags(
314        &self,
315        target: TagAttachmentTarget,
316    ) -> Result<Vec<ahri_tre_protocol::refs::TagSummary>, AppError> {
317        let datastore_id = self.catalogue_datastore_id()?;
318        Ok(self
319            .get_tags(GetTagsRequest { target })
320            .await?
321            .tags
322            .into_iter()
323            .map(|tag| crate::projections::tag_summary(datastore_id, tag))
324            .collect())
325    }
326
327    pub async fn list_catalogue_vocabularies(
328        &self,
329        request: dictionary::ListVocabulariesRequest,
330    ) -> Result<dictionary::VocabularyListResponse, AppError> {
331        let values = self
332            .list_vocabularies(ListVocabulariesRequest {
333                domain: request
334                    .domain
335                    .map(|v| self.catalogue_domain_selector(v))
336                    .transpose()?,
337                study: request
338                    .study
339                    .map(|v| self.catalogue_study_selector(v))
340                    .transpose()?,
341            })
342            .await?;
343        Ok(dictionary::VocabularyListResponse {
344            vocabularies: values
345                .into_iter()
346                .map(|v| self.vocabulary_response(v).map(|v| v.vocabulary.summary))
347                .collect::<Result<_, _>>()?,
348            warnings: None,
349        })
350    }
351
352    pub async fn get_catalogue_vocabulary(
353        &self,
354        request: dictionary::GetVocabularyRequest,
355    ) -> Result<dictionary::VocabularyDetailResponse, AppError> {
356        let vocabulary = self
357            .get_vocabulary(GetVocabularyRequest {
358                vocabulary: self
359                    .catalogue_vocabulary_selector(request.vocabulary)
360                    .await?,
361            })
362            .await?
363            .ok_or_else(|| {
364                AppError::NotFound("Vocabulary was not found or is not visible".into())
365            })?;
366        self.vocabulary_response(vocabulary)
367    }
368
369    pub async fn add_catalogue_vocabulary(
370        &self,
371        request: dictionary::AddVocabularyRequest,
372    ) -> Result<dictionary::VocabularyDetailResponse, AppError> {
373        let domain = self
374            .resolve_domain_selector(
375                self.catalogue_domain_selector(request.domain)?,
376                "vocabulary Domain",
377            )
378            .await?;
379        let declaration = RegisterVocabularyRequest {
380            domain_id: domain.domain_id,
381            vocabulary_id: None,
382            name: parse_vocabulary_name(request.name.into())?,
383            description: request.description,
384            items: parse_vocabulary_item_requests(&request.items)?,
385            force_metadata_updates: false,
386        };
387        let repository = self.semantic_repository.as_ref().ok_or_else(|| {
388            AppError::Infrastructure("Dictionary provenance is unavailable".into())
389        })?;
390        let vocabulary = repository
391            .with_semantic_declarations(|repository| {
392                let repositories =
393                    crate::session::ScopedSessionMetadataRepositories::from_repository(Arc::new(
394                        repository,
395                    ));
396                let service = ScopedAppService::new(repositories.into());
397                futures::FutureExt::now_or_never(service.register_vocabulary(declaration))
398                    .ok_or_else(|| {
399                        CoreError::Infrastructure(
400                            "Dictionary declaration unexpectedly suspended".into(),
401                        )
402                    })?
403                    .map_err(semantic_mutation_error)
404            })
405            .map_err(|error| core_error("declare vocabulary", error))?;
406        self.vocabulary_response(vocabulary)
407    }
408
409    fn vocabulary_response(
410        &self,
411        vocabulary: RegisteredVocabulary,
412    ) -> Result<dictionary::VocabularyDetailResponse, AppError> {
413        let items = self.declared_vocabulary_items(vocabulary.vocabulary.vocabulary_id);
414        let mut vocabulary =
415            crate::projections::semantic_vocabulary(self.catalogue_datastore_id()?, vocabulary);
416        if let Some(items) = items {
417            vocabulary.items = items.into_iter().map(schema_item).collect();
418            vocabulary.summary.item_count = Some(vocabulary.items.len());
419            vocabulary.items_withheld = false;
420        }
421        Ok(dictionary::VocabularyDetailResponse {
422            vocabulary,
423            warnings: None,
424        })
425    }
426
427    pub(super) fn declared_vocabulary_items(
428        &self,
429        id: ahri_tre_types::VocabularyId,
430    ) -> Option<Vec<ahri_tre_types::VocabularyItemRecord>> {
431        // A missing proof, exhausted schema bound, or concurrent derivation
432        // leaves identity available with an explicit withheld marker.
433        self.semantic_repository
434            .as_ref()?
435            .catalogue_vocabulary_items(id)
436            .ok()
437            .flatten()
438    }
439
440    pub async fn list_catalogue_entities(
441        &self,
442        request: model::ListEntitiesRequest,
443    ) -> Result<model::EntityListResponse, AppError> {
444        let records = self
445            .list_entities(ListEntitiesRequest {
446                domain: self.catalogue_domain_selector(request.domain)?,
447            })
448            .await?;
449        let mut values = Vec::with_capacity(records.len());
450        for record in records {
451            values.push(self.entity_response(record).await?.entity.summary);
452        }
453        Ok(model::EntityListResponse {
454            entities: values,
455            next_cursor: None,
456            warnings: None,
457        })
458    }
459    pub async fn get_catalogue_entity(
460        &self,
461        request: model::GetEntityRequest,
462    ) -> Result<model::EntityDetailResponse, AppError> {
463        let record = self
464            .get_entity(GetEntityRequest {
465                entity: self.catalogue_entity_selector(request.entity).await?,
466            })
467            .await?
468            .ok_or_else(|| AppError::NotFound("Entity was not found or is not visible".into()))?;
469        self.entity_response(record).await
470    }
471    pub async fn add_catalogue_entity(
472        &self,
473        request: model::AddEntityRequest,
474    ) -> Result<model::EntityDetailResponse, AppError> {
475        let record = self
476            .add_entity(AddEntityRequest {
477                domain: self.catalogue_domain_selector(request.domain)?,
478                name: request.name.into(),
479                description: request.description,
480                agent_instructions: request.agent_instructions,
481                ontology_namespace: request.ontology_namespace,
482                ontology_class: request.ontology_class,
483            })
484            .await?;
485        self.entity_response(record).await
486    }
487    async fn entity_response(
488        &self,
489        record: ahri_tre_types::EntityRecord,
490    ) -> Result<model::EntityDetailResponse, AppError> {
491        let tags = self
492            .semantic_target_tags(TagAttachmentTarget::Entity(record.entity_id))
493            .await?;
494        let mut entity =
495            crate::projections::semantic_entity(self.catalogue_datastore_id()?, record);
496        entity.summary.tags = tags;
497        Ok(model::EntityDetailResponse {
498            entity,
499            warnings: None,
500        })
501    }
502
503    pub async fn list_catalogue_relations(
504        &self,
505        request: model::ListRelationsRequest,
506    ) -> Result<model::RelationListResponse, AppError> {
507        let records = self
508            .list_entity_relations(ListEntityRelationsRequest {
509                domain: self.catalogue_domain_selector(request.domain)?,
510            })
511            .await?;
512        let mut values = Vec::with_capacity(records.len());
513        for record in records {
514            values.push(self.relation_response(record).await?.relation.summary);
515        }
516        Ok(model::RelationListResponse {
517            relations: values,
518            next_cursor: None,
519            warnings: None,
520        })
521    }
522    pub async fn get_catalogue_relation(
523        &self,
524        request: model::GetRelationRequest,
525    ) -> Result<model::RelationDetailResponse, AppError> {
526        let record = self
527            .get_entity_relation(GetEntityRelationRequest {
528                relation: self.catalogue_relation_selector(request.relation).await?,
529            })
530            .await?
531            .ok_or_else(|| AppError::NotFound("Relation was not found or is not visible".into()))?;
532        self.relation_response(record).await
533    }
534    pub async fn add_catalogue_relation(
535        &self,
536        request: model::AddRelationRequest,
537    ) -> Result<model::RelationDetailResponse, AppError> {
538        let record = self
539            .add_entity_relation(AddEntityRelationRequest {
540                domain: self.catalogue_domain_selector(request.domain)?,
541                name: request.name.into(),
542                description: request.description,
543                agent_instructions: request.agent_instructions,
544                ontology_namespace: request.ontology_namespace,
545                ontology_class: request.ontology_class,
546                subject: request.subject.into(),
547                object: request.object.into(),
548            })
549            .await?;
550        self.relation_response(record).await
551    }
552    async fn relation_response(
553        &self,
554        record: ahri_tre_types::EntityRelationRecord,
555    ) -> Result<model::RelationDetailResponse, AppError> {
556        let mut names = BTreeMap::new();
557        for id in [record.subject_entity_id, record.object_entity_id] {
558            if let Some(entity) = self
559                .get_entity(GetEntityRequest {
560                    entity: EntitySelector::Id { entity_id: id },
561                })
562                .await?
563            {
564                names.insert(id.0, entity.name.as_str().to_string());
565            }
566        }
567        let tags = self
568            .semantic_target_tags(TagAttachmentTarget::EntityRelation(
569                record.entity_relation_id,
570            ))
571            .await?;
572        let mut relation =
573            crate::projections::semantic_relation(self.catalogue_datastore_id()?, record, &names);
574        relation.summary.tags = tags;
575        Ok(model::RelationDetailResponse {
576            relation,
577            warnings: None,
578        })
579    }
580
581    pub async fn list_catalogue_transformations(
582        &self,
583        request: ahri_tre_protocol::transformation::TransformationListRequest,
584    ) -> Result<Vec<ahri_tre_protocol::transformation::TransformationSummary>, AppError> {
585        let study = self
586            .resolve_study_selector(
587                self.catalogue_study_selector(request.study)?,
588                "Transformation Study",
589            )
590            .await?;
591        let mut values = self.study_transformations(study.study_id).await?;
592        // A Transformation can span Studies. Each nested version must itself
593        // be visible; visibility of one output cannot authorize other inputs.
594        let mut visible = BTreeMap::new();
595        for value in &values {
596            for version_id in value
597                .inputs
598                .iter()
599                .map(|link| link.version_id)
600                .chain(value.outputs.iter().map(|link| link.version_id))
601            {
602                if visible.contains_key(&version_id.0) {
603                    continue;
604                }
605                let version = self
606                    .assets
607                    .get_asset_version(version_id)
608                    .await
609                    .map_err(|error| core_error("resolve provenance version", error))?;
610                let allowed = match version {
611                    Some(version) => self
612                        .assets
613                        .get_asset_by_id(version.asset_id)
614                        .await
615                        .map_err(|error| core_error("authorize provenance Asset", error))?
616                        .is_some(),
617                    None => false,
618                };
619                visible.insert(version_id.0, allowed);
620            }
621        }
622        for value in &mut values {
623            value
624                .inputs
625                .retain(|link| visible.get(&link.version_id.0) == Some(&true));
626            value
627                .outputs
628                .retain(|link| visible.get(&link.version_id.0) == Some(&true));
629        }
630        let datastore_id = self.catalogue_datastore_id()?;
631        Ok(values
632            .into_iter()
633            .map(|value| crate::projections::semantic_transformation(datastore_id, value))
634            .collect())
635    }
636
637    pub(super) async fn semantic_domains(
638        &self,
639        domain: Option<DomainSelector>,
640        study: Option<StudySelector>,
641        label: &str,
642    ) -> Result<Vec<DomainRecord>, AppError> {
643        match (domain, study) {
644            (Some(domain), study) => {
645                let domain = self.resolve_domain_selector(domain, label).await?;
646                if let Some(study) = study {
647                    let study = self.resolve_study_selector(study, label).await?;
648                    let domains = self
649                        .study_domains
650                        .list_domains_for_study(study.study_id)
651                        .await
652                        .map_err(|e| core_error("list semantic Study domains", e))?;
653                    if !domains.iter().any(|d| d.domain_id == domain.domain_id) {
654                        return Err(AppError::Conflict(
655                            "Domain does not belong to the selected Study".into(),
656                        ));
657                    }
658                }
659                Ok(vec![domain])
660            }
661            (None, Some(study)) => {
662                let study = self.resolve_study_selector(study, label).await?;
663                self.study_domains
664                    .list_domains_for_study(study.study_id)
665                    .await
666                    .map_err(|e| core_error("list semantic Study domains", e))
667            }
668            (None, None) => Err(AppError::Validation(
669                "Semantic listing requires a Domain or Study".into(),
670            )),
671        }
672    }
673}
674
675fn schema_item(item: ahri_tre_types::VocabularyItemRecord) -> dictionary::VocabularyItemSummary {
676    dictionary::VocabularyItemSummary {
677        value: item.value,
678        code: item.code,
679        description: item.description,
680    }
681}
682
683fn semantic_mutation_error(error: AppError) -> CoreError {
684    match error {
685        AppError::Validation(message) => CoreError::Validation(message),
686        AppError::NotFound(message) => CoreError::NotFound(message),
687        AppError::Conflict(message) => CoreError::Conflict(message),
688        AppError::NotImplemented(message) => CoreError::NotImplemented(message),
689        _ => CoreError::Infrastructure("Semantic mutation is unavailable".into()),
690    }
691}