1pub mod ast;
2pub mod parser;
3
4pub const PRODUCT_NAME: &str = "SoRLa";
5pub const PRODUCT_LINE: &str = "wizard-first";
6
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct ProductBoundary {
9 pub providers_live_in: &'static str,
10 pub owns_provider_implementations: bool,
11}
12
13pub fn product_boundary() -> ProductBoundary {
14 ProductBoundary {
15 providers_live_in: "greentic-sorla-providers",
16 owns_provider_implementations: false,
17 }
18}
19
20#[cfg(test)]
21mod tests {
22 use super::*;
23 use crate::ast::{
24 AgentEndpointApprovalMode, AgentEndpointRisk, FieldAuthority, ProjectionMode, RecordSource,
25 ViewMode,
26 };
27 use crate::parser::parse_package;
28
29 #[test]
30 fn product_boundary_points_to_providers_repo() {
31 let boundary = product_boundary();
32 assert_eq!(boundary.providers_live_in, "greentic-sorla-providers");
33 assert!(!boundary.owns_provider_implementations);
34 }
35
36 #[test]
37 fn parses_native_v0_1_style_record_with_warning() {
38 let parsed = parse_package(
39 r#"
40package:
41 name: leasing
42 version: 0.2.0
43records:
44 - name: Tenant
45 fields:
46 - name: tenant_id
47 type: string
48events:
49 - name: TenantRegistered
50 record: Tenant
51 emits:
52 - name: tenant_id
53 type: string
54projections:
55 - name: TenantCurrentState
56 record: Tenant
57 source_event: TenantRegistered
58"#,
59 )
60 .expect("native record should parse");
61
62 assert_eq!(parsed.package.records[0].source, Some(RecordSource::Native));
63 assert!(parsed.package.agent_endpoints.is_empty());
64 assert_eq!(parsed.warnings.len(), 1);
65 assert_eq!(
66 parsed.package.projections[0].mode,
67 ProjectionMode::CurrentState
68 );
69 }
70
71 #[test]
72 fn parses_external_record_with_authoritative_reference() {
73 let parsed = parse_package(
74 r#"
75package:
76 name: fitout
77 version: 0.2.0
78records:
79 - name: LeaseCase
80 source: external
81 external_ref:
82 system: sharepoint
83 key: lease_case_id
84 authoritative: true
85 fields:
86 - name: lease_case_id
87 type: string
88provider_requirements:
89 - category: external-ref
90 capabilities:
91 - lookup
92events:
93 - name: LeaseCaseSynced
94 record: LeaseCase
95"#,
96 )
97 .expect("external record should parse");
98
99 assert_eq!(
100 parsed.package.records[0].source,
101 Some(RecordSource::External)
102 );
103 assert!(
104 parsed.package.records[0]
105 .external_ref
106 .as_ref()
107 .expect("external ref present")
108 .authoritative
109 );
110 assert_eq!(parsed.package.provider_requirements.len(), 1);
111 }
112
113 #[test]
114 fn parses_agent_endpoint_language_model() {
115 let parsed = parse_package(
116 r#"
117package:
118 name: website-lead-capture
119 version: 0.2.0
120records:
121 - name: Contact
122 source: hybrid
123 external_ref:
124 system: hubspot
125 key: email
126 authoritative: true
127 fields:
128 - name: email
129 type: string
130 authority: external
131 - name: problem_to_solve
132 type: string
133 authority: local
134actions:
135 - name: UpsertContact
136events:
137 - name: ContactCaptured
138 record: Contact
139 kind: integration
140approvals:
141 - name: ReviewHighValuePartner
142agent_endpoints:
143 - id: create_customer_contact
144 title: Create customer contact
145 intent: Capture a customer website enquiry and create or update the CRM contact.
146 description: Use this when a visitor wants to learn more as a customer.
147 inputs:
148 - name: email
149 type: string
150 required: true
151 sensitive: true
152 - name: company_size
153 type: string
154 enum_values:
155 - small
156 - enterprise
157 outputs:
158 - name: contact_id
159 type: string
160 description: CRM contact identifier.
161 side_effects:
162 - crm.contact.upsert
163 - event.ContactCaptured
164 risk: medium
165 approval: policy-driven
166 provider_requirements:
167 - category: crm
168 capabilities:
169 - contacts.read
170 - contacts.write
171 backing:
172 actions:
173 - UpsertContact
174 events:
175 - ContactCaptured
176 approvals:
177 - ReviewHighValuePartner
178 agent_visibility:
179 openapi: true
180 arazzo: false
181 mcp: true
182 llms_txt: true
183 examples:
184 - name: customer-lead
185 summary: Capture an inbound website lead.
186 input:
187 email: buyer@example.com
188 company_size: enterprise
189 expected_output:
190 contact_id: contact-123
191"#,
192 )
193 .expect("agent endpoint should parse");
194
195 let endpoint = &parsed.package.agent_endpoints[0];
196 assert_eq!(endpoint.id, "create_customer_contact");
197 assert_eq!(endpoint.inputs[0].type_name, "string");
198 assert!(endpoint.inputs[0].required);
199 assert!(endpoint.inputs[0].sensitive);
200 assert_eq!(endpoint.inputs[1].enum_values, ["small", "enterprise"]);
201 assert_eq!(endpoint.outputs[0].name, "contact_id");
202 assert_eq!(endpoint.risk, AgentEndpointRisk::Medium);
203 assert_eq!(endpoint.approval, AgentEndpointApprovalMode::PolicyDriven);
204 assert_eq!(endpoint.provider_requirements[0].category, "crm");
205 assert_eq!(endpoint.backing.actions, ["UpsertContact"]);
206 assert_eq!(endpoint.backing.events, ["ContactCaptured"]);
207 assert_eq!(endpoint.backing.approvals, ["ReviewHighValuePartner"]);
208 assert!(!endpoint.agent_visibility.arazzo);
209 assert_eq!(
210 endpoint.examples[0].input["email"].as_str(),
211 Some("buyer@example.com")
212 );
213 assert_eq!(
214 endpoint.examples[0].expected_output["contact_id"].as_str(),
215 Some("contact-123")
216 );
217 }
218
219 #[test]
220 fn agent_endpoint_defaults_are_language_facing() {
221 let parsed = parse_package(
222 r#"
223package:
224 name: minimal-agent
225 version: 0.2.0
226agent_endpoints:
227 - id: create_contact
228 title: Create contact
229 intent: Create a CRM contact.
230"#,
231 )
232 .expect("minimal endpoint should parse");
233
234 let endpoint = &parsed.package.agent_endpoints[0];
235 assert_eq!(endpoint.risk, AgentEndpointRisk::Low);
236 assert_eq!(endpoint.approval, AgentEndpointApprovalMode::None);
237 assert!(endpoint.inputs.is_empty());
238 assert!(endpoint.outputs.is_empty());
239 assert!(endpoint.backing.actions.is_empty());
240 assert!(endpoint.agent_visibility.openapi);
241 assert!(endpoint.agent_visibility.arazzo);
242 assert!(endpoint.agent_visibility.mcp);
243 assert!(endpoint.agent_visibility.llms_txt);
244 }
245
246 #[test]
247 fn rejects_duplicate_agent_endpoint_ids() {
248 let error = parse_package(
249 r#"
250package:
251 name: duplicate-endpoints
252 version: 0.2.0
253agent_endpoints:
254 - id: create_contact
255 title: Create contact
256 intent: Create a contact.
257 - id: create_contact
258 title: Duplicate
259 intent: Duplicate endpoint.
260"#,
261 )
262 .expect_err("duplicate endpoint IDs should be rejected");
263
264 assert!(error.contains("agent_endpoints[1].id"));
265 assert!(error.contains("duplicate agent endpoint id `create_contact`"));
266 }
267
268 #[test]
269 fn rejects_duplicate_agent_endpoint_input_and_output_names() {
270 let duplicate_input = parse_package(
271 r#"
272package:
273 name: duplicate-inputs
274 version: 0.2.0
275agent_endpoints:
276 - id: create_contact
277 title: Create contact
278 intent: Create a contact.
279 inputs:
280 - name: email
281 type: string
282 - name: email
283 type: string
284"#,
285 )
286 .expect_err("duplicate input names should be rejected");
287 assert!(duplicate_input.contains("agent_endpoints[0].inputs[1].name"));
288 assert!(duplicate_input.contains("duplicate input name `email`"));
289
290 let duplicate_output = parse_package(
291 r#"
292package:
293 name: duplicate-outputs
294 version: 0.2.0
295agent_endpoints:
296 - id: create_contact
297 title: Create contact
298 intent: Create a contact.
299 outputs:
300 - name: contact_id
301 type: string
302 - name: contact_id
303 type: string
304"#,
305 )
306 .expect_err("duplicate output names should be rejected");
307 assert!(duplicate_output.contains("agent_endpoints[0].outputs[1].name"));
308 assert!(duplicate_output.contains("duplicate output name `contact_id`"));
309 }
310
311 #[test]
312 fn rejects_empty_agent_endpoint_intent() {
313 let error = parse_package(
314 r#"
315package:
316 name: empty-intent
317 version: 0.2.0
318agent_endpoints:
319 - id: create_contact
320 title: Create contact
321 intent: ""
322"#,
323 )
324 .expect_err("empty endpoint intent should be rejected");
325
326 assert!(error.contains("agent_endpoints[0].intent"));
327 assert!(error.contains("agent endpoint intent must be non-empty"));
328 }
329
330 #[test]
331 fn rejects_high_risk_agent_endpoint_without_required_or_policy_approval() {
332 let error = parse_package(
333 r#"
334package:
335 name: risky-endpoint
336 version: 0.2.0
337agent_endpoints:
338 - id: delete_customer
339 title: Delete customer
340 intent: Delete a customer record.
341 risk: high
342 approval: none
343 side_effects:
344 - crm.contact.delete
345"#,
346 )
347 .expect_err("high-risk endpoint without approval should be rejected");
348
349 assert!(error.contains("agent_endpoints[0].approval"));
350 assert!(error.contains(
351 "high-risk agent endpoint `delete_customer` must use approval: required or approval: policy-driven"
352 ));
353 }
354
355 #[test]
356 fn rejects_unknown_agent_endpoint_backing_references() {
357 let action_error = parse_package(
358 r#"
359package:
360 name: bad-action-ref
361 version: 0.2.0
362agent_endpoints:
363 - id: create_contact
364 title: Create contact
365 intent: Create a contact.
366 backing:
367 actions:
368 - MissingAction
369"#,
370 )
371 .expect_err("unknown backing action should be rejected");
372 assert!(action_error.contains("agent_endpoints[0].backing.actions[0]"));
373 assert!(action_error.contains("unknown backing action reference `MissingAction`"));
374
375 let event_error = parse_package(
376 r#"
377package:
378 name: bad-event-ref
379 version: 0.2.0
380agent_endpoints:
381 - id: create_contact
382 title: Create contact
383 intent: Create a contact.
384 backing:
385 events:
386 - MissingEvent
387"#,
388 )
389 .expect_err("unknown backing event should be rejected");
390 assert!(event_error.contains("agent_endpoints[0].backing.events[0]"));
391 assert!(event_error.contains("unknown backing event reference `MissingEvent`"));
392 }
393
394 #[test]
395 fn validates_agent_endpoint_provider_requirements() {
396 let empty_category = parse_package(
397 r#"
398package:
399 name: empty-provider-category
400 version: 0.2.0
401agent_endpoints:
402 - id: create_contact
403 title: Create contact
404 intent: Create a contact.
405 provider_requirements:
406 - category: ""
407 capabilities:
408 - contacts.write
409"#,
410 )
411 .expect_err("empty provider category should be rejected");
412 assert!(empty_category.contains("agent_endpoints[0].provider_requirements[0].category"));
413
414 let duplicate_capability = parse_package(
415 r#"
416package:
417 name: duplicate-provider-capability
418 version: 0.2.0
419agent_endpoints:
420 - id: create_contact
421 title: Create contact
422 intent: Create a contact.
423 provider_requirements:
424 - category: crm
425 capabilities:
426 - contacts.write
427 - contacts.write
428"#,
429 )
430 .expect_err("duplicate provider capabilities should be rejected");
431 assert!(
432 duplicate_capability
433 .contains("agent_endpoints[0].provider_requirements[0].capabilities[1]")
434 );
435 assert!(duplicate_capability.contains("duplicate provider capability `contacts.write`"));
436 }
437
438 #[test]
439 fn warns_for_agent_endpoint_without_examples() {
440 let parsed = parse_package(
441 r#"
442package:
443 name: no-examples
444 version: 0.2.0
445agent_endpoints:
446 - id: create_contact
447 title: Create contact
448 intent: Create a contact.
449 outputs:
450 - name: contact_id
451 type: string
452 agent_visibility:
453 openapi: false
454 arazzo: false
455 mcp: false
456 llms_txt: false
457"#,
458 )
459 .expect("endpoint without examples should parse with warning");
460
461 assert!(parsed.warnings.iter().any(|warning| {
462 warning.path == "agent_endpoints[0].examples"
463 && warning.message.contains("has no examples")
464 }));
465 }
466
467 #[test]
468 fn parses_hybrid_record_with_field_level_authority() {
469 let parsed = parse_package(
470 r#"
471package:
472 name: tenancy
473 version: 0.2.0
474records:
475 - name: Tenant
476 source: hybrid
477 external_ref:
478 system: crm
479 key: tenant_id
480 authoritative: true
481 fields:
482 - name: tenant_id
483 type: string
484 authority: external
485 - name: approval_state
486 type: string
487 authority: local
488events:
489 - name: TenantApprovalRequested
490 record: Tenant
491 kind: domain
492projections:
493 - name: TenantCurrentState
494 record: Tenant
495 source_event: TenantApprovalRequested
496migrations:
497 - name: tenant-projection-v2
498 compatibility: additive
499 projection_updates:
500 - TenantCurrentState
501"#,
502 )
503 .expect("hybrid record should parse");
504
505 let fields = &parsed.package.records[0].fields;
506 assert_eq!(fields[0].authority, Some(FieldAuthority::External));
507 assert_eq!(fields[1].authority, Some(FieldAuthority::Local));
508 assert_eq!(parsed.package.migrations[0].projection_updates.len(), 1);
509 }
510
511 #[test]
512 fn rejects_hybrid_record_without_field_authority() {
513 let error = parse_package(
514 r#"
515package:
516 name: tenancy
517 version: 0.2.0
518records:
519 - name: Tenant
520 source: hybrid
521 external_ref:
522 system: crm
523 key: tenant_id
524 authoritative: true
525 fields:
526 - name: tenant_id
527 type: string
528events: []
529"#,
530 )
531 .expect_err("hybrid records must declare field-level authority");
532
533 assert!(error.contains("field `tenant_id` must declare `authority: local|external`"));
534 }
535
536 #[test]
537 fn parses_executable_relationships_migrations_and_agent_emits() {
538 let parsed = parse_package(
539 r#"
540package:
541 name: leasing
542 version: 0.3.0
543records:
544 - name: Landlord
545 fields:
546 - name: id
547 type: string
548 - name: Tenant
549 fields:
550 - name: id
551 type: string
552 - name: landlord_id
553 type: string
554 references:
555 record: Landlord
556 field: id
557 - name: preferred_contact_method
558 type: string
559events:
560 - name: TenantCreated
561 record: Tenant
562projections:
563 - name: TenantCurrentState
564 record: Tenant
565 source_event: TenantCreated
566migrations:
567 - name: tenant-v2
568 compatibility: additive
569 idempotence_key: tenant-v2-fields
570 backfills:
571 - record: Tenant
572 field: preferred_contact_method
573 default: email
574 projection_updates:
575 - TenantCurrentState
576agent_endpoints:
577 - id: create_tenant
578 title: Create tenant
579 intent: Create a tenant and emit the event contract.
580 inputs:
581 - name: full_name
582 type: string
583 required: true
584 outputs:
585 - name: tenant_id
586 type: string
587 emits:
588 event: TenantCreated
589 stream: "tenant/{tenant_id}"
590 payload:
591 id: "$generated.tenant_id"
592 full_name: "$input.full_name"
593"#,
594 )
595 .expect("executable contract fields should parse");
596
597 let tenant = &parsed.package.records[1];
598 assert_eq!(
599 tenant.fields[1]
600 .references
601 .as_ref()
602 .expect("tenant landlord reference should parse")
603 .record,
604 "Landlord"
605 );
606 assert_eq!(
607 parsed.package.migrations[0].idempotence_key.as_deref(),
608 Some("tenant-v2-fields")
609 );
610 assert_eq!(
611 parsed.package.migrations[0].backfills[0].field,
612 "preferred_contact_method"
613 );
614 assert_eq!(
615 parsed.package.agent_endpoints[0]
616 .emits
617 .as_ref()
618 .expect("endpoint emit should parse")
619 .event,
620 "TenantCreated"
621 );
622 }
623
624 #[test]
625 fn rejects_invalid_executable_contract_references() {
626 let unknown_record = parse_package(
627 r#"
628package:
629 name: bad-ref
630 version: 0.1.0
631records:
632 - name: Tenant
633 fields:
634 - name: id
635 type: string
636 - name: landlord_id
637 type: string
638 references:
639 record: Landlord
640 field: id
641events: []
642"#,
643 )
644 .expect_err("unknown relationship target should be rejected");
645 assert!(
646 unknown_record.contains("unknown referenced record `Landlord`"),
647 "{unknown_record}"
648 );
649
650 let unknown_input = parse_package(
651 r#"
652package:
653 name: bad-emit
654 version: 0.1.0
655records:
656 - name: Tenant
657 fields:
658 - name: id
659 type: string
660events:
661 - name: TenantCreated
662 record: Tenant
663agent_endpoints:
664 - id: create_tenant
665 title: Create tenant
666 intent: Create a tenant.
667 inputs:
668 - name: full_name
669 type: string
670 emits:
671 event: TenantCreated
672 stream: "tenant/{tenant_id}"
673 payload:
674 full_name: "$input.display_name"
675"#,
676 )
677 .expect_err("unknown input template should be rejected");
678 assert!(unknown_input.contains("payload.full_name"));
679 assert!(
680 unknown_input.contains("unknown input reference `$input.display_name`"),
681 "{unknown_input}"
682 );
683 }
684
685 #[test]
686 fn parses_valid_ontology_model() {
687 let parsed = parse_package(
688 r#"
689package:
690 name: ontology-demo
691 version: 0.1.0
692records:
693 - name: Customer
694 fields:
695 - name: id
696 type: string
697 - name: Contract
698 fields:
699 - name: id
700 type: string
701 - name: CustomerContract
702 fields:
703 - name: customer_id
704 type: string
705 - name: contract_id
706 type: string
707ontology:
708 schema: greentic.sorla.ontology.v1
709 concepts:
710 - id: Party
711 kind: abstract
712 - id: Customer
713 kind: entity
714 extends: Party
715 backed_by:
716 record: Customer
717 sensitivity:
718 classification: confidential
719 pii: true
720 - id: Contract
721 kind: entity
722 backed_by:
723 record: Contract
724 relationships:
725 - id: has_contract
726 label: has contract
727 from: Customer
728 to: Contract
729 cardinality:
730 from: one
731 to: many
732 backed_by:
733 record: CustomerContract
734 from_field: customer_id
735 to_field: contract_id
736 constraints:
737 - id: customer_policy
738 applies_to:
739 concept: Customer
740 requires_policy: customer_data_access
741"#,
742 )
743 .expect("valid ontology should parse");
744
745 let ontology = parsed.package.ontology.expect("ontology should parse");
746 assert_eq!(ontology.concepts.len(), 3);
747 assert_eq!(ontology.concepts[1].extends, ["Party"]);
748 assert!(ontology.concepts[1].sensitivity.as_ref().unwrap().pii);
749 assert_eq!(ontology.relationships[0].id, "has_contract");
750 }
751
752 #[test]
753 fn parses_semantic_aliases_and_entity_linking() {
754 let parsed = parse_package(
755 r#"
756package:
757 name: ontology-demo
758 version: 0.1.0
759records:
760 - name: Customer
761 source: native
762 fields:
763 - name: id
764 type: string
765 - name: email
766 type: string
767ontology:
768 schema: greentic.sorla.ontology.v1
769 concepts:
770 - id: Customer
771 kind: entity
772 backed_by:
773 record: Customer
774 relationships: []
775semantic_aliases:
776 concepts:
777 Customer:
778 - client
779 - account holder
780entity_linking:
781 strategies:
782 - id: email_match
783 applies_to: Customer
784 match:
785 source_field: email
786 target_field: email
787 confidence: 0.95
788 sensitivity:
789 pii: true
790"#,
791 )
792 .expect("semantic aliases and linking should parse");
793
794 let aliases = parsed
795 .package
796 .semantic_aliases
797 .expect("semantic aliases should parse");
798 assert_eq!(aliases.concepts["Customer"], ["client", "account holder"]);
799 let linking = parsed
800 .package
801 .entity_linking
802 .expect("entity linking should parse");
803 assert_eq!(linking.strategies[0].id, "email_match");
804 assert_eq!(linking.strategies[0].confidence.0, 950_000);
805 }
806
807 #[test]
808 fn semantic_aliases_warn_on_duplicate_and_reject_collisions() {
809 let duplicate = parse_package(
810 r#"
811package:
812 name: duplicate-alias
813 version: 0.1.0
814ontology:
815 schema: greentic.sorla.ontology.v1
816 concepts:
817 - id: Customer
818 kind: entity
819 relationships: []
820semantic_aliases:
821 concepts:
822 Customer:
823 - client
824 - " Client "
825"#,
826 )
827 .expect("duplicate alias on the same target should parse with warning");
828 assert!(
829 duplicate
830 .warnings
831 .iter()
832 .any(|warning| warning.path.contains("semantic_aliases.concepts.Customer"))
833 );
834
835 let collision = parse_package(
836 r#"
837package:
838 name: alias-collision
839 version: 0.1.0
840ontology:
841 schema: greentic.sorla.ontology.v1
842 concepts:
843 - id: Customer
844 kind: entity
845 - id: Contract
846 kind: entity
847 relationships: []
848semantic_aliases:
849 concepts:
850 Customer:
851 - client
852 Contract:
853 - " client "
854"#,
855 )
856 .expect_err("same normalized alias on different targets should fail");
857 assert!(collision.contains("collides"));
858 }
859
860 #[test]
861 fn rejects_invalid_alias_and_linking_references() {
862 let unknown_alias_concept = parse_package(
863 r#"
864package:
865 name: unknown-alias
866 version: 0.1.0
867ontology:
868 schema: greentic.sorla.ontology.v1
869 concepts:
870 - id: Customer
871 kind: entity
872 relationships: []
873semantic_aliases:
874 concepts:
875 Missing:
876 - client
877"#,
878 )
879 .expect_err("unknown alias concept should fail");
880 assert!(unknown_alias_concept.contains("unknown ontology concept"));
881
882 let unknown_alias_relationship = parse_package(
883 r#"
884package:
885 name: unknown-relationship-alias
886 version: 0.1.0
887ontology:
888 schema: greentic.sorla.ontology.v1
889 concepts:
890 - id: Customer
891 kind: entity
892 relationships: []
893semantic_aliases:
894 relationships:
895 owns:
896 - belongs to
897"#,
898 )
899 .expect_err("unknown alias relationship should fail");
900 assert!(unknown_alias_relationship.contains("unknown ontology relationship"));
901
902 let unknown_field = parse_package(
903 r#"
904package:
905 name: unknown-link-field
906 version: 0.1.0
907records:
908 - name: Customer
909 source: native
910 fields:
911 - name: id
912 type: string
913ontology:
914 schema: greentic.sorla.ontology.v1
915 concepts:
916 - id: Customer
917 kind: entity
918 backed_by:
919 record: Customer
920 relationships: []
921entity_linking:
922 strategies:
923 - id: email_match
924 applies_to: Customer
925 match:
926 source_field: email
927 target_field: email
928 confidence: 0.95
929"#,
930 )
931 .expect_err("unknown target field should fail");
932 assert!(unknown_field.contains("entity_linking.strategies[0].match.target_field"));
933 }
934
935 #[test]
936 fn rejects_invalid_entity_linking_strategy_shape() {
937 let out_of_range = parse_package(
938 r#"
939package:
940 name: bad-confidence
941 version: 0.1.0
942ontology:
943 schema: greentic.sorla.ontology.v1
944 concepts:
945 - id: Customer
946 kind: entity
947 relationships: []
948entity_linking:
949 strategies:
950 - id: email_match
951 applies_to: Customer
952 source_type: document
953 match:
954 source_field: email
955 target_field: email
956 confidence: 1.5
957"#,
958 )
959 .expect_err("out of range confidence should fail");
960 assert!(out_of_range.contains("confidence must be between"));
961
962 let duplicate_id = parse_package(
963 r#"
964package:
965 name: duplicate-strategy
966 version: 0.1.0
967ontology:
968 schema: greentic.sorla.ontology.v1
969 concepts:
970 - id: Customer
971 kind: entity
972 relationships: []
973entity_linking:
974 strategies:
975 - id: email_match
976 applies_to: Customer
977 source_type: document
978 match:
979 source_field: email
980 target_field: email
981 confidence: 0.9
982 - id: email_match
983 applies_to: Customer
984 source_type: document
985 match:
986 source_field: external_email
987 target_field: email
988 confidence: 0.8
989"#,
990 )
991 .expect_err("duplicate strategy id should fail");
992 assert!(duplicate_id.contains("duplicate entity-linking strategy id"));
993 }
994
995 #[test]
996 fn parses_valid_retrieval_bindings() {
997 let parsed = parse_package(
998 r#"
999package:
1000 name: retrieval-demo
1001 version: 0.1.0
1002ontology:
1003 schema: greentic.sorla.ontology.v1
1004 concepts:
1005 - id: Customer
1006 kind: entity
1007 - id: Contract
1008 kind: entity
1009 relationships:
1010 - id: governed_by
1011 from: Customer
1012 to: Contract
1013retrieval_bindings:
1014 schema: greentic.sorla.retrieval-bindings.v1
1015 providers:
1016 - id: primary_evidence
1017 category: evidence
1018 required_capabilities:
1019 - entity.link
1020 - evidence.query
1021 scopes:
1022 - id: customer_evidence
1023 applies_to:
1024 concept: Customer
1025 provider: primary_evidence
1026 filters:
1027 entity_scope:
1028 include_self: true
1029 include_related:
1030 - relationship: governed_by
1031 direction: outgoing
1032 max_depth: 1
1033"#,
1034 )
1035 .expect("retrieval bindings should parse");
1036 let bindings = parsed
1037 .package
1038 .retrieval_bindings
1039 .expect("retrieval bindings should be present");
1040 assert_eq!(bindings.providers[0].id, "primary_evidence");
1041 assert_eq!(bindings.scopes[0].id, "customer_evidence");
1042 }
1043
1044 #[test]
1045 fn rejects_invalid_retrieval_bindings() {
1046 let unknown_concept = parse_package(
1047 r#"
1048package:
1049 name: bad-retrieval-concept
1050 version: 0.1.0
1051ontology:
1052 schema: greentic.sorla.ontology.v1
1053 concepts:
1054 - id: Customer
1055 kind: entity
1056 relationships: []
1057retrieval_bindings:
1058 schema: greentic.sorla.retrieval-bindings.v1
1059 providers:
1060 - id: evidence
1061 category: evidence
1062 scopes:
1063 - id: missing
1064 applies_to:
1065 concept: Missing
1066 provider: evidence
1067"#,
1068 )
1069 .expect_err("unknown concept should fail");
1070 assert!(unknown_concept.contains("applies_to.concept"));
1071
1072 let invalid_depth = parse_package(
1073 r#"
1074package:
1075 name: bad-retrieval-depth
1076 version: 0.1.0
1077ontology:
1078 schema: greentic.sorla.ontology.v1
1079 concepts:
1080 - id: Customer
1081 kind: entity
1082 - id: Contract
1083 kind: entity
1084 relationships:
1085 - id: governed_by
1086 from: Customer
1087 to: Contract
1088retrieval_bindings:
1089 schema: greentic.sorla.retrieval-bindings.v1
1090 providers:
1091 - id: evidence
1092 category: evidence
1093 scopes:
1094 - id: too_deep
1095 applies_to:
1096 concept: Customer
1097 provider: evidence
1098 filters:
1099 entity_scope:
1100 include_related:
1101 - relationship: governed_by
1102 direction: both
1103 max_depth: 6
1104"#,
1105 )
1106 .expect_err("invalid max depth should fail");
1107 assert!(invalid_depth.contains("max_depth"));
1108
1109 let unknown_provider = parse_package(
1110 r#"
1111package:
1112 name: bad-retrieval-provider
1113 version: 0.1.0
1114ontology:
1115 schema: greentic.sorla.ontology.v1
1116 concepts:
1117 - id: Customer
1118 kind: entity
1119 relationships: []
1120retrieval_bindings:
1121 schema: greentic.sorla.retrieval-bindings.v1
1122 providers: []
1123 scopes:
1124 - id: customer_evidence
1125 applies_to:
1126 concept: Customer
1127 provider: missing
1128"#,
1129 )
1130 .expect_err("unknown provider should fail");
1131 assert!(unknown_provider.contains("unknown retrieval provider"));
1132 }
1133
1134 #[test]
1135 fn rejects_invalid_ontology_references() {
1136 let duplicate = parse_package(
1137 r#"
1138package:
1139 name: duplicate-concepts
1140 version: 0.1.0
1141ontology:
1142 schema: greentic.sorla.ontology.v1
1143 concepts:
1144 - id: Customer
1145 kind: entity
1146 - id: Customer
1147 kind: abstract
1148"#,
1149 )
1150 .expect_err("duplicate concept should fail");
1151 assert!(duplicate.contains("ontology.concepts[1].id"));
1152
1153 let unknown_concept = parse_package(
1154 r#"
1155package:
1156 name: unknown-relationship-concept
1157 version: 0.1.0
1158ontology:
1159 schema: greentic.sorla.ontology.v1
1160 concepts:
1161 - id: Customer
1162 kind: entity
1163 relationships:
1164 - id: owns
1165 from: Customer
1166 to: Asset
1167"#,
1168 )
1169 .expect_err("unknown relationship target should fail");
1170 assert!(unknown_concept.contains("ontology.relationships[0].to"));
1171
1172 let cycle = parse_package(
1173 r#"
1174package:
1175 name: cyclic-ontology
1176 version: 0.1.0
1177ontology:
1178 schema: greentic.sorla.ontology.v1
1179 concepts:
1180 - id: Customer
1181 kind: entity
1182 extends: Party
1183 - id: Party
1184 kind: abstract
1185 extends: Customer
1186"#,
1187 )
1188 .expect_err("inheritance cycle should fail");
1189 assert!(cycle.contains("inheritance cycle"));
1190 }
1191
1192 #[test]
1193 fn rejects_ontology_backing_errors() {
1194 let missing_record = parse_package(
1195 r#"
1196package:
1197 name: missing-backing-record
1198 version: 0.1.0
1199records: []
1200ontology:
1201 schema: greentic.sorla.ontology.v1
1202 concepts:
1203 - id: Customer
1204 kind: entity
1205 backed_by:
1206 record: Customer
1207"#,
1208 )
1209 .expect_err("missing backing record should fail");
1210 assert!(missing_record.contains("ontology.concepts[0].backed_by.record"));
1211
1212 let missing_field = parse_package(
1213 r#"
1214package:
1215 name: missing-backing-field
1216 version: 0.1.0
1217records:
1218 - name: Ownership
1219 fields:
1220 - name: id
1221 type: string
1222ontology:
1223 schema: greentic.sorla.ontology.v1
1224 concepts:
1225 - id: Party
1226 kind: entity
1227 - id: Asset
1228 kind: entity
1229 relationships:
1230 - id: owns
1231 from: Party
1232 to: Asset
1233 backed_by:
1234 record: Ownership
1235 from_field: party_id
1236"#,
1237 )
1238 .expect_err("missing backing field should fail");
1239 assert!(missing_field.contains("ontology.relationships[0].backed_by.from_field"));
1240 }
1241
1242 #[test]
1243 fn rejects_unknown_ontology_fields() {
1244 let error = parse_package(
1245 r#"
1246package:
1247 name: unknown-ontology-field
1248 version: 0.1.0
1249ontology:
1250 schema: greentic.sorla.ontology.v1
1251 unsupported: true
1252"#,
1253 )
1254 .expect_err("unknown ontology fields should be denied by serde");
1255
1256 assert!(error.contains("unknown field"));
1257 }
1258
1259 #[test]
1260 fn parses_versioned_view_contracts_and_legacy_views() {
1261 let parsed = parse_package(
1262 r#"
1263package:
1264 name: leasing
1265 version: 0.2.0
1266records:
1267 - name: Tenant
1268 fields:
1269 - name: id
1270 type: string
1271 - name: full_name
1272 type: string
1273events:
1274 - name: TenantUpdated
1275 record: Tenant
1276agent_endpoints:
1277 - id: update_tenant
1278 title: Update tenant
1279 intent: Update a tenant from view input.
1280 inputs:
1281 - name: tenant_id
1282 type: string
1283 - name: full_name
1284 type: string
1285 emits:
1286 event: TenantUpdated
1287 stream: "tenant/{tenant_id}"
1288views:
1289 - name: LegacyTenant
1290 - name: TenantWrite
1291 version: 2.0.0
1292 mode: read-write
1293 maps_from:
1294 record: Tenant
1295 fields:
1296 tenant_id: id
1297 display_name: full_name
1298 writes:
1299 agent_endpoint: update_tenant
1300 input_mapping:
1301 tenant_id: tenant_id
1302 full_name: display_name
1303"#,
1304 )
1305 .expect("versioned view fixture should parse");
1306
1307 assert_eq!(parsed.package.views.len(), 2);
1308 assert_eq!(parsed.package.views[0].name, "LegacyTenant");
1309 assert_eq!(parsed.package.views[1].mode, Some(ViewMode::ReadWrite));
1310 assert_eq!(
1311 parsed.package.views[1]
1312 .maps_from
1313 .as_ref()
1314 .expect("mapping should parse")
1315 .fields["display_name"],
1316 "full_name"
1317 );
1318 }
1319
1320 #[test]
1321 fn rejects_invalid_versioned_view_references() {
1322 let error = parse_package(
1323 r#"
1324package:
1325 name: leasing
1326 version: 0.2.0
1327records:
1328 - name: Tenant
1329 fields:
1330 - name: id
1331 type: string
1332views:
1333 - name: BrokenTenant
1334 maps_from:
1335 record: Tenant
1336 fields:
1337 email: email
1338"#,
1339 )
1340 .expect_err("unknown mapped record field should fail");
1341
1342 assert!(error.contains("views[0].maps_from.fields.email"));
1343 }
1344
1345 #[test]
1346 fn parses_operational_indexes_and_query_requirements() {
1347 let parsed = parse_package(
1348 r#"
1349package:
1350 name: leasing
1351 version: 0.2.0
1352records:
1353 - name: Tenant
1354 fields:
1355 - name: id
1356 type: string
1357 - name: email
1358 type: string
1359 - name: status
1360 type: string
1361events:
1362 - name: TenantCreated
1363 record: Tenant
1364projections:
1365 - name: ActiveTenants
1366 record: Tenant
1367 source_event: TenantCreated
1368views:
1369 - name: TenantSummary
1370operational_indexes:
1371 schema: greentic.sorla.operational-indexes.v1
1372 indexes:
1373 - id: tenant_by_email
1374 record: Tenant
1375 kind: exact
1376 fields:
1377 - email
1378 - id: tenant_status_lookup
1379 record: Tenant
1380 kind: composite
1381 fields:
1382 - status
1383 - id
1384 query_requirements:
1385 - id: active_tenant_lookup
1386 used_by:
1387 projection: ActiveTenants
1388 requires_index: tenant_status_lookup
1389 - id: tenant_summary_scan
1390 used_by:
1391 view: TenantSummary
1392 scan_ok: true
1393"#,
1394 )
1395 .expect("operational indexes should parse");
1396
1397 let indexes = parsed
1398 .package
1399 .operational_indexes
1400 .expect("indexes should be present");
1401 assert_eq!(indexes.indexes.len(), 2);
1402 assert_eq!(indexes.query_requirements.len(), 2);
1403 }
1404
1405 #[test]
1406 fn rejects_query_requirement_without_index_or_scan_ok() {
1407 let error = parse_package(
1408 r#"
1409package:
1410 name: leasing
1411 version: 0.2.0
1412records:
1413 - name: Tenant
1414 fields:
1415 - name: id
1416 type: string
1417events:
1418 - name: TenantCreated
1419 record: Tenant
1420projections:
1421 - name: ActiveTenants
1422 record: Tenant
1423 source_event: TenantCreated
1424operational_indexes:
1425 schema: greentic.sorla.operational-indexes.v1
1426 query_requirements:
1427 - id: active_tenant_lookup
1428 used_by:
1429 projection: ActiveTenants
1430"#,
1431 )
1432 .expect_err("query requirement without index or scan_ok should fail");
1433
1434 assert!(error.contains("requires_index"));
1435 }
1436
1437 #[test]
1438 fn parses_typed_migration_operations() {
1439 let parsed = parse_package(
1440 r#"
1441package:
1442 name: leasing
1443 version: 0.2.0
1444records:
1445 - name: Tenant
1446 fields:
1447 - name: id
1448 type: string
1449 - name: property_id
1450 type: string
1451 - name: status
1452 type: string
1453 - name: Person
1454 fields:
1455 - name: id
1456 type: string
1457 - name: Tenancy
1458 fields:
1459 - name: id
1460 type: string
1461operational_indexes:
1462 schema: greentic.sorla.operational-indexes.v1
1463 indexes:
1464 - id: active_tenants_by_property
1465 record: Tenant
1466 kind: composite
1467 fields:
1468 - property_id
1469 - status
1470migrations:
1471 - name: tenant-v2
1472 compatibility: backward-compatible
1473 from_version: 1.1.0
1474 to_version: 2.0.0
1475 idempotence_key: tenant:1.1.0:2.0.0
1476 operations:
1477 - kind: add-record
1478 record: Person
1479 - kind: split-record
1480 from_record: Tenant
1481 into_records:
1482 - Person
1483 - Tenancy
1484 - kind: require-index
1485 index: active_tenants_by_property
1486"#,
1487 )
1488 .expect("typed migration operations should parse");
1489
1490 assert_eq!(parsed.package.migrations[0].operations.len(), 3);
1491 assert_eq!(
1492 parsed.package.migrations[0].from_version.as_deref(),
1493 Some("1.1.0")
1494 );
1495 }
1496
1497 #[test]
1498 fn rejects_migration_operation_with_unknown_index() {
1499 let error = parse_package(
1500 r#"
1501package:
1502 name: leasing
1503 version: 0.2.0
1504migrations:
1505 - name: tenant-v2
1506 operations:
1507 - kind: require-index
1508 index: missing_index
1509"#,
1510 )
1511 .expect_err("unknown migration index should fail");
1512
1513 assert!(error.contains("unknown operational index"));
1514 }
1515}