Skip to main content

sorla_provider_core/
lib.rs

1#![forbid(unsafe_code)]
2
3mod traits;
4mod types;
5
6pub use traits::{
7    CanonicalEntityStoreProvider, CanonicalWriteProvider, ConfigValidator, EntityLinkProvider,
8    EntityStoreProvider, EventStoreProvider, EvidenceProvider, ExternalMappingProvider,
9    ExternalReferenceProvider, MetricProvider, OntologyGraphProvider, ProjectionProvider,
10    ProviderHealth, ProviderMetadataSource,
11};
12pub use types::{
13    AppendEventRequest, CanonicalEntityRecord, CanonicalWriteRequest, CanonicalWriteResult,
14    ContractCompatibility, EntityLink, EntityLinkRequest, EntityRecord, EntityRef,
15    EntitySearchQuery, EventRecord, EventStreamRequest, EvidenceItem, EvidenceQuery,
16    EvidenceQueryFilter, ExternalReferencePayload, ExternalReferenceRequest, HealthReport,
17    HealthState, OntologyContractCompatibility, OntologyPath, OntologyPathStep, OntologyScope,
18    PackEmission, PathQuery, PersistProjectionRequest, PolicyContext, PolicyContextRequest,
19    ProjectionCheckpoint, ProjectionRebuildRequest, ProjectionRecord, ProjectionSupport,
20    ProviderCapability, ProviderError, ProviderIndexCapabilities, ProviderMetadata,
21    ProviderMetricAggregateFunction, ProviderMetricAggregation, ProviderMetricDimension,
22    ProviderMetricFilter, ProviderMetricFilterOperator, ProviderMetricQuery, ProviderMetricResult,
23    ProviderMetricRow, ProviderMetricSource, ProviderMetricTimeBucket, ProviderMetricTimeGrain,
24    ProviderMetricValue, ProviderOntologyCapabilities, ProviderSearchCapabilities, ProviderStatus,
25    RelationshipDirection, RelationshipInstance, RelationshipQuery, RelationshipRef,
26    RelationshipTraversalRule, SorEventRecord, SorNamespace, TimeRange,
27};
28
29/// Canonical contract version for SoRLa provider implementations.
30pub const SORLA_PROVIDER_CONTRACT_VERSION: &str = "0.1.0";
31
32#[cfg(test)]
33mod tests {
34    use super::{
35        CanonicalEntityRecord, ContractCompatibility, EntityLink, EntityLinkRequest, EntityRef,
36        EvidenceQueryFilter, ExternalReferenceRequest, OntologyContractCompatibility,
37        OntologyScope, PathQuery, ProjectionSupport, ProviderCapability, ProviderIndexCapabilities,
38        ProviderMetadata, ProviderMetricAggregateFunction, ProviderMetricAggregation,
39        ProviderMetricDimension, ProviderMetricFilter, ProviderMetricFilterOperator,
40        ProviderMetricQuery, ProviderMetricResult, ProviderMetricRow, ProviderMetricSource,
41        ProviderMetricTimeBucket, ProviderMetricTimeGrain, ProviderMetricValue,
42        ProviderSearchCapabilities, ProviderStatus, RelationshipDirection,
43        RelationshipTraversalRule, SORLA_PROVIDER_CONTRACT_VERSION, SorEventRecord, SorNamespace,
44    };
45    use std::collections::BTreeMap;
46
47    fn sample_metadata() -> ProviderMetadata {
48        ProviderMetadata {
49            provider_id: "greentic.sorla.provider.foundationdb".into(),
50            display_name: "FoundationDB".into(),
51            provider_kind: "event-store".into(),
52            version: "0.1.0".into(),
53            status: ProviderStatus::Experimental,
54            is_mock: false,
55            capabilities: vec![
56                ProviderCapability::EventAppend,
57                ProviderCapability::EventStreamRead,
58                ProviderCapability::ProjectionGet,
59                ProviderCapability::ProjectionPut,
60            ],
61            compatibility: ContractCompatibility::new(
62                SORLA_PROVIDER_CONTRACT_VERSION,
63                "0.1",
64                "0.1",
65            ),
66            ontology_capabilities: None,
67        }
68    }
69
70    #[test]
71    fn metadata_reports_capability_presence() {
72        let metadata = sample_metadata();
73        assert!(metadata.supports(ProviderCapability::ProjectionGet));
74        assert!(!metadata.supports(ProviderCapability::EvidenceQuery));
75    }
76
77    #[test]
78    fn compatibility_tracks_contract_version() {
79        let metadata = sample_metadata();
80        assert_eq!(
81            metadata.compatibility.supported_provider_contract_version,
82            SORLA_PROVIDER_CONTRACT_VERSION
83        );
84    }
85
86    #[test]
87    fn entity_ref_round_trips_with_generic_fields() {
88        let entity = EntityRef {
89            entity_type: "Contract".into(),
90            entity_id: "contract-001".into(),
91            namespace: Some("demo".into()),
92            version: Some("v1".into()),
93        };
94
95        let json = serde_json::to_string(&entity).expect("entity should serialize");
96        let parsed: EntityRef = serde_json::from_str(&json).expect("entity should deserialize");
97
98        assert_eq!(parsed, entity);
99    }
100
101    #[test]
102    fn sor_namespace_maps_to_existing_entity_namespace() {
103        let namespace = SorNamespace {
104            tenant_id: "tenant-a".into(),
105            sor_id: "contracts".into(),
106            environment_id: None,
107        };
108        let dev_namespace = SorNamespace {
109            tenant_id: "tenant-a".into(),
110            sor_id: "contracts".into(),
111            environment_id: Some("dev".into()),
112        };
113
114        assert_eq!(namespace.production_key(), "tenant-a\u{1f}contracts");
115        assert_eq!(namespace.to_entity_namespace(), "tenant-a/contracts");
116        assert_eq!(dev_namespace.production_key(), namespace.production_key());
117        assert_eq!(
118            dev_namespace.to_entity_namespace(),
119            "tenant-a/contracts/dev"
120        );
121    }
122
123    #[test]
124    fn canonical_entity_record_has_stable_json_shape() {
125        let record = CanonicalEntityRecord {
126            namespace: SorNamespace {
127                tenant_id: "tenant-a".into(),
128                sor_id: "contracts".into(),
129                environment_id: None,
130            },
131            entity_type: "Contract".into(),
132            entity_id: "contract-001".into(),
133            canonical_version: "2026-05-22".into(),
134            revision: 7,
135            data_json: serde_json::json!({
136                "status": "active",
137                "amount": 1250
138            }),
139            created_at: "2026-05-22T10:00:00Z".into(),
140            updated_at: "2026-05-22T11:00:00Z".into(),
141        };
142
143        let json = serde_json::to_string(&record).expect("record should serialize");
144        let parsed: CanonicalEntityRecord =
145            serde_json::from_str(&json).expect("record should deserialize");
146
147        assert_eq!(
148            json,
149            r#"{"namespace":{"tenant_id":"tenant-a","sor_id":"contracts"},"entity_type":"Contract","entity_id":"contract-001","canonical_version":"2026-05-22","revision":7,"data_json":{"amount":1250,"status":"active"},"created_at":"2026-05-22T10:00:00Z","updated_at":"2026-05-22T11:00:00Z"}"#
150        );
151        assert_eq!(parsed, record);
152        assert_eq!(
153            record.entity_ref(),
154            EntityRef {
155                entity_type: "Contract".into(),
156                entity_id: "contract-001".into(),
157                namespace: Some("tenant-a/contracts".into()),
158                version: Some("2026-05-22".into()),
159            }
160        );
161    }
162
163    #[test]
164    fn sor_event_record_has_stable_json_shape() {
165        let record = SorEventRecord {
166            namespace: SorNamespace {
167                tenant_id: "tenant-a".into(),
168                sor_id: "contracts".into(),
169                environment_id: Some("dev".into()),
170            },
171            event_id: "evt-001".into(),
172            stream_id: "Contract/contract-001".into(),
173            sequence: 3,
174            event_type: "contract.updated".into(),
175            entity_ref: EntityRef {
176                entity_type: "Contract".into(),
177                entity_id: "contract-001".into(),
178                namespace: Some("tenant-a/contracts/dev".into()),
179                version: Some("2026-05-22".into()),
180            },
181            command_id: Some("cmd-001".into()),
182            idempotency_key: Some("idem-001".into()),
183            actor: Some("user:123".into()),
184            source_view_version: None,
185            canonical_version: "2026-05-22".into(),
186            payload_json: serde_json::json!({"field": "status", "value": "active"}),
187            timestamp: "2026-05-22T11:00:00Z".into(),
188        };
189
190        let json = serde_json::to_string(&record).expect("record should serialize");
191        let parsed: SorEventRecord =
192            serde_json::from_str(&json).expect("record should deserialize");
193
194        assert_eq!(
195            json,
196            r#"{"namespace":{"tenant_id":"tenant-a","sor_id":"contracts","environment_id":"dev"},"event_id":"evt-001","stream_id":"Contract/contract-001","sequence":3,"event_type":"contract.updated","entity_ref":{"entity_type":"Contract","entity_id":"contract-001","namespace":"tenant-a/contracts/dev","version":"2026-05-22"},"command_id":"cmd-001","idempotency_key":"idem-001","actor":"user:123","canonical_version":"2026-05-22","payload_json":{"field":"status","value":"active"},"timestamp":"2026-05-22T11:00:00Z"}"#
197        );
198        assert_eq!(parsed, record);
199        assert!(!json.contains("source_view_version"));
200    }
201
202    #[test]
203    fn ontology_scope_round_trips_with_traversal_rules() {
204        let scope = OntologyScope {
205            root_entities: vec![EntityRef {
206                entity_type: "Customer".into(),
207                entity_id: "customer-001".into(),
208                namespace: None,
209                version: None,
210            }],
211            include_related: vec![RelationshipTraversalRule {
212                relationship_type: Some("has_contract".into()),
213                direction: RelationshipDirection::Outgoing,
214                max_depth: Some(2),
215            }],
216            max_depth: Some(2),
217            include_evidence_links: true,
218        };
219
220        let json = serde_json::to_string(&scope).expect("scope should serialize");
221        let parsed: OntologyScope = serde_json::from_str(&json).expect("scope should deserialize");
222
223        assert_eq!(parsed, scope);
224        assert!(json.contains("outgoing"));
225    }
226
227    #[test]
228    fn generic_evidence_query_filter_round_trips() {
229        let filter = EvidenceQueryFilter {
230            ontology_scope: None,
231            source_types: vec!["sharepoint".into()],
232            document_types: vec!["EvidenceDocument".into()],
233            metadata_json: Some(r#"{"sensitivity":"internal"}"#.into()),
234            time_range: None,
235            sensitivity_max: Some("internal".into()),
236        };
237
238        let json = serde_json::to_string(&filter).expect("filter should serialize");
239        let parsed: EvidenceQueryFilter =
240            serde_json::from_str(&json).expect("filter should deserialize");
241
242        assert_eq!(parsed, filter);
243        assert!(!json.contains("building_id"));
244        assert!(!json.contains("floor_id"));
245    }
246
247    #[test]
248    fn generic_external_reference_request_round_trips() {
249        let request = ExternalReferenceRequest {
250            reference_type: "document".into(),
251            reference_id: "doc-001".into(),
252            source_ref: Some("sharepoint://tenant/demo/document/doc-001".into()),
253            metadata_json: Some(r#"{"source_system":"sharepoint"}"#.into()),
254            ontology_scope: None,
255        };
256
257        let json = serde_json::to_string(&request).expect("request should serialize");
258        let parsed: ExternalReferenceRequest =
259            serde_json::from_str(&json).expect("request should deserialize");
260
261        assert_eq!(parsed, request);
262        assert!(!json.contains("building_id"));
263        assert!(!json.contains("floor_id"));
264    }
265
266    #[test]
267    fn ontology_capabilities_have_kebab_case_names() {
268        let serialized = serde_json::to_string(&ProviderCapability::OntologyScopedEvidenceQuery)
269            .expect("capability should serialize");
270
271        assert_eq!(serialized, "\"ontology-scoped-evidence-query\"");
272    }
273
274    #[test]
275    fn new_capabilities_have_kebab_case_names() {
276        assert_eq!(
277            serde_json::to_string(&ProviderCapability::CanonicalState)
278                .expect("capability should serialize"),
279            "\"canonical-state\""
280        );
281        assert_eq!(
282            serde_json::to_string(&ProviderCapability::TextSearchProjection)
283                .expect("capability should serialize"),
284            "\"text-search-projection\""
285        );
286    }
287
288    #[test]
289    fn metric_capabilities_have_kebab_case_names() {
290        assert_eq!(
291            serde_json::to_string(&ProviderCapability::MetricAggregateDistinctCount)
292                .expect("capability should serialize"),
293            "\"metric-aggregate-distinct-count\""
294        );
295        assert_eq!(
296            serde_json::to_string(&ProviderCapability::MetricDimensionGroupBy)
297                .expect("capability should serialize"),
298            "\"metric-dimension-group-by\""
299        );
300        assert_eq!(
301            serde_json::to_string(&ProviderCapability::MetricTimeBucketMonth)
302                .expect("capability should serialize"),
303            "\"metric-time-bucket-month\""
304        );
305    }
306
307    #[test]
308    fn metric_query_round_trips_with_generic_source_and_filters() {
309        let query = ProviderMetricQuery {
310            source: ProviderMetricSource::CanonicalEntities {
311                namespace: SorNamespace {
312                    tenant_id: "tenant-a".into(),
313                    sor_id: "commerce".into(),
314                    environment_id: Some("dev".into()),
315                },
316                entity_type: "Order".into(),
317            },
318            aggregations: vec![
319                ProviderMetricAggregation {
320                    alias: "order_count".into(),
321                    function: ProviderMetricAggregateFunction::Count,
322                    field: None,
323                },
324                ProviderMetricAggregation {
325                    alias: "revenue".into(),
326                    function: ProviderMetricAggregateFunction::Sum,
327                    field: Some("amount".into()),
328                },
329            ],
330            filters: vec![ProviderMetricFilter {
331                field: "status".into(),
332                operator: ProviderMetricFilterOperator::Equals,
333                value: Some(serde_json::json!("paid")),
334                values: vec![],
335            }],
336            dimensions: vec![ProviderMetricDimension {
337                field: "campaign_id".into(),
338                alias: Some("campaign".into()),
339            }],
340            time_bucket: Some(ProviderMetricTimeBucket {
341                field: "created_at".into(),
342                grain: ProviderMetricTimeGrain::Month,
343                alias: Some("month".into()),
344            }),
345            limit: Some(100),
346        };
347
348        let json = serde_json::to_string(&query).expect("metric query should serialize");
349        let parsed: ProviderMetricQuery =
350            serde_json::from_str(&json).expect("metric query should deserialize");
351
352        assert_eq!(parsed, query);
353        assert!(json.contains("\"kind\":\"canonical-entities\""));
354        assert!(json.contains("\"function\":\"sum\""));
355        assert!(json.contains("\"operator\":\"equals\""));
356        assert!(!json.contains("daily_clicks"));
357    }
358
359    #[test]
360    fn metric_result_has_stable_row_shape() {
361        let mut dimensions = BTreeMap::new();
362        dimensions.insert(
363            "month".into(),
364            ProviderMetricValue::String("2026-05".into()),
365        );
366        dimensions.insert(
367            "campaign".into(),
368            ProviderMetricValue::String("campaign-a".into()),
369        );
370
371        let mut metrics = BTreeMap::new();
372        metrics.insert("order_count".into(), ProviderMetricValue::Number(3.0));
373        metrics.insert("revenue".into(), ProviderMetricValue::Number(120.5));
374
375        let result = ProviderMetricResult {
376            source: ProviderMetricSource::Fixture {
377                name: "commerce".into(),
378            },
379            rows: vec![ProviderMetricRow {
380                dimensions,
381                metrics,
382            }],
383        };
384
385        let json = serde_json::to_string(&result).expect("metric result should serialize");
386        let parsed: ProviderMetricResult =
387            serde_json::from_str(&json).expect("metric result should deserialize");
388
389        assert_eq!(parsed, result);
390        assert_eq!(
391            json,
392            r#"{"source":{"kind":"fixture","name":"commerce"},"rows":[{"dimensions":{"campaign":{"type":"string","value":"campaign-a"},"month":{"type":"string","value":"2026-05"}},"metrics":{"order_count":{"type":"number","value":3.0},"revenue":{"type":"number","value":120.5}}}]}"#
393        );
394    }
395
396    #[test]
397    fn path_query_and_entity_link_round_trip() {
398        let from = EntityRef {
399            entity_type: "Customer".into(),
400            entity_id: "customer-001".into(),
401            namespace: None,
402            version: None,
403        };
404        let to = EntityRef {
405            entity_type: "EvidenceDocument".into(),
406            entity_id: "doc-001".into(),
407            namespace: None,
408            version: None,
409        };
410        let query = PathQuery {
411            from,
412            to: to.clone(),
413            relationship_types: vec!["supports".into()],
414            max_depth: 4,
415            limit: 8,
416        };
417        let link = EntityLink {
418            entity: to,
419            source_ref: "sharepoint://tenant/demo/document/doc-001".into(),
420            evidence_id: Some("evidence-001".into()),
421            confidence: 1.0,
422            match_kind: "external-id".into(),
423            provenance: "test".into(),
424            metadata_json: None,
425        };
426        let request = EntityLinkRequest {
427            source_ref: Some(link.source_ref.clone()),
428            evidence_id: link.evidence_id.clone(),
429            content_json: None,
430            candidate_types: vec!["EvidenceDocument".into()],
431            ontology_scope: None,
432        };
433
434        let query_json = serde_json::to_string(&query).expect("path query should serialize");
435        let link_json = serde_json::to_string(&link).expect("link should serialize");
436        let request_json = serde_json::to_string(&request).expect("link request should serialize");
437
438        assert_eq!(
439            serde_json::from_str::<PathQuery>(&query_json).expect("path query should deserialize"),
440            query
441        );
442        assert_eq!(
443            serde_json::from_str::<EntityLink>(&link_json).expect("link should deserialize"),
444            link
445        );
446        assert_eq!(
447            serde_json::from_str::<EntityLinkRequest>(&request_json)
448                .expect("link request should deserialize"),
449            request
450        );
451    }
452
453    #[test]
454    fn ontology_compatibility_validates_schema_ranges() {
455        let valid = OntologyContractCompatibility {
456            supported_ontology_schema: "greentic.sorla.ontology.v1".into(),
457            supported_ontology_schema_range: ">=1.0.0, <2.0.0".into(),
458            supported_retrieval_binding_schema: Some("greentic.sorla.retrieval-bindings.v1".into()),
459            supported_external_mapping_schema: None,
460        };
461        let invalid = OntologyContractCompatibility {
462            supported_ontology_schema: "greentic.sorla.ontology.v1".into(),
463            supported_ontology_schema_range: "not a range".into(),
464            supported_retrieval_binding_schema: None,
465            supported_external_mapping_schema: None,
466        };
467
468        assert!(valid.parses_schema_range());
469        assert!(!invalid.parses_schema_range());
470    }
471
472    #[test]
473    fn ontology_capabilities_deserialize_legacy_v1_without_new_metadata() {
474        let json = r#"{
475            "schema":"greentic.sorla.provider.ontology-capabilities.v1",
476            "compatibility":{
477                "supported_ontology_schema":"greentic.sorla.ontology.v1",
478                "supported_ontology_schema_range":">=1.0.0, <2.0.0",
479                "supported_retrieval_binding_schema":null,
480                "supported_external_mapping_schema":null
481            },
482            "supports_entity_read":true,
483            "supports_entity_search":true,
484            "supports_relationship_query":false,
485            "supports_path_find":false,
486            "supports_entity_linking":false,
487            "supports_ontology_scoped_evidence":false,
488            "supported_concept_types":["*"],
489            "supported_relationship_types":[],
490            "max_traversal_depth":null,
491            "supports_policy_context":false
492        }"#;
493
494        let capabilities: super::ProviderOntologyCapabilities =
495            serde_json::from_str(json).expect("legacy capabilities should deserialize");
496
497        assert!(capabilities.index_capabilities.is_none());
498        assert!(capabilities.search_capabilities.is_none());
499    }
500
501    #[test]
502    fn structured_index_and_search_capabilities_round_trip() {
503        let index = ProviderIndexCapabilities {
504            exact: true,
505            composite: false,
506        };
507        let search = ProviderSearchCapabilities {
508            text_projection: ProjectionSupport::Optional,
509            vector_projection: ProjectionSupport::Unavailable,
510        };
511
512        let json = serde_json::to_string(&(index.clone(), search.clone()))
513            .expect("capabilities should serialize");
514        let parsed: (ProviderIndexCapabilities, ProviderSearchCapabilities) =
515            serde_json::from_str(&json).expect("capabilities should deserialize");
516
517        assert_eq!(parsed, (index, search));
518        assert!(json.contains("text_projection"));
519        assert!(json.contains("optional"));
520    }
521}