1use 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#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct BrowserProvenanceSummarySourceRecord {
87 pub transformation: ahri_tre_pgmeta::PgTransformationReadModel,
89 pub inputs: Vec<BrowserProvenanceCatalogueSourceRecord>,
91 pub outputs: Vec<BrowserProvenanceCatalogueSourceRecord>,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
97pub enum BrowserProvenanceCatalogueSourceRecord {
98 Dataset {
100 study_id: ahri_tre_types::StudyId,
102 dataset_id: ahri_tre_types::VersionId,
104 },
105 DataFile {
107 study_id: ahri_tre_types::StudyId,
109 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}