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
29pub 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}