Skip to main content

greentic_sorla_lang/
lib.rs

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}