Skip to main content

ahri_tre_app/
browser_semantic_discovery.rs

1//! Semantic catalog and provenance projection for broad TRE Browser discovery.
2
3use ahri_tre_protocol::{
4    PublicUuid,
5    domain::DomainSummary,
6    refs::{
7        DomainRef, EntityRef, RelationRef, StudyRef, TagRef, TagScope, TagSummary,
8        TransformationRef,
9    },
10    resource_discovery::BrowserResourceAccessCondition,
11    semantic_discovery::{
12        BrowserEntityDiscoveryRecord, BrowserEntityRelationDiscoveryRecord,
13        BrowserProvenanceCatalogueReference, BrowserProvenanceResponsiblePerson,
14        BrowserProvenanceSummaryDiscoveryRecord, BrowserSemanticCatalogDiscoveryEmptyState,
15        BrowserSemanticCatalogDiscoveryResponse,
16    },
17    study::BrowserContentSummaryPolicy,
18};
19#[cfg(test)]
20use ahri_tre_types::TransformationId;
21use std::fmt;
22use thiserror::Error;
23
24pub const BROWSER_SEMANTIC_CATALOG_DISCOVERY_AUTHORITY: &str =
25    "tre_browser.semantic_catalog_discovery";
26
27#[derive(Debug, Error, Clone, PartialEq, Eq)]
28pub enum BrowserSemanticCatalogDiscoveryError {
29    #[error("selected datastore identity is invalid")]
30    InvalidDatastoreIdentity,
31    #[error("selected datastore is not available for browser discovery")]
32    DatastoreUnavailable,
33    #[error("browser semantic catalog discovery authority is unavailable")]
34    AuthorityUnavailable,
35    #[error("study was not found in the selected datastore")]
36    StudyNotFound,
37    #[error("browser semantic search request is invalid")]
38    InvalidRequest,
39    #[error("semantic reference constraints conflict")]
40    ReferenceConflict,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct BrowserSemanticCatalogDiscoverySource {
45    pub datastore_id: PublicUuid,
46    pub study: ahri_tre_pgmeta::PgStudyReadModel,
47    pub domains: Vec<ahri_tre_pgmeta::PgDomainReadModel>,
48    pub entities: Vec<BrowserSemanticEntitySourceRecord>,
49    pub relations: Vec<BrowserSemanticRelationSourceRecord>,
50    pub provenance_summaries: Vec<BrowserProvenanceSummarySourceRecord>,
51    pub access_conditions: Vec<ahri_tre_pgmeta::PgDuoRestrictionReadModel>,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct BrowserSemanticEntitySourceRecord {
56    pub entity: ahri_tre_pgmeta::PgEntityReadModel,
57    pub tags: Vec<ahri_tre_pgmeta::PgTagReadModel>,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct BrowserSemanticEntityCatalogueRecord {
62    pub domain_name: String,
63    pub record: BrowserSemanticEntitySourceRecord,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct BrowserSemanticRelationSourceRecord {
68    pub relation: ahri_tre_pgmeta::PgEntityRelationReadModel,
69    pub tags: Vec<ahri_tre_pgmeta::PgTagReadModel>,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct BrowserSemanticRelationCatalogueRecord {
74    pub domain_name: String,
75    pub source_entity_name: String,
76    pub target_entity_name: String,
77    pub record: BrowserSemanticRelationSourceRecord,
78}
79
80/// Runtime-owned provenance input from which the app builds the browser-safe projection.
81///
82/// Raw transformation evidence remains private. Only explicitly namespaced,
83/// checksum-valid ORCID actors and public catalogue references can cross the
84/// browser Discovery boundary.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct BrowserProvenanceSummarySourceRecord {
87    /// The persisted transformation metadata, including private fields that the projection omits.
88    pub transformation: ahri_tre_pgmeta::PgTransformationReadModel,
89    /// Public catalogue resources consumed by the transformation.
90    pub inputs: Vec<BrowserProvenanceCatalogueSourceRecord>,
91    /// Public catalogue resources produced by the transformation.
92    pub outputs: Vec<BrowserProvenanceCatalogueSourceRecord>,
93}
94
95/// A direction-neutral public catalogue target resolved behind the metadata adapter.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub enum BrowserProvenanceCatalogueSourceRecord {
98    /// A Dataset version and its owning Study.
99    Dataset {
100        /// Owning Study identity.
101        study_id: ahri_tre_types::StudyId,
102        /// Dataset-version identity.
103        dataset_id: ahri_tre_types::VersionId,
104    },
105    /// A Datafile version and its owning Study.
106    DataFile {
107        /// Owning Study identity.
108        study_id: ahri_tre_types::StudyId,
109        /// Datafile-version identity.
110        datafile_id: ahri_tre_types::VersionId,
111    },
112}
113
114pub trait BrowserSemanticCatalogDiscoveryCapability: fmt::Debug + Send + Sync {
115    fn discover_semantic_catalog(
116        &self,
117        datastore_id: PublicUuid,
118        study_id: PublicUuid,
119    ) -> Result<BrowserSemanticCatalogDiscoverySource, BrowserSemanticCatalogDiscoveryError>;
120
121    fn discover_catalogue_entities(
122        &self,
123        datastore_id: PublicUuid,
124    ) -> Result<Vec<BrowserSemanticEntityCatalogueRecord>, BrowserSemanticCatalogDiscoveryError>;
125
126    fn discover_catalogue_relations(
127        &self,
128        datastore_id: PublicUuid,
129    ) -> Result<Vec<BrowserSemanticRelationCatalogueRecord>, BrowserSemanticCatalogDiscoveryError>;
130}
131
132pub fn browser_semantic_catalog_discovery_response(
133    source: BrowserSemanticCatalogDiscoverySource,
134) -> BrowserSemanticCatalogDiscoveryResponse {
135    let datastore_id = source.datastore_id;
136    let entities = source
137        .entities
138        .into_iter()
139        .map(|value| entity_record(datastore_id, value))
140        .collect::<Vec<_>>();
141    let relations = source
142        .relations
143        .into_iter()
144        .map(|value| relation_record(datastore_id, value))
145        .collect::<Vec<_>>();
146    let provenance_summaries = source
147        .provenance_summaries
148        .into_iter()
149        .map(|value| provenance_summary(datastore_id, value))
150        .collect::<Vec<_>>();
151    let is_empty = entities.is_empty() && relations.is_empty() && provenance_summaries.is_empty();
152    BrowserSemanticCatalogDiscoveryResponse {
153        authority: BROWSER_SEMANTIC_CATALOG_DISCOVERY_AUTHORITY.to_string(),
154        datastore_id: source.datastore_id,
155        study: StudyRef {
156                datastore_id,
157                kind: ahri_tre_protocol::refs::ObjectKind::Study, id: PublicUuid::from_uuid(source.study.study_id.0) },
158        domains: source.domains.into_iter().map(|value| domain_summary(datastore_id, value)).collect(),
159        provenance_summaries,
160        access_conditions: source.access_conditions.into_iter().map(access_condition).collect(),
161        empty_state: is_empty.then(|| BrowserSemanticCatalogDiscoveryEmptyState {
162            code: "no_browser_visible_semantic_catalog".to_string(),
163            message: "No browser-visible semantic catalog metadata is available for this study.".to_string(),
164        }),
165        entities,
166        relations,
167        content_derived_summaries: BrowserContentSummaryPolicy { included: false, reason: "Content-derived summaries are excluded from broad semantic and provenance Discovery metadata.".to_string() },
168    }
169}
170
171pub fn semantic_catalog_discovery_failure_response(
172    datastore_id: PublicUuid,
173    study_id: PublicUuid,
174    code: impl Into<String>,
175    message: impl Into<String>,
176) -> BrowserSemanticCatalogDiscoveryResponse {
177    BrowserSemanticCatalogDiscoveryResponse {
178        authority: BROWSER_SEMANTIC_CATALOG_DISCOVERY_AUTHORITY.to_string(), datastore_id,
179        study: StudyRef {
180                datastore_id,
181                kind: ahri_tre_protocol::refs::ObjectKind::Study, id: study_id }, domains: Vec::new(), entities: Vec::new(), relations: Vec::new(), provenance_summaries: Vec::new(), access_conditions: Vec::new(),
182        empty_state: Some(BrowserSemanticCatalogDiscoveryEmptyState { code: code.into(), message: message.into() }),
183        content_derived_summaries: BrowserContentSummaryPolicy { included: false, reason: "Content-derived summaries are excluded from broad semantic and provenance Discovery metadata.".to_string() },
184    }
185}
186
187fn domain_summary(
188    datastore_id: PublicUuid,
189    domain: ahri_tre_pgmeta::PgDomainReadModel,
190) -> DomainSummary {
191    DomainSummary {
192        domain: crate::projections::domain_ref(datastore_id, domain.domain_id),
193        name: domain.name,
194        description: domain.description,
195        uri: domain.uri,
196    }
197}
198fn tag_summary(datastore_id: PublicUuid, tag: ahri_tre_pgmeta::PgTagReadModel) -> TagSummary {
199    TagSummary {
200        tag: TagRef {
201            datastore_id,
202            kind: ahri_tre_protocol::refs::ObjectKind::Tag,
203            id: ahri_tre_protocol::refs::encode_scoped_integer_ref("tag", tag.tag_id),
204        },
205        scope: TagScope::Global,
206        label: tag.name,
207    }
208}
209pub(crate) fn entity_record(
210    datastore_id: PublicUuid,
211    source: BrowserSemanticEntitySourceRecord,
212) -> BrowserEntityDiscoveryRecord {
213    BrowserEntityDiscoveryRecord {
214        entity: EntityRef {
215            datastore_id,
216            kind: ahri_tre_protocol::refs::ObjectKind::Entity,
217            id: ahri_tre_protocol::refs::encode_scoped_integer_ref(
218                "entity",
219                source.entity.entity_id.0,
220            ),
221        },
222        domain: DomainRef {
223            datastore_id,
224            kind: ahri_tre_protocol::refs::ObjectKind::Domain,
225            id: ahri_tre_protocol::refs::encode_scoped_integer_ref(
226                "domain",
227                source.entity.domain_id.0,
228            ),
229        },
230        name: source.entity.name,
231        description: source.entity.description,
232        tags: source
233            .tags
234            .into_iter()
235            .map(|tag| tag_summary(datastore_id, tag))
236            .collect(),
237    }
238}
239pub(crate) fn relation_record(
240    datastore_id: PublicUuid,
241    source: BrowserSemanticRelationSourceRecord,
242) -> BrowserEntityRelationDiscoveryRecord {
243    BrowserEntityRelationDiscoveryRecord {
244        relation: RelationRef {
245            datastore_id,
246            kind: ahri_tre_protocol::refs::ObjectKind::Relation,
247            id: ahri_tre_protocol::refs::encode_scoped_integer_ref(
248                "relation",
249                source.relation.entity_relation_id.0,
250            ),
251        },
252        domain: DomainRef {
253            datastore_id,
254            kind: ahri_tre_protocol::refs::ObjectKind::Domain,
255            id: ahri_tre_protocol::refs::encode_scoped_integer_ref(
256                "domain",
257                source.relation.domain_id.0,
258            ),
259        },
260        name: source.relation.name,
261        description: source.relation.description,
262        subject_entity: EntityRef {
263            datastore_id,
264            kind: ahri_tre_protocol::refs::ObjectKind::Entity,
265            id: ahri_tre_protocol::refs::encode_scoped_integer_ref(
266                "entity",
267                source.relation.subject_entity_id.0,
268            ),
269        },
270        object_entity: EntityRef {
271            datastore_id,
272            kind: ahri_tre_protocol::refs::ObjectKind::Entity,
273            id: ahri_tre_protocol::refs::encode_scoped_integer_ref(
274                "entity",
275                source.relation.object_entity_id.0,
276            ),
277        },
278        tags: source
279            .tags
280            .into_iter()
281            .map(|tag| tag_summary(datastore_id, tag))
282            .collect(),
283    }
284}
285fn provenance_summary(
286    datastore_id: PublicUuid,
287    source: BrowserProvenanceSummarySourceRecord,
288) -> BrowserProvenanceSummaryDiscoveryRecord {
289    let responsible_person = source
290        .transformation
291        .created_by
292        .as_deref()
293        .and_then(approved_public_orcid)
294        .map(|orcid| BrowserProvenanceResponsiblePerson { orcid });
295    BrowserProvenanceSummaryDiscoveryRecord {
296        transformation: TransformationRef {
297            datastore_id,
298            kind: ahri_tre_protocol::refs::ObjectKind::Transformation,
299            id: ahri_tre_protocol::refs::encode_scoped_integer_ref(
300                "transformation",
301                source.transformation.transformation_id.0,
302            ),
303        },
304        transformation_type: format!("{:?}", source.transformation.transformation_type)
305            .to_lowercase(),
306        description: source.transformation.description,
307        responsible_person,
308        transformation_date: source.transformation.date_created,
309        inputs: source
310            .inputs
311            .into_iter()
312            .map(|value| catalogue_reference(datastore_id, value))
313            .collect(),
314        outputs: source
315            .outputs
316            .into_iter()
317            .map(|value| catalogue_reference(datastore_id, value))
318            .collect(),
319    }
320}
321
322fn catalogue_reference(
323    datastore_id: PublicUuid,
324    source: BrowserProvenanceCatalogueSourceRecord,
325) -> BrowserProvenanceCatalogueReference {
326    match source {
327        BrowserProvenanceCatalogueSourceRecord::Dataset {
328            study_id,
329            dataset_id,
330        } => BrowserProvenanceCatalogueReference::Dataset {
331            study: StudyRef {
332                datastore_id,
333                kind: ahri_tre_protocol::refs::ObjectKind::Study,
334                id: PublicUuid::from_uuid(study_id.0),
335            },
336            dataset: ahri_tre_protocol::refs::DatasetRef {
337                datastore_id,
338                kind: ahri_tre_protocol::refs::ObjectKind::AssetVersion,
339                id: PublicUuid::from_uuid(dataset_id.0),
340            },
341        },
342        BrowserProvenanceCatalogueSourceRecord::DataFile {
343            study_id,
344            datafile_id,
345        } => BrowserProvenanceCatalogueReference::DataFile {
346            study: StudyRef {
347                datastore_id,
348                kind: ahri_tre_protocol::refs::ObjectKind::Study,
349                id: PublicUuid::from_uuid(study_id.0),
350            },
351            datafile: ahri_tre_protocol::refs::DataFileRef {
352                datastore_id,
353                kind: ahri_tre_protocol::refs::ObjectKind::AssetVersion,
354                id: PublicUuid::from_uuid(datafile_id.0),
355            },
356        },
357    }
358}
359
360fn approved_public_orcid(actor: &str) -> Option<String> {
361    let actor = actor.trim();
362    let identifier = actor
363        .strip_prefix("tre_orcid_")
364        .or_else(|| actor.strip_prefix("orcid_"))?
365        .replace('_', "-");
366    let groups = identifier.split('-').collect::<Vec<_>>();
367    if groups.len() != 4
368        || groups.iter().any(|group| group.len() != 4)
369        || groups[..3]
370            .iter()
371            .any(|group| !group.bytes().all(|byte| byte.is_ascii_digit()))
372        || !groups[3]
373            .bytes()
374            .enumerate()
375            .all(|(index, byte)| byte.is_ascii_digit() || (index == 3 && byte == b'X'))
376    {
377        return None;
378    }
379    let compact = identifier.replace('-', "");
380    let total = compact
381        .bytes()
382        .take(15)
383        .fold(0_u32, |total, byte| (total + u32::from(byte - b'0')) * 2);
384    let expected = match (12 - total % 11) % 11 {
385        10 => b'X',
386        digit => b'0' + digit as u8,
387    };
388    if compact.as_bytes()[15] != expected {
389        return None;
390    }
391    Some(identifier)
392}
393fn access_condition(
394    condition: ahri_tre_pgmeta::PgDuoRestrictionReadModel,
395) -> BrowserResourceAccessCondition {
396    BrowserResourceAccessCondition {
397        duo_id: condition.duo_id,
398        code: condition.duo_code,
399        label: condition.label,
400        qualifier_ontology_namespace: condition.qualifier_ontology_namespace,
401        qualifier_ontology_id: condition.qualifier_ontology_id,
402        qualifier_text: condition.qualifier_text,
403        qualifier_date: condition.qualifier_date,
404        qualifier_integer: condition.qualifier_integer,
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411    use ahri_tre_types::{DomainId, EntityId, EntityRelationId, StudyId, TransformationType};
412    use chrono::{TimeZone, Utc};
413    use uuid::Uuid;
414
415    #[test]
416    fn projection_exposes_safe_semantic_metadata_and_excludes_private_evidence() {
417        let response =
418            browser_semantic_catalog_discovery_response(BrowserSemanticCatalogDiscoverySource {
419                datastore_id: PublicUuid::from_uuid(Uuid::from_u128(1)),
420                study: ahri_tre_pgmeta::PgStudyReadModel {
421                    study_id: StudyId(Uuid::from_u128(2)),
422                    name: "Study".into(),
423                    description: None,
424                    documentation: None,
425                    agent_instructions: Some("private runtime evidence".into()),
426                    external_id: None,
427                    study_type_id: None,
428                    date_created: None,
429                    created_by: Some("private@example.test".into()),
430                },
431                domains: vec![ahri_tre_pgmeta::PgDomainReadModel {
432                    domain_id: DomainId(3),
433                    name: "people".into(),
434                    uri: None,
435                    description: None,
436                }],
437                entities: vec![BrowserSemanticEntitySourceRecord {
438                    entity: ahri_tre_pgmeta::PgEntityReadModel {
439                        entity_id: EntityId(4),
440                        domain_id: DomainId(3),
441                        uuid: None,
442                        name: "Person".into(),
443                        description: None,
444                        agent_instructions: Some("private".into()),
445                        ontology_namespace: None,
446                        ontology_class: None,
447                    },
448                    tags: vec![ahri_tre_pgmeta::PgTagReadModel {
449                        tag_id: 5,
450                        name: "core".into(),
451                    }],
452                }],
453                relations: vec![BrowserSemanticRelationSourceRecord {
454                    relation: ahri_tre_pgmeta::PgEntityRelationReadModel {
455                        entity_relation_id: EntityRelationId(6),
456                        subject_entity_id: EntityId(4),
457                        object_entity_id: EntityId(4),
458                        domain_id: DomainId(3),
459                        uuid: None,
460                        name: "related_to".into(),
461                        description: None,
462                        agent_instructions: Some("private".into()),
463                        ontology_namespace: None,
464                        ontology_class: None,
465                    },
466                    tags: vec![],
467                }],
468                provenance_summaries: vec![BrowserProvenanceSummarySourceRecord {
469                    transformation: ahri_tre_pgmeta::PgTransformationReadModel {
470                        transformation_id: TransformationId(7),
471                        transformation_type: TransformationType::Ingest,
472                        description: "Curated import".into(),
473                        repository_url: Some("postgres://secret".into()),
474                        commit_hash: Some("secret".into()),
475                        file_path: Some("/host/private".into()),
476                        date_created: Some(Utc.with_ymd_and_hms(2026, 9, 12, 8, 30, 0).unwrap()),
477                        created_by: Some("orcid_0000-0002-1825-0097".into()),
478                    },
479                    inputs: vec![BrowserProvenanceCatalogueSourceRecord::DataFile {
480                        study_id: StudyId(Uuid::from_u128(2)),
481                        datafile_id: ahri_tre_types::VersionId(Uuid::from_u128(9)),
482                    }],
483                    outputs: vec![BrowserProvenanceCatalogueSourceRecord::Dataset {
484                        study_id: StudyId(Uuid::from_u128(2)),
485                        dataset_id: ahri_tre_types::VersionId(Uuid::from_u128(10)),
486                    }],
487                }],
488                access_conditions: vec![ahri_tre_pgmeta::PgDuoRestrictionReadModel {
489                    restriction_id: 8,
490                    study_id: StudyId(Uuid::from_u128(2)),
491                    duo_id: "DUO:0000042".into(),
492                    duo_code: "GRU".into(),
493                    label: "General research use".into(),
494                    qualifier_ontology_namespace: None,
495                    qualifier_ontology_id: None,
496                    qualifier_text: None,
497                    qualifier_date: None,
498                    qualifier_integer: None,
499                }],
500            });
501        assert_eq!(response.entities[0].name, "Person");
502        assert!(response.entities[0].description.is_none());
503        assert_eq!(
504            response.relations[0].subject_entity,
505            response.relations[0].object_entity
506        );
507        let provenance = &response.provenance_summaries[0];
508        assert_eq!(
509            provenance.responsible_person.as_ref().unwrap().orcid,
510            "0000-0002-1825-0097"
511        );
512        assert_eq!(
513            provenance.transformation_date,
514            Some(Utc.with_ymd_and_hms(2026, 9, 12, 8, 30, 0).unwrap())
515        );
516        assert!(matches!(
517            provenance.inputs[0],
518            BrowserProvenanceCatalogueReference::DataFile { .. }
519        ));
520        assert!(matches!(
521            provenance.outputs[0],
522            BrowserProvenanceCatalogueReference::Dataset { .. }
523        ));
524        assert_eq!(response.access_conditions[0].code, "GRU");
525        assert!(!response.content_derived_summaries.included);
526        let json = serde_json::to_string(&response).unwrap();
527        for forbidden in [
528            "private runtime evidence",
529            "postgres://secret",
530            "/host/private",
531            "entity_instance_count",
532            "relation_instance_count",
533            "agent_instructions",
534            "repository_url",
535            "commit_hash",
536            "file_path",
537        ] {
538            assert!(!json.contains(forbidden), "{forbidden}");
539        }
540    }
541
542    #[test]
543    fn projection_has_stable_empty_catalog_state() {
544        let response =
545            browser_semantic_catalog_discovery_response(BrowserSemanticCatalogDiscoverySource {
546                datastore_id: PublicUuid::from_uuid(Uuid::from_u128(1)),
547                study: ahri_tre_pgmeta::PgStudyReadModel {
548                    study_id: StudyId(Uuid::from_u128(2)),
549                    name: "Study".into(),
550                    description: None,
551                    documentation: None,
552                    agent_instructions: None,
553                    external_id: None,
554                    study_type_id: None,
555                    date_created: None,
556                    created_by: None,
557                },
558                domains: Vec::new(),
559                entities: Vec::new(),
560                relations: Vec::new(),
561                provenance_summaries: Vec::new(),
562                access_conditions: Vec::new(),
563            });
564        assert_eq!(
565            response.empty_state.unwrap().code,
566            "no_browser_visible_semantic_catalog"
567        );
568    }
569
570    #[test]
571    fn provenance_is_browser_visible_semantic_metadata_not_an_empty_catalog() {
572        let response =
573            browser_semantic_catalog_discovery_response(BrowserSemanticCatalogDiscoverySource {
574                datastore_id: PublicUuid::from_uuid(Uuid::from_u128(1)),
575                study: ahri_tre_pgmeta::PgStudyReadModel {
576                    study_id: StudyId(Uuid::from_u128(2)),
577                    name: "Study".into(),
578                    description: None,
579                    documentation: None,
580                    agent_instructions: None,
581                    external_id: None,
582                    study_type_id: None,
583                    date_created: None,
584                    created_by: None,
585                },
586                domains: Vec::new(),
587                entities: Vec::new(),
588                relations: Vec::new(),
589                provenance_summaries: vec![BrowserProvenanceSummarySourceRecord {
590                    transformation: ahri_tre_pgmeta::PgTransformationReadModel {
591                        transformation_id: TransformationId(7),
592                        transformation_type: TransformationType::Ingest,
593                        description: "Curated import".into(),
594                        repository_url: None,
595                        commit_hash: None,
596                        file_path: None,
597                        date_created: None,
598                        created_by: None,
599                    },
600                    inputs: Vec::new(),
601                    outputs: Vec::new(),
602                }],
603                access_conditions: Vec::new(),
604            });
605
606        assert!(response.empty_state.is_none());
607        assert_eq!(response.provenance_summaries.len(), 1);
608    }
609
610    #[test]
611    fn provenance_responsible_person_accepts_only_public_orcid_shapes() {
612        assert_eq!(
613            approved_public_orcid("tre_orcid_0000_0002_1825_0097").as_deref(),
614            Some("0000-0002-1825-0097")
615        );
616        assert_eq!(
617            approved_public_orcid("orcid_0000-0002-1694-233X").as_deref(),
618            Some("0000-0002-1694-233X")
619        );
620        assert!(approved_public_orcid("0000-0002-1825-0097").is_none());
621        assert!(approved_public_orcid("0000-0000-0000-0002").is_none());
622        assert!(approved_public_orcid("private-provenance@example.test").is_none());
623        assert!(approved_public_orcid("ahri_tre_local").is_none());
624    }
625}